new: add interactive wizard and remaining ecosystem templates

This commit is contained in:
2026-09-16 13:49:29 +02:00
parent d044f757e9
commit 9b98f5c7c3
17 changed files with 3930 additions and 99 deletions
+192 -1
View File
@@ -21,6 +21,8 @@
use std::path::Path;
use regex::Regex;
use super::options::TemplateId;
/// Outcome of the detection.
@@ -74,7 +76,7 @@ pub fn detect(dir: &Path) -> Detection {
/// 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> {
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() {
@@ -107,6 +109,102 @@ fn has_shebang(path: &Path) -> bool {
content.starts_with(b"#!")
}
/// License files looked at by [`sniff_license`], in preference order.
const LICENSE_FILES: [&str; 5] = [
"LICENSE",
"LICENSE.md",
"LICENSE.txt",
"COPYING",
"COPYING.txt",
];
/// 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 a short list of recognizable licenses (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 = LICENSE_FILES
.iter()
.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);
}
let text = content.to_ascii_lowercase();
if text.contains("apache license") && text.contains("version 2") {
return Some("Apache-2.0".to_string());
}
if text.contains("lesser general public license") {
return if text.contains("version 3") && !text.contains("version 2.1") {
Some("LGPL-3.0+".to_string())
} else {
Some("LGPL-2.1+".to_string())
};
}
if text.contains("general public license") {
return if text.contains("version 3") {
Some("GPL-3.0+".to_string())
} else {
Some("GPL-2.0+".to_string())
};
}
if text.contains("mit license") || text.contains("permission is hereby granted, free of charge")
{
return Some("MIT".to_string());
}
if text.contains("isc license")
|| text.contains("permission to use, copy, modify, and/or distribute this software")
{
return Some("ISC".to_string());
}
if text.contains("redistribution and use in source and binary forms") {
// The third clause (name endorsement) is what sets BSD-3 apart
// from BSD-2.
return if text.contains("endorse or promote") {
Some("BSD-3-Clause".to_string())
} else {
Some("BSD-2-Clause".to_string())
};
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -203,4 +301,97 @@ mod tests {
// 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.
const LICENSE_TEXTS: [(&str, &str); 9] = [
(
"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",
),
];
#[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);
}
}