Skip to main content

devscout_rs/
freshness.rs

1//! Query-time index-freshness state for a programmatic consumer.
2//!
3//! `manifest::freshness_warning` already detects a stale index -- source
4//! edited since the graph was last built, at the same HEAD -- but only as a
5//! human-readable stderr line, and it collapses three different conditions
6//! into the same `None`: no `index-state.json` sidecar, git unavailable, and
7//! a genuinely fresh index. A `--json` consumer needs to tell those apart:
8//! "verified fresh" is not the same claim as "cannot tell". This module
9//! calls the exact same primitives `freshness_warning` does
10//! (`manifest::read_index_state`, `manifest::git_head`,
11//! `manifest::dirty_indexed_files_at`, `manifest::read_manifest`) so the two
12//! answers can never disagree about the underlying facts, only about how
13//! much of the distinction each one exposes. `manifest.rs` itself is frozen
14//! at its current line count and gains no new logic here -- this is a
15//! separate call site over its existing public functions, not a refactor of
16//! it, so the frozen file's own stderr text and tests are untouched.
17
18use std::collections::HashSet;
19use std::path::Path;
20
21use crate::manifest::{self, Value};
22
23/// The query-time freshness state of an indexed root. Scoped to one root at
24/// the moment it is computed; carries no cross-query identity of its own.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum FreshnessState {
27    /// The index matches the working tree: the same HEAD as when it was
28    /// built, and no indexed file has gone dirty since.
29    Fresh,
30    /// The index is behind the working tree.
31    Stale {
32        /// The HEAD the index was built at.
33        indexed_head: String,
34        /// The HEAD the working tree is at now.
35        current_head: String,
36        /// How many indexed files are dirty now but were not dirty when the
37        /// index was built.
38        changed_files: usize,
39    },
40    /// Freshness could not be established either way.
41    Unknown {
42        /// Why this could not resolve to `Fresh` or `Stale`.
43        reason: UnknownReason,
44    },
45}
46
47/// Why [`FreshnessState::Unknown`] could not resolve further.
48///
49/// Collapsed together on `manifest::freshness_warning`'s stderr path (both
50/// read as silence there); distinguished here because a machine reader must
51/// be able to act differently on "never indexed with this feature" than on
52/// "git is unavailable".
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum UnknownReason {
55    /// No `index-state.json` sidecar was found or it did not parse -- an
56    /// index built before this sidecar existed, or a corrupt one.
57    NoIndexState,
58    /// `git rev-parse HEAD` did not resolve -- git unavailable, or this root
59    /// is not (or is no longer) a git repository.
60    GitUnavailable,
61}
62
63impl UnknownReason {
64    /// The stable word this reason serializes as under `--json`.
65    #[must_use]
66    pub fn as_str(self) -> &'static str {
67        match self {
68            UnknownReason::NoIndexState => "no-index-state",
69            UnknownReason::GitUnavailable => "git-unavailable",
70        }
71    }
72}
73
74// The default scope `manifest::freshness_warning` falls back to when the
75// manifest carries no non-empty `scoped_dirs` -- kept in step with that
76// function's own fallback deliberately, not derived from it (it is not
77// exposed as a constant there).
78fn default_scope() -> Vec<String> {
79    vec![".".to_string()]
80}
81
82fn string_array(v: &Value) -> Option<Vec<String>> {
83    match v {
84        Value::Array(items) => Some(
85            items
86                .iter()
87                .filter_map(Value::as_str)
88                .map(str::to_string)
89                .collect(),
90        ),
91        _ => None,
92    }
93}
94
95/// Computes [`FreshnessState`] for `root`, from the same primitives
96/// `manifest::freshness_warning` uses.
97#[must_use]
98pub fn index_freshness_state(root: &Path) -> FreshnessState {
99    let Some(state) = manifest::read_index_state(root) else {
100        return FreshnessState::Unknown {
101            reason: UnknownReason::NoIndexState,
102        };
103    };
104    let Some(stored_head) = state
105        .get("head")
106        .and_then(Value::as_str)
107        .map(str::to_string)
108    else {
109        return FreshnessState::Unknown {
110            reason: UnknownReason::NoIndexState,
111        };
112    };
113    let Some(current_head) = manifest::git_head(root) else {
114        return FreshnessState::Unknown {
115            reason: UnknownReason::GitUnavailable,
116        };
117    };
118
119    let manifest_doc = manifest::read_manifest(root).ok().flatten();
120    let indexed_files: HashSet<String> = manifest_doc
121        .as_ref()
122        .and_then(|m| m.get("entries"))
123        .and_then(Value::as_object)
124        .map(|entries| entries.iter().map(|(k, _)| k.clone()).collect())
125        .unwrap_or_default();
126    let scope: Vec<String> = manifest_doc
127        .as_ref()
128        .and_then(|m| m.get("scoped_dirs"))
129        .and_then(string_array)
130        .filter(|v: &Vec<String>| !v.is_empty())
131        .unwrap_or_else(default_scope);
132
133    let current_dirty = manifest::dirty_indexed_files_at(root, &scope, &indexed_files);
134    let baseline_dirty: HashSet<String> = state
135        .get("dirty_indexed_files")
136        .and_then(string_array)
137        .map(|v| v.into_iter().collect())
138        .unwrap_or_default();
139    let changed_files = current_dirty
140        .iter()
141        .filter(|p| !baseline_dirty.contains(*p))
142        .count();
143
144    if current_head == stored_head && changed_files == 0 {
145        FreshnessState::Fresh
146    } else {
147        FreshnessState::Stale {
148            indexed_head: stored_head,
149            current_head,
150            changed_files,
151        }
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::fs;
159
160    #[test]
161    fn no_index_state_sidecar_is_unknown_with_that_reason() {
162        let dir = std::env::temp_dir().join(format!(
163            "devscout-freshness-unit-{}-{:?}",
164            std::process::id(),
165            std::time::SystemTime::now()
166        ));
167        fs::create_dir_all(&dir).unwrap();
168        assert_eq!(
169            index_freshness_state(&dir),
170            FreshnessState::Unknown {
171                reason: UnknownReason::NoIndexState
172            }
173        );
174        let _ = fs::remove_dir_all(&dir);
175    }
176
177    #[test]
178    fn unknown_reason_words_are_stable() {
179        assert_eq!(UnknownReason::NoIndexState.as_str(), "no-index-state");
180        assert_eq!(UnknownReason::GitUnavailable.as_str(), "git-unavailable");
181    }
182}