new: scaffold new Debian source packages (non-interactive core)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user