Skip to main content

devscout_rs/
manifest.rs

1// Nav manifest read/write, `find` token scoring, `scope_for`, and the crate's
2// one `git` shell-out (`git_head`).
3//
4// The manifest is completely schema-agnostic: read and write are JSON parse /
5// pretty-print with a path in front, nothing more. A manifest can carry unknown
6// top-level fields, and a read must round-trip them unchanged -- a fixed-schema
7// struct would silently drop them. So the manifest body here is `Value`, a small
8// ordered-JSON tree (below) that is Serialize/Deserialize via serde_json's
9// Deserializer / Serializer traits but keeps object-key insertion order in a
10// `Vec` instead of the default `Value`'s (unordered, `BTreeMap`-backed without
11// the `preserve_order` cargo feature) map type. Order is not cosmetic:
12// `find_in_manifest`'s pools sort by tokens matched, then inbound-edge count,
13// so full ties resolve by manifest on-disk key order -- see
14// `find_in_manifest_detailed`'s doc comment for the full chain.
15
16use std::collections::{HashMap, HashSet};
17use std::fmt;
18use std::fs;
19use std::io;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use serde::de::{Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
25use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer};
26
27use crate::repo::{entry_for, git_common_dir, scout_dir, RegistryEntry, RegistryError};
28
29// ---------------------------------------------------------------------------
30// Ordered JSON value -- the manifest's actual "schema" (i.e. none). Numbers
31// delegate to `serde_json::Number` so integer-vs-float formatting is preserved
32// (mtime values are always integers in practice; the general case is handled
33// anyway since nothing here assumes otherwise). Objects are
34// `Vec<(String, Value)>`, not a map, so read-then-write and read-then-score
35// both preserve on-disk key order without relying on any HashMap/BTreeMap
36// iteration order.
37// ---------------------------------------------------------------------------
38
39#[derive(Debug, Clone, PartialEq)]
40/// Represents `Value`.
41pub enum Value {
42    /// Represents `Null`.
43    Null,
44    /// Represents `Bool`.
45    Bool(bool),
46    /// Represents `Number`.
47    Number(serde_json::Number),
48    /// Represents `String`.
49    String(String),
50    /// Represents `Array`.
51    Array(Vec<Value>),
52    /// Represents `Object`.
53    Object(Vec<(String, Value)>),
54}
55
56impl Value {
57    /// Creates an object from string keys and values.
58    pub fn object(entries: Vec<(&str, Value)>) -> Value {
59        Value::Object(
60            entries
61                .into_iter()
62                .map(|(k, v)| (k.to_string(), v))
63                .collect(),
64        )
65    }
66
67    /// Creates a string value.
68    pub fn string(s: impl Into<String>) -> Value {
69        Value::String(s.into())
70    }
71
72    /// Creates an array value.
73    pub fn array(items: Vec<Value>) -> Value {
74        Value::Array(items)
75    }
76
77    /// Creates a numeric value.
78    pub fn number(n: impl Into<serde_json::Number>) -> Value {
79        Value::Number(n.into())
80    }
81
82    /// Value for `key`, or `None` -- a missing key, or `self` not being an
83    /// object at all. Reading a field off a non-object is `None`, not an error.
84    pub fn get(&self, key: &str) -> Option<&Value> {
85        self.as_object()?
86            .iter()
87            .find(|(k, _)| k == key)
88            .map(|(_, v)| v)
89    }
90
91    /// Returns the object entries, or `None` for another value variant.
92    pub fn as_object(&self) -> Option<&[(String, Value)]> {
93        match self {
94            Value::Object(entries) => Some(entries.as_slice()),
95            _ => None,
96        }
97    }
98
99    /// `Some` only for the `String` variant -- deliberately does NOT coerce
100    /// numbers or bools to text. `find_in_manifest`'s `hay` build and its
101    /// `source` default both rely on this narrower behavior; a non-string
102    /// `purpose`/`source` (not produced by any real manifest writer) is simply
103    /// treated as absent.
104    pub fn as_str(&self) -> Option<&str> {
105        match self {
106            Value::String(s) => Some(s.as_str()),
107            _ => None,
108        }
109    }
110}
111
112impl<'de> Deserialize<'de> for Value {
113    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
114    where
115        D: Deserializer<'de>,
116    {
117        struct ValueVisitor;
118
119        impl<'de> Visitor<'de> for ValueVisitor {
120            type Value = Value;
121
122            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123                f.write_str("a JSON value")
124            }
125
126            fn visit_bool<E>(self, v: bool) -> Result<Value, E> {
127                Ok(Value::Bool(v))
128            }
129            fn visit_i64<E>(self, v: i64) -> Result<Value, E> {
130                Ok(Value::Number(v.into()))
131            }
132            fn visit_u64<E>(self, v: u64) -> Result<Value, E> {
133                Ok(Value::Number(v.into()))
134            }
135            fn visit_f64<E>(self, v: f64) -> Result<Value, E> {
136                Ok(Value::Number(
137                    serde_json::Number::from_f64(v).unwrap_or_else(|| 0.into()),
138                ))
139            }
140            fn visit_str<E>(self, v: &str) -> Result<Value, E> {
141                Ok(Value::String(v.to_owned()))
142            }
143            fn visit_string<E>(self, v: String) -> Result<Value, E> {
144                Ok(Value::String(v))
145            }
146            fn visit_unit<E>(self) -> Result<Value, E> {
147                Ok(Value::Null)
148            }
149            fn visit_none<E>(self) -> Result<Value, E> {
150                Ok(Value::Null)
151            }
152            fn visit_some<D2>(self, deserializer: D2) -> Result<Value, D2::Error>
153            where
154                D2: Deserializer<'de>,
155            {
156                Value::deserialize(deserializer)
157            }
158            fn visit_seq<A>(self, mut seq: A) -> Result<Value, A::Error>
159            where
160                A: SeqAccess<'de>,
161            {
162                let mut items = Vec::new();
163                while let Some(item) = seq.next_element()? {
164                    items.push(item);
165                }
166                Ok(Value::Array(items))
167            }
168            // Document order, not sorted: `MapAccess::next_entry` yields
169            // entries in the order serde_json's Deserializer encounters
170            // them in the source text, independent of what `Value`'s OWN
171            // (BTreeMap-backed) object type would have done -- we never
172            // construct that type, we build this `Vec` instead.
173            fn visit_map<A>(self, mut map: A) -> Result<Value, A::Error>
174            where
175                A: MapAccess<'de>,
176            {
177                let mut entries = Vec::new();
178                while let Some((k, v)) = map.next_entry::<String, Value>()? {
179                    entries.push((k, v));
180                }
181                Ok(Value::Object(entries))
182            }
183        }
184
185        deserializer.deserialize_any(ValueVisitor)
186    }
187}
188
189impl Serialize for Value {
190    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
191    where
192        S: Serializer,
193    {
194        match self {
195            Value::Null => serializer.serialize_unit(),
196            Value::Bool(b) => serializer.serialize_bool(*b),
197            Value::Number(n) => n.serialize(serializer),
198            Value::String(s) => serializer.serialize_str(s),
199            Value::Array(items) => {
200                let mut seq = serializer.serialize_seq(Some(items.len()))?;
201                for item in items {
202                    seq.serialize_element(item)?;
203                }
204                seq.end()
205            }
206            // Iterates the `Vec` in stored order -- this, not any property
207            // of `serde_json`'s formatter, is what makes output key order
208            // match input key order end to end.
209            Value::Object(entries) => {
210                let mut map = serializer.serialize_map(Some(entries.len()))?;
211                for (k, v) in entries {
212                    map.serialize_entry(k, v)?;
213                }
214                map.end()
215            }
216        }
217    }
218}
219
220// ---------------------------------------------------------------------------
221// Manifest path resolution.
222// ---------------------------------------------------------------------------
223
224// The shared, git-common-dir-keyed manifest location when `root` is inside a git
225// repo (worktree-shared: every linked worktree of one repo resolves to the same
226// path), else `legacy_manifest_path(root)`. Kept private deliberately.
227fn manifest_path(root: &Path) -> PathBuf {
228    match git_common_dir(root) {
229        Some(common) => common.join("scout").join("manifest.json"),
230        None => legacy_manifest_path(root),
231    }
232}
233
234/// `<root>/.scout/manifest.json`, still consulted on read so pre-existing
235/// enriched manifests are not orphaned by the shared-path move.
236pub fn legacy_manifest_path(root: &Path) -> PathBuf {
237    scout_dir(root).join("manifest.json")
238}
239
240// ---------------------------------------------------------------------------
241// Errors -- a corrupt manifest file surfaces as `Result::Err` from
242// `read_manifest`, and a corrupt registry surfaces from `scope_for` (propagated
243// from `entry_for`) -- the same error-return discipline repo.rs uses for
244// `RegistryError`.
245// ---------------------------------------------------------------------------
246
247#[derive(Debug)]
248/// Represents `ManifestError`.
249pub enum ManifestError {
250    /// The manifest file could not be read (permission, or the file vanishing
251    /// in a race after the existence check -- the same "safer, not narrower"
252    /// TOCTOU behavior documented on `repo::git_dir_for`).
253    Io {
254        /// The manifest path.
255        path: PathBuf,
256        /// The I/O error text.
257        detail: String,
258    },
259    /// The manifest file is present but not valid JSON.
260    InvalidJson {
261        /// The manifest path.
262        path: PathBuf,
263        /// The JSON parsing error text.
264        detail: String,
265    },
266}
267
268impl fmt::Display for ManifestError {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        match self {
271            ManifestError::Io { path, detail } => {
272                write!(f, "failed to read manifest at {}: {detail}", path.display())
273            }
274            ManifestError::InvalidJson { path, detail } => {
275                write!(
276                    f,
277                    "manifest at {} is not valid JSON: {detail}",
278                    path.display()
279                )
280            }
281        }
282    }
283}
284
285impl std::error::Error for ManifestError {}
286
287fn read_json_value(path: &Path) -> Result<Value, ManifestError> {
288    let text = fs::read_to_string(path).map_err(|e| ManifestError::Io {
289        path: path.to_path_buf(),
290        detail: e.to_string(),
291    })?;
292    serde_json::from_str::<Value>(&text).map_err(|e| ManifestError::InvalidJson {
293        path: path.to_path_buf(),
294        detail: e.to_string(),
295    })
296}
297
298/// Parses the shared-path manifest if present; else falls back to the legacy
299/// `<root>/.scout/manifest.json` if THAT is present; else `Ok(None)`. A read has
300/// no side effects (no migrate/copy/delete). Returns `Err` on a corrupt file.
301pub fn read_manifest(root: &Path) -> Result<Option<Value>, ManifestError> {
302    let path = manifest_path(root);
303    if path.exists() {
304        return read_json_value(&path).map(Some);
305    }
306    let legacy = legacy_manifest_path(root);
307    if legacy.exists() {
308        return read_json_value(&legacy).map(Some);
309    }
310    Ok(None)
311}
312
313/// Creates the manifest's parent directory, then writes `value` as 2-space
314/// pretty-printed JSON (`serde_json::to_string_pretty`). The write is atomic:
315/// tmp-file + rename, so a reader racing a writer never observes a half-written
316/// `manifest.json`.
317pub fn write_manifest(root: &Path, value: &Value) -> io::Result<()> {
318    let path = manifest_path(root);
319    if let Some(dir) = path.parent() {
320        fs::create_dir_all(dir)?;
321    }
322    let json = serde_json::to_string_pretty(value)
323        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
324
325    let suffix = SystemTime::now()
326        .duration_since(UNIX_EPOCH)
327        .map(|d| d.as_nanos())
328        .unwrap_or(0);
329    let tmp_name = format!(
330        "{}.tmp.{}.{suffix}",
331        path.file_name()
332            .and_then(|n| n.to_str())
333            .unwrap_or("manifest.json"),
334        std::process::id(),
335    );
336    let tmp_path = path.with_file_name(tmp_name);
337
338    if let Err(e) = fs::write(&tmp_path, json.as_bytes()) {
339        let _ = fs::remove_file(&tmp_path);
340        return Err(e);
341    }
342    if let Err(e) = fs::rename(&tmp_path, &path) {
343        let _ = fs::remove_file(&tmp_path);
344        return Err(e);
345    }
346    Ok(())
347}
348
349// ---------------------------------------------------------------------------
350// `find` scoring.
351// ---------------------------------------------------------------------------
352
353#[derive(Debug, Clone, PartialEq)]
354/// Represents `FindHit`.
355pub struct FindHit {
356    /// The path value.
357    pub path: String,
358    /// The entry's `purpose`, passed through as-is and NOT defaulted the way
359    /// `source` is. `None` when the key is absent; an explicit JSON `null`
360    /// purpose also collapses to `None` (not produced by any real manifest
361    /// writer).
362    pub purpose: Option<String>,
363    /// The entry's `source`, defaulting to `"heuristic"` -- always resolved,
364    /// never absent in the output (an absent key and an explicit `null` both
365    /// default to `"heuristic"`).
366    pub source: String,
367}
368
369#[derive(Debug)]
370/// Represents `FindError`.
371pub enum FindError {
372    /// Represents `Manifest`.
373    Manifest(ManifestError),
374    /// The manifest has no object-valued `entries` field -- the realistic case
375    /// being a manifest missing its `entries` key entirely. A non-object
376    /// `entries` (e.g. a string or array), which no real manifest writer
377    /// produces, collapses to this same error rather than being silently scored
378    /// as zero entries.
379    EntriesNotObject,
380}
381
382impl From<ManifestError> for FindError {
383    fn from(e: ManifestError) -> Self {
384        FindError::Manifest(e)
385    }
386}
387
388impl fmt::Display for FindError {
389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
390        match self {
391            FindError::Manifest(e) => write!(f, "{e}"),
392            FindError::EntriesNotObject => write!(f, "manifest has no \"entries\" object"),
393        }
394    }
395}
396
397impl std::error::Error for FindError {}
398
399struct Scored {
400    path: String,
401    purpose: Option<String>,
402    source: String,
403    hits: usize,
404    /// The entry's file's precise inbound-edge count, from the ranking map the
405    /// caller passes (see `query::file_inbound_counts` for what feeds it). A
406    /// caller without a graph passes an empty map, so every entry reads 0 and
407    /// the sort below degrades to today's order.
408    inbound: usize,
409}
410
411/// AND across whitespace-split, lowercased tokens over `path + ' ' + purpose`;
412/// when no entry matches every token, fall back to entries matching ANY token,
413/// ranked by hit count descending.
414///
415/// Reads every entry's inbound-edge count at 0 -- see
416/// `find_in_manifest_detailed` for the ranked variant.
417pub fn find_in_manifest(root: &Path, query: &str) -> Result<Vec<FindHit>, FindError> {
418    Ok(find_in_manifest_detailed(root, query, &HashMap::new())?.hits)
419}
420
421/// `find_in_manifest_detailed`'s return shape: the hits plus which pool
422/// answered.
423pub struct FindResult {
424    /// The hits value.
425    pub hits: Vec<FindHit>,
426    /// The fallback value.
427    pub fallback: bool,
428}
429
430/// Same search as `find_in_manifest`, plus WHICH pool answered: `fallback: true`
431/// means no entry matched every token and the OR pool is speaking. The CLI caps
432/// the two pools differently (`cmd_find`); the flat `find_in_manifest` stays for
433/// callers that only want hits.
434///
435/// Ranking, applied to WHICHEVER pool answers and BEFORE either cap: by tokens
436/// matched descending, then by the entry's file's inbound-edge count
437/// (`inbound`) descending. No third sort key -- `sort_by` is documented stable,
438/// so a full tie keeps the manifest's on-disk key order, which is already
439/// deterministic (see the module header). In the AND pool the primary key is
440/// constant by construction (every entry there matched every token), so the
441/// inbound tie-break does all the ranking work; in the OR pool it breaks the
442/// hit-count ties the old sort left in manifest order. An empty `inbound` map
443/// (no graph available) reads 0 everywhere and returns today's order byte for
444/// byte.
445///
446/// Tie stability of the input order itself: `scored` is built by iterating
447/// `entries` -- this module's ordered `Value::Object` `Vec`, populated by
448/// `Value`'s `Deserialize` impl above in exactly the JSON text's key order (see
449/// its `visit_map` doc comment) -- via a single `.map()` that does not reorder.
450/// So JSON text order flows through to `scored` order and, because the sort
451/// preserves ties, to pool order.
452///
453/// Returns `Ok(FindResult{..})` with no hits for no manifest or no tokens;
454/// `Err` on a corrupt manifest file or a missing `entries`.
455pub fn find_in_manifest_detailed(
456    root: &Path,
457    query: &str,
458    inbound: &HashMap<String, usize>,
459) -> Result<FindResult, FindError> {
460    let manifest = match read_manifest(root)? {
461        Some(m) => m,
462        None => {
463            return Ok(FindResult {
464                hits: Vec::new(),
465                fallback: false,
466            })
467        }
468    };
469
470    let tokens: Vec<String> = query
471        .to_lowercase()
472        .split_whitespace()
473        .map(String::from)
474        .collect();
475    if tokens.is_empty() {
476        return Ok(FindResult {
477            hits: Vec::new(),
478            fallback: false,
479        });
480    }
481
482    let entries = manifest
483        .get("entries")
484        .and_then(Value::as_object)
485        .ok_or(FindError::EntriesNotObject)?;
486
487    let scored: Vec<Scored> = entries
488        .iter()
489        .map(|(path, e)| {
490            let purpose = e.get("purpose").and_then(Value::as_str).map(String::from);
491            let source = e
492                .get("source")
493                .and_then(Value::as_str)
494                .map(String::from)
495                .unwrap_or_else(|| "heuristic".to_string());
496            let hay = format!("{path} {}", purpose.as_deref().unwrap_or("")).to_lowercase();
497            let hits = tokens.iter().filter(|t| hay.contains(t.as_str())).count();
498            Scored {
499                path: path.clone(),
500                purpose,
501                source,
502                hits,
503                inbound: inbound.get(path).copied().unwrap_or(0),
504            }
505        })
506        .collect();
507
508    let full: Vec<&Scored> = scored.iter().filter(|s| s.hits == tokens.len()).collect();
509    let fallback = full.is_empty();
510    let mut pool: Vec<&Scored> = if !full.is_empty() {
511        full
512    } else {
513        scored.iter().filter(|s| s.hits > 0).collect()
514    };
515    // Both pools rank identically: tokens first, inbound second, stability
516    // third. One comparator, applied once, keeps that rule in exactly one place.
517    pool.sort_by(|a, b| b.hits.cmp(&a.hits).then_with(|| b.inbound.cmp(&a.inbound)));
518
519    let hits = pool
520        .into_iter()
521        .map(|s| FindHit {
522            path: s.path.clone(),
523            purpose: s.purpose.clone(),
524            source: s.source.clone(),
525        })
526        .collect();
527    Ok(FindResult { hits, fallback })
528}
529
530// ---------------------------------------------------------------------------
531// `git_head` -- the crate's ONE `git` shell-out that lives in this module (see
532// repo.rs's header comment: its git helpers never shell out).
533// ---------------------------------------------------------------------------
534
535/// The repo's `HEAD` commit via `git -C <root> rev-parse HEAD`, trimmed. `None`
536/// on any failure (spawn failure, non-zero exit, e.g. no commits yet or not a
537/// repo).
538pub fn git_head(root: &Path) -> Option<String> {
539    let output = Command::new("git")
540        .arg("-C")
541        .arg(root)
542        .args(["rev-parse", "HEAD"])
543        .output()
544        .ok()?;
545    if !output.status.success() {
546        return None;
547    }
548    let stdout = String::from_utf8(output.stdout).ok()?;
549    Some(stdout.trim().to_string())
550}
551
552// ---------------------------------------------------------------------------
553// `scope_for`.
554// ---------------------------------------------------------------------------
555
556/// The directory scope for `root`: explicit dirs win if non-empty; else the
557/// registry entry's `scope` if non-empty; else `["."]`. A corrupt registry
558/// surfaces as `Err`, propagated straight from `repo::entry_for`.
559pub fn scope_for(
560    root: &Path,
561    explicit_dirs: Option<&[String]>,
562) -> Result<Vec<String>, RegistryError> {
563    if let Some(dirs) = explicit_dirs {
564        if !dirs.is_empty() {
565            return Ok(dirs.to_vec());
566        }
567    }
568    let entry: Option<RegistryEntry> = entry_for(root)?;
569    if let Some(e) = entry {
570        if !e.scope.is_empty() {
571            return Ok(e.scope);
572        }
573    }
574    Ok(vec![".".to_string()])
575}
576
577// ---------------------------------------------------------------------------
578// Index freshness. `index-state.json` sits beside `manifest.json` (same
579// git-common-dir-vs-legacy-`.scout` split). It is never compared byte-identical
580// against anything the way manifest.json / graph.json are -- `indexed_at` is
581// wall-clock, so no two runs could ever produce the same bytes regardless of
582// intent.
583// ---------------------------------------------------------------------------
584
585fn index_state_dir(root: &Path) -> PathBuf {
586    match git_common_dir(root) {
587        Some(common) => common.join("scout"),
588        None => scout_dir(root),
589    }
590}
591
592/// Returns the path of the repository's index-state artifact.
593pub fn index_state_path(root: &Path) -> PathBuf {
594    index_state_dir(root).join("index-state.json")
595}
596
597/// Writes the index-state sidecar. Written on every successful `devscout map`,
598/// the same cadence `manifest.json`'s own `built_at_head` refreshes on.
599/// `dirty_indexed_files` -- the indexed files git ALREADY considered changed at
600/// index time (a repo mapped before its source was ever `git add`ed is the
601/// common case, not an edge case) -- is the baseline `freshness_warning` diffs
602/// against, so those same files staying exactly as dirty as they were is never
603/// mistaken for having changed SINCE the map. Sorted so two writes of the same
604/// set are byte-identical.
605pub fn write_index_state(
606    root: &Path,
607    head: Option<String>,
608    dirty: bool,
609    dirty_indexed_files: &[String],
610    file_count: i64,
611) -> io::Result<()> {
612    let path = index_state_path(root);
613    if let Some(dir) = path.parent() {
614        fs::create_dir_all(dir)?;
615    }
616    let mut sorted = dirty_indexed_files.to_vec();
617    sorted.sort();
618    let value = Value::object(vec![
619        ("head", head.map(Value::string).unwrap_or(Value::Null)),
620        ("dirty", Value::Bool(dirty)),
621        ("indexed_at", Value::string(crate::hookio::iso8601_now())),
622        ("file_count", Value::number(file_count)),
623        (
624            "dirty_indexed_files",
625            Value::array(sorted.into_iter().map(Value::string).collect()),
626        ),
627    ]);
628    let json = serde_json::to_string_pretty(&value)
629        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
630    fs::write(&path, format!("{json}\n"))
631}
632
633/// Tolerant read of the index-state sidecar -- `None` on a missing OR corrupt
634/// sidecar (an index built by a build that predates this sidecar), never a panic
635/// and never an `Err` a caller has to handle.
636pub fn read_index_state(root: &Path) -> Option<Value> {
637    let text = fs::read_to_string(index_state_path(root)).ok()?;
638    serde_json::from_str::<Value>(&text).ok()
639}
640
641// Cap on parsed `git status` lines.
642const STATUS_LINE_CAP: usize = 5000;
643
644// A capped, scoped `git status --porcelain` -- cheap on a large repo mapped with
645// a narrow scope -- as the set of repo-relative paths it reports as changed
646// (modified, staged, or untracked; a rename's OLD and NEW path both count).
647// `--untracked-files=all` matters: without it, git collapses an
648// entirely-untracked directory to one `?? dir/` line instead of one line per
649// file inside it, and the path-set intersection below would never match anything
650// for a freshly-mapped, not-yet-`git add`-ed tree. `None` on any failure (no git
651// binary, not a repo), distinct from an empty set (git ran and found nothing) so
652// callers never mistake "could not check" for "confirmed clean".
653fn git_status_paths(root: &Path, scope: &[String]) -> Option<HashSet<String>> {
654    let mut cmd = Command::new("git");
655    cmd.arg("-C")
656        .arg(root)
657        .args(["status", "--porcelain", "--untracked-files=all", "--"]);
658    for s in scope {
659        cmd.arg(s);
660    }
661    let output = cmd.output().ok()?;
662    if !output.status.success() {
663        return None;
664    }
665    let text = String::from_utf8(output.stdout).ok()?;
666    let mut paths = HashSet::new();
667    for line in text
668        .split('\n')
669        .filter(|l| !l.is_empty())
670        .take(STATUS_LINE_CAP)
671    {
672        if line.len() < 3 {
673            continue;
674        }
675        for part in line[3..].split(" -> ") {
676            paths.insert(part.to_string());
677        }
678    }
679    Some(paths)
680}
681
682/// Index-time convenience over `git_status_paths` -- a plain bool, fail-open to
683/// `false` (a non-git root, or git being unavailable, is not "dirty"; it is
684/// unknown, and `false` is the safer default to persist for it).
685pub fn is_working_tree_dirty(root: &Path, scope: &[String]) -> bool {
686    git_status_paths(root, scope)
687        .map(|p| !p.is_empty())
688        .unwrap_or(false)
689}
690
691/// The indexed files currently dirty -- `git_status_paths` intersected with the
692/// manifest's own entries -- sorted for a stable, comparable list. Called once
693/// at index time (the baseline `write_index_state` stores) and once at query
694/// time (what `freshness_warning` diffs the baseline against).
695pub fn dirty_indexed_files_at(
696    root: &Path,
697    scope: &[String],
698    indexed_files: &HashSet<String>,
699) -> Vec<String> {
700    let Some(changed) = git_status_paths(root, scope) else {
701        return Vec::new();
702    };
703    let mut out: Vec<String> = changed
704        .into_iter()
705        .filter(|p| indexed_files.contains(p))
706        .collect();
707    out.sort();
708    out
709}
710
711/// Query-time check for `find`/`refs`/`impact`: cheap enough to run on every
712/// call (one `git rev-parse HEAD`, one scoped `git status --porcelain`, the same
713/// two costs `devscout map` itself already pays). Returns the one-line stderr
714/// warning, or `None` when there is nothing to say: no index-state.json (never
715/// mapped since this feature shipped, or the root was not a git repo when last
716/// mapped), git unavailable or this root not a repo, or the index is genuinely
717/// fresh.
718///
719/// "Changed" is deliberately NOT "currently dirty and indexed" -- a repo mapped
720/// before its source was ever `git add`ed would then show every file as changed
721/// FOREVER, indistinguishable from a genuinely fresh index. It is
722/// "dirty-and-indexed now but wasn't at index time" -- the set
723/// `write_index_state` recorded as the baseline. The one gap this leaves: a file
724/// already dirty at index time that is edited AGAIN afterward stays in both sets
725/// and is not counted (a known, accepted limitation, not silently swallowed).
726pub fn freshness_warning(root: &Path) -> Option<String> {
727    let state = read_index_state(root)?;
728    let stored_head = state.get("head")?.as_str()?.to_string();
729
730    let current_head = git_head(root)?;
731
732    let manifest = read_manifest(root).ok().flatten();
733    let indexed_files: HashSet<String> = manifest
734        .as_ref()
735        .and_then(|m| m.get("entries"))
736        .and_then(|v| v.as_object())
737        .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
738        .unwrap_or_default();
739    let scope: Vec<String> = manifest
740        .as_ref()
741        .and_then(|m| m.get("scoped_dirs"))
742        .and_then(|v| match v {
743            Value::Array(items) => Some(
744                items
745                    .iter()
746                    .filter_map(Value::as_str)
747                    .map(str::to_string)
748                    .collect::<Vec<_>>(),
749            ),
750            _ => None,
751        })
752        .filter(|v: &Vec<String>| !v.is_empty())
753        .unwrap_or_else(|| vec![".".to_string()]);
754
755    let current_dirty = dirty_indexed_files_at(root, &scope, &indexed_files);
756    let baseline_dirty: HashSet<String> = state
757        .get("dirty_indexed_files")
758        .and_then(|v| match v {
759            Value::Array(items) => Some(
760                items
761                    .iter()
762                    .filter_map(Value::as_str)
763                    .map(str::to_string)
764                    .collect(),
765            ),
766            _ => None,
767        })
768        .unwrap_or_default();
769    let changed_count = current_dirty
770        .iter()
771        .filter(|p| !baseline_dirty.contains(*p))
772        .count();
773
774    if current_head == stored_head && changed_count == 0 {
775        return None;
776    }
777
778    let repo_id = root.file_name().and_then(|n| n.to_str()).unwrap_or("repo");
779    let indexed_short = &stored_head[..stored_head.len().min(7)];
780    let head_short = &current_head[..current_head.len().min(7)];
781    Some(format!(
782        "devscout: index for {repo_id} is stale (indexed at {indexed_short}, HEAD {head_short}; {changed_count} changed files) — rebuild with devscout map"
783    ))
784}
785
786// ---------------------------------------------------------------------------
787// Unit tests -- pure-function / single-process coverage (Value ser/de, path
788// resolution on plain non-git dirs, write+read round trip, find_in_manifest
789// scoring logic, scope_for). Ordering, byte-identical write, and git_head
790// against a real repo are covered in the integration suite.
791// ---------------------------------------------------------------------------
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796    use std::env;
797    use std::sync::atomic::{AtomicU64, Ordering};
798    use std::sync::Mutex;
799
800    static COUNTER: AtomicU64 = AtomicU64::new(0);
801    // Serializes this module's own SCOUT_REGISTRY-mutating tests against each
802    // other (process-global env var, tests run as threads within one binary).
803    // repo.rs's own #[cfg(test)] unit tests never touch this var, so no
804    // cross-binary race exists -- this lock only needs to cover this module's
805    // own tests.
806    static REGISTRY_ENV_LOCK: Mutex<()> = Mutex::new(());
807
808    fn unique_temp_dir(prefix: &str) -> PathBuf {
809        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
810        let dir = env::temp_dir().join(format!(
811            "scout-manifest-rs-{prefix}-{}-{n}",
812            std::process::id()
813        ));
814        fs::create_dir_all(&dir).expect("create temp dir");
815        dir
816    }
817
818    // -- Value ser/de --------------------------------------------------
819
820    #[test]
821    fn value_round_trips_object_array_scalars_preserving_key_order() {
822        let text = r#"{"z": 1, "a": [1, -2, "s", true, false, null], "m": {"k1": 1, "k0": 2}}"#;
823        let v: Value = serde_json::from_str(text).unwrap();
824        match &v {
825            Value::Object(fields) => {
826                assert_eq!(fields[0].0, "z");
827                assert_eq!(fields[1].0, "a");
828                assert_eq!(fields[2].0, "m");
829            }
830            other => panic!("expected object, got {other:?}"),
831        }
832        // Nested object key order preserved too (not re-sorted "k0","k1").
833        let m = v.get("m").unwrap().as_object().unwrap();
834        assert_eq!(m[0].0, "k1");
835        assert_eq!(m[1].0, "k0");
836    }
837
838    #[test]
839    fn value_pretty_print_matches_json_stringify_indent_two_shape() {
840        let v = Value::object(vec![
841            ("a", Value::number(1)),
842            ("b", Value::array(vec![Value::string("x")])),
843        ]);
844        let out = serde_json::to_string_pretty(&v).unwrap();
845        assert_eq!(out, "{\n  \"a\": 1,\n  \"b\": [\n    \"x\"\n  ]\n}");
846    }
847
848    #[test]
849    fn value_empty_object_and_array_have_no_interior_whitespace() {
850        assert_eq!(
851            serde_json::to_string_pretty(&Value::object(vec![])).unwrap(),
852            "{}"
853        );
854        assert_eq!(
855            serde_json::to_string_pretty(&Value::array(vec![])).unwrap(),
856            "[]"
857        );
858    }
859
860    #[test]
861    fn value_unicode_round_trips_unescaped() {
862        let v: Value = serde_json::from_str(r#""café 😀""#).unwrap();
863        assert_eq!(v, Value::string("café 😀"));
864        // Ordinary non-ASCII text is not escaped.
865        assert_eq!(serde_json::to_string_pretty(&v).unwrap(), "\"café 😀\"");
866    }
867
868    // -- manifest_path / legacy_manifest_path ---------------------------
869
870    #[test]
871    fn a_non_git_root_uses_the_legacy_scout_path() {
872        let root = unique_temp_dir("plain");
873        assert_eq!(manifest_path(&root), legacy_manifest_path(&root));
874        assert_eq!(
875            legacy_manifest_path(&root),
876            root.join(".scout").join("manifest.json")
877        );
878    }
879
880    // -- read_manifest / write_manifest ---------------------------------
881
882    #[test]
883    fn read_manifest_is_none_when_nothing_exists() {
884        let root = unique_temp_dir("missing");
885        assert!(read_manifest(&root).unwrap().is_none());
886    }
887
888    #[test]
889    fn read_manifest_errs_on_corrupt_json() {
890        let root = unique_temp_dir("corrupt");
891        fs::create_dir_all(root.join(".scout")).unwrap();
892        fs::write(root.join(".scout").join("manifest.json"), "{not valid json").unwrap();
893        assert!(matches!(
894            read_manifest(&root),
895            Err(ManifestError::InvalidJson { .. })
896        ));
897    }
898
899    #[test]
900    fn write_then_read_round_trips_on_a_plain_root() {
901        let root = unique_temp_dir("roundtrip");
902        let obj = Value::object(vec![
903            ("built_at_head", Value::string("abc")),
904            ("scoped_dirs", Value::array(vec![Value::string("src")])),
905            (
906                "entries",
907                Value::object(vec![(
908                    "src/a.ts",
909                    Value::object(vec![
910                        ("purpose", Value::string("does A")),
911                        ("mtime", Value::number(1)),
912                    ]),
913                )]),
914            ),
915        ]);
916        write_manifest(&root, &obj).unwrap();
917        assert_eq!(read_manifest(&root).unwrap(), Some(obj));
918    }
919
920    #[test]
921    fn write_manifest_leaves_no_tmp_file_behind() {
922        let root = unique_temp_dir("notmp");
923        write_manifest(&root, &Value::object(vec![])).unwrap();
924        let dir = legacy_manifest_path(&root).parent().unwrap().to_path_buf();
925        let stray: Vec<_> = fs::read_dir(&dir)
926            .unwrap()
927            .filter_map(|e| e.ok())
928            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
929            .collect();
930        assert!(stray.is_empty(), "stray tmp files: {stray:?}");
931    }
932
933    // -- find_in_manifest -------------------------------------------------
934
935    fn sample_manifest() -> Value {
936        Value::object(vec![
937            ("built_at_head", Value::string("h")),
938            ("scoped_dirs", Value::array(vec![])),
939            (
940                "entries",
941                Value::object(vec![
942                    (
943                        "src/GroupRepository.cs",
944                        Value::object(vec![
945                            ("purpose", Value::string("Mongo CRUD for groups")),
946                            ("mtime", Value::number(1)),
947                        ]),
948                    ),
949                    (
950                        "src/Other.cs",
951                        Value::object(vec![
952                            ("purpose", Value::string("unrelated")),
953                            ("mtime", Value::number(1)),
954                        ]),
955                    ),
956                ]),
957            ),
958        ])
959    }
960
961    #[test]
962    fn find_matches_path_or_purpose_case_insensitively() {
963        let root = unique_temp_dir("find-case");
964        write_manifest(&root, &sample_manifest()).unwrap();
965        let hits = find_in_manifest(&root, "group").unwrap();
966        assert_eq!(hits.len(), 1);
967        assert_eq!(hits[0].path, "src/GroupRepository.cs");
968        assert_eq!(find_in_manifest(&root, "MONGO").unwrap().len(), 1);
969    }
970
971    #[test]
972    fn find_returns_empty_for_zero_hits_and_missing_manifest() {
973        let root = unique_temp_dir("find-zero");
974        write_manifest(&root, &sample_manifest()).unwrap();
975        assert!(find_in_manifest(&root, "nonexistentzzz")
976            .unwrap()
977            .is_empty());
978        let missing_root = unique_temp_dir("find-missing");
979        assert!(find_in_manifest(&missing_root, "anything")
980            .unwrap()
981            .is_empty());
982    }
983
984    #[test]
985    fn find_defaults_missing_source_to_heuristic() {
986        let root = unique_temp_dir("find-source");
987        let m = Value::object(vec![(
988            "entries",
989            Value::object(vec![(
990                "a.ts",
991                Value::object(vec![("purpose", Value::string("does A"))]),
992            )]),
993        )]);
994        write_manifest(&root, &m).unwrap();
995        let hits = find_in_manifest(&root, "does").unwrap();
996        assert_eq!(hits[0].source, "heuristic");
997    }
998
999    #[test]
1000    fn find_errs_when_entries_is_missing() {
1001        let root = unique_temp_dir("find-no-entries");
1002        write_manifest(&root, &Value::object(vec![("built_at_head", Value::Null)])).unwrap();
1003        assert!(matches!(
1004            find_in_manifest(&root, "x"),
1005            Err(FindError::EntriesNotObject)
1006        ));
1007    }
1008
1009    #[test]
1010    fn find_or_pool_sorts_by_hits_descending_stably_on_ties() {
1011        // Three entries with 0, 1, and 2 of the two tokens hitting; two
1012        // entries tie at 1 hit each -- their relative order must be
1013        // preserved from manifest key order (insertion order).
1014        let root = unique_temp_dir("find-tie");
1015        let m = Value::object(vec![(
1016            "entries",
1017            Value::object(vec![
1018                (
1019                    "first.ts",
1020                    Value::object(vec![("purpose", Value::string("alpha only"))]),
1021                ),
1022                (
1023                    "second.ts",
1024                    Value::object(vec![("purpose", Value::string("alpha beta"))]),
1025                ),
1026                (
1027                    "third.ts",
1028                    Value::object(vec![("purpose", Value::string("beta only"))]),
1029                ),
1030                (
1031                    "fourth.ts",
1032                    Value::object(vec![("purpose", Value::string("neither"))]),
1033                ),
1034            ]),
1035        )]);
1036        write_manifest(&root, &m).unwrap();
1037        let hits = find_in_manifest(&root, "alpha beta").unwrap();
1038        // full match (both tokens) wins outright: only "second.ts" hits both.
1039        assert_eq!(hits.len(), 1);
1040        assert_eq!(hits[0].path, "second.ts");
1041
1042        // Force the OR-pool path: a token that ONLY "second.ts" and
1043        // "third.ts" partially match (one hit each), no entry hits both.
1044        let hits2 = find_in_manifest(&root, "beta zzz").unwrap();
1045        // "second.ts" (beta) and "third.ts" (beta) tie at 1 hit; manifest
1046        // order is second.ts then third.ts -- stable sort must keep it.
1047        let paths: Vec<&str> = hits2.iter().map(|h| h.path.as_str()).collect();
1048        assert_eq!(paths, vec!["second.ts", "third.ts"]);
1049    }
1050
1051    fn find_ranked(root: &Path, query: &str, inbound: &[(&str, usize)]) -> Vec<String> {
1052        let map: HashMap<String, usize> =
1053            inbound.iter().map(|(p, n)| (p.to_string(), *n)).collect();
1054        find_in_manifest_detailed(root, query, &map)
1055            .unwrap()
1056            .hits
1057            .into_iter()
1058            .map(|h| h.path)
1059            .collect()
1060    }
1061
1062    #[test]
1063    fn find_and_pool_ranks_by_inbound_count_on_the_constant_token_key() {
1064        // Both entries match BOTH tokens of the query -- the AND pool's primary
1065        // key is constant by construction -- so the inbound tie-break does all
1066        // the work: the widely referenced file outranks the island despite its
1067        // later manifest position.
1068        let root = unique_temp_dir("find-and-inbound");
1069        let m = Value::object(vec![(
1070            "entries",
1071            Value::object(vec![
1072                (
1073                    "src/Island.cs",
1074                    Value::object(vec![("purpose", Value::string("widget ledger store"))]),
1075                ),
1076                (
1077                    "src/Hub.cs",
1078                    Value::object(vec![("purpose", Value::string("widget ledger hub"))]),
1079                ),
1080            ]),
1081        )]);
1082        write_manifest(&root, &m).unwrap();
1083        let paths = find_ranked(&root, "widget ledger", &[("src/Hub.cs", 7)]);
1084        assert_eq!(paths, vec!["src/Hub.cs", "src/Island.cs"]);
1085
1086        // The ranking reads the map, not the manifest: flipping which file
1087        // carries the references flips the order.
1088        let flipped = find_ranked(&root, "widget ledger", &[("src/Island.cs", 3)]);
1089        assert_eq!(flipped, vec!["src/Island.cs", "src/Hub.cs"]);
1090    }
1091
1092    #[test]
1093    fn find_or_pool_ranks_tokens_matched_before_inbound_count() {
1094        // OR pool: a three-token query no entry matches completely.
1095        // "rich.cs" matched TWO tokens but carries zero references;
1096        // "hub.cs" matched one token but is referenced 99 times. Tokens are
1097        // the primary key, so the extra text match wins and popularity cannot
1098        // buy the weaker text match the top row.
1099        let root = unique_temp_dir("find-or-tokens-first");
1100        let m = Value::object(vec![(
1101            "entries",
1102            Value::object(vec![
1103                (
1104                    "src/hub.cs",
1105                    Value::object(vec![("purpose", Value::string("alpha only"))]),
1106                ),
1107                (
1108                    "src/rich.cs",
1109                    Value::object(vec![("purpose", Value::string("alpha beta"))]),
1110                ),
1111            ]),
1112        )]);
1113        write_manifest(&root, &m).unwrap();
1114        let r = find_in_manifest_detailed(
1115            &root,
1116            "alpha beta zeta",
1117            &[("src/hub.cs".to_string(), 99)].into_iter().collect(),
1118        )
1119        .unwrap();
1120        assert!(
1121            r.fallback,
1122            "no entry matches every token: the OR pool answers"
1123        );
1124        let paths: Vec<&str> = r.hits.iter().map(|h| h.path.as_str()).collect();
1125        assert_eq!(paths, vec!["src/rich.cs", "src/hub.cs"], "tokens primary");
1126
1127        // Same pool, equal hit counts: NOW inbound decides.
1128        let m2 = Value::object(vec![(
1129            "entries",
1130            Value::object(vec![
1131                (
1132                    "src/plain.cs",
1133                    Value::object(vec![("purpose", Value::string("alpha here"))]),
1134                ),
1135                (
1136                    "src/popular.cs",
1137                    Value::object(vec![("purpose", Value::string("alpha too"))]),
1138                ),
1139            ]),
1140        )]);
1141        write_manifest(&root, &m2).unwrap();
1142        let paths2 = find_ranked(
1143            &root,
1144            "alpha zeta",
1145            &[("src/popular.cs", 9), ("src/plain.cs", 1)],
1146        );
1147        assert_eq!(paths2, vec!["src/popular.cs", "src/plain.cs"]);
1148    }
1149
1150    #[test]
1151    fn find_full_tie_keeps_manifest_order() {
1152        // Every key equal -- tokens AND a populated inbound map reading the
1153        // same count everywhere -- so the comparator returns Equal throughout
1154        // and stability must hand back exactly the manifest's on-disk order.
1155        let root = unique_temp_dir("find-full-tie");
1156        let m = Value::object(vec![(
1157            "entries",
1158            Value::object(vec![
1159                (
1160                    "src/first.cs",
1161                    Value::object(vec![("purpose", Value::string("twin widget"))]),
1162                ),
1163                (
1164                    "src/second.cs",
1165                    Value::object(vec![("purpose", Value::string("twin widget"))]),
1166                ),
1167                (
1168                    "src/third.cs",
1169                    Value::object(vec![("purpose", Value::string("twin widget"))]),
1170                ),
1171            ]),
1172        )]);
1173        write_manifest(&root, &m).unwrap();
1174        let tied = [
1175            ("src/first.cs", 4usize),
1176            ("src/second.cs", 4),
1177            ("src/third.cs", 4),
1178        ];
1179        let paths = find_ranked(&root, "twin widget", &tied);
1180        assert_eq!(paths, vec!["src/first.cs", "src/second.cs", "src/third.cs"]);
1181    }
1182
1183    #[test]
1184    fn find_with_an_empty_map_returns_todays_order() {
1185        // No graph, no ranking: the AND pool keeps manifest iteration order,
1186        // and the OR pool still sorts by hits alone with manifest-order ties --
1187        // byte for byte what `find_in_manifest` answered before the map
1188        // existed.
1189        let root = unique_temp_dir("find-empty-map");
1190
1191        // AND pool: both full matches sit on equal keys (every count reads 0),
1192        // so stability hands back manifest order -- "second" stays ahead of
1193        // "first", as on disk.
1194        let m = Value::object(vec![(
1195            "entries",
1196            Value::object(vec![
1197                (
1198                    "src/second.cs",
1199                    Value::object(vec![("purpose", Value::string("alpha beta"))]),
1200                ),
1201                (
1202                    "src/first.cs",
1203                    Value::object(vec![("purpose", Value::string("beta alpha"))]),
1204                ),
1205                (
1206                    "src/partial.cs",
1207                    Value::object(vec![("purpose", Value::string("alpha only"))]),
1208                ),
1209            ]),
1210        )]);
1211        write_manifest(&root, &m).unwrap();
1212        assert_eq!(
1213            find_ranked(&root, "alpha beta", &[]),
1214            vec!["src/second.cs", "src/first.cs"]
1215        );
1216
1217        // OR pool: no entry matches every token of the three-token query.
1218        // Hits primary: the two-hit entry leads; the one-hit ties keep
1219        // manifest order behind it; the zero-hit entry stays out. Paths carry
1220        // no token substrings -- the haystack includes the path, and this
1221        // test wants the PURPOSE text deciding every hit count.
1222        let m2 = Value::object(vec![(
1223            "entries",
1224            Value::object(vec![
1225                (
1226                    "src/a.cs",
1227                    Value::object(vec![("purpose", Value::string("alpha four"))]),
1228                ),
1229                (
1230                    "src/b.cs",
1231                    Value::object(vec![("purpose", Value::string("beta five"))]),
1232                ),
1233                (
1234                    "src/c.cs",
1235                    Value::object(vec![("purpose", Value::string("alpha beta six"))]),
1236                ),
1237                (
1238                    "src/d.cs",
1239                    Value::object(vec![("purpose", Value::string("seven"))]),
1240                ),
1241            ]),
1242        )]);
1243        write_manifest(&root, &m2).unwrap();
1244        assert_eq!(
1245            find_ranked(&root, "alpha beta gamma", &[]),
1246            vec!["src/c.cs", "src/a.cs", "src/b.cs"]
1247        );
1248    }
1249
1250    // -- git_head -----------------------------------------------------
1251
1252    #[test]
1253    fn git_head_is_none_outside_a_repo() {
1254        let root = unique_temp_dir("nogit-head");
1255        assert_eq!(git_head(&root), None);
1256    }
1257
1258    // -- scope_for ------------------------------------------------------
1259
1260    #[test]
1261    fn scope_for_prefers_explicit_dirs_then_registry_then_dot() {
1262        let _guard = REGISTRY_ENV_LOCK.lock().unwrap();
1263        let scoped = unique_temp_dir("scope-scoped");
1264        let unscoped = unique_temp_dir("scope-unscoped");
1265        let reg_dir = unique_temp_dir("scope-reg");
1266        let reg_path = reg_dir.join("repos.json");
1267        fs::write(
1268            &reg_path,
1269            format!(
1270                r#"{{"roots": [{{"root": "{}", "kind": "git", "scope": ["src", "app"]}}]}}"#,
1271                scoped.display()
1272            ),
1273        )
1274        .unwrap();
1275
1276        env::set_var("SCOUT_REGISTRY", &reg_path);
1277        let result = (|| {
1278            assert_eq!(
1279                scope_for(&scoped, None).unwrap(),
1280                vec!["src".to_string(), "app".to_string()]
1281            );
1282            assert_eq!(scope_for(&unscoped, None).unwrap(), vec![".".to_string()]);
1283            let explicit = vec!["a".to_string(), "b".to_string()];
1284            assert_eq!(scope_for(&scoped, Some(&explicit)).unwrap(), explicit);
1285        })();
1286        env::remove_var("SCOUT_REGISTRY");
1287        result
1288    }
1289}