Skip to main content

devscout_rs/
repo.rs

1// scout-root/repo-root discovery, git common dir, worktree resolution, plus
2// registry read.
3//
4// `git_dir_for`/`git_common_dir` deliberately read `.git`'s type and contents
5// with single syscalls rather than a separate existence check followed by a
6// read; a TOCTOU race that would otherwise surface as an error simply yields
7// `None` here ("safer, not narrower").
8
9use std::env;
10use std::fmt;
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14// ---------------------------------------------------------------------------
15// Path resolution helpers -- lexical only, never touching symlinks (distinct
16// from `fs::canonicalize`): resolve, dirname, and `.`/`..` normalization.
17// ---------------------------------------------------------------------------
18
19// Makes `path` absolute against the process cwd when relative, then lexically
20// normalizes (`.`/`..` collapsed, no symlink resolution, cannot climb above the
21// root). The result may name a path that does not exist; nothing here touches
22// disk beyond `current_dir()`.
23pub(crate) fn resolve_path(path: &Path) -> PathBuf {
24    let base = if path.is_absolute() {
25        path.to_path_buf()
26    } else {
27        // An unavailable cwd is effectively unreachable in practice, so this
28        // falls back to `/` rather than propagating a nonexistent failure mode
29        // into every caller's signature.
30        env::current_dir()
31            .unwrap_or_else(|_| PathBuf::from("/"))
32            .join(path)
33    };
34    normalize(&base)
35}
36
37// Lexical `.`/`..` collapse on an already-absolute input: a `..` that would
38// climb above the root is a no-op (there is no path above `/`), which is what
39// makes `resolve_path` a real resolve instead of a mere concatenation.
40fn normalize(path: &Path) -> PathBuf {
41    let mut out = PathBuf::new();
42    for component in path.components() {
43        match component {
44            Component::CurDir => {}
45            Component::ParentDir => {
46                if matches!(out.components().next_back(), Some(Component::Normal(_))) {
47                    out.pop();
48                }
49                // else: already at the root (or empty) -- no-op, clamping at
50                // the root.
51            }
52            other => out.push(other.as_os_str()),
53        }
54    }
55    out
56}
57
58/// Resolves `path` against `base`: `path` alone when it is absolute, otherwise
59/// `path` joined onto `base`; lexically normalized either way, with no symlink
60/// resolution and no requirement that the result exist. `base` is expected
61/// absolute (callers pass a process cwd or an already-resolved directory),
62/// which is what lets this skip prefixing the cwd.
63pub fn resolve_from(base: &Path, path: &Path) -> PathBuf {
64    let joined = if path.is_absolute() {
65        path.to_path_buf()
66    } else {
67        base.join(path)
68    };
69    normalize(&joined)
70}
71
72// Parent directory with a fixed point at the root (`dirname("/") == "/"`),
73// unlike `Path::parent()` which returns `None` there. Preserving that fixed
74// point is what lets `climb`'s loop-termination check (`parent == current`)
75// terminate cleanly.
76fn dirname(p: &Path) -> PathBuf {
77    p.parent()
78        .map(Path::to_path_buf)
79        .unwrap_or_else(|| p.to_path_buf())
80}
81
82// ---------------------------------------------------------------------------
83// Root discovery
84// ---------------------------------------------------------------------------
85
86// Walks `dir` and its ancestors for an entry literally named `marker`
87// (existence does not distinguish file vs. directory, so a worktree's `.git`
88// FILE matches exactly like a normal repo's `.git` DIRECTORY). Returns the
89// first ancestor that has one, or `None` once the search reaches the filesystem
90// root without a match.
91fn climb(dir: &Path, marker: &str) -> Option<PathBuf> {
92    let mut current = dir.to_path_buf();
93    loop {
94        if current.join(marker).exists() {
95            return Some(current);
96        }
97        let parent = dirname(&current);
98        if parent == current {
99            return None;
100        }
101        current = parent;
102    }
103}
104
105// Resolves `start_path` to an absolute path, then uses that path's containing
106// directory unless the path itself is already a directory. A path that does not
107// exist is treated the same as "not a directory": its dirname. `fs::metadata`
108// follows symlinks (not `symlink_metadata`).
109fn start_dir(start_path: &Path) -> PathBuf {
110    let abs = resolve_path(start_path);
111    match fs::metadata(&abs) {
112        Ok(meta) if meta.is_dir() => abs,
113        _ => dirname(&abs),
114    }
115}
116
117/// Nearest ancestor of `start_path` containing a `.scout` entry, or `None`.
118pub fn find_scout_root(start_path: &Path) -> Option<PathBuf> {
119    climb(&start_dir(start_path), ".scout")
120}
121
122/// Nearest ancestor of `start_path` containing a `.git` entry, or `None`. This
123/// matches on *any* `.git` entry regardless of type. Inside a linked worktree,
124/// `<worktree>/.git` is a FILE (see `git_dir_for`), so this returns the
125/// worktree's own directory, not the main repo root -- resolving through to the
126/// shared root is `git_common_dir`'s job, not this one's.
127pub fn find_repo_root(start_path: &Path) -> Option<PathBuf> {
128    climb(&start_dir(start_path), ".git")
129}
130
131/// The `.scout` directory for a repo at `root` -- `root.join(".scout")`. Never fails.
132pub fn scout_dir(root: &Path) -> PathBuf {
133    root.join(".scout")
134}
135
136/// Repo-relative path: resolves both paths, slices `abs_path` from
137/// `resolve(root).len() + 1`, and rejoins on `/`. No containment check: if
138/// `abs_path` is not actually nested under `root`, the result is whatever that
139/// slice produces (unhardened, unspecified). `.get()` (not raw indexing) is
140/// used only to avoid a UTF-8 char-boundary panic on that not-nested case; a
141/// well-formed call (`abs_path` under `root`) always slices at the ASCII
142/// separator byte, and an out-of-range start yields `""`.
143pub fn rel_path(root: &Path, abs_path: &Path) -> String {
144    let root_s = resolve_path(root).to_string_lossy().into_owned();
145    let abs_s = resolve_path(abs_path).to_string_lossy().into_owned();
146    let start = root_s.len() + 1;
147    let sliced = abs_s.get(start..).unwrap_or("");
148    sliced
149        .split(std::path::MAIN_SEPARATOR)
150        .collect::<Vec<_>>()
151        .join("/")
152}
153
154// ---------------------------------------------------------------------------
155// Git dir resolution -- pure filesystem parsing. Nothing here shells out to
156// `git`; `git_dir_for`/`git_common_dir` read `.git`'s type and contents
157// directly. The one `git rev-parse` shell-out in this crate lives in
158// manifest.rs, not here.
159// ---------------------------------------------------------------------------
160
161/// The git dir for a checkout at `root`: a plain directory `<root>/.git` for a
162/// normal repo, or the `gitdir:` pointer parsed out of `<root>/.git` when it is
163/// a FILE (linked worktree). `None` when `<root>/.git` does not exist at all, or
164/// when a FILE exists but has no parseable `gitdir:` line / an empty target.
165pub fn git_dir_for(root: &Path) -> Option<PathBuf> {
166    let dot_git = root.join(".git");
167    let meta = fs::metadata(&dot_git).ok()?;
168    if meta.is_dir() {
169        return Some(resolve_path(&dot_git));
170    }
171    let contents = fs::read_to_string(&dot_git).ok()?;
172    let line = contents
173        .split('\n')
174        .find(|l| l.trim().starts_with("gitdir:"))?;
175    let target = line.trim()["gitdir:".len()..].trim();
176    if target.is_empty() {
177        return None;
178    }
179    Some(resolve_path(&root.join(target)))
180}
181
182/// The SHARED git dir every worktree of the repo has in common:
183/// `git_dir_for(root)` unless that dir carries a `commondir` file (linked
184/// worktree), in which case the file's contents (trimmed) are resolved relative
185/// to the git dir. `None` when `root` has no git dir at all (delegates to
186/// `git_dir_for`).
187pub fn git_common_dir(root: &Path) -> Option<PathBuf> {
188    let git_dir = git_dir_for(root)?;
189    let common_file = git_dir.join("commondir");
190    if !common_file.exists() {
191        return Some(git_dir);
192    }
193    let target = fs::read_to_string(&common_file).ok()?;
194    let target = target.trim();
195    if target.is_empty() {
196        return Some(git_dir);
197    }
198    Some(resolve_path(&git_dir.join(target)))
199}
200
201// ---------------------------------------------------------------------------
202// Minimal JSON reader -- dependency-free, sufficient for the registry's fixed,
203// small schema and not a general-purpose substitute for a real JSON crate (no
204// streaming, no arbitrary-precision numbers). Only `Object`/`Array`/`String`
205// are consumed by the registry reader today -- `Bool`/`Number`/`Null` exist
206// for a JSON parser to be a JSON parser, not because a registry field needs
207// them.
208// ---------------------------------------------------------------------------
209
210#[allow(dead_code)]
211mod json {
212    #[derive(Debug, Clone, PartialEq)]
213    pub enum Value {
214        Null,
215        Bool(bool),
216        Number(f64),
217        String(String),
218        Array(Vec<Value>),
219        Object(Vec<(String, Value)>),
220    }
221
222    pub fn parse(input: &str) -> Result<Value, String> {
223        let chars: Vec<char> = input.chars().collect();
224        let mut pos = 0usize;
225        skip_ws(&chars, &mut pos);
226        let value = parse_value(&chars, &mut pos)?;
227        skip_ws(&chars, &mut pos);
228        if pos != chars.len() {
229            return Err(format!("unexpected trailing data at position {pos}"));
230        }
231        Ok(value)
232    }
233
234    fn skip_ws(chars: &[char], pos: &mut usize) {
235        while matches!(chars.get(*pos), Some(' ' | '\t' | '\n' | '\r')) {
236            *pos += 1;
237        }
238    }
239
240    fn peek(chars: &[char], pos: usize) -> Result<char, String> {
241        chars
242            .get(pos)
243            .copied()
244            .ok_or_else(|| "unexpected end of input".to_string())
245    }
246
247    fn parse_value(chars: &[char], pos: &mut usize) -> Result<Value, String> {
248        skip_ws(chars, pos);
249        match peek(chars, *pos)? {
250            '{' => parse_object(chars, pos),
251            '[' => parse_array(chars, pos),
252            '"' => parse_string(chars, pos).map(Value::String),
253            't' => parse_literal(chars, pos, "true", Value::Bool(true)),
254            'f' => parse_literal(chars, pos, "false", Value::Bool(false)),
255            'n' => parse_literal(chars, pos, "null", Value::Null),
256            c if c == '-' || c.is_ascii_digit() => parse_number(chars, pos),
257            c => Err(format!("unexpected character '{c}' at {}", *pos)),
258        }
259    }
260
261    fn parse_literal(
262        chars: &[char],
263        pos: &mut usize,
264        lit: &str,
265        value: Value,
266    ) -> Result<Value, String> {
267        for expected in lit.chars() {
268            let c = peek(chars, *pos)?;
269            if c != expected {
270                return Err(format!("expected literal '{lit}' at {}", *pos));
271            }
272            *pos += 1;
273        }
274        Ok(value)
275    }
276
277    fn parse_object(chars: &[char], pos: &mut usize) -> Result<Value, String> {
278        *pos += 1; // consume '{'
279        let mut entries = Vec::new();
280        skip_ws(chars, pos);
281        if peek(chars, *pos)? == '}' {
282            *pos += 1;
283            return Ok(Value::Object(entries));
284        }
285        loop {
286            skip_ws(chars, pos);
287            if peek(chars, *pos)? != '"' {
288                return Err(format!("expected string key at {}", *pos));
289            }
290            let key = parse_string(chars, pos)?;
291            skip_ws(chars, pos);
292            if peek(chars, *pos)? != ':' {
293                return Err(format!("expected ':' at {}", *pos));
294            }
295            *pos += 1;
296            let value = parse_value(chars, pos)?;
297            entries.push((key, value));
298            skip_ws(chars, pos);
299            match peek(chars, *pos)? {
300                ',' => *pos += 1,
301                '}' => {
302                    *pos += 1;
303                    break;
304                }
305                c => return Err(format!("expected ',' or '}}' at {} (got '{c}')", *pos)),
306            }
307        }
308        Ok(Value::Object(entries))
309    }
310
311    fn parse_array(chars: &[char], pos: &mut usize) -> Result<Value, String> {
312        *pos += 1; // consume '['
313        let mut items = Vec::new();
314        skip_ws(chars, pos);
315        if peek(chars, *pos)? == ']' {
316            *pos += 1;
317            return Ok(Value::Array(items));
318        }
319        loop {
320            let value = parse_value(chars, pos)?;
321            items.push(value);
322            skip_ws(chars, pos);
323            match peek(chars, *pos)? {
324                ',' => *pos += 1,
325                ']' => {
326                    *pos += 1;
327                    break;
328                }
329                c => return Err(format!("expected ',' or ']' at {} (got '{c}')", *pos)),
330            }
331        }
332        Ok(Value::Array(items))
333    }
334
335    fn parse_string(chars: &[char], pos: &mut usize) -> Result<String, String> {
336        *pos += 1; // consume opening quote
337        let mut out = String::new();
338        loop {
339            let c = peek(chars, *pos)?;
340            *pos += 1;
341            match c {
342                '"' => break,
343                '\\' => {
344                    let esc = peek(chars, *pos)?;
345                    *pos += 1;
346                    match esc {
347                        '"' => out.push('"'),
348                        '\\' => out.push('\\'),
349                        '/' => out.push('/'),
350                        'b' => out.push('\u{8}'),
351                        'f' => out.push('\u{c}'),
352                        'n' => out.push('\n'),
353                        'r' => out.push('\r'),
354                        't' => out.push('\t'),
355                        'u' => {
356                            let cp = parse_hex4(chars, pos)?;
357                            if (0xD800..=0xDBFF).contains(&cp) {
358                                if peek(chars, *pos)? == '\\' && chars.get(*pos + 1) == Some(&'u') {
359                                    *pos += 2;
360                                    let low = parse_hex4(chars, pos)?;
361                                    if (0xDC00..=0xDFFF).contains(&low) {
362                                        let c32 = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
363                                        out.push(
364                                            char::from_u32(c32).ok_or("invalid surrogate pair")?,
365                                        );
366                                    } else {
367                                        return Err("invalid low surrogate".to_string());
368                                    }
369                                } else {
370                                    return Err("unpaired high surrogate".to_string());
371                                }
372                            } else {
373                                out.push(char::from_u32(cp).unwrap_or('\u{FFFD}'));
374                            }
375                        }
376                        other => return Err(format!("invalid escape '\\{other}'")),
377                    }
378                }
379                c if (c as u32) < 0x20 => return Err("control character in string".to_string()),
380                c => out.push(c),
381            }
382        }
383        Ok(out)
384    }
385
386    fn parse_hex4(chars: &[char], pos: &mut usize) -> Result<u32, String> {
387        let mut value = 0u32;
388        for _ in 0..4 {
389            let c = peek(chars, *pos)?;
390            let digit = c
391                .to_digit(16)
392                .ok_or_else(|| format!("invalid hex digit '{c}'"))?;
393            value = value * 16 + digit;
394            *pos += 1;
395        }
396        Ok(value)
397    }
398
399    fn parse_number(chars: &[char], pos: &mut usize) -> Result<Value, String> {
400        let start = *pos;
401        if peek(chars, *pos)? == '-' {
402            *pos += 1;
403        }
404        while matches!(chars.get(*pos), Some(c) if c.is_ascii_digit()) {
405            *pos += 1;
406        }
407        if matches!(chars.get(*pos), Some('.')) {
408            *pos += 1;
409            while matches!(chars.get(*pos), Some(c) if c.is_ascii_digit()) {
410                *pos += 1;
411            }
412        }
413        if matches!(chars.get(*pos), Some('e' | 'E')) {
414            *pos += 1;
415            if matches!(chars.get(*pos), Some('+' | '-')) {
416                *pos += 1;
417            }
418            while matches!(chars.get(*pos), Some(c) if c.is_ascii_digit()) {
419                *pos += 1;
420            }
421        }
422        let text: String = chars[start..*pos].iter().collect();
423        text.parse::<f64>()
424            .map(Value::Number)
425            .map_err(|e| format!("invalid number '{text}': {e}"))
426    }
427}
428
429// ---------------------------------------------------------------------------
430// Registry read path. The write side (registering roots, writing the registry,
431// listing, pruning dead entries) is not implemented here.
432// ---------------------------------------------------------------------------
433
434/// One entry from the registry's `roots` array. The registry stores entries
435/// with no shape validation on read, so a missing key is not an error: this
436/// defaults a missing or wrong-typed field to its zero value rather than
437/// rejecting the whole registry.
438#[derive(Debug, Clone, PartialEq, Eq, Default)]
439pub struct RegistryEntry {
440    /// The root value.
441    pub root: String,
442    /// The kind value.
443    pub kind: String,
444    /// The label value.
445    pub label: Option<String>,
446    /// The scope value.
447    pub scope: Vec<String>,
448    /// The initialized value.
449    pub initialized: String,
450    /// The last seen value.
451    pub last_seen: String,
452}
453
454/// The parsed registry. Only the `roots` array is carried: the registry file
455/// only ever holds `{roots}`, and no read-side API uses any other top-level
456/// field.
457#[derive(Debug, Clone, PartialEq, Eq, Default)]
458pub struct Registry {
459    /// The roots value.
460    pub roots: Vec<RegistryEntry>,
461}
462
463/// The two ways reading the registry can fail, both reported as `registry at
464/// <path> ...`.
465#[derive(Debug)]
466pub enum RegistryError {
467    /// The registry file could not be read or is not valid JSON. Read failures
468    /// (permission, or the file vanishing in a race after the existence check)
469    /// are reported here too, not as a separate IO variant.
470    NotValidJson {
471        /// The registry path.
472        path: PathBuf,
473        /// The read or parsing error text.
474        detail: String,
475    },
476    /// The parsed value is not an object, or its `roots` field is not an array.
477    NoRootsArray {
478        /// The registry path.
479        path: PathBuf,
480    },
481}
482
483impl fmt::Display for RegistryError {
484    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
485        match self {
486            RegistryError::NotValidJson { path, detail } => {
487                write!(
488                    f,
489                    "registry at {} is not valid JSON: {detail}",
490                    path.display()
491                )
492            }
493            RegistryError::NoRootsArray { path } => {
494                write!(f, "registry at {} has no \"roots\" array", path.display())
495            }
496        }
497    }
498}
499
500impl std::error::Error for RegistryError {}
501
502/// The registry file path: the `SCOUT_REGISTRY` env var when set, otherwise
503/// `default_registry_path()`. Only an entirely *unset* var falls back; an
504/// explicitly empty value is used verbatim.
505///
506/// There is no reliable way to derive an install root from wherever this binary
507/// lives at runtime, so `default_registry_path` uses a runtime substitute (see
508/// its doc comment). Every caller that cares about determinism already sets
509/// `SCOUT_REGISTRY` explicitly, as this module's own tests do.
510pub fn registry_path() -> PathBuf {
511    match env::var("SCOUT_REGISTRY") {
512        Ok(v) => PathBuf::from(v),
513        Err(_) => default_registry_path(),
514    }
515}
516
517// Derived from the runtime `$HOME` env var -- the same test seam `initcmd.rs`'s
518// `settings_path` establishes -- joined with `.claude/scout/repos.json`, the
519// conventional install location for this tool. This assumes the tool is
520// installed at `~/.claude/scout`; an install elsewhere (e.g. `cargo install`
521// dropping the binary in `~/.cargo/bin` with no `~/.claude/scout` beside it)
522// will not find a registry there.
523//
524// `HOME` unset is not a hard error here the way it is in `settings_path` (a
525// write path): this is read-only and `read_registry` already treats a
526// missing/unreadable file as an empty registry, so an unresolvable path
527// degrades gracefully to a bare relative `repos.json` (cwd-relative).
528fn default_registry_path() -> PathBuf {
529    match env::var("HOME") {
530        Ok(home) => PathBuf::from(home)
531            .join(".claude")
532            .join("scout")
533            .join("repos.json"),
534        Err(_) => PathBuf::from("repos.json"),
535    }
536}
537
538/// Reads the registry. A missing file reads as an empty registry, not an error
539/// (absence and corruption are deliberately distinguished: silently losing
540/// every configured scope would look exactly like a repo that never had one).
541/// A present-but-corrupt file returns `Err(RegistryError)`.
542pub fn read_registry() -> Result<Registry, RegistryError> {
543    let path = registry_path();
544    if !path.exists() {
545        return Ok(Registry::default());
546    }
547    let text = match fs::read_to_string(&path) {
548        Ok(t) => t,
549        Err(e) => {
550            return Err(RegistryError::NotValidJson {
551                path,
552                detail: e.to_string(),
553            })
554        }
555    };
556    let value = json::parse(&text).map_err(|detail| RegistryError::NotValidJson {
557        path: path.clone(),
558        detail,
559    })?;
560    let roots_value = match &value {
561        json::Value::Object(fields) => fields.iter().find(|(k, _)| k == "roots").map(|(_, v)| v),
562        _ => None,
563    };
564    match roots_value {
565        Some(json::Value::Array(items)) => Ok(Registry {
566            roots: items.iter().map(entry_from_json).collect(),
567        }),
568        _ => Err(RegistryError::NoRootsArray { path }),
569    }
570}
571
572/// The registered entry for `root` (resolved to absolute first), or `Ok(None)`
573/// if none is registered. Returns `Err(RegistryError)` if the registry itself
574/// is corrupt (propagated straight from `read_registry`).
575pub fn entry_for(root: &Path) -> Result<Option<RegistryEntry>, RegistryError> {
576    let abs = resolve_path(root).to_string_lossy().into_owned();
577    let registry = read_registry()?;
578    Ok(registry.roots.into_iter().find(|entry| entry.root == abs))
579}
580
581fn entry_from_json(value: &json::Value) -> RegistryEntry {
582    let fields: &[(String, json::Value)] = match value {
583        json::Value::Object(f) => f.as_slice(),
584        _ => &[],
585    };
586    let find = |key: &str| fields.iter().find(|(k, _)| k == key).map(|(_, v)| v);
587    let as_string = |v: Option<&json::Value>| match v {
588        Some(json::Value::String(s)) => Some(s.clone()),
589        _ => None,
590    };
591    let scope = match find("scope") {
592        Some(json::Value::Array(items)) => items
593            .iter()
594            .filter_map(|v| match v {
595                json::Value::String(s) => Some(s.clone()),
596                _ => None,
597            })
598            .collect(),
599        _ => Vec::new(),
600    };
601    RegistryEntry {
602        root: as_string(find("root")).unwrap_or_default(),
603        kind: as_string(find("kind")).unwrap_or_default(),
604        label: as_string(find("label")),
605        scope,
606        initialized: as_string(find("initialized")).unwrap_or_default(),
607        last_seen: as_string(find("last_seen")).unwrap_or_default(),
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Unit tests -- pure-function coverage only (path lexical helpers, the JSON
613// reader). Filesystem/git/worktree/registry-fixture coverage lives in the
614// integration suite, which needs process-wide `SCOUT_REGISTRY` env mutation
615// serialized behind a mutex -- kept out of this module so it never shares that
616// mutex with an unrelated test binary.
617// ---------------------------------------------------------------------------
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use std::fs as test_fs;
623    use std::sync::atomic::{AtomicU64, Ordering};
624
625    static COUNTER: AtomicU64 = AtomicU64::new(0);
626
627    fn unique_temp_dir(prefix: &str) -> PathBuf {
628        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
629        let dir =
630            env::temp_dir().join(format!("scout-repo-rs-{prefix}-{}-{n}", std::process::id()));
631        test_fs::create_dir_all(&dir).expect("create temp dir");
632        dir
633    }
634
635    // -- resolve_path / normalize --------------------------------------
636
637    #[test]
638    fn resolve_path_collapses_dotdot_past_root() {
639        assert_eq!(
640            resolve_path(Path::new("/a/b/../../../c")),
641            PathBuf::from("/c")
642        );
643    }
644
645    #[test]
646    fn resolve_path_is_idempotent_on_absolute_input() {
647        let p = Path::new("/already/absolute/path");
648        assert_eq!(resolve_path(p), PathBuf::from(p));
649    }
650
651    #[test]
652    fn dirname_of_root_is_root() {
653        assert_eq!(dirname(Path::new("/")), PathBuf::from("/"));
654    }
655
656    // -- climb ------------------------------------------------------------
657
658    #[test]
659    fn climb_finds_marker_in_ancestor() {
660        let root = unique_temp_dir("climb-hit");
661        test_fs::create_dir_all(root.join(".scout")).unwrap();
662        test_fs::create_dir_all(root.join("src/deep")).unwrap();
663        assert_eq!(climb(&root.join("src/deep"), ".scout"), Some(root));
664    }
665
666    #[test]
667    fn climb_returns_none_without_a_match() {
668        let root = unique_temp_dir("climb-miss");
669        test_fs::create_dir_all(root.join("src/deep")).unwrap();
670        assert_eq!(
671            climb(&root.join("src/deep"), ".nonexistent-marker-xyz"),
672            None
673        );
674    }
675
676    // -- rel_path -----------------------------------------------------------
677
678    #[test]
679    fn rel_path_joins_with_forward_slash() {
680        let root = unique_temp_dir("relpath");
681        let abs = root.join("src").join("deep").join("f.ts");
682        assert_eq!(rel_path(&root, &abs), "src/deep/f.ts");
683    }
684
685    #[test]
686    fn rel_path_on_a_path_not_under_root_does_not_panic() {
687        let root = unique_temp_dir("relpath-mismatch-a");
688        let other = unique_temp_dir("relpath-mismatch-b");
689        // Must not panic; content is documented as unhardened/unspecified.
690        let _ = rel_path(&root, &other);
691    }
692
693    // -- git_dir_for / git_common_dir on a plain (non-git) dir --------------
694
695    #[test]
696    fn git_dir_for_none_without_dot_git() {
697        let root = unique_temp_dir("nogit");
698        assert_eq!(git_dir_for(&root), None);
699        assert_eq!(git_common_dir(&root), None);
700    }
701
702    #[test]
703    fn git_dir_for_a_plain_directory_dot_git() {
704        let root = unique_temp_dir("plaingit");
705        test_fs::create_dir_all(root.join(".git")).unwrap();
706        assert_eq!(git_dir_for(&root), Some(root.join(".git")));
707        assert_eq!(git_common_dir(&root), Some(root.join(".git")));
708    }
709
710    #[test]
711    fn git_dir_for_parses_gitdir_pointer_file() {
712        let root = unique_temp_dir("ptrgit");
713        let target = unique_temp_dir("ptrgit-target");
714        test_fs::write(root.join(".git"), format!("gitdir: {}\n", target.display())).unwrap();
715        assert_eq!(git_dir_for(&root), Some(target));
716    }
717
718    #[test]
719    fn git_common_dir_follows_commondir_file() {
720        let root = unique_temp_dir("commondir");
721        let admin = root.join(".git-admin");
722        test_fs::create_dir_all(&admin).unwrap();
723        test_fs::write(root.join(".git"), format!("gitdir: {}\n", admin.display())).unwrap();
724        let shared = unique_temp_dir("commondir-shared");
725        test_fs::write(admin.join("commondir"), format!("{}\n", shared.display())).unwrap();
726        assert_eq!(git_common_dir(&root), Some(shared));
727    }
728
729    // -- JSON reader ----------------------------------------------------
730
731    #[test]
732    fn json_parses_object_array_string_number_bool_null() {
733        let v = json::parse(r#"{"a": [1, -2.5, "s", true, false, null]}"#).unwrap();
734        match v {
735            json::Value::Object(fields) => {
736                assert_eq!(fields.len(), 1);
737                assert_eq!(fields[0].0, "a");
738                match &fields[0].1 {
739                    json::Value::Array(items) => {
740                        assert_eq!(items.len(), 6);
741                        assert_eq!(items[0], json::Value::Number(1.0));
742                        assert_eq!(items[1], json::Value::Number(-2.5));
743                        assert_eq!(items[2], json::Value::String("s".to_string()));
744                        assert_eq!(items[3], json::Value::Bool(true));
745                        assert_eq!(items[4], json::Value::Bool(false));
746                        assert_eq!(items[5], json::Value::Null);
747                    }
748                    other => panic!("expected array, got {other:?}"),
749                }
750            }
751            other => panic!("expected object, got {other:?}"),
752        }
753    }
754
755    #[test]
756    fn json_parses_unicode_escape_and_surrogate_pair() {
757        // é -> 'e' with acute accent; the astral emoji requires a
758        // surrogate pair (😀 -> U+1F600).
759        let v = json::parse(r#""café 😀""#).unwrap();
760        assert_eq!(v, json::Value::String("caf\u{e9} \u{1F600}".to_string()));
761    }
762
763    #[test]
764    fn json_rejects_malformed_input() {
765        assert!(json::parse("{ not json").is_err());
766        assert!(json::parse("").is_err());
767        assert!(json::parse("{\"a\": }").is_err());
768    }
769
770    #[test]
771    fn entry_from_json_defaults_missing_fields() {
772        let v = json::parse(r#"{"root": "/x", "kind": "git"}"#).unwrap();
773        let entry = entry_from_json(&v);
774        assert_eq!(
775            entry,
776            RegistryEntry {
777                root: "/x".to_string(),
778                kind: "git".to_string(),
779                label: None,
780                scope: vec![],
781                initialized: String::new(),
782                last_seen: String::new(),
783            }
784        );
785    }
786
787    #[test]
788    fn entry_from_json_reads_a_full_entry() {
789        let v = json::parse(
790            r#"{"root": "/x", "kind": "git", "label": "lbl", "scope": ["a", "b"], "initialized": "2026-01-01", "last_seen": "2026-01-02"}"#,
791        )
792        .unwrap();
793        let entry = entry_from_json(&v);
794        assert_eq!(entry.root, "/x");
795        assert_eq!(entry.label, Some("lbl".to_string()));
796        assert_eq!(entry.scope, vec!["a".to_string(), "b".to_string()]);
797        assert_eq!(entry.initialized, "2026-01-01");
798        assert_eq!(entry.last_seen, "2026-01-02");
799    }
800}