use std::path::{Component, Path, PathBuf};
use serde::Serialize;
const SKIP: &[&str] = &["target", "node_modules", ".git", ".venv", "lib", "pki"];
const MAX_DEPTH: usize = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Project,
Campaign,
Scenario,
Model,
}
#[must_use]
pub fn classify(text: &str) -> Option<Kind> {
let table: toml::Table = text.parse().ok()?;
let has = |k: &str| table.contains_key(k);
if has("engine") || has("instance") {
Some(Kind::Project)
} else if has("project")
&& ["case", "grid", "sample", "replicates", "out"]
.iter()
.any(|k| has(k))
{
Some(Kind::Campaign)
} else if has("event") || has("stop") || has("set") || has("seed") {
Some(Kind::Scenario)
} else if has("abi") && has("variables") {
Some(Kind::Model)
} else {
None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Entry {
pub path: String,
pub kind: Kind,
}
#[must_use]
pub fn relative(root: &Path, path: &Path) -> String {
let rel = path.strip_prefix(root).unwrap_or(path);
rel.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/")
}
#[must_use]
pub fn scan(root: &Path) -> Vec<Entry> {
let mut found = Vec::new();
let mut stack = vec![(root.to_owned(), 0)];
while let Some((dir, depth)) = stack.pop() {
if dir.join("campaign.json").is_file() || dir.join("run.json").is_file() {
continue;
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let Ok(ty) = entry.file_type() else { continue };
if ty.is_dir() {
if depth < MAX_DEPTH && !name.starts_with('.') && !SKIP.contains(&name.as_str()) {
stack.push((path, depth + 1));
}
} else if name.ends_with(".toml")
&& let Ok(text) = std::fs::read_to_string(&path)
&& let Some(kind) = classify(&text)
{
found.push(Entry {
path: relative(root, &path),
kind,
});
}
}
}
found.sort_by(|a, b| a.path.cmp(&b.path));
found
}
#[must_use]
pub fn resolve(root: &Path, rel: &str) -> Option<PathBuf> {
let path = root.join(rel.trim_start_matches('/')).canonicalize().ok()?;
path.starts_with(root).then_some(path)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "tests")]
mod tests {
use super::*;
#[test]
fn files_are_classified_by_their_tables() {
assert_eq!(classify("[engine]\ntick_ms = 1.0\n"), Some(Kind::Project));
assert_eq!(
classify("project = \"p.toml\"\n[grid]\n\"a.k\" = [1, 2]\n"),
Some(Kind::Campaign)
);
assert_eq!(
classify("[stop]\ntime_s = 1.0\n[[event]]\nat_s = 0.5\n"),
Some(Kind::Scenario)
);
assert_eq!(
classify("name = \"pi\"\n[[variables]]\nname = \"u\"\n[abi]\n"),
Some(Kind::Model)
);
assert_eq!(classify("[package]\nname = \"x\"\n"), None);
assert_eq!(classify("not toml ["), None);
}
#[test]
fn the_scan_skips_outputs_and_paths_stay_inside() {
let root = std::env::temp_dir().join(format!("tw-scan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("out/c/runs/r1")).unwrap();
std::fs::create_dir_all(root.join("sc")).unwrap();
std::fs::write(root.join("p.toml"), "[engine]\ntick_ms = 1.0\n").unwrap();
std::fs::write(root.join("sc/s.toml"), "[stop]\ncycles = 3\n").unwrap();
std::fs::write(root.join("out/c/campaign.json"), "{}").unwrap();
std::fs::write(root.join("out/c/runs/r1/run.toml"), "[stop]\ncycles = 3\n").unwrap();
std::fs::write(root.join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
let root = root.canonicalize().unwrap();
let found = scan(&root);
assert_eq!(
found,
[
Entry {
path: "p.toml".into(),
kind: Kind::Project
},
Entry {
path: "sc/s.toml".into(),
kind: Kind::Scenario
},
]
);
assert_eq!(resolve(&root, "sc/s.toml"), Some(root.join("sc/s.toml")));
assert_eq!(resolve(&root, "/p.toml"), Some(root.join("p.toml")));
assert_eq!(resolve(&root, "../"), None);
assert_eq!(resolve(&root, "sc/../../etc"), None);
assert_eq!(resolve(&root, "nope.toml"), None);
std::fs::remove_dir_all(&root).unwrap();
}
}