Files
pkh/src/new/detect.rs
T
vhaudiquet 04a572cd77 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.
2026-09-18 14:46:28 +02:00

368 lines
13 KiB
Rust

//! Project detection for `pkh new`: which template matches an existing
//! source directory.
//!
//! The rule set is deliberately simple and table-driven (highest precedence
//! first):
//!
//! 1. the `detect.files` marker files declared by the template manifests
//! (`data/templates/<id>/manifest.yml`, in registry order: `Cargo.toml`,
//! `pyproject.toml`/`setup.py`/`setup.cfg`, `meson.build`,
//! `CMakeLists.txt`, `configure.ac`, `go.mod`, `Makefile`) looked for at
//! the top level of the directory — more than one distinct template
//! matching is [`Detection::Ambiguous`]; templates without markers
//! (shell: the single-script heuristic below; empty: never detected)
//! declare none,
//! 2. otherwise, exactly one top-level script (a `*.sh` file, or a file
//! whose first line is a `#!` shebang) → [`TemplateId::SHELL`],
//! several scripts or none → nothing,
//! 3. otherwise [`Detection::Empty`].
//!
//! Detection only looks at the top level on purpose: source files below
//! `src/` etc. carry no extra signal (a `src/main.rs` without `Cargo.toml`
//! is not a Rust project pkh can package), and recursion would turn stray
//! vendored files into false matches.
use std::path::Path;
use regex::Regex;
use super::licenses;
use super::options::TemplateId;
use super::templates;
/// Outcome of the detection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Detection {
/// Exactly one template matches.
Single(TemplateId),
/// Several templates match; the caller must ask (wizard) or demand an
/// explicit `--lang`.
Ambiguous(Vec<TemplateId>),
/// Nothing recognized.
Empty,
}
/// Detect the template matching the project in `dir`: the manifests'
/// marker files in registry order (the detection priority), then the
/// shell single-script heuristic.
pub fn detect(dir: &Path) -> Detection {
let mut hits: Vec<TemplateId> = Vec::new();
for template in templates::all() {
let markers = template.detect_files();
if !markers.is_empty()
&& markers.iter().any(|marker| dir.join(marker).exists())
&& !hits.contains(&template.id())
{
hits.push(template.id());
}
}
match hits.as_slice() {
[] => {}
[only] => return Detection::Single(*only),
_ => return Detection::Ambiguous(hits),
}
if single_script(dir).is_some() {
Detection::Single(TemplateId::SHELL)
} else {
Detection::Empty
}
}
/// The single top-level script of `dir`, if there is exactly one: a file
/// with the `.sh` extension, or whose first line starts with `#!`. Returns
/// `None` when there are zero or several candidates.
pub fn single_script(dir: &Path) -> Option<std::path::PathBuf> {
let mut found: Option<std::path::PathBuf> = None;
let entries = std::fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
// Hidden files and packaging leftovers carry no signal.
if name.starts_with('.') {
continue;
}
let is_script = name.ends_with(".sh") || has_shebang(&path);
if is_script {
if found.is_some() {
return None;
}
found = Some(path);
}
}
found
}
/// Whether the first line of the file starts with `#!`.
fn has_shebang(path: &Path) -> bool {
let Ok(content) = std::fs::read(path) else {
return false;
};
content.starts_with(b"#!")
}
/// 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 the marker sets of the bundled license table
/// (`data/licenses.yml`: 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 = licenses::detect_files()
.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);
}
// The recognizable-license markers live in the bundled table; the
// LICENSE_TEXTS test below is their behavioral lock.
let text = content.to_ascii_lowercase();
licenses::detect_from_text(&text).map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn touch(dir: &Path, name: &str) {
std::fs::write(dir.join(name), "x").unwrap();
}
#[test]
fn marker_files_map_to_templates() {
let cases = [
("Cargo.toml", TemplateId::RUST),
("pyproject.toml", TemplateId::PYTHON),
("setup.py", TemplateId::PYTHON),
("setup.cfg", TemplateId::PYTHON),
("meson.build", TemplateId::MESON),
("CMakeLists.txt", TemplateId::CMAKE),
("configure.ac", TemplateId::AUTOTOOLS),
("go.mod", TemplateId::GO),
("Makefile", TemplateId::MAKEFILE),
];
for (marker, expected) in cases {
let dir = tempdir().unwrap();
touch(dir.path(), marker);
assert_eq!(detect(dir.path()), Detection::Single(expected), "{marker}");
}
}
#[test]
fn multiple_markers_are_ambiguous() {
let dir = tempdir().unwrap();
touch(dir.path(), "Cargo.toml");
touch(dir.path(), "Makefile");
assert_eq!(
detect(dir.path()),
Detection::Ambiguous(vec![TemplateId::RUST, TemplateId::MAKEFILE])
);
let dir = tempdir().unwrap();
touch(dir.path(), "pyproject.toml");
touch(dir.path(), "setup.py");
// Both markers map to the same template: one hit, not ambiguous.
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::PYTHON));
let dir = tempdir().unwrap();
touch(dir.path(), "meson.build");
touch(dir.path(), "CMakeLists.txt");
assert_eq!(
detect(dir.path()),
Detection::Ambiguous(vec![TemplateId::MESON, TemplateId::CMAKE])
);
}
#[test]
fn single_script_is_shell() {
// .sh extension.
let dir = tempdir().unwrap();
touch(dir.path(), "run.sh");
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::SHELL));
// Shebang without extension.
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("run"), "#!/usr/bin/env python3\n").unwrap();
assert_eq!(detect(dir.path()), Detection::Single(TemplateId::SHELL));
// Two scripts: not exactly one, nothing recognized.
let dir = tempdir().unwrap();
touch(dir.path(), "a.sh");
touch(dir.path(), "b.sh");
assert_eq!(detect(dir.path()), Detection::Empty);
// Plain files without shebang are not scripts.
let dir = tempdir().unwrap();
touch(dir.path(), "README");
assert_eq!(detect(dir.path()), Detection::Empty);
}
#[test]
fn nothing_matches_is_empty() {
let dir = tempdir().unwrap();
assert_eq!(detect(dir.path()), Detection::Empty);
// Nonexistent directory: empty, not a panic.
let dir = tempdir().unwrap();
assert_eq!(detect(&dir.path().join("missing")), Detection::Empty);
}
#[test]
fn hidden_files_and_subdirs_are_ignored() {
let dir = tempdir().unwrap();
std::fs::create_dir(dir.path().join("subdir.sh")).unwrap();
std::fs::write(dir.path().join(".hidden.sh"), "#!/bin/sh\n").unwrap();
// The only "real" script candidate is in a subdir or hidden: no hit.
assert_eq!(detect(dir.path()), Detection::Empty);
}
/// Distinctive (shortened) excerpts of the recognizable license texts:
/// the behavioral lock of the marker sets in `data/licenses.yml` — a
/// bad marker edit fails here, not on real packages.
const LICENSE_TEXTS: [(&str, &str); 11] = [
(
"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",
),
// Dual-licensed preamble: the GPL reference outranks the MIT
// boilerplate, like the old hardcoded cascade decided.
(
"GPL-2.0+",
"MIT License\n\nAlternatively, under the terms of the GNU General Public License,\
\nversion 2 of the License.",
),
// LGPL text naming both versions: the 2.1 wording wins.
(
"LGPL-2.1+",
"GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999\n\
This is version 2.1; version 3 is available separately.",
),
];
#[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);
}
}