new: add interactive wizard and remaining ecosystem templates
This commit is contained in:
+13
-5
@@ -234,13 +234,14 @@ fn main() {
|
||||
|
||||
match matches.subcommand() {
|
||||
Some(("new", sub_matches)) => {
|
||||
// Without the interactive wizard (follow-up work), missing
|
||||
// required answers produce one error listing all of them;
|
||||
// --defaults fills everything else from the defaults.
|
||||
let depends: Vec<String> = sub_matches
|
||||
.get_many::<String>("depends")
|
||||
.map(|values| values.cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let no_verify = sub_matches
|
||||
.get_one::<bool>("no_verify")
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
let cli = pkh::new::options::NewCli {
|
||||
name: sub_matches.get_one::<String>("name").cloned(),
|
||||
lang: sub_matches.get_one::<String>("lang").cloned(),
|
||||
@@ -275,9 +276,16 @@ fn main() {
|
||||
.unwrap_or(false),
|
||||
};
|
||||
|
||||
// The wizard (interactive terminal) fills the same NewCli and
|
||||
// resolves through the same pipeline; without a TTY the resolve
|
||||
// error lists every missing answer. Afterwards the two
|
||||
// verification builds are offered (`--no-verify` skips them;
|
||||
// the structural self-checks inside `scaffold` always run).
|
||||
if let Err(e) = rt.block_on(async {
|
||||
let opts = pkh::new::options::resolve(cli).await?;
|
||||
pkh::new::scaffold(opts, &multi)
|
||||
let opts = pkh::new::questions::run(cli).await?;
|
||||
pkh::new::scaffold(opts.clone(), &multi)?;
|
||||
pkh::new::questions::offer_verification(&opts, &multi, no_verify).await;
|
||||
Ok::<(), Box<dyn std::error::Error>>(())
|
||||
}) {
|
||||
error!("{}", e);
|
||||
std::process::exit(1);
|
||||
|
||||
+90
-8
@@ -50,16 +50,45 @@ pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec<OutputFile> {
|
||||
source_format(opts),
|
||||
changelog(opts),
|
||||
control(opts, template),
|
||||
rules(template),
|
||||
rules(opts, template),
|
||||
copyright(opts),
|
||||
debian_gitignore(opts),
|
||||
];
|
||||
if !opts.native {
|
||||
files.push(local_options());
|
||||
}
|
||||
if opts.autopkgtest {
|
||||
files.push(autopkgtest_control());
|
||||
files.push(autopkgtest_smoke(opts));
|
||||
}
|
||||
if let Some(watch) = &opts.watch {
|
||||
files.push(OutputFile::new("debian/watch", watch.clone()));
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
/// `debian/tests/control`: the autopkgtest smoke test definition.
|
||||
fn autopkgtest_control() -> OutputFile {
|
||||
OutputFile::new(
|
||||
"debian/tests/control",
|
||||
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n",
|
||||
)
|
||||
}
|
||||
|
||||
/// `debian/tests/smoke`: run the installed command once; `--help` first,
|
||||
/// `--version` as the fallback (some tools only answer one of them).
|
||||
fn autopkgtest_smoke(opts: &NewOptions) -> OutputFile {
|
||||
OutputFile::executable(
|
||||
"debian/tests/smoke",
|
||||
format!(
|
||||
"#!/bin/sh\n\
|
||||
set -e\n\
|
||||
{command} --help >/dev/null 2>&1 || {command} --version\n",
|
||||
command = opts.command,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// `debian/source/format`: `3.0 (quilt)` by default, `3.0 (native)` with
|
||||
/// `--native`.
|
||||
fn source_format(opts: &NewOptions) -> OutputFile {
|
||||
@@ -173,6 +202,10 @@ fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
||||
build_depends.extend(template.build_depends(opts));
|
||||
control.push_str(&render_field("Build-Depends", &build_depends));
|
||||
|
||||
for (key, value) in template.source_fields(opts) {
|
||||
control.push_str(&format!("{key}: {value}\n"));
|
||||
}
|
||||
|
||||
if let Some(homepage) = &opts.homepage {
|
||||
control.push_str(&format!("Homepage: {homepage}\n"));
|
||||
}
|
||||
@@ -181,7 +214,7 @@ fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
||||
|
||||
// Binary stanza.
|
||||
control.push_str(&format!("Package: {}\n", opts.name));
|
||||
control.push_str(&format!("Architecture: {}\n", template.architecture()));
|
||||
control.push_str(&format!("Architecture: {}\n", template.architecture(opts)));
|
||||
if !opts.depends.is_empty() {
|
||||
control.push_str(&render_field("Depends", &opts.depends));
|
||||
}
|
||||
@@ -191,11 +224,12 @@ fn control(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
||||
OutputFile::new("debian/control", control)
|
||||
}
|
||||
|
||||
/// `debian/rules`: the minimal `dh $@` makefile (plus the template's extra
|
||||
/// overrides, when any), written with the executable bit.
|
||||
fn rules(template: &dyn Template) -> OutputFile {
|
||||
let mut contents = String::from("#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
||||
let extra = template.rules_extra();
|
||||
/// `debian/rules`: the shebang and `%:` target whose recipe is the
|
||||
/// template's dh line (plus the template's extra overrides, when any),
|
||||
/// written with the executable bit.
|
||||
fn rules(opts: &NewOptions, template: &dyn Template) -> OutputFile {
|
||||
let mut contents = format!("#!/usr/bin/make -f\n%:\n\t{}\n", template.rules_dh_line());
|
||||
let extra = template.rules_extra(opts);
|
||||
if !extra.is_empty() {
|
||||
contents.push('\n');
|
||||
contents.push_str(&extra);
|
||||
@@ -464,6 +498,9 @@ mod tests {
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -591,11 +628,56 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rules_is_executable_minimal_makefile() {
|
||||
let rules = super::rules(crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||
let o = opts();
|
||||
let rules = super::rules(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||
assert!(rules.executable);
|
||||
assert_eq!(rules.contents, "#!/usr/bin/make -f\n%:\n\tdh $@\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_files_autopkgtest_and_watch() {
|
||||
let mut o = opts();
|
||||
o.autopkgtest = true;
|
||||
o.watch = Some(
|
||||
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
.to_string(),
|
||||
);
|
||||
let files = super::files(&o, crate::new::templates::get(TemplateId::Shell).unwrap());
|
||||
let find = |path: &str| {
|
||||
files
|
||||
.iter()
|
||||
.find(|f| f.path == path)
|
||||
.unwrap_or_else(|| panic!("{path} missing"))
|
||||
};
|
||||
|
||||
let control = find("debian/tests/control");
|
||||
assert_eq!(
|
||||
control.contents,
|
||||
"Tests: smoke\nDepends: @\nRestrictions: allow-stderr\n"
|
||||
);
|
||||
let smoke = find("debian/tests/smoke");
|
||||
assert!(smoke.executable);
|
||||
assert!(smoke.contents.starts_with("#!/bin/sh\nset -e\n"));
|
||||
assert!(
|
||||
smoke
|
||||
.contents
|
||||
.contains("mytool --help >/dev/null 2>&1 || mytool --version")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find("debian/watch").contents,
|
||||
"version=4\nhttps://github.com/example/mytool/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
);
|
||||
|
||||
// Without the extras none of the files are rendered.
|
||||
let plain = super::files(
|
||||
&opts(),
|
||||
crate::new::templates::get(TemplateId::Shell).unwrap(),
|
||||
);
|
||||
assert!(!plain.iter().any(|f| f.path.starts_with("debian/tests")));
|
||||
assert!(!plain.iter().any(|f| f.path == "debian/watch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copyright_is_dep5() {
|
||||
let c = super::copyright(&opts());
|
||||
|
||||
+192
-1
@@ -21,6 +21,8 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::options::TemplateId;
|
||||
|
||||
/// Outcome of the detection.
|
||||
@@ -74,7 +76,7 @@ pub fn detect(dir: &Path) -> Detection {
|
||||
/// The single top-level script of `dir`, if there is exactly one: a file
|
||||
/// with the `.sh` extension, or whose first line starts with `#!`. Returns
|
||||
/// `None` when there are zero or several candidates.
|
||||
fn single_script(dir: &Path) -> Option<std::path::PathBuf> {
|
||||
pub fn single_script(dir: &Path) -> Option<std::path::PathBuf> {
|
||||
let mut found: Option<std::path::PathBuf> = None;
|
||||
let entries = std::fs::read_dir(dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
@@ -107,6 +109,102 @@ fn has_shebang(path: &Path) -> bool {
|
||||
content.starts_with(b"#!")
|
||||
}
|
||||
|
||||
/// License files looked at by [`sniff_license`], in preference order.
|
||||
const LICENSE_FILES: [&str; 5] = [
|
||||
"LICENSE",
|
||||
"LICENSE.md",
|
||||
"LICENSE.txt",
|
||||
"COPYING",
|
||||
"COPYING.txt",
|
||||
];
|
||||
|
||||
/// Sniff the license of the project in `dir` from its `LICENSE`/`COPYING`
|
||||
/// file: an `SPDX-License-Identifier:` line wins, otherwise the text is
|
||||
/// matched against a short list of recognizable licenses (MIT, BSD-2/3,
|
||||
/// Apache-2.0, GPL-2/3, LGPL-2.1/3, ISC). `None` when no license file
|
||||
/// exists or nothing recognizable is found.
|
||||
pub fn sniff_license(dir: &Path) -> Option<String> {
|
||||
let content = LICENSE_FILES
|
||||
.iter()
|
||||
.find_map(|name| std::fs::read_to_string(dir.join(name)).ok())
|
||||
// Case variants and suffixes (LICENSE-MIT, LICENCE, cpYING…): the
|
||||
// first top-level file whose name looks like a license notice.
|
||||
.or_else(|| {
|
||||
let mut candidates: Vec<std::path::PathBuf> = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.is_file()
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
let name = name.to_ascii_uppercase();
|
||||
// American and British spellings both count.
|
||||
name.starts_with("LICENSE")
|
||||
|| name.starts_with("LICENCE")
|
||||
|| name.starts_with("COPYING")
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
candidates.sort();
|
||||
std::fs::read_to_string(candidates.into_iter().next()?).ok()
|
||||
})?;
|
||||
|
||||
// An explicit SPDX identifier is the most reliable signal.
|
||||
static SPDX_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let spdx = SPDX_REGEX.get_or_init(|| {
|
||||
Regex::new(r"(?i)SPDX-License-Identifier\s*:\s*([A-Za-z0-9+.\- ]+)").unwrap()
|
||||
});
|
||||
if let Some(id) = spdx
|
||||
.captures(&content)
|
||||
.and_then(|caps| caps.get(1))
|
||||
.map(|id| id.as_str().trim_end().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
|
||||
let text = content.to_ascii_lowercase();
|
||||
if text.contains("apache license") && text.contains("version 2") {
|
||||
return Some("Apache-2.0".to_string());
|
||||
}
|
||||
if text.contains("lesser general public license") {
|
||||
return if text.contains("version 3") && !text.contains("version 2.1") {
|
||||
Some("LGPL-3.0+".to_string())
|
||||
} else {
|
||||
Some("LGPL-2.1+".to_string())
|
||||
};
|
||||
}
|
||||
if text.contains("general public license") {
|
||||
return if text.contains("version 3") {
|
||||
Some("GPL-3.0+".to_string())
|
||||
} else {
|
||||
Some("GPL-2.0+".to_string())
|
||||
};
|
||||
}
|
||||
if text.contains("mit license") || text.contains("permission is hereby granted, free of charge")
|
||||
{
|
||||
return Some("MIT".to_string());
|
||||
}
|
||||
if text.contains("isc license")
|
||||
|| text.contains("permission to use, copy, modify, and/or distribute this software")
|
||||
{
|
||||
return Some("ISC".to_string());
|
||||
}
|
||||
if text.contains("redistribution and use in source and binary forms") {
|
||||
// The third clause (name endorsement) is what sets BSD-3 apart
|
||||
// from BSD-2.
|
||||
return if text.contains("endorse or promote") {
|
||||
Some("BSD-3-Clause".to_string())
|
||||
} else {
|
||||
Some("BSD-2-Clause".to_string())
|
||||
};
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -203,4 +301,97 @@ mod tests {
|
||||
// The only "real" script candidate is in a subdir or hidden: no hit.
|
||||
assert_eq!(detect(dir.path()), Detection::Empty);
|
||||
}
|
||||
|
||||
/// Distinctive (shortened) excerpts of the recognizable license texts.
|
||||
const LICENSE_TEXTS: [(&str, &str); 9] = [
|
||||
(
|
||||
"MIT",
|
||||
"MIT License\n\nPermission is hereby granted, free of charge, to any person",
|
||||
),
|
||||
("Apache-2.0", "Apache License\nVersion 2.0, January 2004"),
|
||||
(
|
||||
"GPL-2.0+",
|
||||
"GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991\neither version 2 of the License",
|
||||
),
|
||||
(
|
||||
"GPL-3.0+",
|
||||
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||
),
|
||||
(
|
||||
"LGPL-2.1+",
|
||||
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999",
|
||||
),
|
||||
(
|
||||
"LGPL-3.0+",
|
||||
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||
),
|
||||
(
|
||||
"BSD-2-Clause",
|
||||
"Redistribution and use in source and binary forms, with or without\nmodification, are permitted",
|
||||
),
|
||||
(
|
||||
"BSD-3-Clause",
|
||||
"Redistribution and use in source and binary forms, with or without\nmay be used to endorse or promote products",
|
||||
),
|
||||
(
|
||||
"ISC",
|
||||
"ISC License\nPermission to use, copy, modify, and/or distribute this software",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn sniff_license_recognizes_license_files() {
|
||||
for (expected, text) in LICENSE_TEXTS {
|
||||
// Every candidate file name is looked at.
|
||||
for name in ["LICENSE", "COPYING", "LICENSE.md", "COPYING.txt"] {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(name), text).unwrap();
|
||||
assert_eq!(
|
||||
sniff_license(dir.path()).as_deref(),
|
||||
Some(expected),
|
||||
"{name}: {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_license_prefers_spdx_identifier() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("LICENSE"),
|
||||
"Custom terms here\nSPDX-License-Identifier: Zlib\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sniff_license(dir.path()).as_deref(), Some("Zlib"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sniff_license_handles_case_variants_and_missing_files() {
|
||||
// Unusual spelling found through the directory scan.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Licence.TXT"),
|
||||
"Permission is hereby granted, free of charge",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
|
||||
|
||||
// Exact candidates win over the directory scan (LICENSE before
|
||||
// LICENSE.blurb).
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("LICENSE.blurb"),
|
||||
"GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("LICENSE"), "MIT License").unwrap();
|
||||
assert_eq!(sniff_license(dir.path()).as_deref(), Some("MIT"));
|
||||
|
||||
// Unrecognizable or missing text: silent None.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("LICENSE"), "do whatever you want\n").unwrap();
|
||||
assert_eq!(sniff_license(dir.path()), None);
|
||||
assert_eq!(sniff_license(&dir.path().join("missing")), None);
|
||||
}
|
||||
}
|
||||
|
||||
+158
-31
@@ -2,17 +2,19 @@
|
||||
//! `plans/pkh-new.md`).
|
||||
//!
|
||||
//! This module orchestrates a scaffold run: target directory checks, project
|
||||
//! detection, in-memory rendering of every file (all-or-nothing write), orig
|
||||
//! tarball creation, git initialization, structural verification and the
|
||||
//! next-steps message. The interactive wizard and the remaining templates
|
||||
//! (python, meson, cmake, autotools, go, rust, makefile) are follow-up work
|
||||
//! built on top of the same [`options::NewOptions`] and
|
||||
//! [`templates::Template`] surface.
|
||||
//! detection, in-memory rendering of every file (all-or-nothing write), the
|
||||
//! template post-write hook (e.g. `cargo vendor`), orig tarball creation,
|
||||
//! git initialization, structural verification and the next-steps message.
|
||||
//! The interactive wizard ([`questions`]) fills a [`options::NewCli`] from
|
||||
//! its answers on a TTY and reuses [`options::resolve`] as the single source
|
||||
//! of truth for defaults and validation; without a TTY the same resolution
|
||||
//! runs flag-driven.
|
||||
|
||||
pub mod debian;
|
||||
pub mod detect;
|
||||
pub mod git;
|
||||
pub mod options;
|
||||
pub mod questions;
|
||||
pub mod templates;
|
||||
pub mod verify;
|
||||
|
||||
@@ -27,15 +29,17 @@ use templates::OutputFile;
|
||||
/// Scaffold a full Debian source tree from `opts`.
|
||||
///
|
||||
/// Steps, aborting early with a pointed error message:
|
||||
/// 1. resolve the template (unimplemented ids fail here, not at parse time),
|
||||
/// 1. resolve the template from the registry,
|
||||
/// 2. check the target directory (refuse an existing `debian/control`),
|
||||
/// 3. render every file in memory and check for collisions,
|
||||
/// 4. write the files all-or-nothing (plus the root `.gitignore` in skeleton
|
||||
/// mode, appending to an existing one),
|
||||
/// 5. create the orig tarball (quilt only, refusing overwrites),
|
||||
/// 6. `git init` unless `--no-git` or already inside a repository,
|
||||
/// 7. run the structural verification,
|
||||
/// 8. print the success message with the next steps.
|
||||
/// 5. run the template post-write hook (e.g. `cargo vendor`, so the vendored
|
||||
/// sources land inside the orig tarball created next),
|
||||
/// 6. create the orig tarball (quilt only, refusing overwrites),
|
||||
/// 7. `git init` unless `--no-git` or already inside a repository,
|
||||
/// 8. run the structural verification,
|
||||
/// 9. print the success message with the next steps.
|
||||
pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box<dyn Error>> {
|
||||
let pb = multi.add(ProgressBar::new_spinner());
|
||||
pb.enable_steady_tick(Duration::from_millis(50));
|
||||
@@ -62,17 +66,13 @@ pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box<dyn E
|
||||
/// The scaffold steps proper, reporting progress through `pb`. Nothing is
|
||||
/// written to the filesystem before every file rendered successfully.
|
||||
fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box<dyn Error>> {
|
||||
// 1. Template resolution: unknown-but-valid ids fail here with the
|
||||
// friendly message instead of a parse error.
|
||||
// 1. Template resolution: an id without a registered template fails
|
||||
// here with the friendly message instead of a parse error.
|
||||
let template = templates::get(opts.template).ok_or_else(|| {
|
||||
format!(
|
||||
"The '{}' template is not implemented yet. Implemented templates: {}.",
|
||||
"The '{}' template has no registered implementation. \
|
||||
This is a pkh bug; please report it.",
|
||||
opts.template,
|
||||
templates::all()
|
||||
.iter()
|
||||
.map(|t| t.id().as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -173,17 +173,23 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box<dyn Err
|
||||
std::fs::write(target.join(".gitignore"), contents)?;
|
||||
}
|
||||
|
||||
// 5. Orig tarball (quilt only).
|
||||
// 5. Template post-write hook: run before the orig tarball is created,
|
||||
// so files added here (rust: vendor/ + .cargo/config.toml) land
|
||||
// inside it.
|
||||
pb.set_message("Running template hooks");
|
||||
template.post_write(opts, &target)?;
|
||||
|
||||
// 6. Orig tarball (quilt only).
|
||||
if !opts.native {
|
||||
pb.set_message("Creating orig tarball");
|
||||
debian::create_orig_tarball(&target, &opts.name, &opts.upstream_version_no_epoch())?;
|
||||
}
|
||||
|
||||
// 6. Git.
|
||||
// 7. Git.
|
||||
pb.set_message("Initializing git");
|
||||
git::ensure_repository(&target, opts.git)?;
|
||||
|
||||
// 7. Structural verification.
|
||||
// 8. Structural verification.
|
||||
pb.set_message("Verifying");
|
||||
verify::verify(&target)?;
|
||||
|
||||
@@ -250,6 +256,9 @@ mod tests {
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: false,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,15 +536,6 @@ mod tests {
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("does not exist"), "{err}");
|
||||
|
||||
// Unimplemented template.
|
||||
let dir = tempdir().unwrap();
|
||||
let err = scaffold_in(
|
||||
dir.path(),
|
||||
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("not implemented yet"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -555,6 +555,133 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end rust skeleton: the vendoring hook runs before the orig
|
||||
/// tarball is created, so `.cargo/` (and `vendor/` when dependencies
|
||||
/// exist) travel inside it. The vendoring step needs host cargo; on a
|
||||
/// cargo-less host the scaffold still succeeds with a warning.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn scaffold_rust_skeleton_vendors_before_tarball() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_in(
|
||||
dir.path(),
|
||||
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tree = dir.path().join("mytool");
|
||||
assert!(tree.join("Cargo.toml").exists());
|
||||
assert!(tree.join("src/main.rs").exists());
|
||||
|
||||
// rules: the vendored build overrides, no --locked on a fresh
|
||||
// skeleton without Cargo.lock.
|
||||
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
|
||||
assert!(rules.contains("%:\n\tdh $@\n"));
|
||||
assert!(rules.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
|
||||
assert!(rules.contains("override_dh_auto_install:\n\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool"));
|
||||
assert!(!rules.contains("--locked"));
|
||||
|
||||
// control: Architecture any + the cargo/rustc build-deps.
|
||||
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
|
||||
assert_eq!(control.binaries[0].get("Architecture"), Some("any"));
|
||||
assert_eq!(
|
||||
control.source.get("Build-Depends"),
|
||||
Some("debhelper-compat (= 13),\ncargo:native,\nrustc:native")
|
||||
);
|
||||
|
||||
// The offline config exists when host cargo vendored the skeleton,
|
||||
// and both it and the skeleton land inside the orig tarball.
|
||||
let has_cargo = crate::new::templates::find_on_path("cargo").is_some();
|
||||
if has_cargo {
|
||||
let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap();
|
||||
assert!(config.contains("[source.crates-io]"), "{config}");
|
||||
assert!(config.contains("[net]\noffline = true"), "{config}");
|
||||
}
|
||||
|
||||
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
|
||||
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
|
||||
std::fs::File::open(&tarball).unwrap(),
|
||||
));
|
||||
let names: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|entry| {
|
||||
entry
|
||||
.unwrap()
|
||||
.path()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
|
||||
"{names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.iter().any(|n| n == "mytool-0.1.0/src/main.rs"),
|
||||
"{names:?}"
|
||||
);
|
||||
if has_cargo {
|
||||
assert!(
|
||||
names.iter().any(|n| n == "mytool-0.1.0/.cargo/config.toml"),
|
||||
"{names:?}"
|
||||
);
|
||||
}
|
||||
assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}");
|
||||
}
|
||||
|
||||
/// End-to-end python skeleton: pyproject-based Build-Depends and the
|
||||
/// module skeleton inside the orig tarball.
|
||||
#[test]
|
||||
#[serial]
|
||||
fn scaffold_python_skeleton_tree() {
|
||||
let dir = tempdir().unwrap();
|
||||
scaffold_in(
|
||||
dir.path(),
|
||||
opts(TemplateId::Python, "mytool", SourceDir::Skeleton),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tree = dir.path().join("mytool");
|
||||
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
|
||||
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
|
||||
assert_eq!(
|
||||
control.source.get("Build-Depends"),
|
||||
Some(
|
||||
"debhelper-compat (= 13),\ndh-python,\npython3-all,\n\
|
||||
pybuild-plugin-pyproject,\npython3-setuptools"
|
||||
)
|
||||
);
|
||||
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
|
||||
assert!(rules.contains("%:\n\tdh $@ --with python3 --buildsystem=pybuild\n"));
|
||||
|
||||
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
|
||||
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
|
||||
std::fs::File::open(&tarball).unwrap(),
|
||||
));
|
||||
let names: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|entry| {
|
||||
entry
|
||||
.unwrap()
|
||||
.path()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n == "mytool-0.1.0/pyproject.toml"),
|
||||
"{names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.iter().any(|n| n == "mytool-0.1.0/mytool/__init__.py"),
|
||||
"{names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end: the scaffolded shell tree passes the real source build
|
||||
/// (`dpkg-source` and friends, same prerequisites as the differential
|
||||
/// tests).
|
||||
|
||||
+51
-12
@@ -19,9 +19,8 @@ use crate::new::detect::{self, Detection};
|
||||
/// Build systems / project kinds `pkh new` knows about.
|
||||
///
|
||||
/// The identifiers are stable CLI surface: `--lang` accepts every variant,
|
||||
/// even the ones that have no template implementation yet (those fail at
|
||||
/// scaffold time with a "not implemented yet" error instead of a parse
|
||||
/// error, so scripts written today keep working once they land).
|
||||
/// and every variant has a template implementation registered in
|
||||
/// [`crate::new::templates`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TemplateId {
|
||||
/// Rust project (`Cargo.toml`)
|
||||
@@ -93,10 +92,27 @@ impl TemplateId {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a template implementation (and therefore scaffolding) exists
|
||||
/// for this id. The remaining templates land with the follow-up work.
|
||||
pub fn implemented(&self) -> bool {
|
||||
matches!(self, TemplateId::Shell | TemplateId::Empty)
|
||||
/// Human-readable menu label of this template, as offered by the wizard
|
||||
/// language question (and reused in the summary screen).
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
TemplateId::Rust => "Rust (Cargo.toml)",
|
||||
TemplateId::Python => "Python (pyproject.toml / setup.py)",
|
||||
TemplateId::Meson => "C/C++ (Meson)",
|
||||
TemplateId::Cmake => "C/C++ (CMake)",
|
||||
TemplateId::Autotools => "C/C++ (Autotools)",
|
||||
TemplateId::Go => "Go module",
|
||||
TemplateId::Shell => "Shell script / single interpreted file",
|
||||
TemplateId::Makefile => "Generic (Makefile)",
|
||||
TemplateId::Empty => "Metapackage / empty base (no build system)",
|
||||
}
|
||||
}
|
||||
|
||||
/// The template whose menu label (or CLI identifier) is `label`.
|
||||
pub fn from_label(label: &str) -> Option<TemplateId> {
|
||||
TemplateId::all()
|
||||
.into_iter()
|
||||
.find(|id| id.display_name() == label || id.as_str() == label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +246,17 @@ pub struct NewOptions {
|
||||
pub native: bool,
|
||||
/// Initialize a git repository (gitignores are written regardless).
|
||||
pub git: bool,
|
||||
/// Write the autopkgtest smoke test (`debian/tests/control` +
|
||||
/// `debian/tests/smoke`) running `<command> --help`/`--version`.
|
||||
/// Wizard-only extra, off by default.
|
||||
pub autopkgtest: bool,
|
||||
/// Add `pkg-config` to Build-Depends (the meson/cmake opt-in question;
|
||||
/// most such projects resolve their dependencies through it).
|
||||
/// Wizard-only extra, off by default.
|
||||
pub pkg_config: bool,
|
||||
/// Contents of a `debian/watch` release watcher (GitHub/GitLab tarball
|
||||
/// template). Wizard-only extra, off by default.
|
||||
pub watch: Option<String>,
|
||||
}
|
||||
|
||||
impl NewOptions {
|
||||
@@ -568,7 +595,8 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
|
||||
if !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"Missing required answers (use --defaults to take every default, \
|
||||
or answer interactively once the wizard lands):\n - {}",
|
||||
or re-run pkh new on an interactive terminal to answer the \
|
||||
wizard):\n - {}",
|
||||
missing.join("\n - ")
|
||||
));
|
||||
}
|
||||
@@ -647,6 +675,9 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
|
||||
depends,
|
||||
native: cli.native,
|
||||
git: cli.git,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -671,12 +702,17 @@ mod tests {
|
||||
fn template_ids_roundtrip() {
|
||||
for id in TemplateId::all() {
|
||||
assert_eq!(TemplateId::parse(id.as_str()).unwrap(), id);
|
||||
// Every id resolves from its menu label too, and labels are
|
||||
// unique.
|
||||
assert_eq!(TemplateId::from_label(id.display_name()), Some(id));
|
||||
}
|
||||
assert!(TemplateId::parse("cobol").is_err());
|
||||
// Every id is accepted by the parser; only some are implemented.
|
||||
assert!(!TemplateId::Rust.implemented());
|
||||
assert!(TemplateId::Shell.implemented());
|
||||
assert!(TemplateId::Empty.implemented());
|
||||
assert_eq!(
|
||||
TemplateId::from_label("Rust (Cargo.toml)"),
|
||||
Some(TemplateId::Rust)
|
||||
);
|
||||
assert_eq!(TemplateId::from_label("rust"), Some(TemplateId::Rust));
|
||||
assert_eq!(TemplateId::from_label("nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -754,6 +790,9 @@ mod tests {
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: false,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
};
|
||||
assert_eq!(opts.full_version(), "0.1.0-1");
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,948 @@
|
||||
//! The `pkh new` interactive wizard.
|
||||
//!
|
||||
//! [`run`] is the single entry point: on an interactive terminal it asks the
|
||||
//! questions of the spec's "Proposed UX" transcript, fills a
|
||||
//! [`NewCli`] with the answers (explicit flags are never re-asked), and
|
||||
//! reuses [`options::resolve`] as the single source of truth for defaults,
|
||||
//! detection and validation — so the non-interactive and interactive paths
|
||||
//! cannot drift apart. Without a terminal (or with `--defaults`) it goes
|
||||
//! straight through [`options::resolve`], whose error lists every missing
|
||||
//! answer.
|
||||
//!
|
||||
//! After the summary screen is confirmed, the wizard offers the two
|
||||
//! verification builds of the spec ([`offer_verification`]); a failed
|
||||
//! verification never undoes the scaffold.
|
||||
//!
|
||||
//! The prompt calls live in `run_wizard` and `offer_verification` only;
|
||||
//! everything else in this module is pure and unit-tested.
|
||||
|
||||
use std::error::Error;
|
||||
use std::io::IsTerminal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use indicatif::MultiProgress;
|
||||
|
||||
use crate::new::detect::{self, Detection};
|
||||
use crate::new::options::{self, NewCli, NewOptions, SourceDir, TemplateId};
|
||||
use crate::new::templates::{self, ProbeResult};
|
||||
use crate::ui::prompt;
|
||||
|
||||
/// Answer of the "where is the source code?" question: fresh skeleton.
|
||||
const SOURCE_SKELETON: &str = "Create a new project skeleton here";
|
||||
/// Answer of the "where is the source code?" question: this directory.
|
||||
const SOURCE_HERE: &str = "Package the sources in this directory";
|
||||
/// Answer of the "where is the source code?" question: another directory.
|
||||
const SOURCE_PATH: &str = "Package the sources in another directory…";
|
||||
|
||||
/// The "everything else" entry of the license menu.
|
||||
const LICENSE_OTHER: &str = "Other (enter a SPDX identifier)";
|
||||
|
||||
/// The curated SPDX identifiers of the license menu (without the free-text
|
||||
/// entry), matching [`options::License::parse`]'s known spellings.
|
||||
pub const KNOWN_LICENSES: [&str; 9] = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"GPL-2.0+",
|
||||
"GPL-3.0+",
|
||||
"LGPL-2.1+",
|
||||
"LGPL-3.0+",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
];
|
||||
|
||||
/// Run the `pkh new` flow: the wizard on an interactive terminal, plain
|
||||
/// [`options::resolve`] otherwise (and with `--defaults`).
|
||||
pub async fn run(cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
||||
if cli.defaults || !is_interactive() {
|
||||
return Ok(options::resolve(cli).await?);
|
||||
}
|
||||
run_wizard(cli).await
|
||||
}
|
||||
|
||||
/// Whether both ends of the terminal are interactive; the wizard and the
|
||||
/// verification offers only run when this holds (the prompts' non-TTY
|
||||
/// fallbacks would otherwise silently take defaults).
|
||||
fn is_interactive() -> bool {
|
||||
std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
/// The wizard question flow (spec "Proposed UX"), in order:
|
||||
/// package name, language/build system, source location, upstream version,
|
||||
/// Debian revision, one-line description, homepage, license, command name,
|
||||
/// maintainer, target distribution, target series, metapackage Depends
|
||||
/// (`empty` template only), git init — then the summary screen and the
|
||||
/// final `Generate?` confirmation. Every question with an explicit flag
|
||||
/// answer is skipped (flag > detected/probe > default merge order).
|
||||
async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
|
||||
let cwd = std::env::current_dir()?;
|
||||
let detect_dir = cli.source.clone().unwrap_or_else(|| cwd.clone());
|
||||
let detection = detect::detect(&detect_dir);
|
||||
let probe = match &detection {
|
||||
Detection::Single(id) => templates::get(*id).and_then(|t| t.probe(&detect_dir)),
|
||||
_ => None,
|
||||
};
|
||||
// Whether the flags imply a fresh skeleton (name given, no --source).
|
||||
let implied_skeleton = cli.source.is_none() && cli.name.is_some();
|
||||
// Whether the language question is skipped by a confident detection
|
||||
// (only when packaging the detected directory, never for a skeleton).
|
||||
let detection_decides = matches!(detection, Detection::Single(_)) && !implied_skeleton;
|
||||
|
||||
// 1. Package name: the detected project name, else the sanitized
|
||||
// basename of the current directory.
|
||||
if cli.name.is_none() {
|
||||
let default = default_package_name(&cwd, probe.as_ref());
|
||||
let answer = ask_text("Package name", &default, options::validate_source_name)?;
|
||||
cli.name = Some(answer);
|
||||
}
|
||||
|
||||
// 2. Language / build system.
|
||||
let mut preselected: Option<TemplateId> = None;
|
||||
match &detection {
|
||||
Detection::Single(id) if detection_decides => {
|
||||
log::info!(
|
||||
"Detected: {} project in {}",
|
||||
id.display_name(),
|
||||
detect_dir.display()
|
||||
);
|
||||
cli.lang = Some(id.as_str().to_string());
|
||||
}
|
||||
Detection::Single(id) => {
|
||||
// A skeleton was asked for: still ask, preselecting the
|
||||
// detected ecosystem.
|
||||
preselected = Some(*id);
|
||||
}
|
||||
Detection::Ambiguous(candidates) => {
|
||||
log::info!(
|
||||
"Several build systems found in {} ({}): candidates listed \
|
||||
first, the highest-precedence one preselected",
|
||||
detect_dir.display(),
|
||||
candidates
|
||||
.iter()
|
||||
.map(|id| id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
let menu = language_menu(candidates);
|
||||
let id = select_template(&menu, &menu[0])?;
|
||||
cli.lang = Some(id.as_str().to_string());
|
||||
}
|
||||
Detection::Empty => {}
|
||||
}
|
||||
if cli.lang.is_none() {
|
||||
let menu = language_menu(&[]);
|
||||
let default = preselected
|
||||
.unwrap_or(TemplateId::Empty)
|
||||
.display_name()
|
||||
.to_string();
|
||||
let id = select_template(&menu, &default)?;
|
||||
cli.lang = Some(id.as_str().to_string());
|
||||
}
|
||||
let template = TemplateId::parse(cli.lang.as_deref().unwrap_or_default())?;
|
||||
|
||||
// 3. Source location. Skipped (with the inline notice) when a confident
|
||||
// detection already decided to package the current directory; a
|
||||
// --source flag skips it too.
|
||||
if cli.source.is_none() && !detection_decides {
|
||||
let options = vec![
|
||||
SOURCE_SKELETON.to_string(),
|
||||
SOURCE_HERE.to_string(),
|
||||
SOURCE_PATH.to_string(),
|
||||
];
|
||||
let default = if implied_skeleton {
|
||||
SOURCE_SKELETON
|
||||
} else {
|
||||
SOURCE_HERE
|
||||
};
|
||||
let answer = select_from("Where is the source code?", &options, default, |answer| {
|
||||
options.contains(&answer.to_string())
|
||||
})?;
|
||||
if answer == SOURCE_HERE {
|
||||
cli.source = Some(cwd.clone());
|
||||
} else if answer == SOURCE_PATH {
|
||||
let validator = |path: &str| validate_directory_answer(path);
|
||||
let path = prompt::text("Source directory", "", Some(&validator))?;
|
||||
cli.source = Some(PathBuf::from(path));
|
||||
}
|
||||
// SOURCE_SKELETON: cli.source stays unset (the name decides).
|
||||
} else if cli.source.is_none() {
|
||||
cli.source = Some(cwd.clone());
|
||||
}
|
||||
|
||||
// 4. Upstream version.
|
||||
if cli.upstream_version.is_none() {
|
||||
let default = probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.version.clone())
|
||||
.unwrap_or_else(|| "0.1.0".to_string());
|
||||
let revision = cli.revision.unwrap_or(1);
|
||||
let answer = ask_text("Upstream version", &default, move |version: &str| {
|
||||
options::validate_upstream_version(version, revision)
|
||||
})?;
|
||||
cli.upstream_version = Some(answer);
|
||||
}
|
||||
|
||||
// 5. Debian revision.
|
||||
if cli.revision.is_none() {
|
||||
let answer = ask_text("Debian revision", "1", validate_revision_answer)?;
|
||||
cli.revision = answer.parse::<u32>().ok();
|
||||
}
|
||||
|
||||
// 6. One-line description (required: an empty answer re-asks).
|
||||
if cli.description.is_none() {
|
||||
let default = probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.description.clone())
|
||||
.unwrap_or_default();
|
||||
loop {
|
||||
let answer = ask_text(
|
||||
"One-line description",
|
||||
&default,
|
||||
required_answer("the description"),
|
||||
)?;
|
||||
if !answer.trim().is_empty() {
|
||||
cli.description = Some(answer);
|
||||
break;
|
||||
}
|
||||
log::warn!("A one-line description is required to scaffold a package");
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Homepage.
|
||||
if cli.homepage.is_none() {
|
||||
let default = probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.homepage.clone())
|
||||
.unwrap_or_default();
|
||||
let answer = ask_text(
|
||||
"Homepage (blank to skip)",
|
||||
&default,
|
||||
options::validate_homepage,
|
||||
)?;
|
||||
if !answer.is_empty() {
|
||||
cli.homepage = Some(answer);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. License: curated SPDX menu plus a free-text entry. The default
|
||||
// comes from the project metadata (Cargo.toml / pyproject.toml), then
|
||||
// from sniffing the LICENSE/COPYING file.
|
||||
if cli.license.is_none() {
|
||||
let detected = probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.license.clone())
|
||||
.or_else(|| detect::sniff_license(&detect_dir));
|
||||
let (default, custom_default) = license_question_default(detected.as_deref());
|
||||
let options = license_menu();
|
||||
let answer = select_from("License", &options, &default, |answer| {
|
||||
options.contains(&answer.to_string())
|
||||
})?;
|
||||
if answer == LICENSE_OTHER {
|
||||
let license = loop {
|
||||
let candidate = ask_text(
|
||||
"License (SPDX identifier)",
|
||||
&custom_default,
|
||||
required_answer("the license identifier"),
|
||||
)?;
|
||||
if !candidate.trim().is_empty() {
|
||||
break candidate;
|
||||
}
|
||||
log::warn!("A license identifier is required when picking the free-text entry");
|
||||
};
|
||||
cli.license = Some(license);
|
||||
} else {
|
||||
cli.license = Some(answer);
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Command name (skipped for the empty template, where nothing is
|
||||
// installed).
|
||||
if cli.command.is_none() && template != TemplateId::Empty {
|
||||
let default = probe
|
||||
.as_ref()
|
||||
.and_then(|p| p.command.clone())
|
||||
.unwrap_or_else(|| cli.name.clone().unwrap_or_default());
|
||||
let command = ask_text(
|
||||
"Command name",
|
||||
&default,
|
||||
required_answer("the command name"),
|
||||
)?;
|
||||
cli.command = Some(if command.is_empty() {
|
||||
cli.name.clone().unwrap_or_default()
|
||||
} else {
|
||||
command
|
||||
});
|
||||
}
|
||||
|
||||
// 10. Maintainer, defaulting to the DEBEMAIL/git-config identity (an
|
||||
// empty answer re-asks).
|
||||
if cli.maintainer.is_none() {
|
||||
let default = crate::changelog::get_maintainer_info()
|
||||
.map(|(name, email)| format!("{name} <{email}>"))
|
||||
.unwrap_or_default();
|
||||
let maintainer = loop {
|
||||
let answer = ask_text("Maintainer", &default, |answer: &str| {
|
||||
options::parse_maintainer(answer).map(|_| ())
|
||||
})?;
|
||||
if !answer.is_empty() {
|
||||
break answer;
|
||||
}
|
||||
log::warn!(
|
||||
"Could not determine a maintainer default (no DEBFULLNAME/\
|
||||
DEBEMAIL and no git user config): answer as 'Name <email>'"
|
||||
);
|
||||
};
|
||||
cli.maintainer = Some(maintainer);
|
||||
}
|
||||
|
||||
// 11. Target distribution.
|
||||
if cli.dist.is_none() {
|
||||
let vendor = crate::build::env::current_vendor().to_lowercase();
|
||||
let options = vec!["ubuntu".to_string(), "debian".to_string()];
|
||||
let default = if options.contains(&vendor) {
|
||||
vendor
|
||||
} else {
|
||||
"ubuntu".to_string()
|
||||
};
|
||||
let answer = select_from("Target distribution", &options, &default, |answer| {
|
||||
answer == "ubuntu" || answer == "debian"
|
||||
})?;
|
||||
cli.dist = Some(answer);
|
||||
}
|
||||
let dist = cli
|
||||
.dist
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::build::env::current_vendor().to_lowercase());
|
||||
|
||||
// 12. Target series: the development series first, preselected.
|
||||
if cli.series.is_none() {
|
||||
match crate::distro_info::get_ordered_series_name(&dist).await {
|
||||
Ok(series) if !series.is_empty() => {
|
||||
let answer = prompt::select("Target series", &series, &series[0])?;
|
||||
cli.series = Some(answer);
|
||||
}
|
||||
_ => {
|
||||
log::warn!(
|
||||
"Could not fetch the series list for '{dist}'; \
|
||||
defaulting to its development series"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Metapackage Depends (empty template only).
|
||||
if template == TemplateId::Empty && cli.depends.is_empty() {
|
||||
let answer = ask_text(
|
||||
"Depends (metapackage, comma-separated, blank for an empty base)",
|
||||
"",
|
||||
|answer: &str| options::validate_depends(answer).map(|_| ()),
|
||||
)?;
|
||||
if !answer.trim().is_empty() {
|
||||
cli.depends = vec![answer];
|
||||
}
|
||||
}
|
||||
|
||||
// 14. Git init.
|
||||
cli.git = prompt::confirm("Initialize a git repository?", cli.git)?;
|
||||
|
||||
// Resolve through the same pipeline as the non-interactive path: one
|
||||
// source of truth for defaults and validation.
|
||||
let mut opts = options::resolve(cli).await?;
|
||||
|
||||
// The meson/cmake opt-in question of the spec's template table: does the
|
||||
// build resolve libraries through pkg-config? The project files prefill
|
||||
// the default (dependency() / pkg_check_modules calls found).
|
||||
if matches!(template, TemplateId::Meson | TemplateId::Cmake)
|
||||
&& prompt::confirm(
|
||||
"Does the build resolve libraries through pkg-config (add it to Build-Depends)?",
|
||||
pkg_config_hint(&detect_dir, template),
|
||||
)?
|
||||
{
|
||||
opts.pkg_config = true;
|
||||
}
|
||||
|
||||
// Wizard-only extras (default off).
|
||||
if template != TemplateId::Empty
|
||||
&& prompt::confirm(
|
||||
"Add an autopkgtest smoke test (debian/tests/control)?",
|
||||
false,
|
||||
)?
|
||||
{
|
||||
opts.autopkgtest = true;
|
||||
}
|
||||
if let Some(watch) = watch_template(opts.homepage.as_deref())
|
||||
&& prompt::confirm("Add a debian/watch release watcher?", false)?
|
||||
{
|
||||
opts.watch = Some(watch);
|
||||
}
|
||||
|
||||
// Summary screen + final confirmation: Ctrl+C or 'n' abort with
|
||||
// nothing written (generation is all-or-nothing later anyway).
|
||||
println!("{}", summary_text(&opts));
|
||||
if !prompt::confirm("Generate?", true)? {
|
||||
return Err("Aborted: nothing was written to disk.".into());
|
||||
}
|
||||
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
/// The post-scaffold verification offers (spec "Verification" steps 2–3),
|
||||
/// interactive only and skipped with `--no-verify`: the source build
|
||||
/// (`pkh build`, offered yes) and the binary build (`pkh deb`, offered no —
|
||||
/// it needs network + build deps). A failed verification build never undoes
|
||||
/// the scaffold: the error is printed together with the manual next steps.
|
||||
pub async fn offer_verification(opts: &NewOptions, multi: &MultiProgress, no_verify: bool) {
|
||||
if no_verify || !is_interactive() {
|
||||
return;
|
||||
}
|
||||
let tree = opts.target_dir(&std::env::current_dir().unwrap_or_default());
|
||||
let display = crate::ui::display_path(&tree);
|
||||
let display = if display.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
display
|
||||
};
|
||||
|
||||
let verify_source = match prompt::confirm("Verify with `pkh build` now?", true) {
|
||||
Ok(answer) => answer,
|
||||
Err(_) => return,
|
||||
};
|
||||
if !verify_source {
|
||||
return;
|
||||
}
|
||||
|
||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
||||
if let Err(e) = crate::build::build_source_package(Some(&tree), ui) {
|
||||
log::error!("Verification source build failed: {e}");
|
||||
log::info!(
|
||||
"The scaffolded tree is intact. Inspect it, then retry with \
|
||||
`cd {display} && pkh build`."
|
||||
);
|
||||
log::info!(
|
||||
"Hint: failures here usually come from a missing build dependency \
|
||||
or build file, not from the scaffold itself; check debian/control \
|
||||
and the template's build file."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let verify_deb = match prompt::confirm(
|
||||
"Verify with `pkh deb` now? (needs network + build deps)",
|
||||
false,
|
||||
) {
|
||||
Ok(answer) => answer,
|
||||
Err(_) => return,
|
||||
};
|
||||
if !verify_deb {
|
||||
return;
|
||||
}
|
||||
|
||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
||||
if let Err(e) = crate::deb::build_binary_package(
|
||||
None,
|
||||
Some(&opts.series),
|
||||
None,
|
||||
Some(&tree),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ui,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Verification binary build failed: {e}");
|
||||
log::info!(
|
||||
"The scaffolded tree is intact. Once the build dependencies are \
|
||||
available, retry with `cd {display} && pkh deb`."
|
||||
);
|
||||
log::info!(
|
||||
"Hint: when a build dependency is missing from the {} archive, \
|
||||
`pkh deb --inject <package>` makes it available in the build \
|
||||
environment (e.g. a PEP 517 backend like python3-poetry-core).",
|
||||
opts.series
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The preselected default of the pkg-config opt-in question: whether the
|
||||
/// project's build file hints at pkg-config usage (`dependency(` in
|
||||
/// meson.build, `pkg_check_modules` / `find_package(PkgConfig` in
|
||||
/// CMakeLists.txt).
|
||||
fn pkg_config_hint(dir: &std::path::Path, template: TemplateId) -> bool {
|
||||
let (file, needles): (&str, &[&str]) = match template {
|
||||
TemplateId::Meson => ("meson.build", &["dependency("]),
|
||||
TemplateId::Cmake => (
|
||||
"CMakeLists.txt",
|
||||
&[
|
||||
"pkg_check_modules",
|
||||
"find_package(pkgconfig",
|
||||
"find_package(pkg_config",
|
||||
],
|
||||
),
|
||||
_ => return false,
|
||||
};
|
||||
std::fs::read_to_string(dir.join(file))
|
||||
.map(|content| {
|
||||
let lower = content.to_ascii_lowercase();
|
||||
needles.iter().any(|needle| lower.contains(needle))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The default package name: the detected project's name, else the
|
||||
/// sanitized basename of the current directory.
|
||||
fn default_package_name(cwd: &std::path::Path, probe: Option<&ProbeResult>) -> String {
|
||||
probe
|
||||
.and_then(|p| p.name.as_deref())
|
||||
.and_then(options::sanitize_name)
|
||||
.or_else(|| {
|
||||
cwd.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.and_then(options::sanitize_name)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The language menu: detected candidates first (in their detection order),
|
||||
/// then every other template in registry order.
|
||||
fn language_menu(candidates: &[TemplateId]) -> Vec<String> {
|
||||
candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.chain(
|
||||
TemplateId::all()
|
||||
.into_iter()
|
||||
.filter(|id| !candidates.contains(id)),
|
||||
)
|
||||
.map(|id| id.display_name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The license menu: the curated SPDX list plus the free-text entry.
|
||||
fn license_menu() -> Vec<String> {
|
||||
KNOWN_LICENSES
|
||||
.iter()
|
||||
.copied()
|
||||
.map(str::to_string)
|
||||
.chain(std::iter::once(LICENSE_OTHER.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The defaults of the license question for a probed SPDX identifier: the
|
||||
/// matching curated entry (case-insensitive) is preselected; anything else
|
||||
/// preselects the free-text entry prefilled with the probe. Without a probe
|
||||
/// the curated list defaults to MIT.
|
||||
fn license_question_default(probe_license: Option<&str>) -> (String, String) {
|
||||
match probe_license {
|
||||
Some(license) => match KNOWN_LICENSES
|
||||
.iter()
|
||||
.find(|k| k.eq_ignore_ascii_case(license))
|
||||
{
|
||||
Some(known) => ((*known).to_string(), String::new()),
|
||||
None => (LICENSE_OTHER.to_string(), license.to_string()),
|
||||
},
|
||||
None => ("MIT".to_string(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the language question until a known template label (or CLI
|
||||
/// identifier) is answered — the selector allows typing arbitrary text.
|
||||
fn select_template(options: &[String], default: &str) -> Result<TemplateId, Box<dyn Error>> {
|
||||
loop {
|
||||
let answer = prompt::select(
|
||||
"Which language/build system is your program using?",
|
||||
options,
|
||||
default,
|
||||
)?;
|
||||
match TemplateId::from_label(&answer) {
|
||||
Some(id) => return Ok(id),
|
||||
None => log::warn!(
|
||||
"'{answer}' is not a known template; pick one from the list \
|
||||
(Tab completes its name)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a `select` question until `accept` holds for the answer (the
|
||||
/// selector allows typing arbitrary text, which callers may need to reject).
|
||||
fn select_from(
|
||||
label: &str,
|
||||
options: &[String],
|
||||
default: &str,
|
||||
accept: impl Fn(&str) -> bool,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
loop {
|
||||
let answer = prompt::select(label, options, default)?;
|
||||
if accept(&answer) {
|
||||
return Ok(answer);
|
||||
}
|
||||
log::warn!("'{answer}' is not one of the offered answers; pick from the list");
|
||||
}
|
||||
}
|
||||
|
||||
/// One free-text question implementing the spec's "Enter accepts the
|
||||
/// default": an empty answer falls back to `default` (`Esc` keeps its
|
||||
/// prompt-level meaning of restoring the default). `validate` only ever
|
||||
/// sees non-empty answers — the empty one is accepted by the prompt loop so
|
||||
/// it can take the default path; callers that require an answer re-check
|
||||
/// the result.
|
||||
fn ask_text(
|
||||
label: &str,
|
||||
default: &str,
|
||||
validate: impl Fn(&str) -> Result<(), String> + 'static,
|
||||
) -> Result<String, Box<dyn Error>> {
|
||||
let accept_empty = move |answer: &str| {
|
||||
if answer.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
validate(answer)
|
||||
}
|
||||
};
|
||||
let answer = prompt::text(label, default, Some(&accept_empty))?;
|
||||
Ok(if answer.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
answer
|
||||
})
|
||||
}
|
||||
|
||||
/// A validator requiring a non-empty answer.
|
||||
fn required_answer(what: &str) -> impl Fn(&str) -> Result<(), String> + '_ {
|
||||
move |answer: &str| {
|
||||
if answer.trim().is_empty() {
|
||||
Err(format!("{what} must not be empty"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validator of the source-directory answer: an existing directory.
|
||||
fn validate_directory_answer(path: &str) -> Result<(), String> {
|
||||
if path.trim().is_empty() {
|
||||
return Err("a directory path is required".to_string());
|
||||
}
|
||||
if std::path::Path::new(path).is_dir() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("'{path}' is not a directory"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Validator of the Debian revision answer: a positive integer.
|
||||
fn validate_revision_answer(answer: &str) -> Result<(), String> {
|
||||
match answer.parse::<u32>() {
|
||||
Ok(0) => Err("the Debian revision must be at least 1".to_string()),
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(format!(
|
||||
"'{answer}' is not a valid Debian revision: expected a positive integer"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `debian/watch` template for GitHub/GitLab-hosted projects; `None` when
|
||||
/// the homepage is not one of those hosts (the wizard skips the question).
|
||||
pub fn watch_template(homepage: Option<&str>) -> Option<String> {
|
||||
let homepage = homepage?;
|
||||
let (scheme, path) = homepage.split_once("://")?;
|
||||
if scheme != "https" && scheme != "http" {
|
||||
return None;
|
||||
}
|
||||
let (host, repo_path) = path.split_once('/')?;
|
||||
let host = host.to_ascii_lowercase();
|
||||
if host != "github.com" && host != "gitlab.com" {
|
||||
return None;
|
||||
}
|
||||
let mut segments = repo_path.trim_end_matches('/').split('/');
|
||||
let owner = segments.next()?.trim_end_matches(".git");
|
||||
let repo = segments.next()?.trim_end_matches(".git");
|
||||
if owner.is_empty() || repo.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"version=4\nhttps://{host}/{owner}/{repo}/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
))
|
||||
}
|
||||
|
||||
/// The summary screen shown before the final `Generate?` confirmation
|
||||
/// (spec transcript): identity line, template/license/maintainer line, the
|
||||
/// generated-file overview and — for a metapackage — the Depends payload,
|
||||
/// for a skeleton — the upstream files that will be created, and for rust —
|
||||
/// a warning when dependencies cannot be vendored on this host.
|
||||
pub fn summary_text(opts: &NewOptions) -> String {
|
||||
let template = templates::get(opts.template);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
lines.push("────────────────────────────────────────────".to_string());
|
||||
lines.push(format!(
|
||||
" {} {} · builds for {}/{}",
|
||||
opts.name,
|
||||
opts.full_version(),
|
||||
opts.dist,
|
||||
opts.series
|
||||
));
|
||||
lines.push(format!(
|
||||
" {} · {} · {} <{}>",
|
||||
opts.template.display_name(),
|
||||
opts.license.spdx(),
|
||||
opts.maintainer.0,
|
||||
opts.maintainer.1
|
||||
));
|
||||
|
||||
if let Some(template) = template {
|
||||
lines.push(format!(
|
||||
" debian/control Source + 1 binary (Architecture: {})",
|
||||
template.architecture(opts)
|
||||
));
|
||||
if opts.template == TemplateId::Empty {
|
||||
// The Depends list is the payload of the metapackage flavor.
|
||||
if !opts.depends.is_empty() {
|
||||
lines.push(format!(" Depends {}", opts.depends.join(", ")));
|
||||
}
|
||||
} else if opts.template == TemplateId::Rust {
|
||||
lines
|
||||
.push(" debian/rules cargo build --release --offline (vendored)".to_string());
|
||||
} else {
|
||||
lines.push(format!(" debian/rules {}", template.rules_dh_line()));
|
||||
}
|
||||
}
|
||||
let distribution = if opts.release {
|
||||
opts.series.as_str()
|
||||
} else {
|
||||
crate::distro_info::UNRELEASED
|
||||
};
|
||||
lines.push(format!(
|
||||
" debian/changelog {} {distribution}, Initial release",
|
||||
opts.full_version()
|
||||
));
|
||||
lines.push(format!(
|
||||
" debian/copyright {} (DEP-5)",
|
||||
opts.license.spdx()
|
||||
));
|
||||
if opts.autopkgtest {
|
||||
lines.push(" debian/tests autopkgtest smoke test".to_string());
|
||||
}
|
||||
if opts.watch.is_some() {
|
||||
lines.push(" debian/watch release watcher".to_string());
|
||||
}
|
||||
if matches!(opts.source_dir, SourceDir::Skeleton)
|
||||
&& let Some(template) = template
|
||||
{
|
||||
let names: Vec<String> = template
|
||||
.skeleton(opts)
|
||||
.iter()
|
||||
.map(|file| file.path.clone())
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
lines.push(format!(" + {} (new skeleton)", names.join(", ")));
|
||||
}
|
||||
}
|
||||
if opts.template == TemplateId::Rust && templates::find_on_path("cargo").is_none() {
|
||||
lines.push(
|
||||
" ! cargo not found on PATH: dependencies cannot be vendored at \
|
||||
scaffold time; the package will not build until you run \
|
||||
`cargo vendor`"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::License;
|
||||
use crate::new::options::TemplateId as Tid;
|
||||
|
||||
fn opts(template: Tid) -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template,
|
||||
source_dir: options::SourceDir::Skeleton,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool that does one thing well".into(),
|
||||
long_description: "A tool that does one thing well".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane Doe".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_package_name_prefers_probe_then_basename() {
|
||||
let probe = ProbeResult {
|
||||
name: Some("My_Tool".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let cwd = std::path::Path::new("/home/user/projects");
|
||||
assert_eq!(
|
||||
default_package_name(cwd, Some(&probe)),
|
||||
"my-tool".to_string()
|
||||
);
|
||||
|
||||
// No probe: the directory basename, sanitized.
|
||||
assert_eq!(
|
||||
default_package_name(std::path::Path::new("/tmp/My Tool"), None),
|
||||
"my-tool".to_string()
|
||||
);
|
||||
|
||||
// Nothing sane anywhere: empty (the validator forces an answer).
|
||||
assert_eq!(default_package_name(std::path::Path::new("/"), None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_menu_lists_candidates_first() {
|
||||
let menu = language_menu(&[Tid::Makefile, Tid::Rust]);
|
||||
assert_eq!(menu[0], "Generic (Makefile)");
|
||||
assert_eq!(menu[1], "Rust (Cargo.toml)");
|
||||
// The remaining seven follow in registry order, no duplicates.
|
||||
assert_eq!(menu.len(), Tid::all().len());
|
||||
let unique: std::collections::HashSet<&String> = menu.iter().collect();
|
||||
assert_eq!(unique.len(), menu.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn license_menu_and_defaults() {
|
||||
let menu = license_menu();
|
||||
assert_eq!(menu.len(), KNOWN_LICENSES.len() + 1);
|
||||
assert_eq!(menu[0], "MIT");
|
||||
assert_eq!(menu.last().unwrap(), LICENSE_OTHER);
|
||||
|
||||
// No probe: MIT preselected, no custom prefill.
|
||||
assert_eq!(
|
||||
license_question_default(None),
|
||||
("MIT".to_string(), String::new())
|
||||
);
|
||||
// Curated probe: matched case-insensitively.
|
||||
assert_eq!(
|
||||
license_question_default(Some("apache-2.0")),
|
||||
("Apache-2.0".to_string(), String::new())
|
||||
);
|
||||
// Unusual probe: free-text entry prefilled.
|
||||
assert_eq!(
|
||||
license_question_default(Some("Zlib")),
|
||||
(LICENSE_OTHER.to_string(), "Zlib".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_template_hosts() {
|
||||
assert_eq!(
|
||||
watch_template(Some("https://github.com/foo/bar")),
|
||||
Some(
|
||||
"version=4\nhttps://github.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
watch_template(Some("https://gitlab.com/foo/bar/")),
|
||||
Some(
|
||||
"version=4\nhttps://gitlab.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
// .git suffixes and deeper paths are handled.
|
||||
assert_eq!(
|
||||
watch_template(Some("https://github.com/foo/bar.git/tree")),
|
||||
Some(
|
||||
"version=4\nhttps://github.com/foo/bar/releases .*/v?@ANY_VERSION@\\.tar\\.gz\n"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
// Other hosts, no homepage, or no repo path: skipped.
|
||||
assert!(watch_template(Some("https://example.com/foo/bar")).is_none());
|
||||
assert!(watch_template(Some("https://github.com/foo")).is_none());
|
||||
assert!(watch_template(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_screen_skeleton() {
|
||||
let text = summary_text(&opts(Tid::Makefile));
|
||||
assert!(
|
||||
text.contains("mytool 0.1.0-1 · builds for ubuntu/resolute"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Generic (Makefile) · MIT · Jane Doe <jane@example.com>"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("debian/control Source + 1 binary (Architecture: any)"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("debian/rules dh $@"), "{text}");
|
||||
assert!(
|
||||
text.contains("debian/changelog 0.1.0-1 UNRELEASED, Initial release"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("debian/copyright MIT (DEP-5)"), "{text}");
|
||||
assert!(
|
||||
text.contains("+ hello.c, Makefile (new skeleton)"),
|
||||
"{text}"
|
||||
);
|
||||
// No extra files unless asked for.
|
||||
assert!(!text.contains("debian/tests"));
|
||||
assert!(!text.contains("debian/watch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_screen_metapackage_shows_depends() {
|
||||
let mut o = opts(Tid::Empty);
|
||||
o.depends = vec!["hello".to_string(), "hello-data (>= 1.0)".to_string()];
|
||||
o.source_dir = options::SourceDir::Here;
|
||||
let text = summary_text(&o);
|
||||
assert!(text.contains("Architecture: all"), "{text}");
|
||||
assert!(
|
||||
text.contains("Depends hello, hello-data (>= 1.0)"),
|
||||
"{text}"
|
||||
);
|
||||
// Build info is replaced by the Depends payload; no skeleton line.
|
||||
assert!(!text.contains("debian/rules"), "{text}");
|
||||
assert!(!text.contains("(new skeleton)"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_screen_release_and_extras() {
|
||||
let mut o = opts(Tid::Shell);
|
||||
o.release = true;
|
||||
o.autopkgtest = true;
|
||||
o.watch = Some("version=4\n".to_string());
|
||||
let text = summary_text(&o);
|
||||
assert!(text.contains("0.1.0-1 resolute, Initial release"), "{text}");
|
||||
assert!(
|
||||
text.contains("debian/tests autopkgtest smoke test"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("debian/watch release watcher"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_validators() {
|
||||
assert!(validate_revision_answer("1").is_ok());
|
||||
assert!(validate_revision_answer("0").is_err());
|
||||
assert!(validate_revision_answer("x").is_err());
|
||||
|
||||
assert!(validate_directory_answer("/tmp").is_ok());
|
||||
assert!(validate_directory_answer("").is_err());
|
||||
assert!(validate_directory_answer("/definitely/not/here").is_err());
|
||||
|
||||
assert!(required_answer("x")("").is_err());
|
||||
assert!(required_answer("x")("ok").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! The `autotools` template: a C project with a `configure.ac` built through
|
||||
//! debhelper's auto-detection (dh runs `autoreconf` itself when it finds
|
||||
//! `configure.ac`, debhelper ≥ 10 — no override needed).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::meson::hello_c;
|
||||
use super::{OutputFile, ProbeResult, Template, source_dir_of};
|
||||
use crate::new::options::{NewOptions, TemplateId};
|
||||
|
||||
/// C/C++ with Autotools (`configure.ac`).
|
||||
pub struct Autotools;
|
||||
|
||||
impl Template for Autotools {
|
||||
fn id(&self) -> TemplateId {
|
||||
TemplateId::Autotools
|
||||
}
|
||||
|
||||
/// A minimal `configure.ac`, the matching `Makefile.am` and `hello.c`.
|
||||
/// The first source build runs `autoreconf` (integrated in the dh
|
||||
/// sequence), so no generated configure script is committed.
|
||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
vec![
|
||||
OutputFile::new(
|
||||
"configure.ac",
|
||||
format!(
|
||||
"AC_INIT([{name}], [{version}])\n\
|
||||
AM_INIT_AUTOMAKE([foreign])\n\
|
||||
AC_PROG_CC\n\
|
||||
AC_CONFIG_FILES([Makefile])\n\
|
||||
AC_OUTPUT\n",
|
||||
name = opts.name,
|
||||
version = opts.upstream_version,
|
||||
),
|
||||
),
|
||||
OutputFile::new(
|
||||
"Makefile.am",
|
||||
format!(
|
||||
"bin_PROGRAMS = {command}\n\
|
||||
{command}_SOURCES = hello.c\n",
|
||||
command = opts.command,
|
||||
),
|
||||
),
|
||||
hello_c(opts),
|
||||
]
|
||||
}
|
||||
|
||||
/// No extra debian/ files: plain `dh $@` auto-detects `configure.ac`.
|
||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||
let mut deps = vec![
|
||||
"autoconf".to_string(),
|
||||
"automake".to_string(),
|
||||
"libtool".to_string(),
|
||||
];
|
||||
if uses_gettext(opts) {
|
||||
deps.push("gettext".to_string());
|
||||
}
|
||||
deps
|
||||
}
|
||||
|
||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||
"any"
|
||||
}
|
||||
|
||||
/// Package name and version from the `AC_INIT` macro of `configure.ac`.
|
||||
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||
let content = std::fs::read_to_string(dir.join("configure.ac")).ok()?;
|
||||
static AC_INIT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
// AC_INIT([name], [version]) — the autoconf quotes are optional.
|
||||
let regex = AC_INIT_REGEX.get_or_init(|| {
|
||||
Regex::new(
|
||||
r"AC_INIT\s*\(\s*(?:\[([^\]]*)\]|([^,\s\[]+))\s*,\s*(?:\[([^\]]*)\]|([^,\s\[]+))",
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let caps = regex.captures(&content)?;
|
||||
let value = |bracketed: usize, bare: usize| {
|
||||
caps.get(bracketed)
|
||||
.or_else(|| caps.get(bare))
|
||||
.map(|v| v.as_str().trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
};
|
||||
let name = value(1, 2)?;
|
||||
let version = value(3, 4);
|
||||
Some(ProbeResult {
|
||||
name: Some(name),
|
||||
version,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the packaged `configure.ac` sets up GNU gettext (`AM_GNU_GETTEXT`
|
||||
/// macro): those builds need the `gettext` package. Only meaningful when
|
||||
/// packaging an existing tree (the generated skeleton carries no gettext).
|
||||
fn uses_gettext(opts: &NewOptions) -> bool {
|
||||
source_dir_of(opts).is_some_and(|dir| {
|
||||
std::fs::read_to_string(dir.join("configure.ac"))
|
||||
.is_ok_and(|content| content.contains("AM_GNU_GETTEXT"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts() -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Autotools,
|
||||
source_dir: SourceDir::Skeleton,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotools_template_shape() {
|
||||
let o = opts();
|
||||
let template = super::super::get(TemplateId::Autotools).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(&o), "any");
|
||||
assert_eq!(
|
||||
template.build_depends(&o),
|
||||
vec![
|
||||
"autoconf".to_string(),
|
||||
"automake".to_string(),
|
||||
"libtool".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(template.rules_dh_line(), "dh $@");
|
||||
assert!(template.rules_extra(&o).is_empty());
|
||||
assert!(template.debian(&o).is_empty());
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
let configure = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "configure.ac")
|
||||
.expect("configure.ac skeleton");
|
||||
assert!(
|
||||
configure
|
||||
.contents
|
||||
.starts_with("AC_INIT([mytool], [0.1.0])\n")
|
||||
);
|
||||
let makefile_am = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "Makefile.am")
|
||||
.expect("Makefile.am skeleton");
|
||||
assert!(makefile_am.contents.contains("bin_PROGRAMS = mytool"));
|
||||
assert!(makefile_am.contents.contains("mytool_SOURCES = hello.c"));
|
||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotools_probe_reads_ac_init() {
|
||||
let template = super::super::get(TemplateId::Autotools).unwrap();
|
||||
|
||||
// Bracketed form (the generated skeleton's own shape).
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("configure.ac"),
|
||||
"AC_INIT([mytool], [0.1.0])\nAM_INIT_AUTOMAKE([foreign])\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||
assert_eq!(probe.version.as_deref(), Some("0.1.0"));
|
||||
|
||||
// Bare form with a bug-report address as the third argument.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("configure.ac"),
|
||||
"AC_INIT(mytool, 1.2.3, bugs@example.com)\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||
|
||||
// Name with spaces inside the brackets.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("configure.ac"),
|
||||
"AC_INIT([My Tool], [2.0])\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("My Tool"));
|
||||
|
||||
// No configure.ac: silent None.
|
||||
let dir = tempdir().unwrap();
|
||||
assert!(template.probe(dir.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gettext_detected_in_configure_ac() {
|
||||
// Skeleton: the generated configure.ac has no gettext.
|
||||
let o = opts();
|
||||
assert!(!uses_gettext(&o));
|
||||
assert!(
|
||||
!super::super::get(TemplateId::Autotools)
|
||||
.unwrap()
|
||||
.build_depends(&o)
|
||||
.contains(&"gettext".to_string())
|
||||
);
|
||||
|
||||
// Existing tree with AM_GNU_GETTEXT: gettext joins Build-Depends.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("configure.ac"),
|
||||
"AC_INIT([mytool], [0.1.0])\nAM_GNU_GETTEXT([external])\n",
|
||||
)
|
||||
.unwrap();
|
||||
let o = NewOptions {
|
||||
source_dir: SourceDir::Path(dir.path().to_path_buf()),
|
||||
..opts()
|
||||
};
|
||||
assert!(uses_gettext(&o));
|
||||
assert!(
|
||||
super::super::get(TemplateId::Autotools)
|
||||
.unwrap()
|
||||
.build_depends(&o)
|
||||
.contains(&"gettext".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The `cmake` template: a C/C++ project built with CMake through the
|
||||
//! debhelper cmake buildsystem.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::meson::hello_c;
|
||||
use super::{OutputFile, ProbeResult, Template};
|
||||
use crate::new::options::{NewOptions, TemplateId};
|
||||
|
||||
/// C/C++ with CMake (`CMakeLists.txt`).
|
||||
pub struct Cmake;
|
||||
|
||||
impl Template for Cmake {
|
||||
fn id(&self) -> TemplateId {
|
||||
TemplateId::Cmake
|
||||
}
|
||||
|
||||
/// A minimal `CMakeLists.txt` (project declaration + one installed
|
||||
/// executable) and the classic `hello.c`.
|
||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
vec![
|
||||
OutputFile::new(
|
||||
"CMakeLists.txt",
|
||||
format!(
|
||||
"cmake_minimum_required(VERSION 3.16)\n\
|
||||
project({name} VERSION {version})\n\
|
||||
\n\
|
||||
add_executable({command} hello.c)\n\
|
||||
install(TARGETS {command} RUNTIME DESTINATION bin)\n",
|
||||
name = opts.name,
|
||||
version = opts.upstream_version,
|
||||
command = opts.command,
|
||||
),
|
||||
),
|
||||
hello_c(opts),
|
||||
]
|
||||
}
|
||||
|
||||
/// No extra debian/ files: debhelper's cmake buildsystem handles the
|
||||
/// configure/build/install steps.
|
||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||
let mut deps = vec!["cmake".to_string()];
|
||||
if opts.pkg_config {
|
||||
deps.push("pkg-config".to_string());
|
||||
}
|
||||
deps
|
||||
}
|
||||
|
||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||
"any"
|
||||
}
|
||||
|
||||
fn rules_dh_line(&self) -> String {
|
||||
"dh $@ --buildsystem=cmake".to_string()
|
||||
}
|
||||
|
||||
/// Project name and version from the `project(<name> VERSION …)`
|
||||
/// declaration.
|
||||
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||
let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?;
|
||||
static PROJECT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let regex = PROJECT_REGEX.get_or_init(|| {
|
||||
Regex::new(r"(?im)^\s*project\s*\(\s*([A-Za-z0-9_][A-Za-z0-9_.\-]*)(?:\s+VERSION\s+([0-9][^\s)]*))?").unwrap()
|
||||
});
|
||||
let caps = regex.captures(&content)?;
|
||||
let version = caps
|
||||
.get(2)
|
||||
.map(|v| v.as_str().trim_end_matches('.').to_string())
|
||||
.filter(|v| !v.is_empty());
|
||||
Some(ProbeResult {
|
||||
name: Some(caps[1].to_string()),
|
||||
version,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts() -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Cmake,
|
||||
source_dir: SourceDir::Skeleton,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_template_shape() {
|
||||
let o = opts();
|
||||
let template = super::super::get(TemplateId::Cmake).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(&o), "any");
|
||||
assert_eq!(template.build_depends(&o), vec!["cmake".to_string()]);
|
||||
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=cmake");
|
||||
assert!(template.rules_extra(&o).is_empty());
|
||||
assert!(template.debian(&o).is_empty());
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
let cmakelists = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "CMakeLists.txt")
|
||||
.expect("CMakeLists.txt skeleton");
|
||||
assert!(
|
||||
cmakelists
|
||||
.contents
|
||||
.contains("project(mytool VERSION 0.1.0)")
|
||||
);
|
||||
assert!(
|
||||
cmakelists
|
||||
.contents
|
||||
.contains("add_executable(mytool hello.c)")
|
||||
);
|
||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_probe_reads_project_declaration() {
|
||||
let template = super::super::get(TemplateId::Cmake).unwrap();
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("CMakeLists.txt"),
|
||||
"cmake_minimum_required(VERSION 3.16)\n\
|
||||
project(mytool VERSION 1.2.3 LANGUAGES C)\n\
|
||||
add_executable(mytool hello.c)\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||
|
||||
// Lowercase keyword, name without version.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("CMakeLists.txt"),
|
||||
"project( just_a_name LANGUAGES CXX )\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("just_a_name"));
|
||||
assert_eq!(probe.version, None);
|
||||
|
||||
// No CMakeLists.txt: silent None.
|
||||
let dir = tempdir().unwrap();
|
||||
assert!(template.probe(dir.path()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,9 @@ mod tests {
|
||||
depends,
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +75,9 @@ mod tests {
|
||||
// Metapackage flavor: the depends list travels in the options.
|
||||
let o = opts(vec!["hello".into(), "hello-data (>= 1.0)".into()]);
|
||||
assert!(template.debian(&o).is_empty());
|
||||
assert_eq!(template.architecture(), "all");
|
||||
assert_eq!(template.architecture(&o), "all");
|
||||
assert!(template.build_depends(&o).is_empty());
|
||||
assert!(template.rules_extra(&o).is_empty());
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
assert_eq!(skeleton.len(), 1);
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! The `go` template: a Go module built through dh-golang.
|
||||
//!
|
||||
//! The source stanza carries `XS-Go-Import-Path`, probed from the `module`
|
||||
//! line of `go.mod` when the packaged tree has one, defaulting to the
|
||||
//! package name (fresh skeletons embed the package name in their own
|
||||
//! `go.mod`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{OutputFile, Template, source_dir_of};
|
||||
use crate::new::options::{NewOptions, TemplateId};
|
||||
|
||||
/// Go module (`go.mod`).
|
||||
pub struct Go;
|
||||
|
||||
impl Template for Go {
|
||||
fn id(&self) -> TemplateId {
|
||||
TemplateId::Go
|
||||
}
|
||||
|
||||
/// A stdlib-only `main.go` (no archive dependencies needed to build) and
|
||||
/// the matching `go.mod` whose module path is the package name.
|
||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
vec![
|
||||
OutputFile::new(
|
||||
"go.mod",
|
||||
format!(
|
||||
"module {name}\n\
|
||||
\n\
|
||||
go 1.21\n",
|
||||
name = opts.name,
|
||||
),
|
||||
),
|
||||
OutputFile::new(
|
||||
"main.go",
|
||||
format!(
|
||||
"// Placeholder for {name}, generated by `pkh new`.\n\
|
||||
package main\n\
|
||||
\n\
|
||||
import \"fmt\"\n\
|
||||
\n\
|
||||
func main() {{\n\
|
||||
\tfmt.Println(\"Hello from {command}!\")\n\
|
||||
}}\n",
|
||||
name = opts.name,
|
||||
command = opts.command,
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// No extra debian/ files: dh-golang drives the build.
|
||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
||||
vec!["golang-any".to_string(), "dh-golang".to_string()]
|
||||
}
|
||||
|
||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||
"any"
|
||||
}
|
||||
|
||||
fn rules_dh_line(&self) -> String {
|
||||
"dh $@ --buildsystem=golang".to_string()
|
||||
}
|
||||
|
||||
fn source_fields(&self, opts: &NewOptions) -> Vec<(String, String)> {
|
||||
vec![("XS-Go-Import-Path".to_string(), import_path(opts))]
|
||||
}
|
||||
|
||||
/// Name (and default command) from the `module` line of `go.mod`: the
|
||||
/// last path segment is the conventional binary/package name.
|
||||
fn probe(&self, dir: &Path) -> Option<super::ProbeResult> {
|
||||
let module = read_module_line(dir)?;
|
||||
let name = module.rsplit('/').next()?.to_string();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(super::ProbeResult {
|
||||
name: Some(name.clone()),
|
||||
command: Some(name),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The `XS-Go-Import-Path` value: probed from `go.mod` when the packaged
|
||||
/// tree has one, the package name otherwise (skeleton mode embeds the name
|
||||
/// in the generated `go.mod` anyway).
|
||||
fn import_path(opts: &NewOptions) -> String {
|
||||
source_dir_of(opts)
|
||||
.as_deref()
|
||||
.and_then(read_module_line)
|
||||
.unwrap_or_else(|| opts.name.clone())
|
||||
}
|
||||
|
||||
/// The `module <path>` line of `dir/go.mod`, when present.
|
||||
fn read_module_line(dir: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(dir.join("go.mod")).ok()?;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(rest) = line.strip_prefix("module ") {
|
||||
let module = rest.trim();
|
||||
if !module.is_empty() {
|
||||
return Some(module.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Go,
|
||||
source_dir,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_template_shape() {
|
||||
let o = opts(SourceDir::Skeleton);
|
||||
let template = super::super::get(TemplateId::Go).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(&o), "any");
|
||||
assert_eq!(
|
||||
template.build_depends(&o),
|
||||
vec!["golang-any".to_string(), "dh-golang".to_string()]
|
||||
);
|
||||
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=golang");
|
||||
assert!(template.debian(&o).is_empty());
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
assert!(
|
||||
skeleton
|
||||
.iter()
|
||||
.any(|f| f.path == "go.mod" && f.contents.starts_with("module mytool\n"))
|
||||
);
|
||||
assert!(skeleton.iter().any(|f| f.path == "main.go"));
|
||||
|
||||
// Skeleton mode: no go.mod to probe, the import path is the name.
|
||||
assert_eq!(
|
||||
template.source_fields(&o),
|
||||
vec![("XS-Go-Import-Path".to_string(), "mytool".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_probe_reads_module_line() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("go.mod"),
|
||||
"module example.com/org/mytool\n\ngo 1.21\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let template = super::super::get(TemplateId::Go).unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||
assert_eq!(probe.command.as_deref(), Some("mytool"));
|
||||
|
||||
// The probed module line wins over the package name for the import
|
||||
// path when packaging an existing tree.
|
||||
let o = opts(SourceDir::Path(dir.path().to_path_buf()));
|
||||
assert_eq!(
|
||||
template.source_fields(&o),
|
||||
vec![(
|
||||
"XS-Go-Import-Path".to_string(),
|
||||
"example.com/org/mytool".to_string()
|
||||
)]
|
||||
);
|
||||
|
||||
// No go.mod: no probe result.
|
||||
let empty = tempdir().unwrap();
|
||||
assert!(template.probe(empty.path()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! The `makefile` template: a generic project driven by a plain `Makefile`.
|
||||
//!
|
||||
//! debhelper's makefile buildsystem runs `make` for the build and
|
||||
//! `make install DESTDIR=...` when the Makefile carries an `install:` target
|
||||
//! (missing targets are skipped gracefully), so plain `dh $@` plumbing is
|
||||
//! enough here.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{OutputFile, Template, source_dir_of};
|
||||
use crate::new::options::{NewOptions, SourceDir, TemplateId};
|
||||
|
||||
/// Generic Makefile-based project.
|
||||
pub struct Makefile;
|
||||
|
||||
impl Template for Makefile {
|
||||
fn id(&self) -> TemplateId {
|
||||
TemplateId::Makefile
|
||||
}
|
||||
|
||||
/// A `hello.c` plus a `Makefile` with `all`/`install`/`clean` targets;
|
||||
/// `install` honors `DESTDIR` and copies the binary to
|
||||
/// `$(DESTDIR)/usr/bin/`.
|
||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
vec![
|
||||
super::meson::hello_c(opts),
|
||||
OutputFile::new(
|
||||
"Makefile",
|
||||
format!(
|
||||
"CC ?= cc\n\
|
||||
CFLAGS ?= -O2 -Wall -Wextra\n\
|
||||
PREFIX ?= /usr\n\
|
||||
\n\
|
||||
all: {command}\n\
|
||||
\n\
|
||||
{command}: hello.c\n\
|
||||
\t$(CC) $(CFLAGS) -o $@ hello.c\n\
|
||||
\n\
|
||||
install: {command}\n\
|
||||
\tinstall -Dm755 {command} $(DESTDIR)$(PREFIX)/bin/{command}\n\
|
||||
\n\
|
||||
clean:\n\
|
||||
\trm -f {command}\n\
|
||||
\n\
|
||||
.PHONY: all install clean\n",
|
||||
command = opts.command,
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `debian/install` mapping the built binary into `/usr/bin`, generated
|
||||
/// only when the packaged Makefile carries a phony `install:` target:
|
||||
/// for skeletons that is known by construction; when packaging an
|
||||
/// existing tree the Makefile is probed instead (no `debian/install` is
|
||||
/// emitted there — the source-relative mapping of an unknown artifact is
|
||||
/// only the project's to write, and `make install` already ran).
|
||||
fn debian(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
match &opts.source_dir {
|
||||
SourceDir::Skeleton => {
|
||||
vec![OutputFile::new(
|
||||
"debian/install",
|
||||
format!("{} usr/bin/{}\n", opts.command, opts.command),
|
||||
)]
|
||||
}
|
||||
_ => {
|
||||
if let Some(dir) = source_dir_of(opts)
|
||||
&& phony_install_target(&dir.join("Makefile")).is_some()
|
||||
{
|
||||
log::info!(
|
||||
"Makefile carries a phony 'install:' target: \
|
||||
dh_auto_install will run 'make install DESTDIR=...'"
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Makefile has no phony 'install:' target: \
|
||||
dh_auto_install will skip the install step; write a \
|
||||
debian/install file to map build artifacts manually"
|
||||
);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
||||
vec!["build-essential".to_string()]
|
||||
}
|
||||
|
||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||
"any"
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the phony `install:` target of the Makefile at `path`, when
|
||||
/// there is one: an unindented `install:` rule whose name also appears in a
|
||||
/// `.PHONY:` declaration. `None` when the file is missing or carries no such
|
||||
/// target. (A deliberately minimal line-oriented heuristic, like the other
|
||||
/// project-file readers of the templates.)
|
||||
pub fn phony_install_target(path: &Path) -> Option<String> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let mut has_install_rule = false;
|
||||
let mut phony_mentions_install = false;
|
||||
for line in content.lines() {
|
||||
if let Some(phony) = line.strip_prefix(".PHONY:")
|
||||
&& phony.split_whitespace().any(|target| target == "install")
|
||||
{
|
||||
phony_mentions_install = true;
|
||||
}
|
||||
// Target rules start at column 0; recipes are indented, and special
|
||||
// targets, comments and directives are excluded by the prefix check.
|
||||
if !line.starts_with(['\t', ' ', '.', '#']) && line.starts_with("install:") {
|
||||
has_install_rule = true;
|
||||
}
|
||||
}
|
||||
(has_install_rule && phony_mentions_install).then(|| "install".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Makefile,
|
||||
source_dir,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn makefile_template_shape() {
|
||||
let o = opts(SourceDir::Skeleton);
|
||||
let template = super::super::get(TemplateId::Makefile).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(&o), "any");
|
||||
assert_eq!(
|
||||
template.build_depends(&o),
|
||||
vec!["build-essential".to_string()]
|
||||
);
|
||||
assert_eq!(template.rules_dh_line(), "dh $@");
|
||||
assert!(template.rules_extra(&o).is_empty());
|
||||
|
||||
// Skeleton: hello.c + Makefile with all/install/clean, and the
|
||||
// phony install target maps to debian/install.
|
||||
let skeleton = template.skeleton(&o);
|
||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||
let makefile = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "Makefile")
|
||||
.expect("Makefile skeleton");
|
||||
assert!(makefile.contents.contains("all: mytool\n"));
|
||||
assert!(
|
||||
makefile
|
||||
.contents
|
||||
.contains("install -Dm755 mytool $(DESTDIR)$(PREFIX)/bin/mytool")
|
||||
);
|
||||
assert!(makefile.contents.contains(".PHONY: all install clean"));
|
||||
assert!(
|
||||
makefile
|
||||
.contents
|
||||
.contains("\t$(CC) $(CFLAGS) -o $@ hello.c")
|
||||
);
|
||||
|
||||
let debian = template.debian(&o);
|
||||
assert_eq!(debian.len(), 1);
|
||||
assert_eq!(debian[0].path, "debian/install");
|
||||
assert_eq!(debian[0].contents, "mytool usr/bin/mytool\n");
|
||||
|
||||
// Existing tree: nothing is emitted (probe log only).
|
||||
let o = opts(SourceDir::Here);
|
||||
assert!(template.debian(&o).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phony_install_target_detection() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("Makefile");
|
||||
|
||||
// Phony install target: detected.
|
||||
std::fs::write(
|
||||
&path,
|
||||
"all:\n\t@echo\n\n.PHONY: all install clean\ninstall:\n\t@echo install\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(phony_install_target(&path).is_some());
|
||||
|
||||
// install: without .PHONY: not detected.
|
||||
std::fs::write(&path, "all:\n\t@echo\ninstall:\n\t@echo install\n").unwrap();
|
||||
assert!(phony_install_target(&path).is_none());
|
||||
|
||||
// .PHONY mentioning install but no install: rule: not detected.
|
||||
std::fs::write(&path, ".PHONY: install\nall:\n\t@echo\n").unwrap();
|
||||
assert!(phony_install_target(&path).is_none());
|
||||
|
||||
// Missing file.
|
||||
assert!(phony_install_target(&dir.path().join("missing.mk")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! The `meson` template: a C/C++ project built with Meson through the
|
||||
//! debhelper meson buildsystem.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::{OutputFile, ProbeResult, Template};
|
||||
use crate::new::options::{NewOptions, TemplateId};
|
||||
|
||||
/// C/C++ with Meson (`meson.build`).
|
||||
pub struct Meson;
|
||||
|
||||
impl Template for Meson {
|
||||
fn id(&self) -> TemplateId {
|
||||
TemplateId::Meson
|
||||
}
|
||||
|
||||
/// A minimal `meson.build` (project declaration + one installed
|
||||
/// executable) and the classic `hello.c`.
|
||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
vec![
|
||||
OutputFile::new(
|
||||
"meson.build",
|
||||
format!(
|
||||
"project('{name}', version: '{version}', license: '{license}', \
|
||||
default_options: ['c_std=c11'])\n\
|
||||
\n\
|
||||
executable('{command}', 'hello.c', install: true)\n",
|
||||
name = opts.name,
|
||||
version = opts.upstream_version,
|
||||
license = opts.license.spdx(),
|
||||
command = opts.command,
|
||||
),
|
||||
),
|
||||
hello_c(opts),
|
||||
]
|
||||
}
|
||||
|
||||
/// No extra debian/ files: debhelper's meson buildsystem handles the
|
||||
/// configure/build/install steps.
|
||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||
let mut deps = vec!["meson".to_string()];
|
||||
if opts.pkg_config {
|
||||
deps.push("pkg-config".to_string());
|
||||
}
|
||||
deps
|
||||
}
|
||||
|
||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||
"any"
|
||||
}
|
||||
|
||||
fn rules_dh_line(&self) -> String {
|
||||
"dh $@ --buildsystem=meson".to_string()
|
||||
}
|
||||
|
||||
/// Project name and version from the `project('name', version: …)`
|
||||
/// declaration.
|
||||
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||
let content = std::fs::read_to_string(dir.join("meson.build")).ok()?;
|
||||
static PROJECT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let regex = PROJECT_REGEX.get_or_init(|| {
|
||||
Regex::new(r"(?m)^\s*project\(\s*'([^']+)'\s*(?:,\s*version\s*:\s*'([^']+)')?").unwrap()
|
||||
});
|
||||
let caps = regex.captures(&content)?;
|
||||
Some(ProbeResult {
|
||||
name: Some(caps[1].to_string()),
|
||||
version: caps.get(2).map(|v| v.as_str().to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared `hello.c` placeholder of the C/C++ skeletons.
|
||||
pub(super) fn hello_c(opts: &NewOptions) -> OutputFile {
|
||||
OutputFile::new(
|
||||
"hello.c",
|
||||
format!(
|
||||
"#include <stdio.h>\n\
|
||||
\n\
|
||||
/* Placeholder for {name}, generated by `pkh new`. */\n\
|
||||
int main(void)\n\
|
||||
{{\n\
|
||||
\tprintf(\"Hello from {command}!\\n\");\n\
|
||||
\treturn 0;\n\
|
||||
}}\n",
|
||||
name = opts.name,
|
||||
command = opts.command,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts() -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Meson,
|
||||
source_dir: SourceDir::Skeleton,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meson_template_shape() {
|
||||
let o = opts();
|
||||
let template = super::super::get(TemplateId::Meson).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(&o), "any");
|
||||
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
||||
assert_eq!(template.rules_dh_line(), "dh $@ --buildsystem=meson");
|
||||
assert!(template.rules_extra(&o).is_empty());
|
||||
assert!(template.debian(&o).is_empty());
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
let meson_build = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "meson.build")
|
||||
.expect("meson.build skeleton");
|
||||
assert!(meson_build.contents.contains("project('mytool'"));
|
||||
assert!(meson_build.contents.contains("version: '0.1.0'"));
|
||||
assert!(
|
||||
meson_build
|
||||
.contents
|
||||
.contains("executable('mytool', 'hello.c', install: true)")
|
||||
);
|
||||
assert!(skeleton.iter().any(|f| f.path == "hello.c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meson_probe_reads_project_declaration() {
|
||||
let template = super::super::get(TemplateId::Meson).unwrap();
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("meson.build"),
|
||||
"project('mytool', version: '1.2.3', license: 'MIT', default_options: ['c_std=c11'])\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||
|
||||
// Version is optional in project().
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("meson.build"), "project('just-a-name')\n").unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("just-a-name"));
|
||||
assert_eq!(probe.version, None);
|
||||
|
||||
// No meson.build: silent None.
|
||||
let dir = tempdir().unwrap();
|
||||
assert!(template.probe(dir.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pkg_config_opt_in_extends_build_depends() {
|
||||
let template = super::super::get(TemplateId::Meson).unwrap();
|
||||
let mut o = opts();
|
||||
assert_eq!(template.build_depends(&o), vec!["meson".to_string()]);
|
||||
o.pkg_config = true;
|
||||
assert_eq!(
|
||||
template.build_depends(&o),
|
||||
vec!["meson".to_string(), "pkg-config".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
+261
-37
@@ -1,16 +1,25 @@
|
||||
//! Per-ecosystem template registry for `pkh new`.
|
||||
//!
|
||||
//! Every template implements [`Template`]: it renders the upstream-side
|
||||
//! skeleton files, the extra `debian/` files beyond the common set, and (in
|
||||
//! the future) probes an existing project for metadata. Rendering is plain
|
||||
//! `format!` composition — no template engine, matching the codebase style.
|
||||
//! skeleton files, the extra `debian/` files beyond the common set, probes an
|
||||
//! existing project for metadata used to pre-fill the wizard answers, and
|
||||
//! describes its Build-Depends / architecture / `debian/rules` shape.
|
||||
//! Rendering is plain `format!` composition — no template engine, matching
|
||||
//! the codebase style.
|
||||
|
||||
pub mod autotools;
|
||||
pub mod cmake;
|
||||
pub mod empty;
|
||||
pub mod go;
|
||||
pub mod makefile;
|
||||
pub mod meson;
|
||||
pub mod python;
|
||||
pub mod rust;
|
||||
pub mod shell;
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::options::{NewOptions, TemplateId};
|
||||
use super::options::{NewOptions, SourceDir, TemplateId};
|
||||
|
||||
/// One generated file, rendered in memory before anything touches the disk.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -44,8 +53,8 @@ impl OutputFile {
|
||||
|
||||
/// Metadata extracted from an existing project by [`Template::probe`], used
|
||||
/// by the interactive wizard to pre-fill its answers (explicit flags always
|
||||
/// win). The per-template extraction is follow-up work; the hook already
|
||||
/// exists so templates can grow it independently.
|
||||
/// win). Every field is optional; probe failures are silent and the generic
|
||||
/// defaults apply.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProbeResult {
|
||||
/// Project name (e.g. the `name` key of `Cargo.toml`).
|
||||
@@ -58,6 +67,9 @@ pub struct ProbeResult {
|
||||
pub homepage: Option<String>,
|
||||
/// Project license (SPDX identifier).
|
||||
pub license: Option<String>,
|
||||
/// Installed command / binary name (e.g. the first `[[bin]]` target or
|
||||
/// console script).
|
||||
pub command: Option<String>,
|
||||
}
|
||||
|
||||
/// A package template: one supported ecosystem / build system.
|
||||
@@ -81,45 +93,87 @@ pub trait Template: Sync {
|
||||
}
|
||||
|
||||
/// Architecture of the binary package (`all` or `any`).
|
||||
fn architecture(&self) -> &'static str {
|
||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||
"all"
|
||||
}
|
||||
|
||||
/// Lines appended to `debian/rules` after the default `dh $@` stanza.
|
||||
fn rules_extra(&self) -> String {
|
||||
/// The `dh` invocation (without leading tab) used by the `%:` target of
|
||||
/// `debian/rules`. Templates needing more than the plain `dh $@` spell
|
||||
/// their buildsystem/sequencer options here so the generated rules stay
|
||||
/// valid make.
|
||||
fn rules_dh_line(&self) -> String {
|
||||
"dh $@".to_string()
|
||||
}
|
||||
|
||||
/// Lines appended to `debian/rules` after the default `dh $@` stanza
|
||||
/// (e.g. `override_dh_*` targets). Must use tabs for recipe lines.
|
||||
fn rules_extra(&self, _opts: &NewOptions) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Extra `debian/control` source-stanza fields beyond the common set
|
||||
/// (e.g. `XS-Go-Import-Path`).
|
||||
fn source_fields(&self, _opts: &NewOptions) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Extra defaults derived from project metadata in `dir` (detect.rs);
|
||||
/// `None` when the project carries nothing this template can read.
|
||||
fn probe(&self, _dir: &Path) -> Option<ProbeResult> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Hook run after the generated files have been written to `tree` and
|
||||
/// before the orig tarball is created, for templates that need to run
|
||||
/// host tooling over the freshly written tree (e.g. `cargo vendor`, so
|
||||
/// the vendored sources land inside the tarball).
|
||||
fn post_write(
|
||||
&self,
|
||||
_opts: &NewOptions,
|
||||
_tree: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Static instance of the shell template.
|
||||
pub static SHELL: shell::Shell = shell::Shell;
|
||||
/// Static instance of the empty/metapackage template.
|
||||
pub static EMPTY: empty::Empty = empty::Empty;
|
||||
/// Static instance of the makefile template.
|
||||
pub static MAKEFILE: makefile::Makefile = makefile::Makefile;
|
||||
/// Static instance of the python template.
|
||||
pub static PYTHON: python::Python = python::Python;
|
||||
/// Static instance of the meson template.
|
||||
pub static MESON: meson::Meson = meson::Meson;
|
||||
/// Static instance of the cmake template.
|
||||
pub static CMAKE: cmake::Cmake = cmake::Cmake;
|
||||
/// Static instance of the autotools template.
|
||||
pub static AUTOTOOLS: autotools::Autotools = autotools::Autotools;
|
||||
/// Static instance of the go template.
|
||||
pub static GO: go::Go = go::Go;
|
||||
/// Static instance of the rust template.
|
||||
pub static RUST: rust::Rust = rust::Rust;
|
||||
|
||||
/// Every implemented template (the wizard language menu lists
|
||||
/// [`TemplateId::all()`] and greys the rest out).
|
||||
static TEMPLATES: &[&dyn Template] = &[&SHELL, &EMPTY];
|
||||
/// Every implemented template.
|
||||
static TEMPLATES: &[&dyn Template] = &[
|
||||
&SHELL, &EMPTY, &MAKEFILE, &PYTHON, &MESON, &CMAKE, &AUTOTOOLS, &GO, &RUST,
|
||||
];
|
||||
|
||||
/// Look up the template implementation for `id`; `None` for the ids whose
|
||||
/// template is not implemented yet (callers turn this into the friendly
|
||||
/// "not implemented yet" error).
|
||||
/// Look up the template implementation for `id`; `None` only if a
|
||||
/// [`TemplateId`] ever grows without a registered template (callers turn
|
||||
/// this into a friendly error instead of panicking).
|
||||
pub fn get(id: TemplateId) -> Option<&'static dyn Template> {
|
||||
match id {
|
||||
TemplateId::Shell => Some(&SHELL),
|
||||
TemplateId::Empty => Some(&EMPTY),
|
||||
TemplateId::Rust
|
||||
| TemplateId::Python
|
||||
| TemplateId::Meson
|
||||
| TemplateId::Cmake
|
||||
| TemplateId::Autotools
|
||||
| TemplateId::Go
|
||||
| TemplateId::Makefile => None,
|
||||
TemplateId::Makefile => Some(&MAKEFILE),
|
||||
TemplateId::Python => Some(&PYTHON),
|
||||
TemplateId::Meson => Some(&MESON),
|
||||
TemplateId::Cmake => Some(&CMAKE),
|
||||
TemplateId::Autotools => Some(&AUTOTOOLS),
|
||||
TemplateId::Go => Some(&GO),
|
||||
TemplateId::Rust => Some(&RUST),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,28 +182,37 @@ pub fn all() -> &'static [&'static dyn Template] {
|
||||
TEMPLATES
|
||||
}
|
||||
|
||||
/// Locate `name` on `$PATH` (a tiny `which`): `None` when `PATH` is unset or
|
||||
/// nothing executable-looking matches.
|
||||
pub(crate) fn find_on_path(name: &str) -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(name))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
/// The directory whose sources are being packaged, when there is one:
|
||||
/// `None` for the skeleton mode (the skeleton files are rendered in memory
|
||||
/// and do not exist on disk yet).
|
||||
pub(crate) fn source_dir_of(opts: &NewOptions) -> Option<PathBuf> {
|
||||
match &opts.source_dir {
|
||||
SourceDir::Skeleton => None,
|
||||
SourceDir::Here => std::env::current_dir().ok(),
|
||||
SourceDir::Path(path) => Some(path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_covers_implemented_templates() {
|
||||
for id in [TemplateId::Shell, TemplateId::Empty] {
|
||||
fn registry_covers_every_template() {
|
||||
for id in TemplateId::all() {
|
||||
assert!(get(id).is_some(), "{id} must be registered");
|
||||
assert_eq!(get(id).unwrap().id(), id);
|
||||
}
|
||||
for id in [
|
||||
TemplateId::Rust,
|
||||
TemplateId::Python,
|
||||
TemplateId::Meson,
|
||||
TemplateId::Cmake,
|
||||
TemplateId::Autotools,
|
||||
TemplateId::Go,
|
||||
TemplateId::Makefile,
|
||||
] {
|
||||
assert!(get(id).is_none(), "{id} must not pretend to be implemented");
|
||||
}
|
||||
assert_eq!(all().len(), 2);
|
||||
assert_eq!(all().len(), TemplateId::all().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -161,4 +224,165 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
/// The final `debian/rules` of every template must be valid-looking
|
||||
/// make: `#!/usr/bin/make -f` shebang, exactly one `%:` target whose
|
||||
/// recipe is the template's dh line, tab-indented recipes only, and no
|
||||
/// trailing blank lines.
|
||||
#[test]
|
||||
fn rules_composition_per_template() {
|
||||
let o = NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Empty,
|
||||
source_dir: SourceDir::Here,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: crate::new::options::License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: false,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
};
|
||||
|
||||
for id in TemplateId::all() {
|
||||
let template = get(id).unwrap();
|
||||
let files = super::super::debian::files(&o, template);
|
||||
let rules = files
|
||||
.iter()
|
||||
.find(|f| f.path == "debian/rules")
|
||||
.expect("every template renders debian/rules");
|
||||
|
||||
assert!(rules.executable, "{id}: rules must carry the exec bit");
|
||||
assert!(
|
||||
rules.contents.starts_with("#!/usr/bin/make -f\n%:\n\t"),
|
||||
"{id}: rules must start with the shebang and %: target"
|
||||
);
|
||||
assert!(
|
||||
rules.contents.matches("\n%:\n").count() == 1,
|
||||
"{id}: exactly one %: target expected"
|
||||
);
|
||||
// No recipe may be indented with spaces (make requires tabs).
|
||||
for line in rules.contents.lines() {
|
||||
assert!(
|
||||
!line.starts_with(' '),
|
||||
"{id}: space-indented line in rules: {line:?}"
|
||||
);
|
||||
}
|
||||
// The template's dh line is the %: recipe.
|
||||
assert!(
|
||||
rules
|
||||
.contents
|
||||
.contains(&format!("\n%:\n\t{}\n", template.rules_dh_line())),
|
||||
"{id}: %: recipe must be the dh line {:?} in {:?}",
|
||||
template.rules_dh_line(),
|
||||
rules.contents
|
||||
);
|
||||
// rule_extra targets must be declared at column 0 with tabbed
|
||||
// recipes.
|
||||
let extra = template.rules_extra(&o);
|
||||
if !extra.is_empty() {
|
||||
assert!(rules.contents.contains(&format!("\n{extra}")), "{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-template Build-Depends / architecture / rules shape, locking the
|
||||
/// table from the spec.
|
||||
#[test]
|
||||
fn build_depends_architecture_and_rules_table() {
|
||||
let o = NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Empty,
|
||||
source_dir: SourceDir::Skeleton,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: crate::new::options::License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: false,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
};
|
||||
let deps = |id| {
|
||||
let mut all = vec!["debhelper-compat (= 13)".to_string()];
|
||||
all.extend(get(id).unwrap().build_depends(&o));
|
||||
all.join(", ")
|
||||
};
|
||||
let arch = |id| get(id).unwrap().architecture(&o);
|
||||
let dh = |id| get(id).unwrap().rules_dh_line();
|
||||
|
||||
assert_eq!(deps(TemplateId::Shell), "debhelper-compat (= 13)");
|
||||
assert_eq!(arch(TemplateId::Shell), "all");
|
||||
assert_eq!(dh(TemplateId::Shell), "dh $@");
|
||||
|
||||
assert_eq!(deps(TemplateId::Empty), "debhelper-compat (= 13)");
|
||||
assert_eq!(arch(TemplateId::Empty), "all");
|
||||
|
||||
assert_eq!(
|
||||
deps(TemplateId::Makefile),
|
||||
"debhelper-compat (= 13), build-essential"
|
||||
);
|
||||
assert_eq!(arch(TemplateId::Makefile), "any");
|
||||
assert_eq!(dh(TemplateId::Makefile), "dh $@");
|
||||
|
||||
assert_eq!(
|
||||
dh(TemplateId::Python),
|
||||
"dh $@ --with python3 --buildsystem=pybuild"
|
||||
);
|
||||
// Skeleton projects use the setuptools pyproject backend.
|
||||
assert_eq!(
|
||||
deps(TemplateId::Python),
|
||||
"debhelper-compat (= 13), dh-python, python3-all, \
|
||||
pybuild-plugin-pyproject, python3-setuptools"
|
||||
);
|
||||
assert_eq!(arch(TemplateId::Python), "all");
|
||||
|
||||
assert_eq!(deps(TemplateId::Meson), "debhelper-compat (= 13), meson");
|
||||
assert_eq!(arch(TemplateId::Meson), "any");
|
||||
assert_eq!(dh(TemplateId::Meson), "dh $@ --buildsystem=meson");
|
||||
|
||||
assert_eq!(deps(TemplateId::Cmake), "debhelper-compat (= 13), cmake");
|
||||
assert_eq!(arch(TemplateId::Cmake), "any");
|
||||
assert_eq!(dh(TemplateId::Cmake), "dh $@ --buildsystem=cmake");
|
||||
|
||||
assert_eq!(
|
||||
deps(TemplateId::Autotools),
|
||||
"debhelper-compat (= 13), autoconf, automake, libtool"
|
||||
);
|
||||
assert_eq!(arch(TemplateId::Autotools), "any");
|
||||
assert_eq!(dh(TemplateId::Autotools), "dh $@");
|
||||
|
||||
assert_eq!(
|
||||
deps(TemplateId::Go),
|
||||
"debhelper-compat (= 13), golang-any, dh-golang"
|
||||
);
|
||||
assert_eq!(arch(TemplateId::Go), "any");
|
||||
assert_eq!(dh(TemplateId::Go), "dh $@ --buildsystem=golang");
|
||||
|
||||
assert_eq!(
|
||||
deps(TemplateId::Rust),
|
||||
"debhelper-compat (= 13), cargo:native, rustc:native"
|
||||
);
|
||||
assert_eq!(arch(TemplateId::Rust), "any");
|
||||
assert_eq!(dh(TemplateId::Rust), "dh $@");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
//! The `python` template: a PEP 517 project built with pybuild.
|
||||
//!
|
||||
//! The PEP 517 backend is read from `pyproject.toml` (`build-backend =
|
||||
//! …`) with a deliberately minimal line-oriented reader (see
|
||||
//! [`read_pyproject`]) — pkh has no TOML dependency, and only a handful of
|
||||
//! keys matter here. A bare `setup.py`/`setup.cfg` project falls back to
|
||||
//! setuptools without the `pybuild-plugin-pyproject` helper.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::{OutputFile, ProbeResult, Template, source_dir_of};
|
||||
use crate::new::options::{NewOptions, TemplateId};
|
||||
|
||||
/// Python project (`pyproject.toml` / `setup.py` / `setup.cfg`).
|
||||
pub struct Python;
|
||||
|
||||
/// The PEP 517 backend of a project.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Backend {
|
||||
/// `setuptools.build_meta` (also the PEP 517 default when
|
||||
/// `build-backend` is missing).
|
||||
Setuptools,
|
||||
/// `poetry.core.masonry.api`
|
||||
Poetry,
|
||||
/// `hatchling.build`
|
||||
Hatchling,
|
||||
/// `flit_core.buildapi` (and any other `flit_core` backend)
|
||||
Flit,
|
||||
}
|
||||
|
||||
impl Backend {
|
||||
/// The Debian package providing this backend.
|
||||
fn package(self) -> &'static str {
|
||||
match self {
|
||||
Backend::Setuptools => "python3-setuptools",
|
||||
Backend::Poetry => "python3-poetry-core",
|
||||
Backend::Hatchling => "python3-hatchling",
|
||||
Backend::Flit => "python3-flit-core",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the raw `build-backend` value to the backend enum; unknown values
|
||||
/// fall back to setuptools (the PEP 517 default tooling answer).
|
||||
fn backend_of_value(value: &str) -> Backend {
|
||||
if value.starts_with("poetry") {
|
||||
Backend::Poetry
|
||||
} else if value.starts_with("hatchling") {
|
||||
Backend::Hatchling
|
||||
} else if value.starts_with("flit_core") {
|
||||
Backend::Flit
|
||||
} else {
|
||||
// setuptools.build_meta, setuptools.build_meta:__legacy__, unknown.
|
||||
Backend::Setuptools
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the packaged project is pyproject-based (skeletons always are:
|
||||
/// the generated skeleton carries a `pyproject.toml`), which decides whether
|
||||
/// `pybuild-plugin-pyproject` is needed in Build-Depends.
|
||||
fn uses_pyproject(opts: &NewOptions) -> bool {
|
||||
match source_dir_of(opts) {
|
||||
Some(dir) => dir.join("pyproject.toml").exists(),
|
||||
// Skeleton mode renders a pyproject.toml.
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend of the packaged project: read from `pyproject.toml` when
|
||||
/// there is one (missing `build-backend` = the setuptools default), falling
|
||||
/// back to setuptools for bare `setup.py`/`setup.cfg` projects. Skeleton
|
||||
/// mode uses setuptools (the generated backend).
|
||||
fn backend(opts: &NewOptions) -> Backend {
|
||||
match source_dir_of(opts) {
|
||||
Some(dir) => read_pyproject(&dir.join("pyproject.toml"))
|
||||
.and_then(|project| project.backend)
|
||||
.map(|value| backend_of_value(&value))
|
||||
.unwrap_or(Backend::Setuptools),
|
||||
None => Backend::Setuptools,
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid Python identifier derived from the package name: dpkg names may
|
||||
/// carry `+`/`.` and may start with a digit, none of which a module name
|
||||
/// may.
|
||||
fn module_name(opts: &NewOptions) -> String {
|
||||
let mut module = opts.name.replace(['-', '.', '+'], "_");
|
||||
if module.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
module = format!("_{module}");
|
||||
}
|
||||
module
|
||||
}
|
||||
|
||||
/// Whether the project hints at compiled C extensions (pyo3 in Cargo.toml,
|
||||
/// `ext_modules` / `Extension` imports in setup.py): those need
|
||||
/// `Architecture: any` + `python3-all-dev` instead of `Architecture: all`.
|
||||
fn c_extension_hints(opts: &NewOptions) -> bool {
|
||||
let Some(dir) = source_dir_of(opts) else {
|
||||
return false;
|
||||
};
|
||||
if let Ok(cargo) = std::fs::read_to_string(dir.join("Cargo.toml"))
|
||||
&& cargo.contains("pyo3")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if let Ok(setup) = std::fs::read_to_string(dir.join("setup.py"))
|
||||
&& (setup.contains("ext_modules") || setup.contains("from setuptools import Extension"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl Template for Python {
|
||||
fn id(&self) -> TemplateId {
|
||||
TemplateId::Python
|
||||
}
|
||||
|
||||
/// A minimal setuptools-based `pyproject.toml` with one console script,
|
||||
/// plus the one-module package providing it.
|
||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
let module = module_name(opts);
|
||||
vec![
|
||||
OutputFile::new(
|
||||
"pyproject.toml",
|
||||
format!(
|
||||
"[build-system]\n\
|
||||
requires = [\"setuptools\"]\n\
|
||||
build-backend = \"setuptools.build_meta\"\n\
|
||||
\n\
|
||||
[project]\n\
|
||||
name = \"{name}\"\n\
|
||||
version = \"{version}\"\n\
|
||||
description = \"{summary}\"\n\
|
||||
requires-python = \">=3.8\"\n\
|
||||
\n\
|
||||
[project.scripts]\n\
|
||||
{command} = \"{module}:main\"\n",
|
||||
name = opts.name,
|
||||
version = opts.upstream_version,
|
||||
summary = opts.summary,
|
||||
command = opts.command,
|
||||
module = module,
|
||||
),
|
||||
),
|
||||
OutputFile::new(
|
||||
format!("{module}/__init__.py"),
|
||||
format!(
|
||||
"\"\"\"Placeholder for {name}, generated by `pkh new`.\"\"\"\n\
|
||||
\n\
|
||||
\n\
|
||||
def main() -> None:\n\
|
||||
\x20 print(\"Hello from {command}!\")\n",
|
||||
name = opts.name,
|
||||
command = opts.command,
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// No extra debian/ files: pybuild installs the package and its console
|
||||
/// entry points (under `/usr/bin`) automatically.
|
||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn build_depends(&self, opts: &NewOptions) -> Vec<String> {
|
||||
let mut deps = vec!["dh-python".to_string(), "python3-all".to_string()];
|
||||
if c_extension_hints(opts) {
|
||||
deps.push("python3-all-dev".to_string());
|
||||
}
|
||||
if uses_pyproject(opts) {
|
||||
deps.push("pybuild-plugin-pyproject".to_string());
|
||||
}
|
||||
deps.push(backend(opts).package().to_string());
|
||||
deps
|
||||
}
|
||||
|
||||
/// `all` unless the project hints at compiled C extensions.
|
||||
fn architecture(&self, opts: &NewOptions) -> &'static str {
|
||||
if c_extension_hints(opts) {
|
||||
"any"
|
||||
} else {
|
||||
"all"
|
||||
}
|
||||
}
|
||||
|
||||
fn rules_dh_line(&self) -> String {
|
||||
"dh $@ --with python3 --buildsystem=pybuild".to_string()
|
||||
}
|
||||
|
||||
fn source_fields(&self, _opts: &NewOptions) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Metadata from the `[project]` section of `pyproject.toml` (name,
|
||||
/// version, description, homepage, license, first console script), with
|
||||
/// a minimal `setup.py` `name=…` fallback.
|
||||
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||
let project = read_pyproject(&dir.join("pyproject.toml"));
|
||||
if let Some(project) = project {
|
||||
let result = ProbeResult {
|
||||
name: project.name,
|
||||
version: project.version,
|
||||
description: project.description,
|
||||
homepage: project.homepage,
|
||||
license: project.license,
|
||||
command: project.script,
|
||||
};
|
||||
if result.name.is_some()
|
||||
|| result.version.is_some()
|
||||
|| result.description.is_some()
|
||||
|| result.command.is_some()
|
||||
{
|
||||
return Some(result);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
// Bare setup.py: catch the `name='…'` argument only.
|
||||
let setup = std::fs::read_to_string(dir.join("setup.py")).ok()?;
|
||||
static NAME_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let regex =
|
||||
NAME_REGEX.get_or_init(|| Regex::new(r#"name\s*=\s*["']([^"']+)["']"#).unwrap());
|
||||
let name = regex.captures(&setup)?.get(1)?.as_str().to_string();
|
||||
Some(ProbeResult {
|
||||
name: Some(name),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The few `pyproject.toml` keys pkh new cares about.
|
||||
#[derive(Debug, Default, PartialEq, Eq, Clone)]
|
||||
pub struct PyProject {
|
||||
/// Raw `build-backend` value of `[build-system]`.
|
||||
pub backend: Option<String>,
|
||||
/// `name` of `[project]`.
|
||||
pub name: Option<String>,
|
||||
/// `version` of `[project]`.
|
||||
pub version: Option<String>,
|
||||
/// `description` of `[project]`.
|
||||
pub description: Option<String>,
|
||||
/// `Homepage` of `[project.urls]`.
|
||||
pub homepage: Option<String>,
|
||||
/// `license` of `[project]` (quoted-string form).
|
||||
pub license: Option<String>,
|
||||
/// First console-script key of `[project.scripts]`.
|
||||
pub script: Option<String>,
|
||||
}
|
||||
|
||||
/// Minimal line-oriented reader for the `pyproject.toml` keys pkh new needs:
|
||||
/// it tracks the current `[section]` header, matches `key = value` pairs at
|
||||
/// the start of a line and tolerates quotes and comments. It is not a TOML
|
||||
/// parser — anything it cannot understand is simply ignored and the caller
|
||||
/// falls back to the defaults (pkh has no TOML dependency, and a full parser
|
||||
/// would be out of proportion for a handful of keys).
|
||||
pub fn read_pyproject(path: &Path) -> Option<PyProject> {
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let mut project = PyProject::default();
|
||||
let mut section = String::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
section = header.trim().to_string();
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = split_key_value(line) else {
|
||||
continue;
|
||||
};
|
||||
match (section.as_str(), key) {
|
||||
("build-system", "build-backend") => project.backend = Some(value),
|
||||
("project", "name") => project.name = Some(value),
|
||||
("project", "version") => project.version = Some(value),
|
||||
("project", "description") => project.description = Some(value),
|
||||
("project", "license") => project.license = license_text(&value),
|
||||
("project.urls", "Homepage") => project.homepage = Some(value),
|
||||
("project.scripts", key) => {
|
||||
if project.script.is_none() {
|
||||
project.script = Some(key.to_string());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if project == PyProject::default() {
|
||||
None
|
||||
} else {
|
||||
Some(project)
|
||||
}
|
||||
}
|
||||
|
||||
/// The license out of a raw `license =` value: plain (unquoted by
|
||||
/// [`split_key_value`]) strings pass through, while the PEP 621 inline-table
|
||||
/// form `{text = "…"}` yields its `text` key. Anything else (other table
|
||||
/// forms) is ignored.
|
||||
fn license_text(value: &str) -> Option<String> {
|
||||
if value.starts_with('{') {
|
||||
static TEXT_REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
|
||||
let regex = TEXT_REGEX.get_or_init(|| Regex::new(r#"text\s*=\s*"([^"]+)""#).unwrap());
|
||||
Some(regex.captures(value)?.get(1)?.as_str().to_string())
|
||||
} else {
|
||||
Some(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a `key = value` line into its unquoted parts, ignoring inline
|
||||
/// comments outside of the (first) quoted string. Multi-line values (arrays,
|
||||
/// tables) are not supported on purpose: they never carry the keys read
|
||||
/// here.
|
||||
fn split_key_value(line: &str) -> Option<(&str, String)> {
|
||||
let (key, value) = line.split_once('=')?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() || key.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
let value = value.trim();
|
||||
// Quoted string: strip the quotes; a '#' inside the quotes is literal.
|
||||
let value = if let Some(rest) = value.strip_prefix('"') {
|
||||
rest.split_once('"')?.0.to_string()
|
||||
} else if let Some(rest) = value.strip_prefix('\'') {
|
||||
rest.split_once('\'')?.0.to_string()
|
||||
} else {
|
||||
// Bare value (e.g. true, 5, or {…} tables): cut the inline comment.
|
||||
match value.split_once('#') {
|
||||
Some((bare, _)) => bare.trim().to_string(),
|
||||
None => value.to_string(),
|
||||
}
|
||||
};
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Python,
|
||||
source_dir,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_opts(dir: &Path) -> NewOptions {
|
||||
opts(SourceDir::Path(dir.to_path_buf()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_skeleton_shape() {
|
||||
let o = opts(SourceDir::Skeleton);
|
||||
let template = super::super::get(TemplateId::Python).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(&o), "all");
|
||||
assert!(template.debian(&o).is_empty());
|
||||
assert_eq!(
|
||||
template.rules_dh_line(),
|
||||
"dh $@ --with python3 --buildsystem=pybuild"
|
||||
);
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
let pyproject = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "pyproject.toml")
|
||||
.expect("pyproject skeleton");
|
||||
assert!(
|
||||
pyproject
|
||||
.contents
|
||||
.contains("build-backend = \"setuptools.build_meta\"")
|
||||
);
|
||||
assert!(pyproject.contents.contains("name = \"mytool\""));
|
||||
assert!(pyproject.contents.contains("mytool = \"mytool:main\""));
|
||||
let module = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "mytool/__init__.py")
|
||||
.expect("module skeleton");
|
||||
assert!(module.contents.contains("def main()"));
|
||||
}
|
||||
|
||||
/// dpkg names may carry `+`/`.` and start with a digit — none of which a
|
||||
/// Python module name may.
|
||||
#[test]
|
||||
fn module_name_is_a_valid_python_identifier() {
|
||||
let o = NewOptions {
|
||||
name: "9x.tool+".into(),
|
||||
..opts(SourceDir::Skeleton)
|
||||
};
|
||||
assert_eq!(module_name(&o), "_9x_tool_");
|
||||
|
||||
let o = NewOptions {
|
||||
name: "my-tool".into(),
|
||||
..opts(SourceDir::Skeleton)
|
||||
};
|
||||
assert_eq!(module_name(&o), "my_tool");
|
||||
|
||||
// The skeleton module path and the pyproject script agree.
|
||||
let template = super::super::get(TemplateId::Python).unwrap();
|
||||
let skeleton = template.skeleton(&o);
|
||||
assert!(skeleton.iter().any(|f| f.path == "my_tool/__init__.py"));
|
||||
assert!(skeleton.iter().any(|f| {
|
||||
f.path == "pyproject.toml" && f.contents.contains("mytool = \"my_tool:main\"")
|
||||
}));
|
||||
}
|
||||
|
||||
/// The backend-detection table: `build-backend` value → Debian package,
|
||||
/// plus the missing-file/missing-key/bare-setup.py defaults.
|
||||
#[test]
|
||||
fn backend_detection_table() {
|
||||
// Raw value mapping.
|
||||
for (value, expected) in [
|
||||
("setuptools.build_meta", Backend::Setuptools),
|
||||
("setuptools.build_meta:__legacy__", Backend::Setuptools),
|
||||
("poetry.core.masonry.api", Backend::Poetry),
|
||||
("poetry.core.masonry.api.something", Backend::Poetry),
|
||||
("hatchling.build", Backend::Hatchling),
|
||||
("flit_core.buildapi", Backend::Flit),
|
||||
("flit_core.wheel", Backend::Flit),
|
||||
] {
|
||||
assert_eq!(backend_of_value(value), expected, "{value}");
|
||||
}
|
||||
// Unknown values fall back to setuptools.
|
||||
assert_eq!(backend_of_value("mystery.backend"), Backend::Setuptools);
|
||||
|
||||
let template = super::super::get(TemplateId::Python).unwrap();
|
||||
|
||||
// No source dir (skeleton): setuptools + pyproject plugin.
|
||||
assert_eq!(backend(&opts(SourceDir::Skeleton)), Backend::Setuptools);
|
||||
assert!(uses_pyproject(&opts(SourceDir::Skeleton)));
|
||||
|
||||
// pyproject.toml with each backend, [build-system] section-aware.
|
||||
let cases = [
|
||||
("setuptools.build_meta", "python3-setuptools"),
|
||||
("poetry.core.masonry.api", "python3-poetry-core"),
|
||||
("hatchling.build", "python3-hatchling"),
|
||||
("flit_core.buildapi", "python3-flit-core"),
|
||||
];
|
||||
for (value, package) in cases {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("pyproject.toml"),
|
||||
format!(
|
||||
"[other-section]\nbuild-backend = \"ignored\"\n\n\
|
||||
[build-system]\nrequires = [\"x\"]\n\
|
||||
build-backend = \"{value}\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let o = source_opts(dir.path());
|
||||
assert_eq!(backend(&o), backend_of_value(value), "{value}");
|
||||
let deps = template.build_depends(&o);
|
||||
assert!(deps.contains(&package.to_string()), "{deps:?}");
|
||||
assert!(deps.contains(&"pybuild-plugin-pyproject".to_string()));
|
||||
}
|
||||
|
||||
// pyproject.toml without build-backend: setuptools (PEP 517 default).
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("pyproject.toml"),
|
||||
"[project]\nname = \"x\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let o = source_opts(dir.path());
|
||||
assert_eq!(backend(&o), Backend::Setuptools);
|
||||
|
||||
// Bare setup.py: setuptools WITHOUT the pyproject plugin.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("setup.py"),
|
||||
"from setuptools import setup\nsetup()\n",
|
||||
)
|
||||
.unwrap();
|
||||
let o = source_opts(dir.path());
|
||||
assert!(!uses_pyproject(&o));
|
||||
let deps = template.build_depends(&o);
|
||||
assert!(
|
||||
!deps.contains(&"pybuild-plugin-pyproject".to_string()),
|
||||
"{deps:?}"
|
||||
);
|
||||
assert!(deps.contains(&"python3-setuptools".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c_extension_hints_flip_architecture_and_deps() {
|
||||
let template = super::super::get(TemplateId::Python).unwrap();
|
||||
|
||||
// pyo3 in Cargo.toml.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Cargo.toml"),
|
||||
"[dependencies]\npyo3 = \"0.22\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let o = source_opts(dir.path());
|
||||
assert!(c_extension_hints(&o));
|
||||
let deps = template.build_depends(&o);
|
||||
assert!(deps.contains(&"python3-all-dev".to_string()), "{deps:?}");
|
||||
|
||||
// ext_modules in setup.py.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("setup.py"),
|
||||
"from setuptools import setup, Extension\nsetup(ext_modules=[])\n",
|
||||
)
|
||||
.unwrap();
|
||||
let o = source_opts(dir.path());
|
||||
assert!(c_extension_hints(&o));
|
||||
|
||||
// Nothing relevant: no hints.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("pyproject.toml"), "[project]\n").unwrap();
|
||||
let o = source_opts(dir.path());
|
||||
assert!(!c_extension_hints(&o));
|
||||
assert_eq!(template.build_depends(&o).len(), 4); // dh-python, python3-all, plugin, setuptools
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reads_project_and_scripts() {
|
||||
let template = super::super::get(TemplateId::Python).unwrap();
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("pyproject.toml"),
|
||||
"[build-system]\n\
|
||||
build-backend = \"hatchling.build\" # inline comment\n\
|
||||
\n\
|
||||
[project]\n\
|
||||
name = \"my-tool\"\n\
|
||||
version = \"1.2.3\"\n\
|
||||
description = \"Does things\"\n\
|
||||
license = \"MIT\"\n\
|
||||
\n\
|
||||
[project.urls]\n\
|
||||
Homepage = \"https://example.com/my-tool\"\n\
|
||||
\n\
|
||||
[project.scripts]\n\
|
||||
mycli = \"my_tool.cli:main\"\n\
|
||||
other = \"my_tool.other:run\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("my-tool"));
|
||||
assert_eq!(probe.version.as_deref(), Some("1.2.3"));
|
||||
assert_eq!(probe.description.as_deref(), Some("Does things"));
|
||||
assert_eq!(probe.license.as_deref(), Some("MIT"));
|
||||
assert_eq!(
|
||||
probe.homepage.as_deref(),
|
||||
Some("https://example.com/my-tool")
|
||||
);
|
||||
// First [project.scripts] key becomes the command.
|
||||
assert_eq!(probe.command.as_deref(), Some("mycli"));
|
||||
|
||||
// Bare setup.py: the name= argument.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("setup.py"),
|
||||
"setup(\n name='legacy-tool',\n version=\"9.9\",\n)\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("legacy-tool"));
|
||||
assert_eq!(probe.version, None);
|
||||
|
||||
// Nothing readable: silent None.
|
||||
let dir = tempdir().unwrap();
|
||||
assert!(template.probe(dir.path()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pyproject_reader_is_section_aware_and_tolerant() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("pyproject.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"# leading comment\n\
|
||||
[tool.black]\n\
|
||||
name = \"should not leak\"\n\
|
||||
\n\
|
||||
[project]\n\
|
||||
name = \"quoted-name\"\n\
|
||||
description = 'single quoted'\n\
|
||||
version = \"1.0\" # trailing comment\n\
|
||||
license = {text = \"MIT\"}\n\
|
||||
requires-python = \">=3.8\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let project = read_pyproject(&path).unwrap();
|
||||
assert_eq!(project.name.as_deref(), Some("quoted-name"));
|
||||
assert_eq!(project.description.as_deref(), Some("single quoted"));
|
||||
assert_eq!(project.version.as_deref(), Some("1.0"));
|
||||
// The PEP 621 inline-table license form yields its text key.
|
||||
assert_eq!(project.license.as_deref(), Some("MIT"));
|
||||
|
||||
// Missing file.
|
||||
assert!(read_pyproject(&dir.path().join("missing.toml")).is_none());
|
||||
|
||||
// Only comments: no project.
|
||||
std::fs::write(&path, "# nothing\n").unwrap();
|
||||
assert_eq!(read_pyproject(&path), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
//! The `rust` template: a Cargo project shipped as a **vendored** build.
|
||||
//!
|
||||
//! Standard Debian practice (debcargo → dh-cargo → registry deps) needs every
|
||||
//! Cargo dependency as a `librust-*-dev` archive package — unusable for a
|
||||
//! brand-new program. v1 therefore vendors at scaffold time: `cargo vendor`
|
||||
//! runs over the freshly written tree (before the orig tarball is created, so
|
||||
//! `vendor/` travels inside it), and `debian/rules` builds offline with the
|
||||
//! source replacement. When host `cargo` is missing or vendoring fails, the
|
||||
//! scaffold continues with a loud warning — the package will not build until
|
||||
//! the user vendors manually.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{OutputFile, ProbeResult, Template, find_on_path, source_dir_of};
|
||||
use crate::new::options::{NewOptions, SourceDir, TemplateId};
|
||||
|
||||
/// Rust project (`Cargo.toml`).
|
||||
pub struct Rust;
|
||||
|
||||
/// The source-replacement configuration, used when `cargo vendor` did not
|
||||
/// print one itself (old cargo versions, empty output).
|
||||
const FALLBACK_VENDOR_CONFIG: &str = "[source.crates-io]\nreplace-with = \"vendored-sources\"\n\n[source.vendored-sources]\ndirectory = \"vendor\"";
|
||||
|
||||
/// The cargo crate name of the skeleton: dpkg package names may carry `+`
|
||||
/// or `.`, which cargo rejects in package names.
|
||||
fn crate_name(opts: &NewOptions) -> String {
|
||||
opts.name.replace(['+', '.'], "_")
|
||||
}
|
||||
|
||||
impl Template for Rust {
|
||||
fn id(&self) -> TemplateId {
|
||||
TemplateId::Rust
|
||||
}
|
||||
|
||||
/// A zero-dependency `Cargo.toml` and the matching `src/main.rs`.
|
||||
fn skeleton(&self, opts: &NewOptions) -> Vec<OutputFile> {
|
||||
vec![
|
||||
OutputFile::new(
|
||||
"Cargo.toml",
|
||||
format!(
|
||||
"[package]\n\
|
||||
name = \"{name}\"\n\
|
||||
version = \"{version}\"\n\
|
||||
edition = \"2021\"\n\
|
||||
\n\
|
||||
[dependencies]\n",
|
||||
name = crate_name(opts),
|
||||
version = opts.upstream_version,
|
||||
),
|
||||
),
|
||||
OutputFile::new(
|
||||
"src/main.rs",
|
||||
format!(
|
||||
"// Placeholder for {name}, generated by `pkh new`.\n\
|
||||
fn main() {{\n\
|
||||
\tprintln!(\"Hello from {command}!\");\n\
|
||||
}}\n",
|
||||
name = opts.name,
|
||||
command = opts.command,
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// No extra debian/ files: the vendored build lives entirely in the
|
||||
/// rules overrides.
|
||||
fn debian(&self, _opts: &NewOptions) -> Vec<OutputFile> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn build_depends(&self, _opts: &NewOptions) -> Vec<String> {
|
||||
vec!["cargo:native".to_string(), "rustc:native".to_string()]
|
||||
}
|
||||
|
||||
fn architecture(&self, _opts: &NewOptions) -> &'static str {
|
||||
"any"
|
||||
}
|
||||
|
||||
/// The vendored build overrides. `--locked` is used only when the
|
||||
/// packaged tree already carries a `Cargo.lock` (fresh skeletons have
|
||||
/// none yet); omitting it is always safe. The built artifact of a
|
||||
/// skeleton is named after its crate (a sanitized package name) and
|
||||
/// installed under the command name.
|
||||
fn rules_extra(&self, opts: &NewOptions) -> String {
|
||||
let locked = if lockfile_present(opts) {
|
||||
" --locked"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
// The skeleton's cargo artifact is the crate name; for an existing
|
||||
// project the (probed or answered) command names the binary.
|
||||
let artifact = match opts.source_dir {
|
||||
SourceDir::Skeleton => crate_name(opts),
|
||||
_ => opts.command.clone(),
|
||||
};
|
||||
format!(
|
||||
"override_dh_auto_build:\n\
|
||||
\tcargo build --release --offline{locked}\n\
|
||||
\n\
|
||||
override_dh_auto_install:\n\
|
||||
\tinstall -Dm755 target/release/{artifact} debian/{name}/usr/bin/{command}\n\
|
||||
\n\
|
||||
override_dh_auto_test:\n\
|
||||
\tcargo test --release --offline{locked}\n\
|
||||
\n\
|
||||
override_dh_auto_clean:\n\
|
||||
\tcargo clean\n",
|
||||
locked = locked,
|
||||
artifact = artifact,
|
||||
command = opts.command,
|
||||
name = opts.name,
|
||||
)
|
||||
}
|
||||
|
||||
/// Name, version, description, homepage, license and first binary from
|
||||
/// `cargo metadata --no-deps` (when host cargo is available), with a
|
||||
/// minimal line-parse of `Cargo.toml` as fallback.
|
||||
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||
if let Some(result) = probe_cargo_metadata(dir) {
|
||||
return Some(result);
|
||||
}
|
||||
probe_cargo_toml(dir)
|
||||
}
|
||||
|
||||
/// Vendor the Cargo dependencies into the freshly written tree: run
|
||||
/// `cargo vendor` in it and write `.cargo/config.toml` with the printed
|
||||
/// source replacement plus `offline = true`, so the build never touches
|
||||
/// the network. Failures warn loudly and continue: the scaffold stays in
|
||||
/// place, the package just will not build until vendored manually.
|
||||
fn post_write(
|
||||
&self,
|
||||
_opts: &NewOptions,
|
||||
tree: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !tree.join("Cargo.toml").exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(cargo) = find_on_path("cargo") else {
|
||||
log::warn!(
|
||||
"cargo was not found on PATH: the Rust package will NOT build \
|
||||
until its dependencies are vendored. Run `cargo vendor` in the \
|
||||
tree and add the printed source replacement to \
|
||||
.cargo/config.toml (with `[net] offline = true`)."
|
||||
);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
log::info!("Vendoring Cargo dependencies (`cargo vendor`) — needs one network sync");
|
||||
match std::process::Command::new(&cargo)
|
||||
.arg("vendor")
|
||||
.current_dir(tree)
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let printed = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let snippet = if printed.contains("[source.") {
|
||||
printed
|
||||
} else {
|
||||
FALLBACK_VENDOR_CONFIG.to_string()
|
||||
};
|
||||
let config_path = tree.join(".cargo/config.toml");
|
||||
if config_path.exists() {
|
||||
log::warn!(
|
||||
"'{}' already exists: the vendored-source replacement \
|
||||
printed by `cargo vendor` was NOT written there; add it \
|
||||
manually (plus `[net] offline = true`).",
|
||||
config_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(config_path.parent().unwrap_or(tree))?;
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!("{snippet}\n\n[net]\noffline = true\n"),
|
||||
)?;
|
||||
log::info!(
|
||||
"Vendored sources and '{}' written; the package builds \
|
||||
fully offline",
|
||||
config_path.display()
|
||||
);
|
||||
}
|
||||
Ok(output) => {
|
||||
log::warn!(
|
||||
"`cargo vendor` failed ({}): the package will NOT build until \
|
||||
its dependencies are vendored. Run `cargo vendor` in the tree \
|
||||
and add the printed source replacement to .cargo/config.toml. \
|
||||
Last stderr line: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
.lines()
|
||||
.last()
|
||||
.unwrap_or("(no output)")
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"could not run `cargo vendor` ({e}): the package will NOT \
|
||||
build until its dependencies are vendored. Run \
|
||||
`cargo vendor` in the tree and add the printed source \
|
||||
replacement to .cargo/config.toml."
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the packaged tree carries a `Cargo.lock` (skeletons do not yet).
|
||||
fn lockfile_present(opts: &NewOptions) -> bool {
|
||||
source_dir_of(opts).is_some_and(|dir| dir.join("Cargo.lock").exists())
|
||||
}
|
||||
|
||||
/// Probe through `cargo metadata --no-deps --format-version 1`: silent `None`
|
||||
/// when cargo is unavailable or fails.
|
||||
fn probe_cargo_metadata(dir: &Path) -> Option<ProbeResult> {
|
||||
let cargo = find_on_path("cargo")?;
|
||||
let output = std::process::Command::new(cargo)
|
||||
.args(["metadata", "--no-deps", "--format-version", "1"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).ok()?;
|
||||
let package = metadata.get("packages")?.as_array()?.first()?;
|
||||
let command = package
|
||||
.get("targets")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|target| {
|
||||
target
|
||||
.get("kind")
|
||||
.and_then(|kind| kind.as_array())
|
||||
.is_some_and(|kinds| kinds.iter().any(|k| k.as_str() == Some("bin")))
|
||||
})
|
||||
.and_then(|target| target.get("name"))
|
||||
.and_then(|name| name.as_str())
|
||||
.map(str::to_string);
|
||||
let field = |key: &str| {
|
||||
package
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
};
|
||||
Some(ProbeResult {
|
||||
name: field("name"),
|
||||
version: field("version"),
|
||||
description: field("description"),
|
||||
homepage: field("homepage"),
|
||||
license: field("license"),
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
/// Minimal line-parse fallback for `Cargo.toml` (no TOML dependency): the
|
||||
/// `key = value` pairs of the `[package]` section.
|
||||
fn probe_cargo_toml(dir: &Path) -> Option<ProbeResult> {
|
||||
let content = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
|
||||
let mut result = ProbeResult::default();
|
||||
let mut in_package = false;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some(header) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
in_package = header.trim() == "package";
|
||||
continue;
|
||||
}
|
||||
if !in_package {
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim().trim_matches('"').trim_matches('\'').trim();
|
||||
match key.trim() {
|
||||
"name" => result.name = Some(value.to_string()),
|
||||
"version" => result.version = Some(value.to_string()),
|
||||
"description" => result.description = Some(value.to_string()),
|
||||
"homepage" => result.homepage = Some(value.to_string()),
|
||||
"license" => result.license = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if result.name.is_none() && result.version.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts(source_dir: SourceDir) -> NewOptions {
|
||||
NewOptions {
|
||||
name: "mytool".into(),
|
||||
template: TemplateId::Rust,
|
||||
source_dir,
|
||||
upstream_version: "0.1.0".into(),
|
||||
revision: 1,
|
||||
summary: "A tool".into(),
|
||||
long_description: "A tool".into(),
|
||||
homepage: None,
|
||||
license: License::Mit,
|
||||
command: "mytool".into(),
|
||||
maintainer: ("Jane".into(), "jane@example.com".into()),
|
||||
dist: "ubuntu".into(),
|
||||
series: "resolute".into(),
|
||||
release: false,
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_template_shape() {
|
||||
let o = opts(SourceDir::Skeleton);
|
||||
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(&o), "any");
|
||||
assert_eq!(
|
||||
template.build_depends(&o),
|
||||
vec!["cargo:native".to_string(), "rustc:native".to_string()]
|
||||
);
|
||||
assert_eq!(template.rules_dh_line(), "dh $@");
|
||||
assert!(template.debian(&o).is_empty());
|
||||
|
||||
// Skeleton: Cargo.toml + src/main.rs.
|
||||
let skeleton = template.skeleton(&o);
|
||||
assert!(
|
||||
skeleton
|
||||
.iter()
|
||||
.any(|f| f.path == "Cargo.toml" && f.contents.contains("name = \"mytool\""))
|
||||
);
|
||||
assert!(skeleton.iter().any(|f| f.path == "src/main.rs"));
|
||||
|
||||
// Fresh skeleton: no Cargo.lock, so no --locked flag anywhere.
|
||||
let extra = template.rules_extra(&o);
|
||||
assert!(extra.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
|
||||
assert!(
|
||||
extra.contains("\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool")
|
||||
);
|
||||
assert!(extra.contains("override_dh_auto_test:\n\tcargo test --release --offline\n"));
|
||||
assert!(extra.contains("override_dh_auto_clean:\n\tcargo clean"));
|
||||
assert!(!extra.contains("--locked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_use_locked_only_with_lockfile() {
|
||||
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
|
||||
let with_lock = opts(SourceDir::Path(dir.path().to_path_buf()));
|
||||
assert!(!lockfile_present(&with_lock));
|
||||
let extra = template.rules_extra(&with_lock);
|
||||
assert!(!extra.contains("--locked"), "{extra}");
|
||||
|
||||
std::fs::write(dir.path().join("Cargo.lock"), "# generated\n").unwrap();
|
||||
assert!(lockfile_present(&with_lock));
|
||||
let extra = template.rules_extra(&with_lock);
|
||||
assert!(extra.contains("\tcargo build --release --offline --locked\n"));
|
||||
assert!(extra.contains("\tcargo test --release --offline --locked\n"));
|
||||
}
|
||||
|
||||
/// Package names may carry `+`/`.` (legal dpkg, rejected by cargo): the
|
||||
/// skeleton crate name is sanitized, and the install override picks the
|
||||
/// crate-named artifact and installs it under the command name.
|
||||
#[test]
|
||||
fn skeleton_sanitizes_the_crate_name() {
|
||||
let o = NewOptions {
|
||||
name: "my.tool+".into(),
|
||||
command: "mytool".into(),
|
||||
..opts(SourceDir::Skeleton)
|
||||
};
|
||||
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
let cargo_toml = skeleton
|
||||
.iter()
|
||||
.find(|f| f.path == "Cargo.toml")
|
||||
.expect("Cargo.toml skeleton");
|
||||
assert!(cargo_toml.contents.contains("name = \"my_tool_\""));
|
||||
|
||||
let extra = template.rules_extra(&o);
|
||||
assert!(
|
||||
extra.contains(
|
||||
"\tinstall -Dm755 target/release/my_tool_ debian/my.tool+/usr/bin/mytool\n"
|
||||
),
|
||||
"{extra}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_reads_cargo_toml_lines() {
|
||||
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Cargo.toml"),
|
||||
"# comment\n\
|
||||
[package]\n\
|
||||
name = \"mytool\"\n\
|
||||
version = \"2.3.4\"\n\
|
||||
description = \"A cargo tool\"\n\
|
||||
homepage = \"https://example.com/mytool\"\n\
|
||||
license = \"MIT OR Apache-2.0\"\n\
|
||||
\n\
|
||||
[dependencies]\n\
|
||||
serde = \"1\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("mytool"));
|
||||
assert_eq!(probe.version.as_deref(), Some("2.3.4"));
|
||||
assert_eq!(probe.description.as_deref(), Some("A cargo tool"));
|
||||
assert_eq!(
|
||||
probe.homepage.as_deref(),
|
||||
Some("https://example.com/mytool")
|
||||
);
|
||||
assert_eq!(probe.license.as_deref(), Some("MIT OR Apache-2.0"));
|
||||
|
||||
// Only section headers and comments: nothing to report.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Cargo.toml"), "[dependencies]\n").unwrap();
|
||||
assert!(template.probe(dir.path()).is_none());
|
||||
}
|
||||
|
||||
/// With host cargo available, `cargo metadata` wins and yields the bin
|
||||
/// target as the command. (Without cargo on PATH the line-parse fallback
|
||||
/// above is exercised.)
|
||||
#[test]
|
||||
fn probe_prefers_cargo_metadata() {
|
||||
if find_on_path("cargo").is_none() {
|
||||
// No cargo on this host: metadata probing is silent and the
|
||||
// fallback applies (already covered by the line-parse test).
|
||||
return;
|
||||
}
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"metaprobe\"\nversion = \"0.9.0\"\nedition = \"2021\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("src")).unwrap();
|
||||
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
|
||||
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("metaprobe"));
|
||||
assert_eq!(probe.version.as_deref(), Some("0.9.0"));
|
||||
assert_eq!(probe.command.as_deref(), Some("metaprobe"));
|
||||
}
|
||||
|
||||
/// The vendoring hook over a zero-dependency skeleton: offline config
|
||||
/// written, no failure (needs host cargo; without it the warning path
|
||||
/// keeps the tree intact).
|
||||
#[test]
|
||||
fn post_write_vendors_skeleton() {
|
||||
let dir = tempdir().unwrap();
|
||||
let o = opts(SourceDir::Skeleton);
|
||||
let template = super::super::get(TemplateId::Rust).unwrap();
|
||||
for file in template.skeleton(&o) {
|
||||
let path = dir.path().join(&file.path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, file.contents).unwrap();
|
||||
}
|
||||
|
||||
template.post_write(&o, dir.path()).unwrap();
|
||||
|
||||
if find_on_path("cargo").is_some() {
|
||||
let config = std::fs::read_to_string(dir.path().join(".cargo/config.toml")).unwrap();
|
||||
assert!(config.contains("[source.crates-io]"), "{config}");
|
||||
assert!(config.contains("replace-with = \"vendored-sources\""));
|
||||
assert!(config.contains("[net]\noffline = true"), "{config}");
|
||||
}
|
||||
// An existing .cargo/config.toml is never overwritten.
|
||||
let existing = dir.path().join(".cargo/config.toml");
|
||||
if existing.exists() {
|
||||
template.post_write(&o, dir.path()).unwrap();
|
||||
let config = std::fs::read_to_string(&existing).unwrap();
|
||||
assert!(config.contains("[source.crates-io]"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! The `shell` template: a single interpreted script installed to
|
||||
//! `/usr/bin` with plain `dh $@` plumbing.
|
||||
|
||||
use super::{OutputFile, Template};
|
||||
use crate::new::options::{NewOptions, SourceDir};
|
||||
use std::path::Path;
|
||||
|
||||
use super::{OutputFile, ProbeResult, Template};
|
||||
use crate::new::options::{self, NewOptions, SourceDir};
|
||||
|
||||
/// Shell script / single interpreted file.
|
||||
pub struct Shell;
|
||||
@@ -39,12 +41,26 @@ impl Template for Shell {
|
||||
format!("{}.sh usr/bin/{}\n", opts.command, opts.command),
|
||||
)]
|
||||
}
|
||||
|
||||
/// The file name of the single top-level script (sanitized) pre-fills the
|
||||
/// package name and command questions.
|
||||
fn probe(&self, dir: &Path) -> Option<ProbeResult> {
|
||||
let path = crate::new::detect::single_script(dir)?;
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let name = options::sanitize_name(stem)?;
|
||||
Some(ProbeResult {
|
||||
command: Some(name.clone()),
|
||||
name: Some(name),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::new::options::{License, SourceDir, TemplateId};
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn opts() -> NewOptions {
|
||||
NewOptions {
|
||||
@@ -65,6 +81,9 @@ mod tests {
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: true,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,9 +92,9 @@ mod tests {
|
||||
let o = opts();
|
||||
let template = super::super::get(TemplateId::Shell).unwrap();
|
||||
|
||||
assert_eq!(template.architecture(), "all");
|
||||
assert_eq!(template.architecture(&o), "all");
|
||||
assert!(template.build_depends(&o).is_empty());
|
||||
assert!(template.rules_extra().is_empty());
|
||||
assert!(template.rules_extra(&o).is_empty());
|
||||
|
||||
let skeleton = template.skeleton(&o);
|
||||
assert_eq!(skeleton.len(), 1);
|
||||
@@ -88,4 +107,30 @@ mod tests {
|
||||
assert_eq!(debian[0].path, "debian/install");
|
||||
assert_eq!(debian[0].contents, "mytool.sh usr/bin/mytool\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_probe_reads_script_file_name() {
|
||||
let template = super::super::get(TemplateId::Shell).unwrap();
|
||||
|
||||
// The .sh extension is stripped, the stem sanitized into a package
|
||||
// name and command.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("My Tool.sh"), "#!/bin/sh\n").unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("my-tool"));
|
||||
assert_eq!(probe.command.as_deref(), Some("my-tool"));
|
||||
|
||||
// A shebang file without extension works too.
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("runtool"), "#!/usr/bin/env bash\n").unwrap();
|
||||
let probe = template.probe(dir.path()).expect("probe result");
|
||||
assert_eq!(probe.name.as_deref(), Some("runtool"));
|
||||
|
||||
// Zero or several scripts: silent None.
|
||||
let dir = tempdir().unwrap();
|
||||
assert!(template.probe(dir.path()).is_none());
|
||||
std::fs::write(dir.path().join("a.sh"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(dir.path().join("b.sh"), "#!/bin/sh\n").unwrap();
|
||||
assert!(template.probe(dir.path()).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,9 @@ mod tests {
|
||||
depends: Vec::new(),
|
||||
native: false,
|
||||
git: false,
|
||||
autopkgtest: false,
|
||||
pkg_config: false,
|
||||
watch: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user