use std::path::Path;
use serde_json::{json, Value};
use super::parse_json_input;
use crate::test_runner::{run_command_with_timeout, CommandResult};
const TEST_TIMEOUT_SECS: u64 = 180;
const DIAG_TIMEOUT_SECS: u64 = 120;
pub(super) fn schemas() -> Vec<Value> {
vec![
json!({
"type": "function",
"function": {
"name": "run_tests",
"description": "Run the project test suite. Auto-detects framework (cargo, npm, pytest, go) from project files. Returns pass/fail counts + failures (name, file, line, message). 180s timeout.",
"parameters": {
"type": "object",
"properties": {
"framework": { "type": "string", "description": "Override auto-detect: 'cargo', 'npm', 'pytest', 'go', or 'auto' (default)." },
"filter": { "type": "string", "description": "Optional test-name substring filter (passed to the framework's own filter flag)." }
},
"required": []
}
}
}),
json!({
"type": "function",
"function": {
"name": "diagnostics",
"description": "Run the project typechecker/linter and return structured errors {file, line, code, severity, message}. Auto-detects (cargo check, clippy, tsc, ruff, mypy) from project files. 120s timeout.",
"parameters": {
"type": "object",
"properties": {
"tool": { "type": "string", "description": "Override auto-detect: 'cargo', 'clippy', 'tsc', 'mypy', 'ruff', or 'auto' (default)." }
},
"required": []
}
}
}),
]
}
pub(super) fn dispatch(name: &str, input: &str) -> Option<Result<String, String>> {
let result = match name {
"run_tests" => run_tests(input),
"diagnostics" => run_diagnostics(input),
_ => return None,
};
Some(result)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Framework {
Cargo,
Npm,
Pytest,
Go,
}
impl Framework {
fn parse(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"cargo" | "rust" => Some(Self::Cargo),
"npm" | "node" | "jest" => Some(Self::Npm),
"pytest" | "python" | "py" => Some(Self::Pytest),
"go" | "golang" => Some(Self::Go),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
Self::Cargo => "cargo",
Self::Npm => "npm",
Self::Pytest => "pytest",
Self::Go => "go",
}
}
}
fn detect_framework(start: &Path) -> Option<Framework> {
let mut current: Option<&Path> = Some(start);
while let Some(dir) = current {
if dir.join("Cargo.toml").exists() {
return Some(Framework::Cargo);
}
if dir.join("package.json").exists() {
return Some(Framework::Npm);
}
if dir.join("pytest.ini").exists() || dir.join("pyproject.toml").exists() {
return Some(Framework::Pytest);
}
if dir.join("go.mod").exists() {
return Some(Framework::Go);
}
current = dir.parent();
}
None
}
fn run_tests(input: &str) -> Result<String, String> {
let v = parse_json_input(input, "run_tests")?;
let cwd = crate::missions::active_cwd();
let requested = v.get("framework").and_then(Value::as_str).unwrap_or("auto");
let filter = v
.get("filter")
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
let framework = if requested == "auto" || requested.is_empty() {
detect_framework(&cwd).ok_or_else(|| {
format!(
"run_tests: could not auto-detect a test framework under {} \
(looked for Cargo.toml, package.json, pytest.ini/pyproject.toml, go.mod). \
Pass `framework` explicitly.",
cwd.display()
)
})?
} else {
Framework::parse(requested).ok_or_else(|| {
format!(
"run_tests: unknown framework '{requested}' \
— use 'auto', 'cargo', 'npm', 'pytest', or 'go'."
)
})?
};
let result = invoke(framework, filter, &cwd);
Ok(format_result(framework, &result).to_string())
}
fn invoke(framework: Framework, filter: Option<&str>, cwd: &Path) -> CommandResult {
match framework {
Framework::Cargo => {
let mut args: Vec<&str> = vec!["test"];
if let Some(f) = filter {
args.push("--");
args.push(f);
}
run_command_with_timeout("cargo", &args, TEST_TIMEOUT_SECS, Some(cwd))
}
Framework::Npm => {
let pattern = filter.map(|f| format!("--testNamePattern={f}"));
let mut args: Vec<&str> = vec!["test"];
if pattern.is_some() {
args.push("--");
args.push(pattern.as_deref().unwrap());
}
run_command_with_timeout("npm", &args, TEST_TIMEOUT_SECS, Some(cwd))
}
Framework::Pytest => {
let mut args: Vec<&str> = vec![];
if let Some(f) = filter {
args.push("-k");
args.push(f);
}
run_command_with_timeout("pytest", &args, TEST_TIMEOUT_SECS, Some(cwd))
}
Framework::Go => {
let mut args: Vec<&str> = vec!["test", "./..."];
if let Some(f) = filter {
args.push("-run");
args.push(f);
}
run_command_with_timeout("go", &args, TEST_TIMEOUT_SECS, Some(cwd))
}
}
}
fn format_result(framework: Framework, result: &CommandResult) -> Value {
let combined = format!("{}\n{}", result.stdout, result.stderr);
let (passed, failed) = match framework {
Framework::Cargo => parse_cargo_counts(&combined),
Framework::Pytest => parse_pytest_counts(&combined),
Framework::Go => parse_go_counts(&combined),
Framework::Npm => parse_jest_counts(&combined),
};
let failures = match framework {
Framework::Cargo => parse_cargo_failures(&combined),
Framework::Pytest => parse_pytest_failures(&combined),
Framework::Go => parse_go_failures(&combined),
Framework::Npm => parse_jest_failures(&combined),
};
json!({
"framework": framework.label(),
"exit_code": result.exit_code,
"timed_out": result.timed_out,
"passed": passed,
"failed": failed,
"failures": failures,
"stdout_tail": tail(&result.stdout, 2000),
"stderr_tail": tail(&result.stderr, 2000),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiagTool {
CargoCheck,
Clippy,
Tsc,
Mypy,
Ruff,
}
impl DiagTool {
fn parse(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"cargo" | "cargo-check" | "check" => Some(Self::CargoCheck),
"clippy" => Some(Self::Clippy),
"tsc" | "typescript" => Some(Self::Tsc),
"mypy" => Some(Self::Mypy),
"ruff" => Some(Self::Ruff),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
Self::CargoCheck => "cargo-check",
Self::Clippy => "clippy",
Self::Tsc => "tsc",
Self::Mypy => "mypy",
Self::Ruff => "ruff",
}
}
}
fn detect_diag_tool(start: &Path) -> Option<DiagTool> {
let mut current: Option<&Path> = Some(start);
while let Some(dir) = current {
if dir.join("Cargo.toml").exists() {
return Some(DiagTool::CargoCheck);
}
if dir.join("tsconfig.json").exists() {
return Some(DiagTool::Tsc);
}
if dir.join("pyproject.toml").exists() || dir.join("ruff.toml").exists() {
return Some(DiagTool::Ruff);
}
if dir.join("mypy.ini").exists() {
return Some(DiagTool::Mypy);
}
current = dir.parent();
}
None
}
fn run_diagnostics(input: &str) -> Result<String, String> {
let v = parse_json_input(input, "diagnostics")?;
let cwd = crate::missions::active_cwd();
let requested = v.get("tool").and_then(Value::as_str).unwrap_or("auto");
let tool = if requested == "auto" || requested.is_empty() {
detect_diag_tool(&cwd).ok_or_else(|| {
format!(
"diagnostics: could not auto-detect a checker under {} \
(looked for Cargo.toml, tsconfig.json, pyproject.toml/ruff.toml, mypy.ini). \
Pass `tool` explicitly.",
cwd.display()
)
})?
} else {
DiagTool::parse(requested).ok_or_else(|| {
format!(
"diagnostics: unknown tool '{requested}' \
— use 'auto', 'cargo', 'clippy', 'tsc', 'mypy', or 'ruff'."
)
})?
};
let (program, args) = match tool {
DiagTool::CargoCheck => (
"cargo",
vec!["check", "--message-format=json", "--all-targets"],
),
DiagTool::Clippy => (
"cargo",
vec![
"clippy",
"--message-format=json",
"--all-targets",
"--all-features",
],
),
DiagTool::Tsc => ("npx", vec!["tsc", "--noEmit"]),
DiagTool::Mypy => ("mypy", vec![".", "--no-color-output"]),
DiagTool::Ruff => ("ruff", vec!["check", "--output-format=json"]),
};
let result = run_command_with_timeout(program, &args, DIAG_TIMEOUT_SECS, Some(&cwd));
let errors = parse_diag_errors(tool, &result.stdout, &result.stderr);
Ok(json!({
"tool": tool.label(),
"exit_code": result.exit_code,
"timed_out": result.timed_out,
"errors": errors,
"stdout_tail": tail(&result.stdout, 2000),
"stderr_tail": tail(&result.stderr, 2000),
})
.to_string())
}
fn parse_diag_errors(tool: DiagTool, stdout: &str, stderr: &str) -> Vec<Value> {
match tool {
DiagTool::CargoCheck | DiagTool::Clippy => parse_cargo_messages(stdout),
DiagTool::Tsc => parse_tsc_lines(&format!("{stdout}\n{stderr}")),
DiagTool::Mypy => parse_mypy_lines(&format!("{stdout}\n{stderr}")),
DiagTool::Ruff => parse_ruff_json(stdout),
}
}
fn parse_cargo_messages(stdout: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for line in stdout.lines() {
let Ok(v) = serde_json::from_str::<Value>(line) else {
continue;
};
if v.get("reason").and_then(Value::as_str) != Some("compiler-message") {
continue;
}
let Some(msg) = v.get("message") else {
continue;
};
let level = msg
.get("level")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if level != "error" && level != "warning" {
continue;
}
let code = msg
.pointer("/code/code")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let message = msg
.get("message")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let (file, line, col) = msg
.get("spans")
.and_then(Value::as_array)
.and_then(|spans| {
spans.iter().find(|s| {
s.get("is_primary")
.and_then(Value::as_bool)
.unwrap_or(false)
})
})
.map(|primary| {
(
primary
.get("file_name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
primary
.get("line_start")
.and_then(Value::as_u64)
.unwrap_or(0),
primary
.get("column_start")
.and_then(Value::as_u64)
.unwrap_or(0),
)
})
.unwrap_or_default();
out.push(json!({
"file": file,
"line": line,
"column": col,
"code": code,
"severity": level,
"message": message,
}));
}
out
}
fn parse_tsc_lines(s: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for line in s.lines() {
let Some(paren_open) = line.find('(') else {
continue;
};
let Some(paren_close) = line[paren_open..].find(')').map(|i| paren_open + i) else {
continue;
};
let Some(colon) = line[paren_close..].find(": ").map(|i| paren_close + i) else {
continue;
};
let file = line[..paren_open].to_string();
let inside = &line[paren_open + 1..paren_close];
let (line_str, col_str) = inside.split_once(',').unwrap_or((inside, "0"));
let tail = &line[colon + 2..];
let severity = if tail.starts_with("error") {
"error"
} else {
"warning"
};
let after_sev = tail.trim_start_matches(severity).trim_start();
let (code, message) = after_sev.split_once(": ").unwrap_or((after_sev, ""));
out.push(json!({
"file": file,
"line": line_str.parse::<u64>().unwrap_or(0),
"column": col_str.parse::<u64>().unwrap_or(0),
"code": code.trim(),
"severity": severity,
"message": message.trim(),
}));
}
out
}
fn parse_mypy_lines(s: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for line in s.lines() {
let Some(first_colon) = line.find(':') else {
continue;
};
let rest = &line[first_colon + 1..];
let Some(line_end) = rest.find(':') else {
continue;
};
let line_num: u64 = rest[..line_end].trim().parse().unwrap_or(0);
if line_num == 0 {
continue;
}
let after = &rest[line_end + 1..];
let severity = if after.trim_start().starts_with("error") {
"error"
} else if after.trim_start().starts_with("warning") {
"warning"
} else {
continue;
};
let body = after
.trim_start()
.trim_start_matches(severity)
.trim_start_matches(": ");
let (message, code) = if let Some(open) = body.rfind('[') {
if let Some(close) = body[open..].find(']') {
(
body[..open].trim().to_string(),
body[open + 1..open + close].to_string(),
)
} else {
(body.to_string(), String::new())
}
} else {
(body.to_string(), String::new())
};
out.push(json!({
"file": line[..first_colon].to_string(),
"line": line_num,
"column": 0,
"code": code,
"severity": severity,
"message": message,
}));
}
out
}
fn parse_ruff_json(stdout: &str) -> Vec<Value> {
let Ok(arr) = serde_json::from_str::<Value>(stdout) else {
return Vec::new();
};
let Some(items) = arr.as_array() else {
return Vec::new();
};
items
.iter()
.map(|item| {
json!({
"file": item.get("filename").and_then(Value::as_str).unwrap_or(""),
"line": item
.pointer("/location/row")
.and_then(Value::as_u64)
.unwrap_or(0),
"column": item
.pointer("/location/column")
.and_then(Value::as_u64)
.unwrap_or(0),
"code": item.get("code").and_then(Value::as_str).unwrap_or(""),
"severity": "error",
"message": item.get("message").and_then(Value::as_str).unwrap_or(""),
})
})
.collect()
}
fn tail(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let start = s.len() - max;
let mut byte = start;
while byte < s.len() && !s.is_char_boundary(byte) {
byte += 1;
}
format!("...{}", &s[byte..])
}
fn parse_cargo_counts(s: &str) -> (u32, u32) {
let mut passed = 0u32;
let mut failed = 0u32;
for line in s.lines() {
let Some(rest) = line.strip_prefix("test result: ") else {
continue;
};
let tokens: Vec<&str> = rest.split([' ', ';']).filter(|t| !t.is_empty()).collect();
for window in tokens.windows(2) {
let Ok(n) = window[0].parse::<u32>() else {
continue;
};
let next = window[1].trim_end_matches(';');
if next.starts_with("passed") {
passed += n;
} else if next.starts_with("failed") {
failed += n;
}
}
}
(passed, failed)
}
fn parse_cargo_failures(s: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for window in s.lines().collect::<Vec<_>>().windows(8) {
let header = window[0];
if let Some(rest) = header.strip_prefix("---- ") {
let name = rest.trim_end_matches(" stdout ----").trim().to_string();
let mut file = String::new();
let mut line_num = 0u32;
let mut message = String::new();
for entry in window.iter().skip(1) {
if let Some(after) = entry.find("panicked at ") {
let tail = &entry[after + "panicked at ".len()..];
if let Some(colon) = tail.find(':') {
let path_or_loc = &tail[..colon];
if let Some((f, l)) = path_or_loc.rsplit_once(':') {
file = f.to_string();
line_num = l.parse().unwrap_or(0);
message = tail[colon + 1..].trim().to_string();
} else {
message = tail.trim().to_string();
}
} else {
message = tail.trim().to_string();
}
break;
}
}
out.push(json!({
"name": name,
"file": file,
"line": line_num,
"message": message,
}));
}
}
out
}
fn parse_pytest_counts(s: &str) -> (u32, u32) {
let mut passed = 0u32;
let mut failed = 0u32;
for line in s.lines().rev() {
if line.contains(" passed") || line.contains(" failed") {
let mut tokens = line.split_whitespace().peekable();
while let Some(t) = tokens.next() {
if let Ok(n) = t.parse::<u32>() {
match tokens.peek() {
Some(&w) if w.starts_with("passed") => passed += n,
Some(&w) if w.starts_with("failed") => failed += n,
_ => {}
}
}
}
if passed > 0 || failed > 0 {
break;
}
}
}
(passed, failed)
}
fn parse_pytest_failures(s: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for line in s.lines() {
if let Some(rest) = line.strip_prefix("FAILED ") {
let (loc, msg) = rest.split_once(" - ").map_or((rest, ""), |(a, b)| (a, b));
let (file, name) = loc.split_once("::").unwrap_or((loc, ""));
out.push(json!({
"name": name.trim(),
"file": file.trim(),
"line": 0,
"message": msg.trim(),
}));
}
}
out
}
fn parse_go_counts(s: &str) -> (u32, u32) {
let mut passed = 0u32;
let mut failed = 0u32;
for line in s.lines() {
if line.starts_with("--- PASS") {
passed += 1;
} else if line.starts_with("--- FAIL") {
failed += 1;
}
}
(passed, failed)
}
fn parse_go_failures(s: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for line in s.lines() {
if let Some(rest) = line.strip_prefix("--- FAIL: ") {
let name = rest.split_whitespace().next().unwrap_or("").to_string();
out.push(json!({
"name": name,
"file": "",
"line": 0,
"message": "",
}));
}
}
out
}
fn parse_jest_counts(s: &str) -> (u32, u32) {
let mut passed = 0u32;
let mut failed = 0u32;
for line in s.lines() {
if !line.trim_start().starts_with("Tests:") {
continue;
}
let mut tokens = line.split([',', ' ', ':']).peekable();
while let Some(t) = tokens.next() {
if let Ok(n) = t.parse::<u32>() {
match tokens.peek() {
Some(&"passed") => passed += n,
Some(&"failed") => failed += n,
_ => {}
}
}
}
}
(passed, failed)
}
fn parse_jest_failures(s: &str) -> Vec<Value> {
let mut out: Vec<Value> = Vec::new();
for line in s.lines() {
if let Some(rest) = line.strip_prefix("FAIL ") {
let file = rest.trim().to_string();
if !file.is_empty() {
out.push(json!({
"name": "",
"file": file,
"line": 0,
"message": "",
}));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schemas_lists_two_tools() {
let schemas = schemas();
assert_eq!(schemas.len(), 2);
let names: Vec<&str> = schemas
.iter()
.filter_map(|v| v.pointer("/function/name").and_then(Value::as_str))
.collect();
assert_eq!(names, ["run_tests", "diagnostics"]);
}
#[test]
fn diagnostics_rejects_unknown_tool() {
let err = run_diagnostics(r#"{"tool":"banana"}"#).unwrap_err();
assert!(err.contains("unknown tool"), "got: {err}");
assert!(
err.contains("cargo") && err.contains("ruff") && err.contains("tsc"),
"error must enumerate supported tools: {err}"
);
}
#[test]
fn parse_cargo_messages_extracts_primary_span() {
let line = r#"{"reason":"compiler-message","message":{"level":"error","message":"cannot find value `x` in this scope","code":{"code":"E0425"},"spans":[{"is_primary":true,"file_name":"src/lib.rs","line_start":12,"column_start":7}]}}"#;
let errors = parse_cargo_messages(line);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0]["file"], "src/lib.rs");
assert_eq!(errors[0]["line"], 12);
assert_eq!(errors[0]["code"], "E0425");
assert_eq!(errors[0]["severity"], "error");
}
#[test]
fn parse_cargo_messages_skips_non_compiler_lines() {
let mixed = r#"{"reason":"build-script-executed","package_id":"x"}
{"reason":"compiler-artifact"}
not json at all
{"reason":"compiler-message","message":{"level":"warning","message":"unused import","code":{"code":"unused_imports"},"spans":[{"is_primary":true,"file_name":"src/foo.rs","line_start":3,"column_start":1}]}}"#;
let errors = parse_cargo_messages(mixed);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0]["severity"], "warning");
}
#[test]
fn parse_tsc_lines_extracts_position_and_code() {
let out = "src/foo.ts(12,3): error TS2304: Cannot find name 'bar'.\n\
src/bar.ts(5,11): warning TS6133: 'x' is declared but never used.\n";
let errors = parse_tsc_lines(out);
assert_eq!(errors.len(), 2);
assert_eq!(errors[0]["file"], "src/foo.ts");
assert_eq!(errors[0]["line"], 12);
assert_eq!(errors[0]["code"], "TS2304");
assert_eq!(errors[0]["severity"], "error");
assert_eq!(errors[1]["severity"], "warning");
}
#[test]
fn parse_mypy_lines_extracts_code_in_brackets() {
let out = "foo.py:12: error: Incompatible types in assignment [assignment]\n\
bar.py:5: note: Revealed type is 'builtins.int'\n";
let errors = parse_mypy_lines(out);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0]["file"], "foo.py");
assert_eq!(errors[0]["line"], 12);
assert_eq!(errors[0]["code"], "assignment");
assert_eq!(errors[0]["severity"], "error");
}
#[test]
fn parse_ruff_json_array() {
let out = r#"[{"code":"E501","message":"Line too long","filename":"foo.py","location":{"row":12,"column":80}},{"code":"F401","message":"`os` imported but unused","filename":"bar.py","location":{"row":1,"column":0}}]"#;
let errors = parse_ruff_json(out);
assert_eq!(errors.len(), 2);
assert_eq!(errors[0]["code"], "E501");
assert_eq!(errors[1]["file"], "bar.py");
}
#[test]
fn detect_diag_tool_prefers_closest_marker() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let root = std::env::temp_dir().join(format!("claudette-diag-{stamp}"));
let leaf = root.join("sub").join("leaf");
std::fs::create_dir_all(&leaf).expect("mkdir");
std::fs::write(leaf.join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("write toml");
let t = detect_diag_tool(&leaf);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(t, Some(DiagTool::CargoCheck));
}
#[test]
fn run_tests_rejects_unknown_framework() {
let err = run_tests(r#"{"framework":"banana"}"#).unwrap_err();
assert!(err.contains("unknown framework"), "got: {err}");
assert!(
err.contains("cargo") && err.contains("pytest") && err.contains("go"),
"error must enumerate supported frameworks: {err}"
);
}
#[test]
fn parse_cargo_counts_pulls_passed_and_failed() {
let out = "running 5 tests\n\
test ok ... ok\n\
test result: ok. 12 passed; 3 failed; 0 ignored; 0 measured\n";
assert_eq!(parse_cargo_counts(out), (12, 3));
}
#[test]
fn parse_pytest_counts_pulls_summary_line() {
let out = "============================= test session starts ==============================\n\
collected 5 items\n\
tests/test_x.py::test_one PASSED\n\
tests/test_x.py::test_two FAILED\n\
========================= 3 passed, 2 failed in 0.42s ==========================";
assert_eq!(parse_pytest_counts(out), (3, 2));
}
#[test]
fn parse_pytest_failures_extracts_name_and_file() {
let out = "FAILED tests/test_x.py::test_two - AssertionError: x != y\n\
FAILED tests/test_y.py::test_three - ValueError: bad input\n";
let failures = parse_pytest_failures(out);
assert_eq!(failures.len(), 2);
assert_eq!(failures[0]["file"], "tests/test_x.py");
assert_eq!(failures[0]["name"], "test_two");
assert_eq!(failures[0]["message"], "AssertionError: x != y");
}
#[test]
fn parse_go_counts_tallies_marker_lines() {
let out = "--- PASS: TestOne (0.00s)\n\
--- FAIL: TestTwo (0.01s)\n\
--- PASS: TestThree (0.00s)\n";
assert_eq!(parse_go_counts(out), (2, 1));
}
#[test]
fn parse_jest_counts_handles_full_summary() {
let out = "Tests: 2 failed, 10 passed, 12 total\n\
Snapshots: 0 total\n";
assert_eq!(parse_jest_counts(out), (10, 2));
}
#[test]
fn detect_framework_prefers_closest_marker() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let root = std::env::temp_dir().join(format!("claudette-quality-{stamp}"));
let leaf = root.join("sub").join("leaf");
std::fs::create_dir_all(&leaf).expect("mkdir");
std::fs::write(leaf.join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("write toml");
let f = detect_framework(&leaf);
let _ = std::fs::remove_dir_all(&root);
assert_eq!(f, Some(Framework::Cargo));
}
#[test]
fn detect_framework_returns_none_when_no_markers() {
let root = std::env::temp_dir().join(format!(
"claudette-quality-empty-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
std::fs::create_dir_all(&root).expect("mkdir");
let _ = detect_framework(&root);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn active_cwd_returns_path() {
let _: std::path::PathBuf = crate::missions::active_cwd();
}
}