new: template manifests and registry infrastructure

Split the Template trait into a data half and a logic half. Every
template is now declared by a manifest under data/templates/<id>/
(CLI id, wizard label, detection markers, Build-Depends, architecture,
rules dh line, rules-extra body, control source fields, gitignore
entries and static file bodies with {placeholder} substitution),
embedded through the TEMPLATE_SOURCES index and parsed once into the
registry; the order of the index is the wizard menu order and the
detection priority at once. The logic half is the slim TemplateHooks
trait (probe, post_write, file-body overrides merged over the manifest
bodies by path shadowing, Build-Depends/architecture amendments and
extra context values), registered per template as a HOOKS static: a
template without hooks needs zero Rust.

- TemplateId becomes a Copy wrapper of the stable CLI string; the
  enum, its all/as_str/display_name/from_label matches and the old
  statics array collapse into the registry accessors.
- rust's rules overrides move to data/templates/rust/rules.extra.tpl
  with {locked}/{artifact} hook context; python's backend table,
  meson/cmake's pkg-config opt-in, autotools' gettext and python's
  C-extension hints become hook amendments over the manifest baseline.
- detect.rs drops its hardcoded marker cascade: the manifests'
  detect.files drive detection in registry order, with the shell
  single-script heuristic and the never-detected empty template kept
  as the code special cases they are. License sniffing is untouched.
- The template tests port to manifest validation: registry coverage
  and stable order, placeholder presence in the rendering context,
  rules composition, the Build-Depends/architecture/dh-line table now
  asserted against the manifest data, and the hook shadowing merge.

