new: add interactive wizard and remaining ecosystem templates

This commit is contained in:
2026-09-16 13:49:29 +02:00
parent d044f757e9
commit 9b98f5c7c3
17 changed files with 3930 additions and 99 deletions
+158 -31
View File
@@ -2,17 +2,19 @@
//! `plans/pkh-new.md`).
//!
//! This module orchestrates a scaffold run: target directory checks, project
//! detection, in-memory rendering of every file (all-or-nothing write), orig
//! tarball creation, git initialization, structural verification and the
//! next-steps message. The interactive wizard and the remaining templates
//! (python, meson, cmake, autotools, go, rust, makefile) are follow-up work
//! built on top of the same [`options::NewOptions`] and
//! [`templates::Template`] surface.
//! detection, in-memory rendering of every file (all-or-nothing write), the
//! template post-write hook (e.g. `cargo vendor`), orig tarball creation,
//! git initialization, structural verification and the next-steps message.
//! The interactive wizard ([`questions`]) fills a [`options::NewCli`] from
//! its answers on a TTY and reuses [`options::resolve`] as the single source
//! of truth for defaults and validation; without a TTY the same resolution
//! runs flag-driven.
pub mod debian;
pub mod detect;
pub mod git;
pub mod options;
pub mod questions;
pub mod templates;
pub mod verify;
@@ -27,15 +29,17 @@ use templates::OutputFile;
/// Scaffold a full Debian source tree from `opts`.
///
/// Steps, aborting early with a pointed error message:
/// 1. resolve the template (unimplemented ids fail here, not at parse time),
/// 1. resolve the template from the registry,
/// 2. check the target directory (refuse an existing `debian/control`),
/// 3. render every file in memory and check for collisions,
/// 4. write the files all-or-nothing (plus the root `.gitignore` in skeleton
/// mode, appending to an existing one),
/// 5. create the orig tarball (quilt only, refusing overwrites),
/// 6. `git init` unless `--no-git` or already inside a repository,
/// 7. run the structural verification,
/// 8. print the success message with the next steps.
/// 5. run the template post-write hook (e.g. `cargo vendor`, so the vendored
/// sources land inside the orig tarball created next),
/// 6. create the orig tarball (quilt only, refusing overwrites),
/// 7. `git init` unless `--no-git` or already inside a repository,
/// 8. run the structural verification,
/// 9. print the success message with the next steps.
pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box<dyn Error>> {
let pb = multi.add(ProgressBar::new_spinner());
pb.enable_steady_tick(Duration::from_millis(50));
@@ -62,17 +66,13 @@ pub fn scaffold(opts: NewOptions, multi: &MultiProgress) -> Result<(), Box<dyn E
/// The scaffold steps proper, reporting progress through `pb`. Nothing is
/// written to the filesystem before every file rendered successfully.
fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box<dyn Error>> {
// 1. Template resolution: unknown-but-valid ids fail here with the
// friendly message instead of a parse error.
// 1. Template resolution: an id without a registered template fails
// here with the friendly message instead of a parse error.
let template = templates::get(opts.template).ok_or_else(|| {
format!(
"The '{}' template is not implemented yet. Implemented templates: {}.",
"The '{}' template has no registered implementation. \
This is a pkh bug; please report it.",
opts.template,
templates::all()
.iter()
.map(|t| t.id().as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
@@ -173,17 +173,23 @@ fn scaffold_steps(opts: &NewOptions, pb: &ProgressBar) -> Result<(), Box<dyn Err
std::fs::write(target.join(".gitignore"), contents)?;
}
// 5. Orig tarball (quilt only).
// 5. Template post-write hook: run before the orig tarball is created,
// so files added here (rust: vendor/ + .cargo/config.toml) land
// inside it.
pb.set_message("Running template hooks");
template.post_write(opts, &target)?;
// 6. Orig tarball (quilt only).
if !opts.native {
pb.set_message("Creating orig tarball");
debian::create_orig_tarball(&target, &opts.name, &opts.upstream_version_no_epoch())?;
}
// 6. Git.
// 7. Git.
pb.set_message("Initializing git");
git::ensure_repository(&target, opts.git)?;
// 7. Structural verification.
// 8. Structural verification.
pb.set_message("Verifying");
verify::verify(&target)?;
@@ -250,6 +256,9 @@ mod tests {
depends: Vec::new(),
native: false,
git: false,
autopkgtest: false,
pkg_config: false,
watch: None,
}
}
@@ -527,15 +536,6 @@ mod tests {
)
.unwrap_err();
assert!(err.to_string().contains("does not exist"), "{err}");
// Unimplemented template.
let dir = tempdir().unwrap();
let err = scaffold_in(
dir.path(),
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
)
.unwrap_err();
assert!(err.to_string().contains("not implemented yet"), "{err}");
}
#[test]
@@ -555,6 +555,133 @@ mod tests {
);
}
/// End-to-end rust skeleton: the vendoring hook runs before the orig
/// tarball is created, so `.cargo/` (and `vendor/` when dependencies
/// exist) travel inside it. The vendoring step needs host cargo; on a
/// cargo-less host the scaffold still succeeds with a warning.
#[test]
#[serial]
fn scaffold_rust_skeleton_vendors_before_tarball() {
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Rust, "mytool", SourceDir::Skeleton),
)
.unwrap();
let tree = dir.path().join("mytool");
assert!(tree.join("Cargo.toml").exists());
assert!(tree.join("src/main.rs").exists());
// rules: the vendored build overrides, no --locked on a fresh
// skeleton without Cargo.lock.
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
assert!(rules.contains("%:\n\tdh $@\n"));
assert!(rules.contains("override_dh_auto_build:\n\tcargo build --release --offline\n"));
assert!(rules.contains("override_dh_auto_install:\n\tinstall -Dm755 target/release/mytool debian/mytool/usr/bin/mytool"));
assert!(!rules.contains("--locked"));
// control: Architecture any + the cargo/rustc build-deps.
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
assert_eq!(control.binaries[0].get("Architecture"), Some("any"));
assert_eq!(
control.source.get("Build-Depends"),
Some("debhelper-compat (= 13),\ncargo:native,\nrustc:native")
);
// The offline config exists when host cargo vendored the skeleton,
// and both it and the skeleton land inside the orig tarball.
let has_cargo = crate::new::templates::find_on_path("cargo").is_some();
if has_cargo {
let config = std::fs::read_to_string(tree.join(".cargo/config.toml")).unwrap();
assert!(config.contains("[source.crates-io]"), "{config}");
assert!(config.contains("[net]\noffline = true"), "{config}");
}
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&tarball).unwrap(),
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
names.iter().any(|n| n == "mytool-0.1.0/Cargo.toml"),
"{names:?}"
);
assert!(
names.iter().any(|n| n == "mytool-0.1.0/src/main.rs"),
"{names:?}"
);
if has_cargo {
assert!(
names.iter().any(|n| n == "mytool-0.1.0/.cargo/config.toml"),
"{names:?}"
);
}
assert!(!names.iter().any(|n| n.contains("debian")), "{names:?}");
}
/// End-to-end python skeleton: pyproject-based Build-Depends and the
/// module skeleton inside the orig tarball.
#[test]
#[serial]
fn scaffold_python_skeleton_tree() {
let dir = tempdir().unwrap();
scaffold_in(
dir.path(),
opts(TemplateId::Python, "mytool", SourceDir::Skeleton),
)
.unwrap();
let tree = dir.path().join("mytool");
let control = crate::debian::ControlInfo::parse(&tree.join("debian/control")).unwrap();
assert_eq!(control.binaries[0].get("Architecture"), Some("all"));
assert_eq!(
control.source.get("Build-Depends"),
Some(
"debhelper-compat (= 13),\ndh-python,\npython3-all,\n\
pybuild-plugin-pyproject,\npython3-setuptools"
)
);
let rules = std::fs::read_to_string(tree.join("debian/rules")).unwrap();
assert!(rules.contains("%:\n\tdh $@ --with python3 --buildsystem=pybuild\n"));
let tarball = dir.path().join("mytool_0.1.0.orig.tar.xz");
let mut archive = tar::Archive::new(xz2::read::XzDecoder::new(
std::fs::File::open(&tarball).unwrap(),
));
let names: Vec<String> = archive
.entries()
.unwrap()
.map(|entry| {
entry
.unwrap()
.path()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect();
assert!(
names.iter().any(|n| n == "mytool-0.1.0/pyproject.toml"),
"{names:?}"
);
assert!(
names.iter().any(|n| n == "mytool-0.1.0/mytool/__init__.py"),
"{names:?}"
);
}
/// End-to-end: the scaffolded shell tree passes the real source build
/// (`dpkg-source` and friends, same prerequisites as the differential
/// tests).