Skip to main content

devscout_rs/
initcmd.rs

1// `scout init`: registry registration + `.git/info/exclude` handling (incl.
2// worktree common-dir sharing), zero-questions on a normal repo. Also holds the
3// registry WRITE side (the read side is repo.rs's, and deliberately read-only;
4// see the "Registry write path" section below).
5//
6// ## What `cmd_init` actually does
7//
8// Parse `--label <l>` / positional scope-dir args; resolve the nearest `.git`
9// ancestor of `cwd` or fall back to `resolve(cwd)`; on a non-git root, refuse
10// (exit 2) if it directly contains OTHER git repos as immediate subdirectories
11// (a `.scout` above several checkouts has no single HEAD, so the
12// manifest-staleness check would silently stop firing -- refusing beats
13// documenting a footgun); on a git root, ensure `.scout/` is listed in the git
14// COMMON dir's `info/exclude` (git shares `info/` across every linked worktree,
15// so one write covers all of them) and create `<root>/.scout/`; either way,
16// register the root in the machine-local registry (`repos.json`) and print one
17// summary line. The failure-prone step (resolving the common dir, writing the
18// exclude) runs BEFORE anything is created or registered, so a failure there
19// leaves nothing behind.
20//
21// ## Registry write path
22//
23// repo.rs exposes registry READ only, by its own module header's explicit
24// design. The WRITE path lives here instead, round-tripped through
25// `manifest::Value` (the schema-agnostic, order-preserving JSON type) rather
26// than `repo::RegistryEntry` (a typed, six-known-field, read-only struct): a
27// `Value`-based round-trip preserves any field beyond those six on an EXISTING
28// entry untouched. Going through `RegistryEntry` first would silently drop
29// anything else on every `devscout init` re-run.
30
31use std::fs;
32use std::io;
33use std::path::{Path, PathBuf};
34use std::time::{SystemTime, UNIX_EPOCH};
35
36use crate::manifest;
37use crate::mapcmd;
38use crate::repo;
39use crate::walk;
40
41// ---------------------------------------------------------------------------
42// calendar date as `YYYY-MM-DD`. No date/time crate is used --
43// `civil_from_days` is Howard Hinnant's well-known constexpr
44// days-since-epoch -> (y, m, d) algorithm
45// (http://howardhinnant.github.io/date_algorithms.html), proleptic
46// Gregorian, valid for every date this tool will ever see.
47// ---------------------------------------------------------------------------
48
49fn civil_from_days(z: i64) -> (i64, u32, u32) {
50    let z = z + 719_468;
51    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
52    let doe = (z - era * 146_097) as u64; // [0, 146096]
53    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
54    let y = yoe as i64 + era * 400;
55    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
56    let mp = (5 * doy + 2) / 153; // [0, 11]
57    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
58    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; // [1, 12]
59    (y + if m <= 2 { 1 } else { 0 }, m, d)
60}
61
62fn today() -> String {
63    let secs = SystemTime::now()
64        .duration_since(UNIX_EPOCH)
65        .map(|d| d.as_secs())
66        .unwrap_or(0);
67    let days = (secs / 86_400) as i64;
68    let (y, m, d) = civil_from_days(days);
69    format!("{y:04}-{m:02}-{d:02}")
70}
71
72// ---------------------------------------------------------------------------
73// `parse_init_args(args)`. `--label <l>` consumes its value (a missing value
74// when `--label` is the last token collapses to `None`); every other token is a
75// scope dir, in the order given.
76// ---------------------------------------------------------------------------
77
78fn parse_init_args(args: &[String]) -> (Option<String>, Vec<String>) {
79    let mut label: Option<String> = None;
80    let mut scope: Vec<String> = Vec::new();
81    let mut i = 0usize;
82    while i < args.len() {
83        if args[i] == "--label" {
84            label = args.get(i + 1).cloned();
85            i += 2;
86            continue;
87        }
88        scope.push(args[i].clone());
89        i += 1;
90    }
91    (label, scope)
92}
93
94// ---------------------------------------------------------------------------
95// `nested_git_repos(root)` -- immediate subdirectories of `root` that themselves
96// contain a `.git` entry (file or dir -- `Path::exists` does not distinguish).
97// `read_dir` order is OS-dependent, so the result is sorted explicitly: it feeds
98// directly into a user-facing error message, so its order is observable, not
99// incidental (the same determinism discipline walk.rs applies to enumeration).
100// ---------------------------------------------------------------------------
101
102fn nested_git_repos(root: &Path) -> io::Result<Vec<String>> {
103    let mut names = Vec::new();
104    for entry in fs::read_dir(root)? {
105        let entry = entry?;
106        if entry.file_type()?.is_dir() {
107            let name = entry.file_name().to_string_lossy().into_owned();
108            if root.join(&name).join(".git").exists() {
109                names.push(name);
110            }
111        }
112    }
113    names.sort();
114    Ok(names)
115}
116
117// ---------------------------------------------------------------------------
118// `.git/info/exclude` handling -- the body of `cmd_init`'s git-root branch. The
119// exclude file is read with LOSSY UTF-8 decoding (see mapcmd.rs's
120// `read_source_lossy` for the same convention and why `fs::read_to_string` --
121// strict -- is the wrong primitive to reach for by default in this codebase); a
122// git exclude file is effectively guaranteed plain ASCII/UTF-8 in practice, but
123// the lossy read costs nothing and keeps the convention uniform.
124// ---------------------------------------------------------------------------
125
126fn ensure_scout_excluded(info_dir: &Path) -> io::Result<()> {
127    fs::create_dir_all(info_dir)?;
128    let exclude_path = info_dir.join("exclude");
129    let body = if exclude_path.exists() {
130        String::from_utf8_lossy(&fs::read(&exclude_path)?).into_owned()
131    } else {
132        String::new()
133    };
134    let already_listed = body.split('\n').any(|l| l.trim() == ".scout/");
135    if !already_listed {
136        let prefix = if body.is_empty() || body.ends_with('\n') {
137            ""
138        } else {
139            "\n"
140        };
141        use io::Write as _;
142        let mut f = fs::OpenOptions::new()
143            .create(true)
144            .append(true)
145            .open(&exclude_path)?;
146        write!(f, "{prefix}.scout/\n")?;
147    }
148    Ok(())
149}
150
151// ---------------------------------------------------------------------------
152// Registry write path (`register_root`, `write_registry_value`, and the
153// `today()` label-derivation helper above). See the module header for why this
154// round-trips through `manifest::Value` rather than `repo::RegistryEntry`.
155// ---------------------------------------------------------------------------
156
157// Sets `key` on an object `Value`: on an EXISTING key the value is replaced and
158// its POSITION preserved; a new key is appended at the end. Same pattern as
159// mapcmd.rs's `with_ensured_source` (that module's own doc comment has the
160// fuller rationale) -- duplicated rather than imported because that version is
161// private and hardcodes `"source"`, while this one operates on `manifest::Value`
162// generically (any key).
163fn set_field(value: manifest::Value, key: &str, new_value: manifest::Value) -> manifest::Value {
164    let fields = value.as_object().unwrap_or(&[]).to_vec();
165    let mut out = Vec::with_capacity(fields.len() + 1);
166    let mut replaced = false;
167    for (k, v) in fields {
168        if k == key {
169            out.push((k, new_value.clone()));
170            replaced = true;
171        } else {
172            out.push((k, v));
173        }
174    }
175    if !replaced {
176        out.push((key.to_string(), new_value));
177    }
178    manifest::Value::Object(out)
179}
180
181// Reads the registry, returning the WHOLE parsed value (not just its `roots`
182// array) so a write-back preserves any top-level field beyond `roots` untouched
183// -- see module header. A missing file reads as `{roots: []}`; a present-but-
184// corrupt file, or one with no `roots` array, errors with the exact message text
185// `repo::RegistryError`'s `Display` impl uses for the same two failures (kept in
186// sync by hand: this module cannot `use` that error type -- it is private to
187// repo.rs -- so the message text is quoted here verbatim).
188fn read_registry_value() -> Result<manifest::Value, String> {
189    let path = repo::registry_path();
190    if !path.exists() {
191        return Ok(manifest::Value::object(vec![(
192            "roots",
193            manifest::Value::array(vec![]),
194        )]));
195    }
196    let bytes = fs::read(&path)
197        .map_err(|e| format!("registry at {} is not valid JSON: {e}", path.display()))?;
198    let text = String::from_utf8_lossy(&bytes).into_owned();
199    let value: manifest::Value = serde_json::from_str(&text)
200        .map_err(|e| format!("registry at {} is not valid JSON: {e}", path.display()))?;
201    match value.get("roots") {
202        Some(manifest::Value::Array(_)) => Ok(value),
203        _ => Err(format!(
204            "registry at {} has no \"roots\" array",
205            path.display()
206        )),
207    }
208}
209
210// Writes the registry as 2-space pretty-printed JSON with a trailing `\n`. NOTE
211// the trailing `\n`: unlike `manifest::write_manifest` (which does NOT append
212// one), the registry writer does.
213//
214// The write is atomic: tmp-file + rename -- the same rationale
215// `manifest::write_manifest`'s doc comment states -- so a concurrent reader
216// never observes a truncated file. This closes that window without changing the
217// final bytes.
218fn write_registry_value(value: &manifest::Value) -> Result<(), String> {
219    let path = repo::registry_path();
220    let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
221    let body = format!("{json}\n");
222
223    if let Some(dir) = path.parent() {
224        fs::create_dir_all(dir).map_err(|e| e.to_string())?;
225    }
226    let suffix = SystemTime::now()
227        .duration_since(UNIX_EPOCH)
228        .map(|d| d.as_nanos())
229        .unwrap_or(0);
230    let tmp_name = format!(
231        "{}.tmp.{}.{suffix}",
232        path.file_name()
233            .and_then(|n| n.to_str())
234            .unwrap_or("repos.json"),
235        std::process::id()
236    );
237    let tmp_path = path.with_file_name(tmp_name);
238
239    if let Err(e) = fs::write(&tmp_path, body.as_bytes()) {
240        let _ = fs::remove_file(&tmp_path);
241        return Err(e.to_string());
242    }
243    if let Err(e) = fs::rename(&tmp_path, &path) {
244        let _ = fs::remove_file(&tmp_path);
245        return Err(e.to_string());
246    }
247    Ok(())
248}
249
250fn new_entry_value(
251    root_abs: &str,
252    kind: &str,
253    label: Option<&str>,
254    scope: &[String],
255    stamp: &str,
256) -> manifest::Value {
257    manifest::Value::object(vec![
258        ("root", manifest::Value::string(root_abs)),
259        ("kind", manifest::Value::string(kind)),
260        (
261            "label",
262            label
263                .map(manifest::Value::string)
264                .unwrap_or(manifest::Value::Null),
265        ),
266        (
267            "scope",
268            manifest::Value::array(
269                scope
270                    .iter()
271                    .map(|s| manifest::Value::string(s.clone()))
272                    .collect(),
273            ),
274        ),
275        ("initialized", manifest::Value::string(stamp)),
276        ("last_seen", manifest::Value::string(stamp)),
277    ])
278}
279
280// Updates an EXISTING registry entry: `kind` is set unconditionally;
281// `label`/`scope` overwrite only when non-empty (a `None`/empty label or empty
282// scope leaves the prior value in place); `last_seen` always bumps to today.
283fn update_entry_value(
284    entry: manifest::Value,
285    kind: &str,
286    label: Option<&str>,
287    scope: &[String],
288    stamp: &str,
289) -> manifest::Value {
290    let mut e = set_field(entry, "kind", manifest::Value::string(kind));
291    if let Some(l) = label {
292        if !l.is_empty() {
293            e = set_field(e, "label", manifest::Value::string(l));
294        }
295    }
296    if !scope.is_empty() {
297        e = set_field(
298            e,
299            "scope",
300            manifest::Value::array(
301                scope
302                    .iter()
303                    .map(|s| manifest::Value::string(s.clone()))
304                    .collect(),
305            ),
306        );
307    }
308    e = set_field(e, "last_seen", manifest::Value::string(stamp));
309    e
310}
311
312// Registers or updates `root_abs` in the registry. `root_abs` is already the
313// fully resolved absolute root (`cmd_init`'s `root`), so no further resolution
314// happens here.
315fn register_root(
316    root_abs: &str,
317    kind: &str,
318    label: Option<&str>,
319    scope: &[String],
320) -> Result<(), String> {
321    let registry = read_registry_value()?;
322    let roots: Vec<manifest::Value> = match registry.get("roots") {
323        Some(manifest::Value::Array(items)) => items.clone(),
324        _ => Vec::new(),
325    };
326
327    let stamp = today();
328    let mut found = false;
329    let mut new_roots = Vec::with_capacity(roots.len() + 1);
330    for entry in roots {
331        if entry.get("root").and_then(manifest::Value::as_str) == Some(root_abs) {
332            found = true;
333            new_roots.push(update_entry_value(entry, kind, label, scope, &stamp));
334        } else {
335            new_roots.push(entry);
336        }
337    }
338    if !found {
339        new_roots.push(new_entry_value(root_abs, kind, label, scope, &stamp));
340    }
341
342    let updated_registry = set_field(registry, "roots", manifest::Value::Array(new_roots));
343    write_registry_value(&updated_registry)
344}
345
346// ---------------------------------------------------------------------------
347// `cmd_init(cwd, args)`.
348// ---------------------------------------------------------------------------
349
350/// Runs `scout init`. Returns `(code, out)` like every other command in cli.rs
351/// (the `run(argv, cwd)` -> `{code, out}` convention documented at the top of
352/// that file's refs/impact/find section).
353pub fn cmd_init(cwd: &Path, args: &[String]) -> (i32, String) {
354    let (label, scope) = parse_init_args(args);
355    let git_root = repo::find_repo_root(cwd);
356    let root = git_root.clone().unwrap_or_else(|| repo::resolve_path(cwd));
357
358    if git_root.is_none() {
359        match nested_git_repos(&root) {
360            Ok(nested) if !nested.is_empty() => {
361                return (
362                    2,
363                    format!(
364                        "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.",
365                        root.display(),
366                        nested.join(", "),
367                    ),
368                );
369            }
370            Ok(_) => {}
371            Err(e) => return (1, format!("error: {e}")),
372        }
373    }
374
375    let scope_note = if scope.is_empty() {
376        String::new()
377    } else {
378        format!(", scope {}", scope.join(", "))
379    };
380    let scout_dir = repo::scout_dir(&root);
381    let root_abs = root.to_string_lossy().into_owned();
382
383    if let Some(git_root) = &git_root {
384        // The failure-prone step (resolving the common dir, writing the
385        // exclude) runs FIRST, so a failure here leaves nothing created and
386        // nothing registered.
387        let common = match repo::git_common_dir(git_root) {
388            Some(c) => c,
389            // Unreachable in practice: `git_root` was itself found via a `.git`
390            // marker, so `git_common_dir` resolving it should never fail except
391            // under a TOCTOU race (the marker vanishing between the two calls).
392            // This aborts with an error rather than panicking.
393            None => {
394                return (
395                    1,
396                    format!(
397                        "error: unable to resolve git common dir for {}",
398                        git_root.display()
399                    ),
400                )
401            }
402        };
403        if let Err(e) = ensure_scout_excluded(&common.join("info")) {
404            return (1, format!("error: {e}"));
405        }
406        if let Err(e) = fs::create_dir_all(&scout_dir) {
407            return (1, format!("error: {e}"));
408        }
409        if let Err(e) = register_root(&root_abs, "git", label.as_deref(), &scope) {
410            return (1, format!("error: {e}"));
411        }
412        return (
413            0,
414            format!(
415                "devscout initialized at {}{scope_note}",
416                scout_dir.display()
417            ),
418        );
419    }
420
421    if let Err(e) = fs::create_dir_all(&scout_dir) {
422        return (1, format!("error: {e}"));
423    }
424    if let Err(e) = register_root(&root_abs, "plain", label.as_deref(), &scope) {
425        return (1, format!("error: {e}"));
426    }
427    (
428        0,
429        format!(
430            "devscout initialized at {} (non-git root: {}){scope_note}",
431            scout_dir.display(),
432            root.display()
433        ),
434    )
435}
436
437// ---------------------------------------------------------------------------
438// Out-of-the-box productization.
439//
440// Hook installation lives here, NOT in `cmd_init`: the init tests call
441// `cmd_init` directly with the real `HOME`, so folding it in would write the
442// operator's live settings on every test run.
443// ---------------------------------------------------------------------------
444
445// C# gets the complete extraction path. TypeScript-family files are indexed
446// and resolved into the graph, but with narrower edge coverage. The remaining
447// extensions enumerated by `walk::SOURCE_EXT` are counted but not indexed.
448const FULLY_SUPPORTED_EXT: &[&str] = &[".cs"];
449const INDEXED_AND_GRAPHED_EXT: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
450
451// Flags handled only here -- stripped from `args` before they ever reach
452// `parse_init_args` (which treats unknown tokens as scope-dir positionals), so
453// `--no-hooks`/`--no-map` are never mistaken for a scope directory and never
454// leak into the registry's `scope` field.
455fn extract_rust_only_flags(args: &[String]) -> (bool, bool, Vec<String>) {
456    let mut no_hooks = false;
457    let mut no_map = false;
458    let mut rest = Vec::with_capacity(args.len());
459    for a in args {
460        match a.as_str() {
461            "--no-hooks" => no_hooks = true,
462            "--no-map" => no_map = true,
463            _ => rest.push(a.clone()),
464        }
465    }
466    (no_hooks, no_map, rest)
467}
468
469/// The CLI entry point for `init` (wired from cli.rs, replacing a direct
470/// `cmd_init` call). Runs the core `cmd_init` first; on any core failure
471/// (nonzero code) returns immediately, unchanged, with NOTHING below this point
472/// attempted, so the exit code is nonzero only when the core steps failed. On
473/// success, runs the three extra steps (language census, hook install, first
474/// map) and appends one status line each, always exiting 0: a failure in any of
475/// them is reported on its own line and does not change the exit code or block
476/// the other steps (each step is independent -- a map failure does not skip the
477/// hooks line or vice versa).
478pub fn cmd_init_full(cwd: &Path, args: &[String]) -> (i32, String) {
479    let (no_hooks, no_map, parity_args) = extract_rust_only_flags(args);
480    let (code, out) = cmd_init(cwd, &parity_args);
481    if code != 0 {
482        return (code, out);
483    }
484
485    // Same root-resolution `cmd_init` itself used -- not returned by that
486    // function, so re-derived here rather than plumbed through a changed
487    // signature (keeping `cmd_init`'s signature untouched is deliberate).
488    let git_root = repo::find_repo_root(cwd);
489    let root = git_root.unwrap_or_else(|| repo::resolve_path(cwd));
490
491    let lines = [
492        out,
493        census_line(&root),
494        hooks_line(no_hooks),
495        map_line(&root, no_map),
496    ];
497    (0, lines.join("\n"))
498}
499
500// ---------------------------------------------------------------------------
501// Language census. Rather than a second, independent recursive walker, this
502// counts extensions over exactly the file set `walk::list_source_files` already
503// enumerates (its `SKIP_DIRS`/`SOURCE_EXT` contract). Bounded consequence, noted
504// rather than solved: a real-but-unsupported language outside `walk::SOURCE_EXT`
505// entirely (Python, Go, ...) is invisible to this census, same as it is
506// invisible to `devscout map` itself.
507// ---------------------------------------------------------------------------
508
509fn census_line(root: &Path) -> String {
510    let files = match walk::list_source_files(root, &[".".to_string()]) {
511        Ok(f) => f,
512        Err(e) => return format!("languages: error walking sources ({e})"),
513    };
514    if files.is_empty() {
515        return "languages: no source files found".to_string();
516    }
517
518    let mut counts: Vec<(String, usize)> = Vec::new();
519    for f in &files {
520        let ext = match f.rfind('.') {
521            Some(i) => f[i..].to_string(),
522            None => String::new(),
523        };
524        match counts.iter_mut().find(|(e, _)| *e == ext) {
525            Some(entry) => entry.1 += 1,
526            None => counts.push((ext, 1)),
527        }
528    }
529    counts.sort();
530    let (fully_supported, other): (Vec<_>, Vec<_>) = counts
531        .into_iter()
532        .partition(|(e, _)| FULLY_SUPPORTED_EXT.contains(&e.as_str()));
533    let (indexed_and_graphed, not_indexed): (Vec<_>, Vec<_>) = other
534        .into_iter()
535        .partition(|(e, _)| INDEXED_AND_GRAPHED_EXT.contains(&e.as_str()));
536    let fmt_group = |g: &[(String, usize)]| {
537        g.iter()
538            .map(|(e, c)| format!("{c} {e}"))
539            .collect::<Vec<_>>()
540            .join(", ")
541    };
542
543    let mut groups = Vec::new();
544    if !fully_supported.is_empty() {
545        groups.push(format!("{} (fully supported)", fmt_group(&fully_supported)));
546    }
547    if !indexed_and_graphed.is_empty() {
548        groups.push(format!(
549            "{} (indexed and graphed, narrower edge coverage)",
550            fmt_group(&indexed_and_graphed)
551        ));
552    }
553    if !not_indexed.is_empty() {
554        groups.push(format!(
555            "{} (present, not indexed)",
556            fmt_group(&not_indexed)
557        ));
558    }
559    format!("languages: {}", groups.join("; "))
560}
561
562// ---------------------------------------------------------------------------
563// Hook install. Reads `$HOME/.claude/settings.json` (read-only, for
564// SHAPE) to decide whether it is safe to mirror that structure; writes only
565// when the shape is recognized AND at least one of the two entries
566// (`hook read` / `hook bash`) is actually missing. Fail-safe on anything
567// else: missing file, invalid JSON, or a shape this code does not
568// recognize all take the SAME "print the snippet, do not write" path,
569// deliberately collapsed into one outcome, since "is this safe to merge
570// into" is a single yes/no question regardless of which specific way the
571// answer came out no.
572// ---------------------------------------------------------------------------
573
574enum HookOutcome {
575    AlreadyInstalled,
576    Installed {
577        added: Vec<&'static str>,
578        backup: PathBuf,
579    },
580    NeedsManualInsert {
581        reason: String,
582        snippet: String,
583    },
584}
585
586fn hooks_line(no_hooks: bool) -> String {
587    if no_hooks {
588        return "hooks: skipped (--no-hooks)".to_string();
589    }
590    match install_hooks() {
591        Ok(HookOutcome::AlreadyInstalled) => "hooks: already installed (idempotent)".to_string(),
592        Ok(HookOutcome::Installed { added, backup }) => {
593            format!(
594                "hooks: installed ({}); backup {}",
595                added.join(", "),
596                backup.display()
597            )
598        }
599        Ok(HookOutcome::NeedsManualInsert { reason, snippet }) => {
600            format!("hooks: {reason} -- add manually:\n{snippet}")
601        }
602        Err(e) => format!("hooks: error: {e}"),
603    }
604}
605
606// `$HOME/.claude/settings.json` -- respects the `HOME` env var (the test seam),
607// never hardcodes a path. `Err` only when `HOME` itself is unset, which this
608// function treats as a hard error (there is no live-settings hazard here: with
609// no `HOME`, there is no path to touch).
610fn settings_path() -> Result<PathBuf, String> {
611    let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
612    Ok(PathBuf::from(home).join(".claude").join("settings.json"))
613}
614
615// The absolute path to the CURRENTLY RUNNING binary -- what the installed hook
616// command actually invokes. `current_exe` + `canonicalize` (falling back to the
617// uncanonicalized path if that fails, e.g. a since-deleted binary mid-run)
618// resolves through any symlink so the installed command keeps working even if a
619// PATH symlink is later repointed.
620fn current_binary_path() -> Result<String, String> {
621    let exe = std::env::current_exe().map_err(|e| e.to_string())?;
622    let exe = fs::canonicalize(&exe).unwrap_or(exe);
623    Ok(exe.to_string_lossy().into_owned())
624}
625
626fn install_hooks() -> Result<HookOutcome, String> {
627    let bin = current_binary_path()?;
628    let path = settings_path()?;
629
630    if !path.exists() {
631        return Ok(HookOutcome::NeedsManualInsert {
632            reason: format!("no settings file at {}", path.display()),
633            snippet: hook_snippet(&bin),
634        });
635    }
636
637    let bytes = fs::read(&path).map_err(|e| e.to_string())?;
638    let text = String::from_utf8_lossy(&bytes).into_owned();
639    let value: manifest::Value = match serde_json::from_str(&text) {
640        Ok(v) => v,
641        Err(_) => {
642            return Ok(HookOutcome::NeedsManualInsert {
643                reason: format!("settings file at {} is not valid JSON", path.display()),
644                snippet: hook_snippet(&bin),
645            })
646        }
647    };
648
649    if !matches!(value, manifest::Value::Object(_)) || !hooks_shape_ok(&value) {
650        return Ok(HookOutcome::NeedsManualInsert {
651            reason: format!(
652                "settings file at {} has an unexpected shape",
653                path.display()
654            ),
655            snippet: hook_snippet(&bin),
656        });
657    }
658
659    let (updated, added) = merge_hooks(value, &bin);
660    if added.is_empty() {
661        return Ok(HookOutcome::AlreadyInstalled);
662    }
663
664    // Backup happens ONLY here -- right before the one write path, and only
665    // once we know a write is actually about to happen (an idempotent
666    // re-run with nothing to add returns above and never reaches this
667    // line, so it never creates a second backup).
668    let backup = backup_settings(&path, &bytes)?;
669    write_settings(&path, &updated)?;
670    Ok(HookOutcome::Installed { added, backup })
671}
672
673// `true` when `value`'s `hooks`/`hooks.PostToolUse` shape (if either key is
674// even present) is one this code is willing to merge into. An ABSENT `hooks` key,
675// or an absent `hooks.PostToolUse` key, is fine (nothing to validate -- both get
676// created fresh by `merge_hooks`); anything PRESENT but not the expected shape is
677// not.
678fn hooks_shape_ok(value: &manifest::Value) -> bool {
679    let Some(hooks) = value.get("hooks") else {
680        return true;
681    };
682    let manifest::Value::Object(_) = hooks else {
683        return false;
684    };
685    let Some(post_tool_use) = hooks.get("PostToolUse") else {
686        return true;
687    };
688    let manifest::Value::Array(entries) = post_tool_use else {
689        return false;
690    };
691    entries.iter().all(entry_shape_ok)
692}
693
694// One `hooks.PostToolUse[]` element: `{matcher?: string, hooks?: [{type?:
695// string, command?: string}]}`. Every field is optional (some real-world entries
696// omit `matcher`), but any field that IS present must have the right JSON type --
697// a present-but-wrong-typed field is exactly the "shape I don't recognize, don't
698// touch it" case this whole check exists for.
699fn entry_shape_ok(entry: &manifest::Value) -> bool {
700    if !matches!(entry, manifest::Value::Object(_)) {
701        return false;
702    }
703    if let Some(matcher) = entry.get("matcher") {
704        if !matches!(matcher, manifest::Value::String(_)) {
705            return false;
706        }
707    }
708    let Some(hooks) = entry.get("hooks") else {
709        return true;
710    };
711    let manifest::Value::Array(items) = hooks else {
712        return false;
713    };
714    items.iter().all(|item| {
715        if !matches!(item, manifest::Value::Object(_)) {
716            return false;
717        }
718        if let Some(t) = item.get("type") {
719            if !matches!(t, manifest::Value::String(_)) {
720                return false;
721            }
722        }
723        if let Some(c) = item.get("command") {
724            if !matches!(c, manifest::Value::String(_)) {
725                return false;
726            }
727        }
728        true
729    })
730}
731
732// `true` when some existing `hooks.PostToolUse[].hooks[].command` already
733// contains both `"scout"` and `marker` (`"hook read"`/`"hook bash"`) -- the
734// idempotency rule. Scans every entry regardless of its `matcher` (an
735// already-installed command under any matcher still counts), matching the rule as
736// stated rather than narrowing it.
737fn already_has_hook_command(entries: &[manifest::Value], marker: &str) -> bool {
738    entries.iter().any(|entry| {
739        let Some(manifest::Value::Array(items)) = entry.get("hooks") else {
740            return false;
741        };
742        items.iter().any(|item| {
743            item.get("command")
744                .and_then(manifest::Value::as_str)
745                .is_some_and(|c| c.contains("scout") && c.contains(marker))
746        })
747    })
748}
749
750fn hook_entry(matcher: &str, bin: &str, subcmd: &str) -> manifest::Value {
751    manifest::Value::object(vec![
752        ("matcher", manifest::Value::string(matcher)),
753        (
754            "hooks",
755            manifest::Value::array(vec![manifest::Value::object(vec![
756                ("type", manifest::Value::string("command")),
757                (
758                    "command",
759                    manifest::Value::string(format!("{bin} hook {subcmd}")),
760                ),
761            ])]),
762        ),
763    ])
764}
765
766// Appends whichever of the Read/Bash entries are missing to
767// `hooks.PostToolUse`, preserving every other top-level key (`PreToolUse`,
768// `UserPromptSubmit`, ...) and every existing `PostToolUse` entry unchanged (via
769// `set_field`'s position-preserving merge, the same primitive `register_root`
770// uses on the registry). Never removes or reorders anything that was already
771// there.
772fn merge_hooks(value: manifest::Value, bin: &str) -> (manifest::Value, Vec<&'static str>) {
773    let hooks_val = value
774        .get("hooks")
775        .cloned()
776        .unwrap_or_else(|| manifest::Value::object(vec![]));
777    let ptu_val = hooks_val
778        .get("PostToolUse")
779        .cloned()
780        .unwrap_or_else(|| manifest::Value::array(vec![]));
781    let mut entries = match ptu_val {
782        manifest::Value::Array(items) => items,
783        _ => Vec::new(),
784    };
785
786    let mut added: Vec<&'static str> = Vec::new();
787    if !already_has_hook_command(&entries, "hook read") {
788        entries.push(hook_entry("Read", bin, "read"));
789        added.push("Read");
790    }
791    if !already_has_hook_command(&entries, "hook bash") {
792        entries.push(hook_entry("Bash", bin, "bash"));
793        added.push("Bash");
794    }
795
796    let updated_hooks = set_field(hooks_val, "PostToolUse", manifest::Value::Array(entries));
797    let updated = set_field(value, "hooks", updated_hooks);
798    (updated, added)
799}
800
801// The exact JSON a human should paste into `settings.json` by hand when this
802// code declines to write it itself. Built through `manifest::Value` +
803// `serde_json`'s pretty printer (not hand-formatted string interpolation) so the
804// binary path is correctly JSON-escaped even in the pathological case of a path
805// containing a `"` or backslash.
806fn hook_snippet(bin: &str) -> String {
807    let v = manifest::Value::object(vec![(
808        "hooks",
809        manifest::Value::object(vec![(
810            "PostToolUse",
811            manifest::Value::array(vec![
812                hook_entry("Read", bin, "read"),
813                hook_entry("Bash", bin, "bash"),
814            ]),
815        )]),
816    )]);
817    serde_json::to_string_pretty(&v).unwrap_or_default()
818}
819
820// `YYYYMMDD-HHMMSS-nnnnnnnnn` (UTC, sub-second suffix for collision safety on
821// rapid successive installs within the same test process). Reuses
822// `civil_from_days` rather than pulling in a date/time crate.
823fn now_stamp() -> String {
824    let dur = SystemTime::now()
825        .duration_since(UNIX_EPOCH)
826        .unwrap_or_default();
827    let secs = dur.as_secs();
828    let days = (secs / 86_400) as i64;
829    let (y, m, d) = civil_from_days(days);
830    let sod = secs % 86_400;
831    let (h, mi, s) = (sod / 3600, (sod % 3600) / 60, sod % 60);
832    format!(
833        "{y:04}{m:02}{d:02}-{h:02}{mi:02}{s:02}-{:09}",
834        dur.subsec_nanos()
835    )
836}
837
838// Copies the ORIGINAL bytes (pre-parse, pre-modification) next to the settings
839// file, timestamped. Never overwrites an existing backup (the
840// nanosecond-resolution stamp makes a same-name collision practically
841// impossible; on the vanishingly unlikely collision `fs::write` just overwrites
842// it, which is still strictly safer than skipping the backup).
843fn backup_settings(path: &Path, original_bytes: &[u8]) -> Result<PathBuf, String> {
844    let name = path
845        .file_name()
846        .and_then(|n| n.to_str())
847        .unwrap_or("settings.json");
848    let backup_path = path.with_file_name(format!("{name}.bak.{}", now_stamp()));
849    fs::write(&backup_path, original_bytes).map_err(|e| e.to_string())?;
850    Ok(backup_path)
851}
852
853// Same atomic tmp-file + rename convention as `write_registry_value` /
854// `manifest::write_manifest` -- never leaves a half-written `settings.json` for
855// Claude Code (or anything else reading it live) to observe mid-write.
856fn write_settings(path: &Path, value: &manifest::Value) -> Result<(), String> {
857    let json = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
858    let body = format!("{json}\n");
859
860    let suffix = SystemTime::now()
861        .duration_since(UNIX_EPOCH)
862        .map(|d| d.as_nanos())
863        .unwrap_or(0);
864    let tmp_name = format!(
865        "{}.tmp.{}.{suffix}",
866        path.file_name()
867            .and_then(|n| n.to_str())
868            .unwrap_or("settings.json"),
869        std::process::id()
870    );
871    let tmp_path = path.with_file_name(tmp_name);
872
873    if let Err(e) = fs::write(&tmp_path, body.as_bytes()) {
874        let _ = fs::remove_file(&tmp_path);
875        return Err(e.to_string());
876    }
877    if let Err(e) = fs::rename(&tmp_path, path) {
878        let _ = fs::remove_file(&tmp_path);
879        return Err(e.to_string());
880    }
881    Ok(())
882}
883
884// ---------------------------------------------------------------------------
885// First map. Thin wrapper over `mapcmd::map_repo` (the same function `cli.rs`'s
886// `cmd_map` calls for a plain `devscout map`), run unscoped (matching a bare
887// `devscout map` with no dir args). `MapReport::summary_line()` already reports
888// files/defs/edges/elapsed in one line -- reused as-is rather than re-deriving
889// the same four numbers into a second format.
890// ---------------------------------------------------------------------------
891
892fn map_line(root: &Path, no_map: bool) -> String {
893    if no_map {
894        return "map: skipped (--no-map)".to_string();
895    }
896    match mapcmd::map_repo(root, &[], mapcmd::MapOptions::from_env()) {
897        Ok(report) => format!("map: {}", report.summary_line()),
898        Err(e) => format!("map: error: {e}"),
899    }
900}
901
902// ---------------------------------------------------------------------------
903// Unit tests -- pure-function coverage only (arg parsing, date math, the
904// exclude-file body logic, `set_field`'s position semantics, and
905// `nested_git_repos` on a plain filesystem fixture with no registry
906// involvement). Anything touching `SCOUT_REGISTRY` (the registry round-trip,
907// `cmd_init` end to end) lives in the integration suite instead, on purpose:
908// manifest.rs's own `#[cfg(test)]` block already mutates that same
909// process-global env var behind a LOCAL mutex scoped to its own tests, and
910// `cargo test`'s default `--lib` run puts every `src/*.rs` module's unit tests
911// in ONE shared binary -- two independent local mutexes in two different modules
912// do NOT synchronize with each other, so adding a second SCOUT_REGISTRY-mutating
913// test site here would be a real, silent race, not a hypothetical one. The
914// integration suite runs as its own separate process (cargo test's standard
915// treatment of each `tests/*.rs` file), sidestepping the hazard entirely -- the
916// same reasoning repo.rs's registry-read tests rely on.
917// ---------------------------------------------------------------------------
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use std::sync::atomic::{AtomicU64, Ordering};
923
924    static COUNTER: AtomicU64 = AtomicU64::new(0);
925
926    fn temp_dir(prefix: &str) -> PathBuf {
927        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
928        let dir = std::env::temp_dir().join(format!(
929            "scout-initcmd-rs-{prefix}-{}-{n}",
930            std::process::id()
931        ));
932        fs::create_dir_all(&dir).expect("create temp dir");
933        dir
934    }
935
936    // -- parse_init_args ----------------------------------------------------
937
938    #[test]
939    fn no_args_is_no_label_no_scope() {
940        let (label, scope) = parse_init_args(&[]);
941        assert_eq!(label, None);
942        assert!(scope.is_empty());
943    }
944
945    #[test]
946    fn label_flag_consumes_its_value() {
947        let args: Vec<String> = ["--label", "backend-cs"]
948            .iter()
949            .map(|s| s.to_string())
950            .collect();
951        let (label, scope) = parse_init_args(&args);
952        assert_eq!(label, Some("backend-cs".to_string()));
953        assert!(scope.is_empty());
954    }
955
956    #[test]
957    fn label_as_last_token_with_no_value_is_none() {
958        let args: Vec<String> = ["src", "--label"].iter().map(|s| s.to_string()).collect();
959        let (label, scope) = parse_init_args(&args);
960        assert_eq!(label, None);
961        assert_eq!(scope, vec!["src".to_string()]);
962    }
963
964    #[test]
965    fn scope_dirs_collected_in_order_around_label() {
966        let args: Vec<String> = ["src", "--label", "l", "lib", "app"]
967            .iter()
968            .map(|s| s.to_string())
969            .collect();
970        let (label, scope) = parse_init_args(&args);
971        assert_eq!(label, Some("l".to_string()));
972        assert_eq!(
973            scope,
974            vec!["src".to_string(), "lib".to_string(), "app".to_string()]
975        );
976    }
977
978    // -- civil_from_days / today ------------------------------------------
979
980    #[test]
981    fn civil_from_days_epoch_is_1970_01_01() {
982        assert_eq!(civil_from_days(0), (1970, 1, 1));
983    }
984
985    #[test]
986    fn civil_from_days_known_reference_points() {
987        // 2000-03-01 is Hinnant's own reference point for this algorithm.
988        // (11017 = days between 1970-01-01 and 2000-03-01, cross-checked
989        // with `python3 -c "import datetime; print((datetime.date(2000,3,1)
990        // - datetime.date(1970,1,1)).days)"`.)
991        assert_eq!(civil_from_days(11_017), (2000, 3, 1));
992        // 2024-01-01 (a leap year's first day) -- cross-checked against
993        // `date -u -d @1704067200 +%Y-%m-%d` / `date -u -r 1704067200
994        // +%Y-%m-%d` (1704067200 / 86400 = 19723).
995        assert_eq!(civil_from_days(19_723), (2024, 1, 1));
996        // 2024-02-29 -- the leap day itself, 59 days into a leap year.
997        assert_eq!(civil_from_days(19_723 + 59), (2024, 2, 29));
998    }
999
1000    #[test]
1001    fn today_is_well_formed_yyyy_mm_dd() {
1002        let t = today();
1003        assert_eq!(t.len(), 10);
1004        assert_eq!(t.as_bytes()[4], b'-');
1005        assert_eq!(t.as_bytes()[7], b'-');
1006        assert!(t.chars().all(|c| c.is_ascii_digit() || c == '-'));
1007    }
1008
1009    // -- ensure_scout_excluded ------------------------------------------
1010
1011    #[test]
1012    fn exclude_created_fresh_when_missing() {
1013        let dir = temp_dir("exclude-fresh");
1014        let info_dir = dir.join("info");
1015        ensure_scout_excluded(&info_dir).unwrap();
1016        let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1017        assert_eq!(body, ".scout/\n");
1018    }
1019
1020    #[test]
1021    fn exclude_appended_after_existing_content_without_trailing_newline() {
1022        let dir = temp_dir("exclude-append-no-nl");
1023        let info_dir = dir.join("info");
1024        fs::create_dir_all(&info_dir).unwrap();
1025        fs::write(info_dir.join("exclude"), "*.log").unwrap();
1026        ensure_scout_excluded(&info_dir).unwrap();
1027        let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1028        assert_eq!(body, "*.log\n.scout/\n");
1029    }
1030
1031    #[test]
1032    fn exclude_appended_after_existing_content_with_trailing_newline() {
1033        let dir = temp_dir("exclude-append-nl");
1034        let info_dir = dir.join("info");
1035        fs::create_dir_all(&info_dir).unwrap();
1036        fs::write(info_dir.join("exclude"), "*.log\n").unwrap();
1037        ensure_scout_excluded(&info_dir).unwrap();
1038        let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1039        assert_eq!(body, "*.log\n.scout/\n");
1040    }
1041
1042    #[test]
1043    fn exclude_is_idempotent_when_already_listed() {
1044        let dir = temp_dir("exclude-idempotent");
1045        let info_dir = dir.join("info");
1046        fs::create_dir_all(&info_dir).unwrap();
1047        fs::write(info_dir.join("exclude"), "*.log\n.scout/\nmore\n").unwrap();
1048        ensure_scout_excluded(&info_dir).unwrap();
1049        let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1050        assert_eq!(body, "*.log\n.scout/\nmore\n", "must not double-append");
1051    }
1052
1053    #[test]
1054    fn exclude_matches_on_trimmed_line_not_exact_bytes() {
1055        // The listed-line check trims each line, so a line carrying trailing
1056        // whitespace or a CRLF still counts as already-listed.
1057        let dir = temp_dir("exclude-trim-match");
1058        let info_dir = dir.join("info");
1059        fs::create_dir_all(&info_dir).unwrap();
1060        fs::write(info_dir.join("exclude"), "  .scout/  \n").unwrap();
1061        ensure_scout_excluded(&info_dir).unwrap();
1062        let body = fs::read_to_string(info_dir.join("exclude")).unwrap();
1063        assert_eq!(
1064            body, "  .scout/  \n",
1065            "already-listed (after trim) must not append again"
1066        );
1067    }
1068
1069    // -- set_field -----------------------------------------------------
1070
1071    #[test]
1072    fn set_field_overwrites_in_place_preserving_position() {
1073        let v = manifest::Value::object(vec![
1074            ("a", manifest::Value::string("1")),
1075            ("b", manifest::Value::string("2")),
1076        ]);
1077        let out = set_field(v, "a", manifest::Value::string("9"));
1078        let fields = out.as_object().unwrap();
1079        assert_eq!(fields[0].0, "a");
1080        assert_eq!(out.get("a").and_then(manifest::Value::as_str), Some("9"));
1081        assert_eq!(fields[1].0, "b");
1082    }
1083
1084    #[test]
1085    fn set_field_appends_when_absent() {
1086        let v = manifest::Value::object(vec![("a", manifest::Value::string("1"))]);
1087        let out = set_field(v, "z", manifest::Value::string("new"));
1088        let fields = out.as_object().unwrap();
1089        assert_eq!(fields.last().unwrap().0, "z");
1090    }
1091
1092    // -- nested_git_repos --------------------------------------------------
1093
1094    #[test]
1095    fn nested_git_repos_finds_immediate_subdirs_with_dot_git_only() {
1096        let root = temp_dir("nested-detect");
1097        fs::create_dir_all(root.join("repo-a/.git")).unwrap();
1098        fs::create_dir_all(root.join("repo-b/.git")).unwrap();
1099        fs::create_dir_all(root.join("plain-dir")).unwrap();
1100        fs::create_dir_all(root.join("repo-a/nested-repo/.git")).unwrap(); // NOT immediate -- must not count
1101
1102        let found = nested_git_repos(&root).unwrap();
1103        assert_eq!(
1104            found,
1105            vec!["repo-a".to_string(), "repo-b".to_string()],
1106            "sorted, immediate subdirs only"
1107        );
1108    }
1109
1110    #[test]
1111    fn nested_git_repos_empty_when_none_present() {
1112        let root = temp_dir("nested-none");
1113        fs::create_dir_all(root.join("plain-dir")).unwrap();
1114        assert!(nested_git_repos(&root).unwrap().is_empty());
1115    }
1116
1117    // -- new_entry_value / update_entry_value -------------------------------
1118
1119    #[test]
1120    fn update_entry_ignores_falsy_label_and_empty_scope() {
1121        let entry = new_entry_value(
1122            "/x",
1123            "git",
1124            Some("orig"),
1125            &["src".to_string()],
1126            "2026-01-01",
1127        );
1128        let updated = update_entry_value(entry, "git", None, &[], "2026-01-02");
1129        assert_eq!(
1130            updated.get("label").and_then(manifest::Value::as_str),
1131            Some("orig"),
1132            "no label given -- keeps existing"
1133        );
1134        let scope = updated.get("scope").unwrap();
1135        assert!(
1136            matches!(scope, manifest::Value::Array(items) if items.len() == 1),
1137            "empty scope given -- keeps existing"
1138        );
1139        assert_eq!(
1140            updated.get("last_seen").and_then(manifest::Value::as_str),
1141            Some("2026-01-02")
1142        );
1143    }
1144
1145    #[test]
1146    fn update_entry_overwrites_truthy_label_and_nonempty_scope() {
1147        let entry = new_entry_value(
1148            "/x",
1149            "git",
1150            Some("orig"),
1151            &["src".to_string()],
1152            "2026-01-01",
1153        );
1154        let updated = update_entry_value(
1155            entry,
1156            "git",
1157            Some("fresh"),
1158            &["lib".to_string(), "app".to_string()],
1159            "2026-01-02",
1160        );
1161        assert_eq!(
1162            updated.get("label").and_then(manifest::Value::as_str),
1163            Some("fresh")
1164        );
1165        let scope = updated.get("scope").unwrap();
1166        assert!(matches!(scope, manifest::Value::Array(items) if items.len() == 2));
1167    }
1168
1169    // -- pure-function coverage only. Anything touching HOME or the real
1170    // filesystem's settings.json path lives in the integration suite instead,
1171    // for the same env-var-race reason SCOUT_REGISTRY tests do (this module's
1172    // own header, above).
1173    // -------------------------------------------------------------------
1174
1175    #[test]
1176    fn extract_rust_only_flags_strips_no_hooks_and_no_map_only() {
1177        let args: Vec<String> = ["--no-hooks", "src", "--no-map", "--label", "l"]
1178            .iter()
1179            .map(|s| s.to_string())
1180            .collect();
1181        let (no_hooks, no_map, rest) = extract_rust_only_flags(&args);
1182        assert!(no_hooks);
1183        assert!(no_map);
1184        assert_eq!(
1185            rest,
1186            vec!["src".to_string(), "--label".to_string(), "l".to_string()],
1187            "everything else passes through untouched, in order"
1188        );
1189    }
1190
1191    #[test]
1192    fn extract_rust_only_flags_defaults_false_when_absent() {
1193        let args: Vec<String> = ["--label", "l", "src"]
1194            .iter()
1195            .map(|s| s.to_string())
1196            .collect();
1197        let (no_hooks, no_map, rest) = extract_rust_only_flags(&args);
1198        assert!(!no_hooks);
1199        assert!(!no_map);
1200        assert_eq!(rest, args);
1201    }
1202
1203    // -- census_line ---------------------------------------------------
1204
1205    #[test]
1206    fn census_line_reports_all_three_support_tiers() {
1207        let dir = temp_dir("census-mixed");
1208        fs::write(dir.join("A.cs"), "namespace X { class A {} }").unwrap();
1209        fs::write(dir.join("B.cs"), "namespace X { class B {} }").unwrap();
1210        fs::write(dir.join("c.ts"), "export const x = 1;").unwrap();
1211        fs::write(dir.join("d.md"), "# doc").unwrap();
1212        let line = census_line(&dir);
1213        assert_eq!(line, "languages: 2 .cs (fully supported); 1 .ts (indexed and graphed, narrower edge coverage); 1 .md (present, not indexed)");
1214    }
1215
1216    #[test]
1217    fn census_line_all_supported_omits_the_unsupported_clause() {
1218        let dir = temp_dir("census-cs-only");
1219        fs::write(dir.join("A.cs"), "namespace X { class A {} }").unwrap();
1220        let line = census_line(&dir);
1221        assert_eq!(line, "languages: 1 .cs (fully supported)");
1222    }
1223
1224    #[test]
1225    fn census_line_no_supported_still_reports_unsupported() {
1226        let dir = temp_dir("census-unsupported-only");
1227        fs::write(dir.join("a.md"), "# doc").unwrap();
1228        let line = census_line(&dir);
1229        assert_eq!(line, "languages: 1 .md (present, not indexed)");
1230    }
1231
1232    #[test]
1233    fn census_line_empty_repo_reports_no_source_files() {
1234        let dir = temp_dir("census-empty");
1235        assert_eq!(census_line(&dir), "languages: no source files found");
1236    }
1237
1238    // -- hooks_shape_ok / entry_shape_ok --------------------------------
1239
1240    #[test]
1241    fn hooks_shape_ok_on_absent_hooks_key() {
1242        let v = manifest::Value::object(vec![("other", manifest::Value::string("x"))]);
1243        assert!(hooks_shape_ok(&v));
1244    }
1245
1246    #[test]
1247    fn hooks_shape_ok_on_the_live_shape() {
1248        // Mirrors the real ~/.claude/settings.json PostToolUse shape this
1249        // code was designed to recognize.
1250        let v = manifest::Value::object(vec![(
1251            "hooks",
1252            manifest::Value::object(vec![(
1253                "PostToolUse",
1254                manifest::Value::array(vec![
1255                    manifest::Value::object(vec![
1256                        ("matcher", manifest::Value::string("Read")),
1257                        (
1258                            "hooks",
1259                            manifest::Value::array(vec![manifest::Value::object(vec![
1260                                ("type", manifest::Value::string("command")),
1261                                (
1262                                    "command",
1263                                    manifest::Value::string("node scout-read-hook.js"),
1264                                ),
1265                            ])]),
1266                        ),
1267                    ]),
1268                    manifest::Value::object(vec![("matcher", manifest::Value::string("Bash"))]),
1269                ]),
1270            )]),
1271        )]);
1272        assert!(hooks_shape_ok(&v));
1273    }
1274
1275    #[test]
1276    fn hooks_shape_rejects_hooks_as_non_object() {
1277        let v = manifest::Value::object(vec![("hooks", manifest::Value::array(vec![]))]);
1278        assert!(!hooks_shape_ok(&v));
1279    }
1280
1281    #[test]
1282    fn hooks_shape_rejects_post_tool_use_as_non_array() {
1283        let v = manifest::Value::object(vec![(
1284            "hooks",
1285            manifest::Value::object(vec![("PostToolUse", manifest::Value::string("nope"))]),
1286        )]);
1287        assert!(!hooks_shape_ok(&v));
1288    }
1289
1290    #[test]
1291    fn hooks_shape_rejects_entry_with_wrong_typed_matcher() {
1292        let entries = manifest::Value::array(vec![manifest::Value::object(vec![(
1293            "matcher",
1294            manifest::Value::number(1),
1295        )])]);
1296        let v = manifest::Value::object(vec![(
1297            "hooks",
1298            manifest::Value::object(vec![("PostToolUse", entries)]),
1299        )]);
1300        assert!(!hooks_shape_ok(&v));
1301    }
1302
1303    #[test]
1304    fn hooks_shape_rejects_entry_that_is_not_an_object() {
1305        let entry = manifest::Value::string("not an object");
1306        assert!(!entry_shape_ok(&entry));
1307    }
1308
1309    // -- already_has_hook_command ----------------------------------------
1310
1311    #[test]
1312    fn already_has_hook_command_matches_scout_plus_marker() {
1313        let entries = vec![manifest::Value::object(vec![(
1314            "hooks",
1315            manifest::Value::array(vec![manifest::Value::object(vec![(
1316                "command",
1317                manifest::Value::string("/abs/devscout hook read"),
1318            )])]),
1319        )])];
1320        assert!(already_has_hook_command(&entries, "hook read"));
1321        assert!(
1322            !already_has_hook_command(&entries, "hook bash"),
1323            "different marker must not match"
1324        );
1325    }
1326
1327    #[test]
1328    fn already_has_hook_command_ignores_scout_without_the_marker() {
1329        // An existing hook whose command contains "scout" (a path segment) but
1330        // never the literal text "hook read"/"hook bash" -- must NOT be treated
1331        // as already-installed.
1332        let entries = vec![manifest::Value::object(vec![(
1333            "hooks",
1334            manifest::Value::array(vec![manifest::Value::object(vec![(
1335                "command",
1336                manifest::Value::string("node /opt/x/scout/scout-read-hook.js"),
1337            )])]),
1338        )])];
1339        assert!(!already_has_hook_command(&entries, "hook read"));
1340    }
1341
1342    #[test]
1343    fn already_has_hook_command_false_on_empty_entries() {
1344        assert!(!already_has_hook_command(&[], "hook read"));
1345    }
1346
1347    // -- merge_hooks -----------------------------------------------------
1348
1349    #[test]
1350    fn merge_hooks_adds_both_entries_and_preserves_other_top_level_keys() {
1351        let v = manifest::Value::object(vec![
1352            ("model", manifest::Value::string("x")),
1353            ("hooks", manifest::Value::object(vec![])),
1354        ]);
1355        let (updated, added) = merge_hooks(v, "/abs/devscout");
1356        assert_eq!(added, vec!["Read", "Bash"]);
1357        assert_eq!(
1358            updated.get("model").and_then(manifest::Value::as_str),
1359            Some("x"),
1360            "unrelated top-level key preserved"
1361        );
1362        let ptu = updated.get("hooks").unwrap().get("PostToolUse").unwrap();
1363        assert!(matches!(ptu, manifest::Value::Array(items) if items.len() == 2));
1364    }
1365
1366    #[test]
1367    fn merge_hooks_is_idempotent_on_second_call() {
1368        let v = manifest::Value::object(vec![("hooks", manifest::Value::object(vec![]))]);
1369        let (once, added1) = merge_hooks(v, "/abs/devscout");
1370        assert_eq!(added1, vec!["Read", "Bash"]);
1371        let (twice, added2) = merge_hooks(once.clone(), "/abs/devscout");
1372        assert!(
1373            added2.is_empty(),
1374            "second merge over its own output must add nothing"
1375        );
1376        assert_eq!(once, twice, "no-op merge must not alter the value at all");
1377    }
1378
1379    #[test]
1380    fn merge_hooks_preserves_existing_post_tool_use_entries() {
1381        let existing = manifest::Value::array(vec![manifest::Value::object(vec![(
1382            "matcher",
1383            manifest::Value::string("Write"),
1384        )])]);
1385        let v = manifest::Value::object(vec![(
1386            "hooks",
1387            manifest::Value::object(vec![("PostToolUse", existing)]),
1388        )]);
1389        let (updated, added) = merge_hooks(v, "/abs/devscout");
1390        assert_eq!(added, vec!["Read", "Bash"]);
1391        let ptu = updated.get("hooks").unwrap().get("PostToolUse").unwrap();
1392        assert!(
1393            matches!(ptu, manifest::Value::Array(items) if items.len() == 3),
1394            "1 existing + 2 new"
1395        );
1396        if let manifest::Value::Array(items) = ptu {
1397            assert_eq!(
1398                items[0].get("matcher").and_then(manifest::Value::as_str),
1399                Some("Write"),
1400                "existing entry stays first, untouched"
1401            );
1402        }
1403    }
1404
1405    // -- hook_snippet ------------------------------------------------------
1406
1407    #[test]
1408    fn hook_snippet_is_valid_json_containing_both_subcommands() {
1409        let snippet = hook_snippet("/abs/devscout");
1410        let parsed: manifest::Value =
1411            serde_json::from_str(&snippet).expect("snippet must be valid JSON");
1412        let ptu = parsed.get("hooks").unwrap().get("PostToolUse").unwrap();
1413        assert!(matches!(ptu, manifest::Value::Array(items) if items.len() == 2));
1414        assert!(snippet.contains("/abs/devscout hook read"));
1415        assert!(snippet.contains("/abs/devscout hook bash"));
1416    }
1417
1418    // -- now_stamp / backup_settings / write_settings ---------------------
1419
1420    #[test]
1421    fn now_stamp_is_well_formed() {
1422        let s = now_stamp();
1423        // YYYYMMDD-HHMMSS-nnnnnnnnn
1424        assert_eq!(s.len(), 8 + 1 + 6 + 1 + 9);
1425        assert!(
1426            s.chars().enumerate().all(|(i, c)| if i == 8 || i == 15 {
1427                c == '-'
1428            } else {
1429                c.is_ascii_digit()
1430            }),
1431            "got {s:?}"
1432        );
1433    }
1434
1435    #[test]
1436    fn backup_settings_writes_original_bytes_verbatim() {
1437        let dir = temp_dir("backup");
1438        let path = dir.join("settings.json");
1439        let original = b"{\"a\":1}";
1440        let backup = backup_settings(&path, original).unwrap();
1441        assert!(backup
1442            .file_name()
1443            .unwrap()
1444            .to_string_lossy()
1445            .starts_with("settings.json.bak."));
1446        assert_eq!(fs::read(&backup).unwrap(), original);
1447    }
1448
1449    #[test]
1450    fn write_settings_then_read_round_trips_and_ends_with_newline() {
1451        let dir = temp_dir("write-settings");
1452        let path = dir.join("settings.json");
1453        let v = manifest::Value::object(vec![("a", manifest::Value::string("1"))]);
1454        write_settings(&path, &v).unwrap();
1455        let body = fs::read_to_string(&path).unwrap();
1456        assert!(body.ends_with('\n'));
1457        let reread: manifest::Value = serde_json::from_str(&body).unwrap();
1458        assert_eq!(reread, v);
1459    }
1460
1461    // -- language tier sanity ----------------------------------------------
1462
1463    #[test]
1464    fn census_language_tiers_are_explicit() {
1465        assert_eq!(FULLY_SUPPORTED_EXT, &[".cs"]);
1466        assert_eq!(INDEXED_AND_GRAPHED_EXT, &[".ts", ".tsx", ".js", ".jsx"]);
1467    }
1468}