Skip to main content

varar_core/
drift.rs

1//! Spec 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::VarDoc;
7use crate::hash::hash_source;
8use crate::plan::{ExecutionPlan, derive_example_name};
9use crate::span::Span;
10use crate::value::Value;
11use regex::Regex;
12use std::collections::{BTreeMap, HashSet};
13use std::sync::LazyLock;
14
15/// The word-similarity threshold for re-identifying a moved/reworded example.
16pub const SIMILARITY_THRESHOLD: f64 = 0.5;
17
18/// One example-producing paragraph, as recorded in the baseline.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct BaselineExample {
21    pub name: String,
22    pub line: usize,
23}
24
25/// The committed baseline for one spec file.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct SpecBaseline {
28    pub source_hash: String,
29    pub examples: Vec<BaselineExample>,
30}
31
32/// The whole `varar.lock.json`: every spec keyed by its POSIX path.
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct VarLock {
35    pub version: u32,
36    pub specs: BTreeMap<String, SpecBaseline>,
37}
38
39/// A paragraph the baseline says was an example and now matches no step.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Drifted {
42    pub name: String,
43    pub line: usize,
44    pub span: Span,
45}
46
47/// Persistence port for `varar.lock.json`. The core owns the format; adapters move
48/// only raw text.
49pub trait BaselineStore {
50    /// The whole lockfile's contents, or `None` when there is no baseline yet.
51    fn read(&self) -> Option<String>;
52
53    fn write(&mut self, contents: &str);
54}
55
56static TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\p{L}\p{N}]+").unwrap());
57
58// Do the two spans overlap at all (offset ranges intersect)? A candidate
59// paragraph relates to its planned example either way round: a header-bound row
60// sits *inside* its binding paragraph, while a merged example's span *covers*
61// each of the candidates it absorbed (ADR 0012). Overlap catches both.
62fn overlaps(a: Span, b: Span) -> bool {
63    a.start_offset < b.end_offset && b.start_offset < a.end_offset
64}
65
66// A candidate paragraph is "live" (still an example) if it overlaps at least one
67// planned example. A now-prose paragraph — one whose step def was renamed or
68// deleted — overlaps none (it became a delimiter, splitting any example it was
69// part of), so drift catches it.
70fn is_live(candidate_span: Span, plan: &ExecutionPlan) -> bool {
71    plan.examples
72        .iter()
73        .any(|pe| overlaps(pe.span, candidate_span))
74}
75
76fn tokenize(text: &str) -> HashSet<String> {
77    TOKEN_RE
78        .find_iter(&text.to_lowercase())
79        .map(|m| m.as_str().to_string())
80        .collect()
81}
82
83fn similarity(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
84    if a.is_empty() && b.is_empty() {
85        return 1.0;
86    }
87    let intersection = a.iter().filter(|t| b.contains(*t)).count();
88    let union = a.len() + b.len() - intersection;
89    if union == 0 {
90        0.0
91    } else {
92        intersection as f64 / union as f64
93    }
94}
95
96/// The current example-producing paragraphs, in document order.
97pub fn live_examples(var_doc: &VarDoc, plan: &ExecutionPlan) -> Vec<BaselineExample> {
98    var_doc
99        .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 a spec: fingerprint plus live examples.
110pub fn derive_spec_baseline(source: &str, var_doc: &VarDoc, plan: &ExecutionPlan) -> SpecBaseline {
111    SpecBaseline {
112        source_hash: hash_source(source),
113        examples: live_examples(var_doc, plan),
114    }
115}
116
117/// Paragraphs the baseline recorded as examples that now match zero steps.
118pub fn detect_drift(
119    baseline: Option<&SpecBaseline>,
120    var_doc: &VarDoc,
121    plan: &ExecutionPlan,
122) -> Vec<Drifted> {
123    let Some(baseline) = baseline else {
124        return Vec::new();
125    };
126    let candidates = &var_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 spec'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    spec_path: &str,
183    source: &str,
184    var_doc: &VarDoc,
185    plan: &ExecutionPlan,
186    update: bool,
187) -> Vec<Drifted> {
188    let lock = store.read().as_deref().and_then(parse_var_lock);
189    let drifts = if update {
190        Vec::new()
191    } else {
192        detect_drift(lock.as_ref().and_then(|l| l.specs.get(spec_path)), var_doc, plan)
193    };
194    if update || drifts.is_empty() {
195        let next = derive_spec_baseline(source, var_doc, plan);
196        let mut specs = lock.map_or_else(BTreeMap::new, |l| l.specs);
197        specs.insert(spec_path.to_string(), next);
198        store.write(&stringify_var_lock(&VarLock { version: 1, specs }));
199    }
200    drifts
201}
202
203/// Serializes `varar.lock.json` deterministically (fixed field order, sorted spec
204/// paths, two-space indent, trailing newline) — NOT [`crate::canonical_json`].
205pub fn stringify_var_lock(lock: &VarLock) -> String {
206    let mut sb = String::new();
207    sb.push_str("{\n  \"version\": 1,\n  \"specs\": ");
208    if lock.specs.is_empty() {
209        sb.push_str("{}");
210    } else {
211        sb.push_str("{\n");
212        let n = lock.specs.len();
213        // `BTreeMap` iterates spec paths in sorted order.
214        for (p, (path, baseline)) in lock.specs.iter().enumerate() {
215            sb.push_str("    ");
216            write_json_string(&mut sb, path);
217            sb.push_str(": {\n      \"sourceHash\": ");
218            write_json_string(&mut sb, &baseline.source_hash);
219            sb.push_str(",\n      \"examples\": ");
220            if baseline.examples.is_empty() {
221                sb.push_str("[]");
222            } else {
223                sb.push_str("[\n");
224                let en = baseline.examples.len();
225                for (e, ex) in baseline.examples.iter().enumerate() {
226                    sb.push_str("        {\n          \"name\": ");
227                    write_json_string(&mut sb, &ex.name);
228                    sb.push_str(",\n          \"line\": ");
229                    sb.push_str(&ex.line.to_string());
230                    sb.push_str("\n        }");
231                    if e + 1 < en {
232                        sb.push(',');
233                    }
234                    sb.push('\n');
235                }
236                sb.push_str("      ]");
237            }
238            sb.push_str("\n    }");
239            if p + 1 < n {
240                sb.push(',');
241            }
242            sb.push('\n');
243        }
244        sb.push_str("  }");
245    }
246    sb.push_str("\n}\n");
247    sb
248}
249
250fn write_json_string(sb: &mut String, s: &str) {
251    use std::fmt::Write;
252    sb.push('"');
253    for c in s.chars() {
254        match c {
255            '"' => sb.push_str("\\\""),
256            '\\' => sb.push_str("\\\\"),
257            '\n' => sb.push_str("\\n"),
258            '\r' => sb.push_str("\\r"),
259            '\t' => sb.push_str("\\t"),
260            '\u{0008}' => sb.push_str("\\b"),
261            '\u{000c}' => sb.push_str("\\f"),
262            c if (c as u32) < 0x20 => {
263                let _ = write!(sb, "\\u{:04x}", c as u32);
264            }
265            c => sb.push(c),
266        }
267    }
268    sb.push('"');
269}
270
271/// Parses `varar.lock.json`; `None` on malformed input (treated as no baseline).
272pub fn parse_var_lock(text: &str) -> Option<VarLock> {
273    let parsed = JsonReader::new(text).parse_whole()?;
274    let Value::Map(obj) = parsed else { return None };
275    if !matches!(obj.get("version"), Some(Value::Int(1))) {
276        return None;
277    }
278    let Some(Value::Map(specs_raw)) = obj.get("specs") else {
279        return None;
280    };
281    let mut specs = BTreeMap::new();
282    for (k, v) in specs_raw {
283        specs.insert(k.clone(), parse_spec_baseline(v)?);
284    }
285    Some(VarLock { version: 1, specs })
286}
287
288fn parse_spec_baseline(value: &Value) -> Option<SpecBaseline> {
289    let Value::Map(map) = value else { return None };
290    let Some(Value::String(source_hash)) = map.get("sourceHash") else {
291        return None;
292    };
293    let Some(Value::List(examples_raw)) = map.get("examples") else {
294        return None;
295    };
296    let mut examples = Vec::new();
297    for item in examples_raw {
298        let Value::Map(e) = item else { return None };
299        let Some(Value::String(name)) = e.get("name") else {
300            return None;
301        };
302        let Some(Value::Int(line)) = e.get("line") else {
303            return None;
304        };
305        examples.push(BaselineExample {
306            name: name.clone(),
307            line: *line as usize,
308        });
309    }
310    Some(SpecBaseline {
311        source_hash: source_hash.clone(),
312        examples,
313    })
314}
315
316/// A tiny recursive-descent JSON reader — enough for `varar.lock.json`, returning
317/// `None` on malformed input (Java's caught-exception → null).
318struct JsonReader {
319    chars: Vec<char>,
320    i: usize,
321}
322
323impl JsonReader {
324    fn new(text: &str) -> JsonReader {
325        JsonReader {
326            chars: text.chars().collect(),
327            i: 0,
328        }
329    }
330
331    fn parse_whole(&mut self) -> Option<Value> {
332        let v = self.value()?;
333        self.skip_ws();
334        if self.i != self.chars.len() {
335            return None;
336        }
337        Some(v)
338    }
339
340    fn value(&mut self) -> Option<Value> {
341        self.skip_ws();
342        match self.peek()? {
343            '{' => self.object(),
344            '[' => self.array(),
345            '"' => self.string().map(Value::String),
346            't' | 'f' => self.boolean(),
347            'n' => self.null(),
348            _ => self.number(),
349        }
350    }
351
352    fn object(&mut self) -> Option<Value> {
353        self.expect('{')?;
354        let mut map = BTreeMap::new();
355        self.skip_ws();
356        if self.peek()? == '}' {
357            self.i += 1;
358            return Some(Value::Map(map));
359        }
360        loop {
361            self.skip_ws();
362            let key = self.string()?;
363            self.skip_ws();
364            self.expect(':')?;
365            map.insert(key, self.value()?);
366            self.skip_ws();
367            match self.next()? {
368                '}' => return Some(Value::Map(map)),
369                ',' => {}
370                _ => return None,
371            }
372        }
373    }
374
375    fn array(&mut self) -> Option<Value> {
376        self.expect('[')?;
377        let mut list = Vec::new();
378        self.skip_ws();
379        if self.peek()? == ']' {
380            self.i += 1;
381            return Some(Value::List(list));
382        }
383        loop {
384            list.push(self.value()?);
385            self.skip_ws();
386            match self.next()? {
387                ']' => return Some(Value::List(list)),
388                ',' => {}
389                _ => return None,
390            }
391        }
392    }
393
394    fn string(&mut self) -> Option<String> {
395        self.expect('"')?;
396        let mut out = String::new();
397        loop {
398            match self.next()? {
399                '"' => return Some(out),
400                '\\' => match self.next()? {
401                    '"' => out.push('"'),
402                    '\\' => out.push('\\'),
403                    '/' => out.push('/'),
404                    'n' => out.push('\n'),
405                    'r' => out.push('\r'),
406                    't' => out.push('\t'),
407                    'b' => out.push('\u{0008}'),
408                    'f' => out.push('\u{000c}'),
409                    'u' => {
410                        let code = self.hex4()?;
411                        out.push(char::from_u32(code)?);
412                    }
413                    _ => return None,
414                },
415                c => out.push(c),
416            }
417        }
418    }
419
420    fn hex4(&mut self) -> Option<u32> {
421        if self.i + 4 > self.chars.len() {
422            return None;
423        }
424        let slice: String = self.chars[self.i..self.i + 4].iter().collect();
425        self.i += 4;
426        u32::from_str_radix(&slice, 16).ok()
427    }
428
429    fn number(&mut self) -> Option<Value> {
430        let start = self.i;
431        while self.i < self.chars.len() && "-+.eE0123456789".contains(self.chars[self.i]) {
432            self.i += 1;
433        }
434        if self.i == start {
435            return None;
436        }
437        let num: String = self.chars[start..self.i].iter().collect();
438        if num.contains(['.', 'e', 'E']) {
439            num.parse::<f64>().ok().map(Value::Float)
440        } else {
441            num.parse::<i64>().ok().map(Value::Int)
442        }
443    }
444
445    fn boolean(&mut self) -> Option<Value> {
446        if self.starts_with("true") {
447            self.i += 4;
448            Some(Value::Bool(true))
449        } else if self.starts_with("false") {
450            self.i += 5;
451            Some(Value::Bool(false))
452        } else {
453            None
454        }
455    }
456
457    fn null(&mut self) -> Option<Value> {
458        if self.starts_with("null") {
459            self.i += 4;
460            Some(Value::Null)
461        } else {
462            None
463        }
464    }
465
466    fn starts_with(&self, lit: &str) -> bool {
467        let lit: Vec<char> = lit.chars().collect();
468        self.i + lit.len() <= self.chars.len() && self.chars[self.i..self.i + lit.len()] == lit[..]
469    }
470
471    fn skip_ws(&mut self) {
472        while self.i < self.chars.len() && matches!(self.chars[self.i], ' ' | '\n' | '\r' | '\t') {
473            self.i += 1;
474        }
475    }
476
477    fn peek(&self) -> Option<char> {
478        self.chars.get(self.i).copied()
479    }
480
481    fn next(&mut self) -> Option<char> {
482        let c = self.chars.get(self.i).copied()?;
483        self.i += 1;
484        Some(c)
485    }
486
487    fn expect(&mut self, c: char) -> Option<()> {
488        if self.next()? == c { Some(()) } else { None }
489    }
490}