The static skeleton bodies of the shell/empty/makefile/go templates
stay in their Rust hooks for now; the next commit moves them into
their manifests.
This commit is contained in:
2026-09-18 14:46:28 +02:00
parent fc0d2f247e
commit 04a572cd77
26 changed files with 1477 additions and 716 deletions
+33 -32
View File
@@ -139,7 +139,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
LanguageChoice::Ask(preselected) => {
let menu = language_menu(&[]);
let default = preselected
.unwrap_or(TemplateId::Empty)
.unwrap_or(TemplateId::EMPTY)
.display_name()
.to_string();
let id = select_template(&menu, &default)?;
@@ -166,7 +166,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// The rust toolchain pin of the packaged project does not travel into
// the chroot build: surface it now so a too-old pin is not a surprise
// when `pkh deb` compiles with the distribution's rustc.
let toolchain_pin = if template == TemplateId::Rust {
let toolchain_pin = if template == TemplateId::RUST {
probe.as_ref().and_then(|p| p.toolchain_pin.clone())
} else {
None
@@ -390,7 +390,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// same `validate_command` bar as `resolve` applies (which also
// requires a non-empty answer), so an unusable probe is withheld and
// invalid input re-asks here instead of failing late in `resolve`.
if cli.command.is_none() && template != TemplateId::Empty {
if cli.command.is_none() && template != TemplateId::EMPTY {
let default = probe
.as_ref()
.and_then(|p| p.command.clone())
@@ -469,7 +469,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
}
// 13. Metapackage Depends (empty template only).
if template == TemplateId::Empty && cli.depends.is_empty() {
if template == TemplateId::EMPTY && cli.depends.is_empty() {
let validate = |answer: &str| options::validate_depends(answer).map(|_| ());
let answer = ask_text(
"Depends (metapackage, comma-separated, blank for an empty base)",
@@ -504,7 +504,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
// 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)
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),
@@ -514,7 +514,7 @@ async fn run_wizard(mut cli: NewCli) -> Result<NewOptions, Box<dyn Error>> {
}
// Wizard-only extras (default off).
if template != TemplateId::Empty
if template != TemplateId::EMPTY
&& prompt::confirm(
"Add an autopkgtest smoke test (debian/tests/control)?",
false,
@@ -686,8 +686,8 @@ fn same_directory(a: &std::path::Path, b: &std::path::Path) -> bool {
/// 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 => (
TemplateId::MESON => ("meson.build", &["dependency("]),
TemplateId::CMAKE => (
"CMakeLists.txt",
&[
"pkg_check_modules",
@@ -727,7 +727,8 @@ fn language_menu(candidates: &[TemplateId]) -> Vec<String> {
.copied()
.chain(
TemplateId::all()
.into_iter()
.iter()
.copied()
.filter(|id| !candidates.contains(id)),
)
.map(|id| id.display_name().to_string())
@@ -1033,12 +1034,12 @@ pub fn summary_text(opts: &NewOptions, toolchain_pin: Option<&str>) -> String {
" debian/control Source + 1 binary (Architecture: {})",
template.architecture(opts)
));
if opts.template == TemplateId::Empty {
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 {
} else if opts.template == TemplateId::RUST {
// Nothing is vendored yet at this point: only announce that the
// generation will attempt it.
lines.push(
@@ -1094,7 +1095,7 @@ pub fn summary_text(opts: &NewOptions, toolchain_pin: Option<&str>) -> String {
lines.push(format!(" + {} (new skeleton)", names.join(", ")));
}
}
if opts.template == TemplateId::Rust && templates::find_on_path("cargo").is_none() {
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 \
@@ -1162,7 +1163,7 @@ mod tests {
#[test]
fn language_menu_lists_candidates_first() {
let menu = language_menu(&[Tid::Makefile, Tid::Rust]);
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.
@@ -1179,8 +1180,8 @@ mod tests {
#[test]
fn language_choice_flag_wins_over_detection() {
let detections = [
Detection::Single(Tid::Rust),
Detection::Ambiguous(vec![Tid::Rust, Tid::Python]),
Detection::Single(Tid::RUST),
Detection::Ambiguous(vec![Tid::RUST, Tid::PYTHON]),
Detection::Empty,
];
for detection in &detections {
@@ -1201,21 +1202,21 @@ mod tests {
#[test]
fn language_choice_without_flag_follows_detection() {
assert_eq!(
language_choice(None, &Detection::Single(Tid::Rust), true),
LanguageChoice::Detected(Tid::Rust)
language_choice(None, &Detection::Single(Tid::RUST), true),
LanguageChoice::Detected(Tid::RUST)
);
// Skeleton run: ask, preselecting the detected ecosystem.
assert_eq!(
language_choice(None, &Detection::Single(Tid::Rust), false),
LanguageChoice::Ask(Some(Tid::Rust))
language_choice(None, &Detection::Single(Tid::RUST), false),
LanguageChoice::Ask(Some(Tid::RUST))
);
assert_eq!(
language_choice(
None,
&Detection::Ambiguous(vec![Tid::Go, Tid::Python]),
&Detection::Ambiguous(vec![Tid::GO, Tid::PYTHON]),
true
),
LanguageChoice::Ambiguous(vec![Tid::Go, Tid::Python])
LanguageChoice::Ambiguous(vec![Tid::GO, Tid::PYTHON])
);
// Nothing detected: plain menu, the empty template preselected.
assert_eq!(
@@ -1327,12 +1328,12 @@ mod tests {
#[test]
fn summary_screen_shows_format_and_orig_origin() {
// Native skeleton: the format row, no orig row.
let text = summary_text(&opts(Tid::Shell), None);
let text = summary_text(&opts(Tid::SHELL), None);
assert!(text.contains("debian/source/format 3.0 (native)"), "{text}");
assert!(!text.contains("orig tarball"), "{text}");
// Quilt over an existing project: both rows.
let mut quilt = opts(Tid::Shell);
let mut quilt = opts(Tid::SHELL);
quilt.source_dir = options::SourceDir::Here;
quilt.source_format = options::SourceFormat::Quilt;
quilt.orig = Some(options::OrigOrigin::GitArchive {
@@ -1360,7 +1361,7 @@ mod tests {
#[test]
fn summary_screen_skeleton() {
let text = summary_text(&opts(Tid::Makefile), None);
let text = summary_text(&opts(Tid::MAKEFILE), None);
assert!(
text.contains("mytool 0.1.0-1 · builds for ubuntu/resolute"),
"{text}"
@@ -1390,7 +1391,7 @@ mod tests {
#[test]
fn summary_screen_metapackage_shows_depends() {
let mut o = opts(Tid::Empty);
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, None);
@@ -1406,7 +1407,7 @@ mod tests {
#[test]
fn summary_screen_release_and_extras() {
let mut o = opts(Tid::Shell);
let mut o = opts(Tid::SHELL);
o.release = true;
o.autopkgtest = true;
o.watch = Some("version=4\n".to_string());
@@ -1424,7 +1425,7 @@ mod tests {
/// yet (regression: it claimed "(vendored)" before generating).
#[test]
fn summary_screen_rust_does_not_presume_vendoring() {
let text = summary_text(&opts(Tid::Rust), None);
let text = summary_text(&opts(Tid::RUST), None);
assert!(
text.contains("cargo build --release --offline (vendored at generation)"),
"{text}"
@@ -1436,17 +1437,17 @@ mod tests {
/// (rust template only), flagged as ignored by the chroot build.
#[test]
fn summary_screen_shows_the_toolchain_pin() {
let text = summary_text(&opts(Tid::Rust), Some("1.98.0"));
let text = summary_text(&opts(Tid::RUST), Some("1.98.0"));
assert!(
text.contains("rust-toolchain 1.98.0 (ignored by the chroot build)"),
"{text}"
);
// No pin, no row.
assert!(!summary_text(&opts(Tid::Rust), None).contains("rust-toolchain"));
assert!(!summary_text(&opts(Tid::RUST), None).contains("rust-toolchain"));
// A pin under a template other than rust is not shown either (the
// pin only matters for a cargo build).
assert!(!summary_text(&opts(Tid::Go), Some("1.98.0")).contains("rust-toolchain"));
assert!(!summary_text(&opts(Tid::GO), Some("1.98.0")).contains("rust-toolchain"));
}
/// The git-init question is only asked when a git init would actually
@@ -1595,7 +1596,7 @@ mod tests {
// The initial pass over dir A.
let (detection_a, probe_a) = detect_and_probe(dir_a.path());
assert_eq!(detection_a, Detection::Single(Tid::Rust));
assert_eq!(detection_a, Detection::Single(Tid::RUST));
let probe_a = probe_a.expect("dir A is a rust project");
assert_eq!(probe_a.name.as_deref(), Some("alpha"));
assert_eq!(probe_a.version.as_deref(), Some("0.1.0"));
@@ -1604,7 +1605,7 @@ mod tests {
// The user chose dir B instead: the refreshed probe comes from B,
// never from A.
let (detection_b, probe_b) = detect_and_probe(dir_b.path());
assert_eq!(detection_b, Detection::Single(Tid::Rust));
assert_eq!(detection_b, Detection::Single(Tid::RUST));
let probe_b = probe_b.expect("dir B is a rust project");
assert_eq!(probe_b.name.as_deref(), Some("beta"));
assert_eq!(probe_b.version.as_deref(), Some("2.9.9"));