use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::manifest;
use crate::mapcmd;
use crate::repo;
use crate::walk;
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (y + if m <= 2 { 1 } else { 0 }, m, d)
}
fn today() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = (secs / 86_400) as i64;
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}")
}
fn parse_init_args(args: &[String]) -> (Option<String>, Vec<String>) {
let mut label: Option<String> = None;
let mut scope: Vec<String> = Vec::new();
let mut i = 0usize;
while i < args.len() {
if args[i] == "--label" {
label = args.get(i + 1).cloned();
i += 2;
continue;
}
scope.push(args[i].clone());
i += 1;
}
(label, scope)
}
fn nested_git_repos(root: &Path) -> io::Result<Vec<String>> {
let mut names = Vec::new();
for entry in fs::read_dir(root)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let name = entry.file_name().to_string_lossy().into_owned();
if root.join(&name).join(".git").exists() {
names.push(name);
}
}
}
names.sort();
Ok(names)
}
fn ensure_scout_excluded(info_dir: &Path) -> io::Result<()> {
fs::create_dir_all(info_dir)?;
let exclude_path = info_dir.join("exclude");
let body = if exclude_path.exists() {
String::from_utf8_lossy(&fs::read(&exclude_path)?).into_owned()
} else {
String::new()
};
let already_listed = body.split('\n').any(|l| l.trim() == ".scout/");
if !already_listed {
let prefix = if body.is_empty() || body.ends_with('\n') {
""
} else {
"\n"
};
use io::Write as _;
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&exclude_path)?;
write!(f, "{prefix}.scout/\n")?;
}
Ok(())
}
fn set_field(value: manifest::Value, key: &str, new_value: manifest::Value) -> manifest::Value {
let fields = value.as_object().unwrap_or(&[]).to_vec();
let mut out = Vec::with_capacity(fields.len() + 1);
let mut replaced = false;
for (k, v) in fields {
if k == key {
out.push((k, new_value.clone()));
replaced = true;
} else {
out.push((k, v));
}
}
if !replaced {
out.push((key.to_string(), new_value));
}
manifest::Value::Object(out)
}
fn read_registry_value() -> Result<manifest::Value, String> {
let path = repo::registry_path();
if !path.exists() {
return Ok(manifest::Value::object(vec![(
"roots",
manifest::Value::array(vec![]),
)]));
}
let bytes = fs::read(&path)
.map_err(|e| format!("registry at {} is not valid JSON: {e}", path.display()))?;
let text = String::from_utf8_lossy(&bytes).into_owned();
let value: manifest::Value = serde_json::from_str(&text)
.map_err(|e| format!("registry at {} is not valid JSON: {e}", path.display()))?;
match value.get("roots") {
Some(manifest::Value::Array(_)) => Ok(value),
_ => Err(format!(
"registry at {} has no \"roots\" array",
path.display()
)),
}
}
fn write_registry_value(value: &manifest::Value) -> Result<(), String> {
let path = repo::registry_path();
let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
let body = format!("{json}\n");
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_name = format!(
"{}.tmp.{}.{suffix}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repos.json"),
std::process::id()
);
let tmp_path = path.with_file_name(tmp_name);
if let Err(e) = fs::write(&tmp_path, body.as_bytes()) {
let _ = fs::remove_file(&tmp_path);
return Err(e.to_string());
}
if let Err(e) = fs::rename(&tmp_path, &path) {
let _ = fs::remove_file(&tmp_path);
return Err(e.to_string());
}
Ok(())
}
fn new_entry_value(
root_abs: &str,
kind: &str,
label: Option<&str>,
scope: &[String],
stamp: &str,
) -> manifest::Value {
manifest::Value::object(vec![
("root", manifest::Value::string(root_abs)),
("kind", manifest::Value::string(kind)),
(
"label",
label
.map(manifest::Value::string)
.unwrap_or(manifest::Value::Null),
),
(
"scope",
manifest::Value::array(
scope
.iter()
.map(|s| manifest::Value::string(s.clone()))
.collect(),
),
),
("initialized", manifest::Value::string(stamp)),
("last_seen", manifest::Value::string(stamp)),
])
}
fn update_entry_value(
entry: manifest::Value,
kind: &str,
label: Option<&str>,
scope: &[String],
stamp: &str,
) -> manifest::Value {
let mut e = set_field(entry, "kind", manifest::Value::string(kind));
if let Some(l) = label {
if !l.is_empty() {
e = set_field(e, "label", manifest::Value::string(l));
}
}
if !scope.is_empty() {
e = set_field(
e,
"scope",
manifest::Value::array(
scope
.iter()
.map(|s| manifest::Value::string(s.clone()))
.collect(),
),
);
}
e = set_field(e, "last_seen", manifest::Value::string(stamp));
e
}
fn register_root(
root_abs: &str,
kind: &str,
label: Option<&str>,
scope: &[String],
) -> Result<(), String> {
let registry = read_registry_value()?;
let roots: Vec<manifest::Value> = match registry.get("roots") {
Some(manifest::Value::Array(items)) => items.clone(),
_ => Vec::new(),
};
let stamp = today();
let mut found = false;
let mut new_roots = Vec::with_capacity(roots.len() + 1);
for entry in roots {
if entry.get("root").and_then(manifest::Value::as_str) == Some(root_abs) {
found = true;
new_roots.push(update_entry_value(entry, kind, label, scope, &stamp));
} else {
new_roots.push(entry);
}
}
if !found {
new_roots.push(new_entry_value(root_abs, kind, label, scope, &stamp));
}
let updated_registry = set_field(registry, "roots", manifest::Value::Array(new_roots));
write_registry_value(&updated_registry)
}
pub fn cmd_init(cwd: &Path, args: &[String]) -> (i32, String) {
let (label, scope) = parse_init_args(args);
let git_root = repo::find_repo_root(cwd);
let root = git_root.clone().unwrap_or_else(|| repo::resolve_path(cwd));
if git_root.is_none() {
match nested_git_repos(&root) {
Ok(nested) if !nested.is_empty() => {
return (
2,
format!(
"refusing to init {}: it contains git repos ({}).\nA .scout above several repos has no single HEAD, so manifest staleness detection silently stops firing. Run devscout init inside each repo instead.",
root.display(),
nested.join(", "),
),
);
}
Ok(_) => {}
Err(e) => return (1, format!("error: {e}")),
}
}
let scope_note = if scope.is_empty() {
String::new()
} else {
format!(", scope {}", scope.join(", "))
};
let scout_dir = repo::scout_dir(&root);
let root_abs = root.to_string_lossy().into_owned();
if let Some(git_root) = &git_root {
let common = match repo::git_common_dir(git_root) {
Some(c) => c,
None => {
return (
1,
format!(
"error: unable to resolve git common dir for {}",
git_root.display()
),
)
}
};
if let Err(e) = ensure_scout_excluded(&common.join("info")) {
return (1, format!("error: {e}"));
}
if let Err(e) = fs::create_dir_all(&scout_dir) {
return (1, format!("error: {e}"));
}
if let Err(e) = register_root(&root_abs, "git", label.as_deref(), &scope) {
return (1, format!("error: {e}"));
}
return (
0,
format!(
"devscout initialized at {}{scope_note}",
scout_dir.display()
),
);
}
if let Err(e) = fs::create_dir_all(&scout_dir) {
return (1, format!("error: {e}"));
}
if let Err(e) = register_root(&root_abs, "plain", label.as_deref(), &scope) {
return (1, format!("error: {e}"));
}
(
0,
format!(
"devscout initialized at {} (non-git root: {}){scope_note}",
scout_dir.display(),
root.display()
),
)
}
const FULLY_SUPPORTED_EXT: &[&str] = &[".cs"];
const INDEXED_AND_GRAPHED_EXT: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
fn extract_rust_only_flags(args: &[String]) -> (bool, bool, Vec<String>) {
let mut no_hooks = false;
let mut no_map = false;
let mut rest = Vec::with_capacity(args.len());
for a in args {
match a.as_str() {
"--no-hooks" => no_hooks = true,
"--no-map" => no_map = true,
_ => rest.push(a.clone()),
}
}
(no_hooks, no_map, rest)
}
pub fn cmd_init_full(cwd: &Path, args: &[String]) -> (i32, String) {
let (no_hooks, no_map, parity_args) = extract_rust_only_flags(args);
let (code, out) = cmd_init(cwd, &parity_args);
if code != 0 {
return (code, out);
}
let git_root = repo::find_repo_root(cwd);
let root = git_root.unwrap_or_else(|| repo::resolve_path(cwd));
let lines = [
out,
census_line(&root),
hooks_line(no_hooks),
map_line(&root, no_map),
];
(0, lines.join("\n"))
}
fn census_line(root: &Path) -> String {
let files = match walk::list_source_files(root, &[".".to_string()]) {
Ok(f) => f,
Err(e) => return format!("languages: error walking sources ({e})"),
};
if files.is_empty() {
return "languages: no source files found".to_string();
}
let mut counts: Vec<(String, usize)> = Vec::new();
for f in &files {
let ext = match f.rfind('.') {
Some(i) => f[i..].to_string(),
None => String::new(),
};
match counts.iter_mut().find(|(e, _)| *e == ext) {
Some(entry) => entry.1 += 1,
None => counts.push((ext, 1)),
}
}
counts.sort();
let (fully_supported, other): (Vec<_>, Vec<_>) = counts
.into_iter()
.partition(|(e, _)| FULLY_SUPPORTED_EXT.contains(&e.as_str()));
let (indexed_and_graphed, not_indexed): (Vec<_>, Vec<_>) = other
.into_iter()
.partition(|(e, _)| INDEXED_AND_GRAPHED_EXT.contains(&e.as_str()));
let fmt_group = |g: &[(String, usize)]| {
g.iter()
.map(|(e, c)| format!("{c} {e}"))
.collect::<Vec<_>>()
.join(", ")
};
let mut groups = Vec::new();
if !fully_supported.is_empty() {
groups.push(format!("{} (fully supported)", fmt_group(&fully_supported)));
}
if !indexed_and_graphed.is_empty() {
groups.push(format!(
"{} (indexed and graphed, narrower edge coverage)",
fmt_group(&indexed_and_graphed)
));
}
if !not_indexed.is_empty() {
groups.push(format!(
"{} (present, not indexed)",
fmt_group(¬_indexed)
));
}
format!("languages: {}", groups.join("; "))
}
enum HookOutcome {
AlreadyInstalled,
Installed {
added: Vec<&'static str>,
backup: PathBuf,
},
NeedsManualInsert {
reason: String,
snippet: String,
},
}
fn hooks_line(no_hooks: bool) -> String {
if no_hooks {
return "hooks: skipped (--no-hooks)".to_string();
}
match install_hooks() {
Ok(HookOutcome::AlreadyInstalled) => "hooks: already installed (idempotent)".to_string(),
Ok(HookOutcome::Installed { added, backup }) => {
format!(
"hooks: installed ({}); backup {}",
added.join(", "),
backup.display()
)
}
Ok(HookOutcome::NeedsManualInsert { reason, snippet }) => {
format!("hooks: {reason} -- add manually:\n{snippet}")
}
Err(e) => format!("hooks: error: {e}"),
}
}
fn settings_path() -> Result<PathBuf, String> {
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
Ok(PathBuf::from(home).join(".claude").join("settings.json"))
}
fn current_binary_path() -> Result<String, String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let exe = fs::canonicalize(&exe).unwrap_or(exe);
Ok(exe.to_string_lossy().into_owned())
}
fn install_hooks() -> Result<HookOutcome, String> {
let bin = current_binary_path()?;
let path = settings_path()?;
if !path.exists() {
return Ok(HookOutcome::NeedsManualInsert {
reason: format!("no settings file at {}", path.display()),
snippet: hook_snippet(&bin),
});
}
let bytes = fs::read(&path).map_err(|e| e.to_string())?;
let text = String::from_utf8_lossy(&bytes).into_owned();
let value: manifest::Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => {
return Ok(HookOutcome::NeedsManualInsert {
reason: format!("settings file at {} is not valid JSON", path.display()),
snippet: hook_snippet(&bin),
})
}
};
if !matches!(value, manifest::Value::Object(_)) || !hooks_shape_ok(&value) {
return Ok(HookOutcome::NeedsManualInsert {
reason: format!(
"settings file at {} has an unexpected shape",
path.display()
),
snippet: hook_snippet(&bin),
});
}
let (updated, added) = merge_hooks(value, &bin);
if added.is_empty() {
return Ok(HookOutcome::AlreadyInstalled);
}
let backup = backup_settings(&path, &bytes)?;
write_settings(&path, &updated)?;
Ok(HookOutcome::Installed { added, backup })
}
fn hooks_shape_ok(value: &manifest::Value) -> bool {
let Some(hooks) = value.get("hooks") else {
return true;
};
let manifest::Value::Object(_) = hooks else {
return false;
};
let Some(post_tool_use) = hooks.get("PostToolUse") else {
return true;
};
let manifest::Value::Array(entries) = post_tool_use else {
return false;
};
entries.iter().all(entry_shape_ok)
}
fn entry_shape_ok(entry: &manifest::Value) -> bool {
if !matches!(entry, manifest::Value::Object(_)) {
return false;
}
if let Some(matcher) = entry.get("matcher") {
if !matches!(matcher, manifest::Value::String(_)) {
return false;
}
}
let Some(hooks) = entry.get("hooks") else {
return true;
};
let manifest::Value::Array(items) = hooks else {
return false;
};
items.iter().all(|item| {
if !matches!(item, manifest::Value::Object(_)) {
return false;
}
if let Some(t) = item.get("type") {
if !matches!(t, manifest::Value::String(_)) {
return false;
}
}
if let Some(c) = item.get("command") {
if !matches!(c, manifest::Value::String(_)) {
return false;
}
}
true
})
}
fn already_has_hook_command(entries: &[manifest::Value], marker: &str) -> bool {
entries.iter().any(|entry| {
let Some(manifest::Value::Array(items)) = entry.get("hooks") else {
return false;
};
items.iter().any(|item| {
item.get("command")
.and_then(manifest::Value::as_str)
.is_some_and(|c| c.contains("scout") && c.contains(marker))
})
})
}
fn hook_entry(matcher: &str, bin: &str, subcmd: &str) -> manifest::Value {
manifest::Value::object(vec![
("matcher", manifest::Value::string(matcher)),
(
"hooks",
manifest::Value::array(vec![manifest::Value::object(vec![
("type", manifest::Value::string("command")),
(
"command",
manifest::Value::string(format!("{bin} hook {subcmd}")),
),
])]),
),
])
}
fn merge_hooks(value: manifest::Value, bin: &str) -> (manifest::Value, Vec<&'static str>) {
let hooks_val = value
.get("hooks")
.cloned()
.unwrap_or_else(|| manifest::Value::object(vec![]));
let ptu_val = hooks_val
.get("PostToolUse")
.cloned()
.unwrap_or_else(|| manifest::Value::array(vec![]));
let mut entries = match ptu_val {
manifest::Value::Array(items) => items,
_ => Vec::new(),
};
let mut added: Vec<&'static str> = Vec::new();
if !already_has_hook_command(&entries, "hook read") {
entries.push(hook_entry("Read", bin, "read"));
added.push("Read");
}
if !already_has_hook_command(&entries, "hook bash") {
entries.push(hook_entry("Bash", bin, "bash"));
added.push("Bash");
}
let updated_hooks = set_field(hooks_val, "PostToolUse", manifest::Value::Array(entries));
let updated = set_field(value, "hooks", updated_hooks);
(updated, added)
}
fn hook_snippet(bin: &str) -> String {
let v = manifest::Value::object(vec![(
"hooks",
manifest::Value::object(vec![(
"PostToolUse",
manifest::Value::array(vec![
hook_entry("Read", bin, "read"),
hook_entry("Bash", bin, "bash"),
]),
)]),
)]);
serde_json::to_string_pretty(&v).unwrap_or_default()
}
fn now_stamp() -> String {
let dur = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
let days = (secs / 86_400) as i64;
let (y, m, d) = civil_from_days(days);
let sod = secs % 86_400;
let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60);
format!(
"{y:04}{m:02}{d:02}-{h:02}{mi:02}{s:02}-{:09}",
dur.subsec_nanos()
)
}
fn backup_settings(path: &Path, original_bytes: &[u8]) -> Result<PathBuf, String> {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("settings.json");
let backup_path = path.with_file_name(format!("{name}.bak.{}", now_stamp()));
fs::write(&backup_path, original_bytes).map_err(|e| e.to_string())?;
Ok(backup_path)
}
fn write_settings(path: &Path, value: &manifest::Value) -> Result<(), String> {
let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
let body = format!("{json}\n");
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp_name = format!(
"{}.tmp.{}.{suffix}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("settings.json"),
std::process::id()
);
let tmp_path = path.with_file_name(tmp_name);
if let Err(e) = fs::write(&tmp_path, body.as_bytes()) {
let _ = fs::remove_file(&tmp_path);
return Err(e.to_string());
}
if let Err(e) = fs::rename(&tmp_path, path) {
let _ = fs::remove_file(&tmp_path);
return Err(e.to_string());
}
Ok(())
}
fn map_line(root: &Path, no_map: bool) -> String {
if no_map {
return "map: skipped (--no-map)".to_string();
}
match mapcmd::map_repo(root, &[], mapcmd::MapOptions::from_env()) {
Ok(report) => format!("map: {}", report.summary_line()),
Err(e) => format!("map: error: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_dir(prefix: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"scout-initcmd-rs-{prefix}-{}-{n}",
std::process::id()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn no_args_is_no_label_no_scope() {
let (label, scope) = parse_init_args(&[]);
assert_eq!(label, None);
assert!(scope.is_empty());
}
#[test]
fn label_flag_consumes_its_value() {
let args: Vec<String> = ["--label", "backend-cs"]
.iter()
.map(|s| s.to_string())
.collect();
let (label, scope) = parse_init_args(&args);
assert_eq!(label, Some("backend-cs".to_string()));
assert!(scope.is_empty());
}
#[test]
fn label_as_last_token_with_no_value_is_none() {
let args: Vec<String> = ["src", "--label"].iter().map(|s| s.to_string()).collect();
let (label, scope) = parse_init_args(&args);
assert_eq!(label, None);
assert_eq!(scope, vec!["src".to_string()]);
}
#[test]
fn scope_dirs_collected_in_order_around_label() {
let args: Vec<String> = ["src", "--label", "l", "lib", "app"]
.iter()
.map(|s| s.to_string())
.collect();
let (label, scope) = parse_init_args(&args);
assert_eq!(label, Some("l".to_string()));
assert_eq!(
scope,
vec!["src".to_string(), "lib".to_string(), "app".to_string()]
);
}
#[test]
fn civil_from_days_epoch_is_1970_01_01() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
}
#[test]
fn civil_from_days_known_reference_points() {
assert_eq!(civil_from_days(11_017), (2000, 3, 1));
assert_eq!(civil_from_days(19_723), (2024, 1, 1));
assert_eq!(civil_from_days(19_723 + 59), (2024, 2, 29));
}
#[test]
fn today_is_well_formed_yyyy_mm_dd() {
let t = today();
assert_eq!(t.len(), 10);
assert_eq!(t.as_bytes()[4], b'-');
assert_eq!(t.as_bytes()[7], b'-');
assert!(t.chars().all(|c| c.is_ascii_digit() || c == '-'));
}
#[test]
fn exclude_created_fresh_when_missing() {
let dir = temp_dir("exclude-fresh");
let info_dir = dir.join("info");
ensure_scout_excluded(&info_dir).unwrap();
let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
assert_eq!(body, ".scout/\n");
}
#[test]
fn exclude_appended_after_existing_content_without_trailing_newline() {
let dir = temp_dir("exclude-append-no-nl");
let info_dir = dir.join("info");
fs::create_dir_all(&info_dir).unwrap();
fs::write(info_dir.join("exclude"), "*.log").unwrap();
ensure_scout_excluded(&info_dir).unwrap();
let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
assert_eq!(body, "*.log\n.scout/\n");
}
#[test]
fn exclude_appended_after_existing_content_with_trailing_newline() {
let dir = temp_dir("exclude-append-nl");
let info_dir = dir.join("info");
fs::create_dir_all(&info_dir).unwrap();
fs::write(info_dir.join("exclude"), "*.log\n").unwrap();
ensure_scout_excluded(&info_dir).unwrap();
let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
assert_eq!(body, "*.log\n.scout/\n");
}
#[test]
fn exclude_is_idempotent_when_already_listed() {
let dir = temp_dir("exclude-idempotent");
let info_dir = dir.join("info");
fs::create_dir_all(&info_dir).unwrap();
fs::write(info_dir.join("exclude"), "*.log\n.scout/\nmore\n").unwrap();
ensure_scout_excluded(&info_dir).unwrap();
let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
assert_eq!(body, "*.log\n.scout/\nmore\n", "must not double-append");
}
#[test]
fn exclude_matches_on_trimmed_line_not_exact_bytes() {
let dir = temp_dir("exclude-trim-match");
let info_dir = dir.join("info");
fs::create_dir_all(&info_dir).unwrap();
fs::write(info_dir.join("exclude"), " .scout/ \n").unwrap();
ensure_scout_excluded(&info_dir).unwrap();
let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
assert_eq!(
body, " .scout/ \n",
"already-listed (after trim) must not append again"
);
}
#[test]
fn set_field_overwrites_in_place_preserving_position() {
let v = manifest::Value::object(vec![
("a", manifest::Value::string("1")),
("b", manifest::Value::string("2")),
]);
let out = set_field(v, "a", manifest::Value::string("9"));
let fields = out.as_object().unwrap();
assert_eq!(fields[0].0, "a");
assert_eq!(out.get("a").and_then(manifest::Value::as_str), Some("9"));
assert_eq!(fields[1].0, "b");
}
#[test]
fn set_field_appends_when_absent() {
let v = manifest::Value::object(vec![("a", manifest::Value::string("1"))]);
let out = set_field(v, "z", manifest::Value::string("new"));
let fields = out.as_object().unwrap();
assert_eq!(fields.last().unwrap().0, "z");
}
#[test]
fn nested_git_repos_finds_immediate_subdirs_with_dot_git_only() {
let root = temp_dir("nested-detect");
fs::create_dir_all(root.join("repo-a/.git")).unwrap();
fs::create_dir_all(root.join("repo-b/.git")).unwrap();
fs::create_dir_all(root.join("plain-dir")).unwrap();
fs::create_dir_all(root.join("repo-a/nested-repo/.git")).unwrap();
let found = nested_git_repos(&root).unwrap();
assert_eq!(
found,
vec!["repo-a".to_string(), "repo-b".to_string()],
"sorted, immediate subdirs only"
);
}
#[test]
fn nested_git_repos_empty_when_none_present() {
let root = temp_dir("nested-none");
fs::create_dir_all(root.join("plain-dir")).unwrap();
assert!(nested_git_repos(&root).unwrap().is_empty());
}
#[test]
fn update_entry_ignores_falsy_label_and_empty_scope() {
let entry = new_entry_value(
"/x",
"git",
Some("orig"),
&["src".to_string()],
"2026-01-01",
);
let updated = update_entry_value(entry, "git", None, &[], "2026-01-02");
assert_eq!(
updated.get("label").and_then(manifest::Value::as_str),
Some("orig"),
"no label given -- keeps existing"
);
let scope = updated.get("scope").unwrap();
assert!(
matches!(scope, manifest::Value::Array(items) if items.len() == 1),
"empty scope given -- keeps existing"
);
assert_eq!(
updated.get("last_seen").and_then(manifest::Value::as_str),
Some("2026-01-02")
);
}
#[test]
fn update_entry_overwrites_truthy_label_and_nonempty_scope() {
let entry = new_entry_value(
"/x",
"git",
Some("orig"),
&["src".to_string()],
"2026-01-01",
);
let updated = update_entry_value(
entry,
"git",
Some("fresh"),
&["lib".to_string(), "app".to_string()],
"2026-01-02",
);
assert_eq!(
updated.get("label").and_then(manifest::Value::as_str),
Some("fresh")
);
let scope = updated.get("scope").unwrap();
assert!(matches!(scope, manifest::Value::Array(items) if items.len() == 2));
}
#[test]
fn extract_rust_only_flags_strips_no_hooks_and_no_map_only() {
let args: Vec<String> = ["--no-hooks", "src", "--no-map", "--label", "l"]
.iter()
.map(|s| s.to_string())
.collect();
let (no_hooks, no_map, rest) = extract_rust_only_flags(&args);
assert!(no_hooks);
assert!(no_map);
assert_eq!(
rest,
vec!["src".to_string(), "--label".to_string(), "l".to_string()],
"everything else passes through untouched, in order"
);
}
#[test]
fn extract_rust_only_flags_defaults_false_when_absent() {
let args: Vec<String> = ["--label", "l", "src"]
.iter()
.map(|s| s.to_string())
.collect();
let (no_hooks, no_map, rest) = extract_rust_only_flags(&args);
assert!(!no_hooks);
assert!(!no_map);
assert_eq!(rest, args);
}
#[test]
fn census_line_reports_all_three_support_tiers() {
let dir = temp_dir("census-mixed");
fs::write(dir.join("A.cs"), "namespace X { class A {} }").unwrap();
fs::write(dir.join("B.cs"), "namespace X { class B {} }").unwrap();
fs::write(dir.join("c.ts"), "export const x = 1;").unwrap();
fs::write(dir.join("d.md"), "# doc").unwrap();
let line = census_line(&dir);
assert_eq!(line, "languages: 2 .cs (fully supported); 1 .ts (indexed and graphed, narrower edge coverage); 1 .md (present, not indexed)");
}
#[test]
fn census_line_all_supported_omits_the_unsupported_clause() {
let dir = temp_dir("census-cs-only");
fs::write(dir.join("A.cs"), "namespace X { class A {} }").unwrap();
let line = census_line(&dir);
assert_eq!(line, "languages: 1 .cs (fully supported)");
}
#[test]
fn census_line_no_supported_still_reports_unsupported() {
let dir = temp_dir("census-unsupported-only");
fs::write(dir.join("a.md"), "# doc").unwrap();
let line = census_line(&dir);
assert_eq!(line, "languages: 1 .md (present, not indexed)");
}
#[test]
fn census_line_empty_repo_reports_no_source_files() {
let dir = temp_dir("census-empty");
assert_eq!(census_line(&dir), "languages: no source files found");
}
#[test]
fn hooks_shape_ok_on_absent_hooks_key() {
let v = manifest::Value::object(vec![("other", manifest::Value::string("x"))]);
assert!(hooks_shape_ok(&v));
}
#[test]
fn hooks_shape_ok_on_the_live_shape() {
let v = manifest::Value::object(vec![(
"hooks",
manifest::Value::object(vec![(
"PostToolUse",
manifest::Value::array(vec![
manifest::Value::object(vec![
("matcher", manifest::Value::string("Read")),
(
"hooks",
manifest::Value::array(vec![manifest::Value::object(vec![
("type", manifest::Value::string("command")),
(
"command",
manifest::Value::string("node scout-read-hook.js"),
),
])]),
),
]),
manifest::Value::object(vec![("matcher", manifest::Value::string("Bash"))]),
]),
)]),
)]);
assert!(hooks_shape_ok(&v));
}
#[test]
fn hooks_shape_rejects_hooks_as_non_object() {
let v = manifest::Value::object(vec![("hooks", manifest::Value::array(vec![]))]);
assert!(!hooks_shape_ok(&v));
}
#[test]
fn hooks_shape_rejects_post_tool_use_as_non_array() {
let v = manifest::Value::object(vec![(
"hooks",
manifest::Value::object(vec![("PostToolUse", manifest::Value::string("nope"))]),
)]);
assert!(!hooks_shape_ok(&v));
}
#[test]
fn hooks_shape_rejects_entry_with_wrong_typed_matcher() {
let entries = manifest::Value::array(vec![manifest::Value::object(vec![(
"matcher",
manifest::Value::number(1),
)])]);
let v = manifest::Value::object(vec![(
"hooks",
manifest::Value::object(vec![("PostToolUse", entries)]),
)]);
assert!(!hooks_shape_ok(&v));
}
#[test]
fn hooks_shape_rejects_entry_that_is_not_an_object() {
let entry = manifest::Value::string("not an object");
assert!(!entry_shape_ok(&entry));
}
#[test]
fn already_has_hook_command_matches_scout_plus_marker() {
let entries = vec![manifest::Value::object(vec![(
"hooks",
manifest::Value::array(vec![manifest::Value::object(vec![(
"command",
manifest::Value::string("/abs/devscout hook read"),
)])]),
)])];
assert!(already_has_hook_command(&entries, "hook read"));
assert!(
!already_has_hook_command(&entries, "hook bash"),
"different marker must not match"
);
}
#[test]
fn already_has_hook_command_ignores_scout_without_the_marker() {
let entries = vec![manifest::Value::object(vec![(
"hooks",
manifest::Value::array(vec![manifest::Value::object(vec![(
"command",
manifest::Value::string("node /opt/x/scout/scout-read-hook.js"),
)])]),
)])];
assert!(!already_has_hook_command(&entries, "hook read"));
}
#[test]
fn already_has_hook_command_false_on_empty_entries() {
assert!(!already_has_hook_command(&[], "hook read"));
}
#[test]
fn merge_hooks_adds_both_entries_and_preserves_other_top_level_keys() {
let v = manifest::Value::object(vec![
("model", manifest::Value::string("x")),
("hooks", manifest::Value::object(vec![])),
]);
let (updated, added) = merge_hooks(v, "/abs/devscout");
assert_eq!(added, vec!["Read", "Bash"]);
assert_eq!(
updated.get("model").and_then(manifest::Value::as_str),
Some("x"),
"unrelated top-level key preserved"
);
let ptu = updated.get("hooks").unwrap().get("PostToolUse").unwrap();
assert!(matches!(ptu, manifest::Value::Array(items) if items.len() == 2));
}
#[test]
fn merge_hooks_is_idempotent_on_second_call() {
let v = manifest::Value::object(vec![("hooks", manifest::Value::object(vec![]))]);
let (once, added1) = merge_hooks(v, "/abs/devscout");
assert_eq!(added1, vec!["Read", "Bash"]);
let (twice, added2) = merge_hooks(once.clone(), "/abs/devscout");
assert!(
added2.is_empty(),
"second merge over its own output must add nothing"
);
assert_eq!(once, twice, "no-op merge must not alter the value at all");
}
#[test]
fn merge_hooks_preserves_existing_post_tool_use_entries() {
let existing = manifest::Value::array(vec![manifest::Value::object(vec![(
"matcher",
manifest::Value::string("Write"),
)])]);
let v = manifest::Value::object(vec![(
"hooks",
manifest::Value::object(vec![("PostToolUse", existing)]),
)]);
let (updated, added) = merge_hooks(v, "/abs/devscout");
assert_eq!(added, vec!["Read", "Bash"]);
let ptu = updated.get("hooks").unwrap().get("PostToolUse").unwrap();
assert!(
matches!(ptu, manifest::Value::Array(items) if items.len() == 3),
"1 existing + 2 new"
);
if let manifest::Value::Array(items) = ptu {
assert_eq!(
items[0].get("matcher").and_then(manifest::Value::as_str),
Some("Write"),
"existing entry stays first, untouched"
);
}
}
#[test]
fn hook_snippet_is_valid_json_containing_both_subcommands() {
let snippet = hook_snippet("/abs/devscout");
let parsed: manifest::Value =
serde_json::from_str(&snippet).expect("snippet must be valid JSON");
let ptu = parsed.get("hooks").unwrap().get("PostToolUse").unwrap();
assert!(matches!(ptu, manifest::Value::Array(items) if items.len() == 2));
assert!(snippet.contains("/abs/devscout hook read"));
assert!(snippet.contains("/abs/devscout hook bash"));
}
#[test]
fn now_stamp_is_well_formed() {
let s = now_stamp();
assert_eq!(s.len(), 8 + 1 + 6 + 1 + 9);
assert!(
s.chars().enumerate().all(|(i, c)| if i == 8 || i == 15 {
c == '-'
} else {
c.is_ascii_digit()
}),
"got {s:?}"
);
}
#[test]
fn backup_settings_writes_original_bytes_verbatim() {
let dir = temp_dir("backup");
let path = dir.join("settings.json");
let original = b"{\"a\":1}";
let backup = backup_settings(&path, original).unwrap();
assert!(backup
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("settings.json.bak."));
assert_eq!(fs::read(&backup).unwrap(), original);
}
#[test]
fn write_settings_then_read_round_trips_and_ends_with_newline() {
let dir = temp_dir("write-settings");
let path = dir.join("settings.json");
let v = manifest::Value::object(vec![("a", manifest::Value::string("1"))]);
write_settings(&path, &v).unwrap();
let body = fs::read_to_string(&path).unwrap();
assert!(body.ends_with('\n'));
let reread: manifest::Value = serde_json::from_str(&body).unwrap();
assert_eq!(reread, v);
}
#[test]
fn census_language_tiers_are_explicit() {
assert_eq!(FULLY_SUPPORTED_EXT, &[".cs"]);
assert_eq!(INDEXED_AND_GRAPHED_EXT, &[".ts", ".tsx", ".js", ".jsx"]);
}
}