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}