use std::path::Path;
pub struct ProbeOutcome {
pub passed: bool,
pub message: String,
}
impl ProbeOutcome {
fn pass(message: impl Into<String>) -> ProbeOutcome {
ProbeOutcome {
passed: true,
message: message.into(),
}
}
fn fail(message: impl Into<String>) -> ProbeOutcome {
ProbeOutcome {
passed: false,
message: message.into(),
}
}
}
fn stat(path: &Path) -> std::io::Result<Option<std::fs::Metadata>> {
match std::fs::metadata(path) {
Ok(m) => Ok(Some(m)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn lstat(path: &Path) -> std::io::Result<Option<std::fs::Metadata>> {
match std::fs::symlink_metadata(path) {
Ok(m) => Ok(Some(m)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub fn mechanical_probe(id: &str) -> Option<fn(&Path) -> std::io::Result<ProbeOutcome>> {
match id {
"base.doc-pattern" => Some(probe_doc_pattern),
"base.issue-tracking" => Some(probe_issue_tracking),
"base.git-hygiene" => Some(probe_git_hygiene),
"base.readme" => Some(probe_readme),
"base.gitignore" => Some(probe_gitignore),
"canon.s22" => Some(probe_core_cli_split),
_ => None,
}
}
#[cfg(test)]
const MECHANICAL_PROBE_IDS: [&str; 6] = [
"base.doc-pattern",
"base.issue-tracking",
"base.git-hygiene",
"base.readme",
"base.gitignore",
"canon.s22",
];
fn probe_doc_pattern(repo: &Path) -> std::io::Result<ProbeOutcome> {
let agents = stat(&repo.join("AGENTS.md"))?.is_some_and(|m| m.is_file());
let claude = stat(&repo.join("CLAUDE.md"))?.is_some_and(|m| m.is_file());
Ok(match (agents, claude) {
(true, true) => ProbeOutcome::pass("AGENTS.md and CLAUDE.md present"),
(false, true) => ProbeOutcome::fail("AGENTS.md missing at repo root"),
(true, false) => ProbeOutcome::fail("CLAUDE.md missing or not a file at repo root"),
(false, false) => ProbeOutcome::fail("AGENTS.md and CLAUDE.md both missing at repo root"),
})
}
fn probe_issue_tracking(repo: &Path) -> std::io::Result<ProbeOutcome> {
Ok(if stat(&repo.join("issues"))?.is_some_and(|m| m.is_dir()) {
ProbeOutcome::pass("issues/ directory present")
} else {
ProbeOutcome::fail("issues/ directory missing")
})
}
fn probe_git_hygiene(repo: &Path) -> std::io::Result<ProbeOutcome> {
Ok(if lstat(&repo.join(".git"))?.is_some() {
ProbeOutcome::pass(".git present")
} else {
ProbeOutcome::fail(".git missing — not a git repository")
})
}
fn probe_readme(repo: &Path) -> std::io::Result<ProbeOutcome> {
Ok(
if stat(&repo.join("README.md"))?.is_some_and(|m| m.is_file()) {
ProbeOutcome::pass("README.md present")
} else {
ProbeOutcome::fail("README.md missing")
},
)
}
fn probe_gitignore(repo: &Path) -> std::io::Result<ProbeOutcome> {
Ok(
if stat(&repo.join(".gitignore"))?.is_some_and(|m| m.is_file()) {
ProbeOutcome::pass(".gitignore present")
} else {
ProbeOutcome::fail(".gitignore missing")
},
)
}
fn probe_core_cli_split(repo: &Path) -> std::io::Result<ProbeOutcome> {
let crates = repo.join("crates");
let entries = match std::fs::read_dir(&crates) {
Ok(e) => e,
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(ProbeOutcome::fail(
"no crates/ directory (missing or not a directory) — no core/cli split",
));
}
Err(e) => return Err(e),
};
let (mut has_core, mut has_cli) = (false, false);
for entry in entries {
let entry = entry?; if !entry.metadata()?.is_dir() {
continue;
}
let name = entry.file_name();
let name = name.to_string_lossy();
has_core |= name.ends_with("-core");
has_cli |= name.ends_with("-cli");
}
Ok(match (has_core, has_cli) {
(true, true) => ProbeOutcome::pass("crates/*-core + *-cli split present"),
_ => ProbeOutcome::fail("missing a crates/*-core and/or crates/*-cli directory"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use project_canon_core::Model;
struct TmpRepo {
path: std::path::PathBuf,
}
impl TmpRepo {
fn new(tag: &str) -> TmpRepo {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("pc-probes-{tag}-{}-{n}", std::process::id()));
std::fs::create_dir_all(&path).unwrap();
TmpRepo { path }
}
fn touch(&self, rel: &str) {
let p = self.path.join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&p, b"x").unwrap();
}
fn mkdir(&self, rel: &str) {
std::fs::create_dir_all(self.path.join(rel)).unwrap();
}
#[cfg(unix)]
fn symlink(&self, target: &str, link: &str) {
std::os::unix::fs::symlink(target, self.path.join(link)).unwrap();
}
}
impl Drop for TmpRepo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn passed(outcome: std::io::Result<ProbeOutcome>) -> bool {
outcome.expect("no I/O fault on a tmp repo").passed
}
#[test]
fn doc_pattern_probe_distinguishes_missing_files() {
let repo = TmpRepo::new("doc");
assert!(!passed(probe_doc_pattern(&repo.path)));
repo.touch("AGENTS.md");
assert!(!passed(probe_doc_pattern(&repo.path))); repo.touch("CLAUDE.md");
assert!(passed(probe_doc_pattern(&repo.path)));
}
#[cfg(unix)]
#[test]
fn doc_pattern_probe_rejects_a_dangling_claude_symlink() {
let repo = TmpRepo::new("doc-symlink");
repo.touch("AGENTS.md");
repo.symlink("AGENTS.md", "CLAUDE.md");
assert!(passed(probe_doc_pattern(&repo.path)));
std::fs::remove_file(repo.path.join("CLAUDE.md")).unwrap();
repo.symlink("nowhere.md", "CLAUDE.md");
assert!(!passed(probe_doc_pattern(&repo.path)));
}
#[cfg(unix)]
#[test]
fn doc_pattern_probe_rejects_a_directory_named_claude() {
let repo = TmpRepo::new("doc-dir");
repo.touch("AGENTS.md");
repo.mkdir("CLAUDE.md"); assert!(!passed(probe_doc_pattern(&repo.path)));
}
#[test]
fn structural_probes_detect_presence() {
let repo = TmpRepo::new("struct");
assert!(!passed(probe_issue_tracking(&repo.path)));
assert!(!passed(probe_git_hygiene(&repo.path)));
assert!(!passed(probe_readme(&repo.path)));
assert!(!passed(probe_gitignore(&repo.path)));
repo.mkdir("issues");
repo.mkdir(".git");
repo.touch("README.md");
repo.touch(".gitignore");
assert!(passed(probe_issue_tracking(&repo.path)));
assert!(passed(probe_git_hygiene(&repo.path)));
assert!(passed(probe_readme(&repo.path)));
assert!(passed(probe_gitignore(&repo.path)));
}
#[test]
fn core_cli_split_probe_needs_both_crates() {
let repo = TmpRepo::new("split");
assert!(!passed(probe_core_cli_split(&repo.path))); repo.mkdir("crates/foo-core");
assert!(!passed(probe_core_cli_split(&repo.path))); repo.mkdir("crates/foo-cli");
assert!(passed(probe_core_cli_split(&repo.path)));
}
#[test]
fn core_cli_split_treats_a_crates_file_as_a_miss_not_a_fault() {
let repo = TmpRepo::new("crates-file");
repo.touch("crates");
let outcome = probe_core_cli_split(&repo.path).expect("a stray crates file is not a fault");
assert!(!outcome.passed);
assert!(
outcome.message.contains("not a directory"),
"{}",
outcome.message
);
}
#[test]
fn every_mechanical_probe_id_exists_in_the_model() {
let model = Model::standard();
for id in MECHANICAL_PROBE_IDS {
assert!(
model.dimension(id).is_some(),
"probe id {id:?} no longer exists in the model"
);
assert!(
mechanical_probe(id).is_some(),
"probe id {id:?} missing from the mechanical_probe registry"
);
}
}
#[test]
fn a_probe_io_fault_propagates_as_err() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let repo = TmpRepo::new("fault");
repo.mkdir("crates");
std::fs::set_permissions(
repo.path.join("crates"),
std::fs::Permissions::from_mode(0o000),
)
.unwrap();
let result = probe_core_cli_split(&repo.path);
let _ = std::fs::set_permissions(
repo.path.join("crates"),
std::fs::Permissions::from_mode(0o755),
);
if let Err(e) = &result {
assert_ne!(e.kind(), std::io::ErrorKind::NotFound);
}
}
}
}