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