Skip to main content

varar_core/
drift.rs

1//! Oath drift detection — port of `drift.ts` / `Drift.java`. A paragraph the
2//! committed `varar.lock.json` baseline recorded as an example that now matches no
3//! step. Byte-identical to the other ports (FNV-1a fingerprint, insertion-ordered
4//! lockfile serializer, Jaccard word-similarity re-identification).
5
6use crate::ast::Doc;
7use crate::hash::hash_source;
8use crate::json_value::parse_json_value;
9use crate::plan::{ExecutionPlan, derive_example_name};
10use crate::span::Span;
11use crate::value::Value;
12use regex::Regex;
13use std::collections::{BTreeMap, HashSet};
14use std::sync::LazyLock;
15
16/// The word-similarity threshold for re-identifying a moved/reworded example.
17pub const SIMILARITY_THRESHOLD: f64 = 0.5;
18
19/// One example-producing paragraph, as recorded in the baseline.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct BaselineExample {
22    pub name: String,
23    pub line: usize,
24}
25
26/// The committed baseline for one oath file.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct OathBaseline {
29    pub source_hash: String,
30    pub examples: Vec<BaselineExample>,
31}
32
33/// The whole `varar.lock.json`: every oath keyed by its POSIX path.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct LockFile {
36    pub version: u32,
37    pub oaths: BTreeMap<String, OathBaseline>,
38}
39
40/// A paragraph the baseline says was an example and now matches no step.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct Drifted {
43    pub name: String,
44    pub line: usize,
45    pub span: Span,
46}
47
48/// Persistence port for `varar.lock.json`. The core owns the format; adapters move
49/// only raw text.
50pub trait BaselineStore {
51    /// The whole lockfile's contents, or `None` when there is no baseline yet.
52    fn read(&self) -> Option<String>;
53
54    fn write(&mut self, contents: &str);
55}
56
57static TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\p{L}\p{N}]+").unwrap());
58
59// Do the two spans overlap at all (offset ranges intersect)? A candidate
60// paragraph relates to its planned example either way round: a header-bound row
61// sits *inside* its binding paragraph, while a merged example's span *covers*
62// each of the candidates it absorbed (ADR 0012). Overlap catches both.
63fn overlaps(a: Span, b: Span) -> bool {
64    a.start_offset < b.end_offset && b.start_offset < a.end_offset
65}
66
67// A candidate paragraph is "live" (still an example) if it overlaps at least one
68// planned example. A now-prose paragraph — one whose step def was renamed or
69// deleted — overlaps none (it became a delimiter, splitting any example it was
70// part of), so drift catches it.
71fn is_live(candidate_span: Span, plan: &ExecutionPlan) -> bool {
72    plan.examples
73        .iter()
74        .any(|pe| overlaps(pe.span, candidate_span))
75}
76
77fn tokenize(text: &str) -> HashSet<String> {
78    TOKEN_RE
79        .find_iter(&text.to_lowercase())
80        .map(|m| m.as_str().to_string())
81        .collect()
82}
83
84fn similarity(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
85    if a.is_empty() && b.is_empty() {
86        return 1.0;
87    }
88    let intersection = a.iter().filter(|t| b.contains(*t)).count();
89    let union = a.len() + b.len() - intersection;
90    if union == 0 {
91        0.0
92    } else {
93        intersection as f64 / union as f64
94    }
95}
96
97/// The current example-producing paragraphs, in document order.
98pub fn live_examples(doc: &Doc, plan: &ExecutionPlan) -> Vec<BaselineExample> {
99    doc.examples
100        .iter()
101        .filter(|c| is_live(c.span, plan))
102        .map(|c| BaselineExample {
103            name: derive_example_name(&c.body),
104            line: c.span.start_line,
105        })
106        .collect()
107}
108
109/// The full baseline record for an oath: fingerprint plus live examples.
110pub fn derive_oath_baseline(source: &str, doc: &Doc, plan: &ExecutionPlan) -> OathBaseline {
111    OathBaseline {
112        source_hash: hash_source(source),
113        examples: live_examples(doc, plan),
114    }
115}
116
117/// Paragraphs the baseline recorded as examples that now match zero steps.
118pub fn detect_drift(
119    baseline: Option<&OathBaseline>,
120    doc: &Doc,
121    plan: &ExecutionPlan,
122) -> Vec<Drifted> {
123    let Some(baseline) = baseline else {
124        return Vec::new();
125    };
126    let candidates = &doc.examples;
127    let n = candidates.len();
128    let tokens: Vec<HashSet<String>> = candidates
129        .iter()
130        .map(|c| tokenize(&derive_example_name(&c.body)))
131        .collect();
132    let live: Vec<bool> = candidates.iter().map(|c| is_live(c.span, plan)).collect();
133
134    let mut drifts = Vec::new();
135    for b in &baseline.examples {
136        let b_tokens = tokenize(&b.name);
137        let mut best_idx: Option<usize> = None;
138        let mut best_score = 0.0f64;
139        for i in 0..n {
140            let score = similarity(&b_tokens, &tokens[i]);
141            if score < SIMILARITY_THRESHOLD {
142                continue;
143            }
144            let line = candidates[i].span.start_line as isize;
145            let best_line = best_idx.map_or(0, |bi| candidates[bi].span.start_line as isize);
146            let b_line = b.line as isize;
147            if best_idx.is_none()
148                || score > best_score
149                || (score == best_score && (line - b_line).abs() < (best_line - b_line).abs())
150            {
151                best_idx = Some(i);
152                best_score = score;
153            }
154        }
155        if let Some(bi) = best_idx {
156            if !live[bi] {
157                let cand = &candidates[bi];
158                drifts.push(Drifted {
159                    name: b.name.clone(),
160                    line: cand.span.start_line,
161                    span: cand.span,
162                });
163            }
164        }
165    }
166    drifts
167}
168
169/// The human-readable message for a drift.
170pub fn message(drifted: &Drifted) -> String {
171    format!(
172        "This paragraph was an example and no longer matches any step (drift): \"{}\".\nFix the step so it matches again, or accept it as prose (run in update mode).",
173        drifted.name
174    )
175}
176
177/// One oath's baseline reconciliation against a [`BaselineStore`]. `update`
178/// accepts all drift; otherwise detect drift and rewrite the baseline only on a
179/// clean run.
180pub fn reconcile_drift(
181    store: &mut dyn BaselineStore,
182    oath_path: &str,
183    source: &str,
184    doc: &Doc,
185    plan: &ExecutionPlan,
186    update: bool,
187) -> Vec<Drifted> {
188    let lock = store.read().as_deref().and_then(parse_lock_file);
189    let drifts = if update {
190        Vec::new()
191    } else {
192        detect_drift(lock.as_ref().and_then(|l| l.oaths.get(oath_path)), doc, plan)
193    };
194    if update || drifts.is_empty() {
195        let next = derive_oath_baseline(source, doc, plan);
196        let mut oaths = lock.map_or_else(BTreeMap::new, |l| l.oaths);
197        oaths.insert(oath_path.to_string(), next);
198        store.write(&stringify_lock_file(&LockFile { version: 2, oaths }));
199    }
200    drifts
201}
202
203/// Drops every baseline whose oath path is not in `keep_paths` — the entries left
204/// behind when an oath is deleted or moved. Pure counterpart of [`parse_lock_file`]
205/// / [`stringify_lock_file`]; the caller decides what "still exists" means.
206pub fn prune_lock_file(lock: &LockFile, keep_paths: &[String]) -> LockFile {
207    LockFile {
208        version: 2,
209        oaths: lock
210            .oaths
211            .iter()
212            .filter(|(path, _)| keep_paths.iter().any(|k| k == *path))
213            .map(|(path, baseline)| (path.clone(), baseline.clone()))
214            .collect(),
215    }
216}
217
218/// The whole-lock counterpart of [`reconcile_drift`], run ONCE per run rather than
219/// per oath: reconciliation cannot see paths that no longer exist, so without this
220/// the lock silently accumulates dead entries and stops being a faithful inventory
221/// of the oath set (#70).
222///
223/// `keep_paths` MUST be everything the `docs` globs currently match — never the set
224/// the run happened to execute. Runs are routinely filtered, and pruning against a
225/// filtered set would delete live baselines.
226///
227/// Removal is still not *gated*: a deleted oath is a different signal from drift and
228/// stays ungated (ADR 0002). This only stops preserving dead state, and only under
229/// `update`. Returns the paths removed (or, without `update`, the ones that would be).
230pub fn prune_baselines(
231    store: &mut dyn BaselineStore,
232    keep_paths: &[String],
233    update: bool,
234) -> Vec<String> {
235    let Some(lock) = store.read().as_deref().and_then(parse_lock_file) else {
236        return Vec::new();
237    };
238    let stale: Vec<String> = lock
239        .oaths
240        .keys()
241        .filter(|path| !keep_paths.iter().any(|k| k == *path))
242        .cloned()
243        .collect();
244    if update && !stale.is_empty() {
245        store.write(&stringify_lock_file(&prune_lock_file(&lock, keep_paths)));
246    }
247    stale
248}
249
250/// Serializes `varar.lock.json` deterministically (fixed field order, sorted oath
251/// paths, two-space indent, trailing newline) — NOT [`crate::canonical_json`].
252pub fn stringify_lock_file(lock: &LockFile) -> String {
253    let mut sb = String::new();
254    sb.push_str("{\n  \"version\": 2,\n  \"oaths\": ");
255    if lock.oaths.is_empty() {
256        sb.push_str("{}");
257    } else {
258        sb.push_str("{\n");
259        let n = lock.oaths.len();
260        // `BTreeMap` iterates oath paths in sorted order.
261        for (p, (path, baseline)) in lock.oaths.iter().enumerate() {
262            sb.push_str("    ");
263            write_json_string(&mut sb, path);
264            sb.push_str(": {\n      \"sourceHash\": ");
265            write_json_string(&mut sb, &baseline.source_hash);
266            sb.push_str(",\n      \"examples\": ");
267            if baseline.examples.is_empty() {
268                sb.push_str("[]");
269            } else {
270                sb.push_str("[\n");
271                let en = baseline.examples.len();
272                for (e, ex) in baseline.examples.iter().enumerate() {
273                    sb.push_str("        {\n          \"name\": ");
274                    write_json_string(&mut sb, &ex.name);
275                    sb.push_str(",\n          \"line\": ");
276                    sb.push_str(&ex.line.to_string());
277                    sb.push_str("\n        }");
278                    if e + 1 < en {
279                        sb.push(',');
280                    }
281                    sb.push('\n');
282                }
283                sb.push_str("      ]");
284            }
285            sb.push_str("\n    }");
286            if p + 1 < n {
287                sb.push(',');
288            }
289            sb.push('\n');
290        }
291        sb.push_str("  }");
292    }
293    sb.push_str("\n}\n");
294    sb
295}
296
297fn write_json_string(sb: &mut String, s: &str) {
298    use std::fmt::Write;
299    sb.push('"');
300    for c in s.chars() {
301        match c {
302            '"' => sb.push_str("\\\""),
303            '\\' => sb.push_str("\\\\"),
304            '\n' => sb.push_str("\\n"),
305            '\r' => sb.push_str("\\r"),
306            '\t' => sb.push_str("\\t"),
307            '\u{0008}' => sb.push_str("\\b"),
308            '\u{000c}' => sb.push_str("\\f"),
309            c if (c as u32) < 0x20 => {
310                let _ = write!(sb, "\\u{:04x}", c as u32);
311            }
312            c => sb.push(c),
313        }
314    }
315    sb.push('"');
316}
317
318/// Parses `varar.lock.json`; `None` on malformed input (treated as no baseline).
319pub fn parse_lock_file(text: &str) -> Option<LockFile> {
320    let parsed = parse_json_value(text)?;
321    let Value::Map(obj) = parsed else { return None };
322    if !matches!(obj.get("version"), Some(Value::Int(2))) {
323        return None;
324    }
325    let Some(Value::Map(oaths_raw)) = obj.get("oaths") else {
326        return None;
327    };
328    let mut oaths = BTreeMap::new();
329    for (k, v) in oaths_raw {
330        oaths.insert(k.clone(), parse_oath_baseline(v)?);
331    }
332    Some(LockFile { version: 2, oaths })
333}
334
335fn parse_oath_baseline(value: &Value) -> Option<OathBaseline> {
336    let Value::Map(map) = value else { return None };
337    let Some(Value::String(source_hash)) = map.get("sourceHash") else {
338        return None;
339    };
340    let Some(Value::List(examples_raw)) = map.get("examples") else {
341        return None;
342    };
343    let mut examples = Vec::new();
344    for item in examples_raw {
345        let Value::Map(e) = item else { return None };
346        let Some(Value::String(name)) = e.get("name") else {
347            return None;
348        };
349        let Some(Value::Int(line)) = e.get("line") else {
350            return None;
351        };
352        examples.push(BaselineExample {
353            name: name.clone(),
354            line: *line as usize,
355        });
356    }
357    Some(OathBaseline {
358        source_hash: source_hash.clone(),
359        examples,
360    })
361}