diff --git a/src/main.rs b/src/main.rs index 39d1289..990e08b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = sub_matches .get_many::("depends") .map(|values| values.cloned().collect()) .unwrap_or_default(); + let no_verify = sub_matches + .get_one::("no_verify") + .copied() + .unwrap_or(false); let cli = pkh::new::options::NewCli { name: sub_matches.get_one::("name").cloned(), lang: sub_matches.get_one::("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>(()) }) { error!("{}", e); std::process::exit(1); diff --git a/src/new/debian.rs b/src/new/debian.rs index 6a89591..2f76c25 100644 --- a/src/new/debian.rs +++ b/src/new/debian.rs @@ -50,16 +50,45 @@ pub fn files(opts: &NewOptions, template: &dyn Template) -> Vec { 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()); diff --git a/src/new/detect.rs b/src/new/detect.rs index 49b5727..d57e81c 100644 --- a/src/new/detect.rs +++ b/src/new/detect.rs @@ -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 { +pub fn single_script(dir: &Path) -> Option { let mut found: Option = 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 { + 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::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 = 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); + } } diff --git a/src/new/mod.rs b/src/new/mod.rs index a8c2761..82571b4 100644 --- a/src/new/mod.rs +++ b/src/new/mod.rs @@ -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> { 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 Result<(), Box> { - // 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::>() - .join(", ") ) })?; @@ -173,17 +173,23 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box = 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 = 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). diff --git a/src/new/options.rs b/src/new/options.rs index 63d661f..f1cf27c 100644 --- a/src/new/options.rs +++ b/src/new/options.rs @@ -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::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 ` --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, } impl NewOptions { @@ -568,7 +595,8 @@ pub async fn resolve(cli: NewCli) -> Result { 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 { 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!( diff --git a/src/new/questions.rs b/src/new/questions.rs new file mode 100644 index 0000000..207fafe --- /dev/null +++ b/src/new/questions.rs @@ -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> { + 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> { + 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 = 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::>() + .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::().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 '" + ); + }; + 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 ` 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 { + 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 { + 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> { + 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> { + 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> { + 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::() { + 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 { + 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 = 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 "), + "{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()); + } +} diff --git a/src/new/templates/autotools.rs b/src/new/templates/autotools.rs new file mode 100644 index 0000000..b97e92b --- /dev/null +++ b/src/new/templates/autotools.rs @@ -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 { + 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 { + Vec::new() + } + + fn build_depends(&self, opts: &NewOptions) -> Vec { + 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 { + let content = std::fs::read_to_string(dir.join("configure.ac")).ok()?; + static AC_INIT_REGEX: std::sync::OnceLock = 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()) + ); + } +} diff --git a/src/new/templates/cmake.rs b/src/new/templates/cmake.rs new file mode 100644 index 0000000..3acc091 --- /dev/null +++ b/src/new/templates/cmake.rs @@ -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 { + 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 { + Vec::new() + } + + fn build_depends(&self, opts: &NewOptions) -> Vec { + 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( VERSION …)` + /// declaration. + fn probe(&self, dir: &Path) -> Option { + let content = std::fs::read_to_string(dir.join("CMakeLists.txt")).ok()?; + static PROJECT_REGEX: std::sync::OnceLock = 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()); + } +} diff --git a/src/new/templates/empty.rs b/src/new/templates/empty.rs index bb15b1f..8db53b5 100644 --- a/src/new/templates/empty.rs +++ b/src/new/templates/empty.rs @@ -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); diff --git a/src/new/templates/go.rs b/src/new/templates/go.rs new file mode 100644 index 0000000..c9c4f38 --- /dev/null +++ b/src/new/templates/go.rs @@ -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 { + 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 { + Vec::new() + } + + fn build_depends(&self, _opts: &NewOptions) -> Vec { + 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 { + 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 ` line of `dir/go.mod`, when present. +fn read_module_line(dir: &Path) -> Option { + 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()); + } +} diff --git a/src/new/templates/makefile.rs b/src/new/templates/makefile.rs new file mode 100644 index 0000000..24e274d --- /dev/null +++ b/src/new/templates/makefile.rs @@ -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 { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/src/new/templates/meson.rs b/src/new/templates/meson.rs new file mode 100644 index 0000000..3cb9fd1 --- /dev/null +++ b/src/new/templates/meson.rs @@ -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 { + 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 { + Vec::new() + } + + fn build_depends(&self, opts: &NewOptions) -> Vec { + 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 { + let content = std::fs::read_to_string(dir.join("meson.build")).ok()?; + static PROJECT_REGEX: std::sync::OnceLock = 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 \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()] + ); + } +} diff --git a/src/new/templates/mod.rs b/src/new/templates/mod.rs index aef8aee..8c924fa 100644 --- a/src/new/templates/mod.rs +++ b/src/new/templates/mod.rs @@ -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, /// Project license (SPDX identifier). pub license: Option, + /// Installed command / binary name (e.g. the first `[[bin]]` target or + /// console script). + pub command: Option, } /// 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 { 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> { + 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 { + 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 { + 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 $@"); + } } diff --git a/src/new/templates/python.rs b/src/new/templates/python.rs new file mode 100644 index 0000000..15b88f3 --- /dev/null +++ b/src/new/templates/python.rs @@ -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 { + 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 { + Vec::new() + } + + fn build_depends(&self, opts: &NewOptions) -> Vec { + 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 { + 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 = 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, + /// `name` of `[project]`. + pub name: Option, + /// `version` of `[project]`. + pub version: Option, + /// `description` of `[project]`. + pub description: Option, + /// `Homepage` of `[project.urls]`. + pub homepage: Option, + /// `license` of `[project]` (quoted-string form). + pub license: Option, + /// First console-script key of `[project.scripts]`. + pub script: Option, +} + +/// 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 { + 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 { + if value.starts_with('{') { + static TEXT_REGEX: std::sync::OnceLock = 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); + } +} diff --git a/src/new/templates/rust.rs b/src/new/templates/rust.rs new file mode 100644 index 0000000..4f76730 --- /dev/null +++ b/src/new/templates/rust.rs @@ -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 { + 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 { + Vec::new() + } + + fn build_depends(&self, _opts: &NewOptions) -> Vec { + 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 { + 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> { + 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 { + 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 { + 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]")); + } + } +} diff --git a/src/new/templates/shell.rs b/src/new/templates/shell.rs index 6c9f966..8a6625b 100644 --- a/src/new/templates/shell.rs +++ b/src/new/templates/shell.rs @@ -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 { + 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()); + } } diff --git a/src/new/verify.rs b/src/new/verify.rs index 89a82a0..f2e3088 100644 --- a/src/new/verify.rs +++ b/src/new/verify.rs @@ -117,6 +117,9 @@ mod tests { depends: Vec::new(), native: false, git: false, + autopkgtest: false, + pkg_config: false, + watch: None, } }