use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const SPEC_SHORT_ID_LEN: usize = 8;
const COMMENT_PREFIXES: &[&str] = &["//", "#", "--", ";"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SparringStatus {
Unlinked,
Linked,
Reconciling,
}
impl SparringStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
SparringStatus::Unlinked => "unlinked",
SparringStatus::Linked => "linked",
SparringStatus::Reconciling => "reconciling",
}
}
}
pub fn validate_spec_link(test_path: &str, test_body: &str, spec_id: &str) -> Result<(), String> {
if find_marker_spec_id(test_body).as_deref() == Some(spec_id) {
return Ok(());
}
if filename_claims_spec(test_path, spec_id) {
return Ok(());
}
Err(format!(
"test_body has no `trv-spec: {spec_id}` marker and basename of \
`{test_path}` lacks `spec_{short}` — add a line-comment marker \
(`// trv-spec: {spec_id}`, `# trv-spec: ...`, `-- trv-spec: ...`, \
or `; trv-spec: ...`) to the body or name the file to include \
`spec_{short}`",
short = short_id(spec_id),
))
}
#[must_use]
pub fn find_marker_spec_id(body: &str) -> Option<String> {
for raw in body.lines() {
let line = raw.trim_start();
let Some(rest) = strip_comment_prefix(line) else {
continue;
};
let rest = rest.trim_start();
let Some(after) = rest.strip_prefix("trv-spec:") else {
continue;
};
let id = after
.trim_start()
.split(|c: char| c.is_whitespace() || c == '*')
.next()
.unwrap_or("")
.trim_end_matches(|c: char| !c.is_ascii_hexdigit() && c != '-');
if id.is_empty() {
continue;
}
return Some(id.to_string());
}
None
}
#[must_use]
pub fn filename_claims_spec(test_path: &str, spec_id: &str) -> bool {
let Some(base) = Path::new(test_path).file_name().and_then(|s| s.to_str()) else {
return false;
};
let needle = format!("spec_{}", short_id(spec_id));
base.contains(&needle)
}
fn short_id(spec_id: &str) -> &str {
let cut = spec_id
.char_indices()
.nth(SPEC_SHORT_ID_LEN)
.map(|(i, _)| i)
.unwrap_or(spec_id.len());
&spec_id[..cut]
}
fn strip_comment_prefix(line: &str) -> Option<&str> {
for prefix in COMMENT_PREFIXES {
if let Some(rest) = line.strip_prefix(prefix) {
return Some(rest);
}
}
None
}
#[must_use]
pub fn scan_spec_links(repo_root: &Path, spec_ids: &[String]) -> HashMap<String, PathBuf> {
use std::collections::HashSet;
if spec_ids.is_empty() {
return HashMap::new();
}
let wanted: HashSet<&str> = spec_ids.iter().map(|s| s.as_str()).collect();
let short_to_full: HashMap<String, &String> = spec_ids
.iter()
.map(|s| (short_id(s).to_string(), s))
.collect();
let mut out: HashMap<String, PathBuf> = HashMap::new();
let mut stack: Vec<PathBuf> = vec![repo_root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if file_type.is_dir() {
if SCAN_SKIP_DIRS.contains(&name_str.as_ref()) {
continue;
}
if name_str.starts_with('.') && name_str.as_ref() != ".travelagent" {
continue;
}
stack.push(path);
continue;
}
if !file_type.is_file() {
continue;
}
let rel = path.strip_prefix(repo_root).unwrap_or(&path);
let rel_str = rel.to_string_lossy().replace('\\', "/");
claim_via_filename(&rel_str, &short_to_full, &mut out);
claim_via_marker(&path, &rel_str, &wanted, &mut out);
}
}
out
}
fn claim_via_filename(
rel_str: &str,
short_to_full: &HashMap<String, &String>,
out: &mut HashMap<String, PathBuf>,
) {
let Some(base) = Path::new(rel_str).file_name().and_then(|s| s.to_str()) else {
return;
};
for (short, full) in short_to_full {
let needle = format!("spec_{short}");
if base.contains(&needle) {
out.entry((*full).clone())
.or_insert_with(|| PathBuf::from(rel_str));
}
}
}
fn claim_via_marker(
abs: &Path,
rel_str: &str,
wanted: &std::collections::HashSet<&str>,
out: &mut HashMap<String, PathBuf>,
) {
let Ok(meta) = std::fs::metadata(abs) else {
return;
};
if meta.len() > MAX_SCAN_BYTES as u64 {
return;
}
let Ok(bytes) = std::fs::read(abs) else {
return;
};
let sniff_len = bytes.len().min(SNIFF_BYTES);
if bytes[..sniff_len].contains(&0u8) {
return;
}
let Ok(text) = std::str::from_utf8(&bytes) else {
return;
};
for raw in text.lines() {
let line = raw.trim_start();
let Some(rest) = strip_comment_prefix(line) else {
continue;
};
let Some(after) = rest.trim_start().strip_prefix("trv-spec:") else {
continue;
};
let id = after
.trim_start()
.split(|c: char| c.is_whitespace() || c == '*')
.next()
.unwrap_or("")
.trim_end_matches(|c: char| !c.is_ascii_hexdigit() && c != '-');
if id.is_empty() {
continue;
}
if wanted.contains(id) {
out.entry(id.to_string())
.or_insert_with(|| PathBuf::from(rel_str));
}
}
}
const MAX_SCAN_BYTES: usize = 512 * 1024;
const SNIFF_BYTES: usize = 8 * 1024;
const SCAN_SKIP_DIRS: &[&str] = &[
".git",
"target",
"node_modules",
"dist",
"build",
"__pycache__",
".venv",
"venv",
".tox",
".mypy_cache",
".pytest_cache",
];
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const FULL_ID: &str = "deadbeef-cafe-babe-feed-0123456789ab";
const SHORT: &str = "deadbeef";
#[test]
fn sparring_status_wire_names_are_stable() {
assert_eq!(SparringStatus::Unlinked.as_str(), "unlinked");
assert_eq!(SparringStatus::Linked.as_str(), "linked");
assert_eq!(SparringStatus::Reconciling.as_str(), "reconciling");
}
#[test]
fn find_marker_accepts_double_slash() {
let body = "// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\nfn test_x() {}\n";
assert_eq!(find_marker_spec_id(body).as_deref(), Some(FULL_ID));
}
#[test]
fn find_marker_accepts_hash_prefix() {
let body = "# trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n\ndef test_x(): pass\n";
assert_eq!(find_marker_spec_id(body).as_deref(), Some(FULL_ID));
}
#[test]
fn find_marker_accepts_double_dash_prefix() {
let body = "-- trv-spec: deadbeef-cafe-babe-feed-0123456789ab\nSELECT 1;\n";
assert_eq!(find_marker_spec_id(body).as_deref(), Some(FULL_ID));
}
#[test]
fn find_marker_accepts_semicolon_prefix() {
let body = "; trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n";
assert_eq!(find_marker_spec_id(body).as_deref(), Some(FULL_ID));
}
#[test]
fn find_marker_tolerates_leading_whitespace() {
let body = " // trv-spec: deadbeef-cafe-babe-feed-0123456789ab \n";
assert_eq!(find_marker_spec_id(body).as_deref(), Some(FULL_ID));
}
#[test]
fn find_marker_returns_none_without_marker() {
let body = "fn test_x() {}\n";
assert_eq!(find_marker_spec_id(body), None);
}
#[test]
fn filename_claims_spec_matches_short_prefix() {
assert!(filename_claims_spec(
"tests/spec_deadbeef_addition.rs",
FULL_ID
));
assert!(filename_claims_spec("a/b/spec_deadbeef.rs", FULL_ID));
}
#[test]
fn filename_claims_spec_rejects_mismatched_short_id() {
assert!(!filename_claims_spec(
"tests/spec_feedface_addition.rs",
FULL_ID
));
}
#[test]
fn filename_claims_spec_rejects_non_spec_folder() {
assert!(!filename_claims_spec(".specs/deadbeef.rs", FULL_ID));
}
#[test]
fn validate_spec_link_accepts_marker() {
let body = "// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n";
assert!(validate_spec_link("tests/whatever.rs", body, FULL_ID).is_ok());
}
#[test]
fn validate_spec_link_accepts_filename_fallback() {
let body = "fn test_x() {}\n";
assert!(validate_spec_link("tests/spec_deadbeef_adds.rs", body, FULL_ID).is_ok());
}
#[test]
fn validate_spec_link_rejects_both_missing() {
let body = "fn test_x() {}\n";
let err = validate_spec_link("tests/whatever.rs", body, FULL_ID).unwrap_err();
assert!(err.contains("trv-spec"));
assert!(err.contains(SHORT));
}
#[test]
fn validate_spec_link_rejects_marker_for_different_spec() {
let body = "// trv-spec: feedface-cafe-babe-feed-0123456789ab\n";
assert!(validate_spec_link("tests/whatever.rs", body, FULL_ID).is_err());
}
#[test]
fn scan_finds_markered_file() {
let tmp = tempfile::tempdir().expect("tmpdir");
fs::create_dir_all(tmp.path().join("tests")).unwrap();
fs::write(
tmp.path().join("tests/bar.rs"),
"// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\nfn test_x() {}\n",
)
.unwrap();
let specs = vec![FULL_ID.to_string()];
let links = scan_spec_links(tmp.path(), &specs);
assert_eq!(links.len(), 1);
assert_eq!(
links.get(FULL_ID).unwrap().to_string_lossy(),
"tests/bar.rs"
);
}
#[test]
fn scan_finds_filename_fallback() {
let tmp = tempfile::tempdir().expect("tmpdir");
fs::create_dir_all(tmp.path().join("tests")).unwrap();
fs::write(
tmp.path().join("tests/spec_deadbeef_adds.py"),
"def test_x(): pass\n",
)
.unwrap();
let specs = vec![FULL_ID.to_string()];
let links = scan_spec_links(tmp.path(), &specs);
assert_eq!(
links.get(FULL_ID).unwrap().to_string_lossy(),
"tests/spec_deadbeef_adds.py"
);
}
#[test]
fn scan_skips_git_and_target_dirs() {
let tmp = tempfile::tempdir().expect("tmpdir");
fs::create_dir_all(tmp.path().join(".git")).unwrap();
fs::create_dir_all(tmp.path().join("target/debug")).unwrap();
fs::write(
tmp.path().join(".git/trap.rs"),
"// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n",
)
.unwrap();
fs::write(
tmp.path().join("target/debug/trap.rs"),
"// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n",
)
.unwrap();
let specs = vec![FULL_ID.to_string()];
let links = scan_spec_links(tmp.path(), &specs);
assert!(links.is_empty(), "must not pick up files under .git/target");
}
#[test]
fn scan_skips_binary_files() {
let tmp = tempfile::tempdir().expect("tmpdir");
let mut bytes = b"// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n".to_vec();
bytes.push(0); bytes.extend_from_slice(b"more\n");
fs::write(tmp.path().join("embedded.bin"), bytes).unwrap();
let specs = vec![FULL_ID.to_string()];
let links = scan_spec_links(tmp.path(), &specs);
assert!(links.is_empty());
}
#[test]
fn scan_is_noop_when_no_specs() {
let tmp = tempfile::tempdir().expect("tmpdir");
fs::write(
tmp.path().join("stray.rs"),
"// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n",
)
.unwrap();
let links = scan_spec_links(tmp.path(), &[]);
assert!(links.is_empty());
}
#[test]
fn scan_first_match_wins() {
let tmp = tempfile::tempdir().expect("tmpdir");
fs::create_dir_all(tmp.path().join("a")).unwrap();
fs::create_dir_all(tmp.path().join("b")).unwrap();
fs::write(
tmp.path().join("a/one.rs"),
"// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n",
)
.unwrap();
fs::write(
tmp.path().join("b/two.rs"),
"// trv-spec: deadbeef-cafe-babe-feed-0123456789ab\n",
)
.unwrap();
let specs = vec![FULL_ID.to_string()];
let links = scan_spec_links(tmp.path(), &specs);
assert_eq!(links.len(), 1);
let path = links.get(FULL_ID).unwrap();
let s = path.to_string_lossy();
assert!(s == "a/one.rs" || s == "b/two.rs", "got: {s}");
}
}