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.
368 lines
13 KiB
Rust
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);
|
|
}
|
|
}
|