new: scaffold new Debian source packages (non-interactive core)

This commit is contained in:
2026-09-16 12:14:09 +02:00
parent 9c3394750d
commit d044f757e9
12 changed files with 3243 additions and 1 deletions
+206
View File
@@ -0,0 +1,206 @@
//! 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. well-known build-system marker files at the top level of the
//! directory (`Cargo.toml`, `pyproject.toml`/`setup.py`/`setup.cfg`,
//! `meson.build`, `CMakeLists.txt`, `configure.ac`, `go.mod`,
//! `Makefile`) — more than one distinct template matching is
//! [`Detection::Ambiguous`],
//! 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 super::options::TemplateId;
/// 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,
}
/// Marker files per template, in precedence order (see the module docs).
const MARKERS: [(TemplateId, &[&str]); 7] = [
(TemplateId::Rust, &["Cargo.toml"]),
(
TemplateId::Python,
&["pyproject.toml", "setup.py", "setup.cfg"],
),
(TemplateId::Meson, &["meson.build"]),
(TemplateId::Cmake, &["CMakeLists.txt"]),
(TemplateId::Autotools, &["configure.ac"]),
(TemplateId::Go, &["go.mod"]),
(TemplateId::Makefile, &["Makefile"]),
];
/// Detect the template matching the project in `dir`.
pub fn detect(dir: &Path) -> Detection {
let mut hits: Vec<TemplateId> = Vec::new();
for (id, markers) in MARKERS {
if markers.iter().any(|marker| dir.join(marker).exists()) && !hits.contains(&id) {
hits.push(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.
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"#!")
}
#[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);
}
}