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
58fn within(inner: Span, outer: Span) -> bool {
59    inner.start_offset >= outer.start_offset && inner.end_offset <= outer.end_offset
60}
61
62fn is_live(candidate_span: Span, plan: &ExecutionPlan) -> bool {
63    plan.examples
64        .iter()
65        .any(|pe| within(pe.span, candidate_span))
66}
67
68fn tokenize(text: &str) -> HashSet<String> {
69    TOKEN_RE
70        .find_iter(&text.to_lowercase())
71        .map(|m| m.as_str().to_string())
72        .collect()
73}
74
75fn similarity(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
76    if a.is_empty() && b.is_empty() {
77        return 1.0;
78    }
79    let intersection = a.iter().filter(|t| b.contains(*t)).count();
80    let union = a.len() + b.len() - intersection;
81    if union == 0 {
82        0.0
83    } else {
84        intersection as f64 / union as f64
85    }
86}
87
88/// The current example-producing paragraphs, in document order.
89pub fn live_examples(var_doc: &VarDoc, plan: &ExecutionPlan) -> Vec<BaselineExample> {
90    var_doc
91        .examples
92        .iter()
93        .filter(|c| is_live(c.span, plan))
94        .map(|c| BaselineExample {
95            name: derive_example_name(&c.body),
96            line: c.span.start_line,
97        })
98        .collect()
99}
100
101/// The full baseline record for a spec: fingerprint plus live examples.
102pub fn derive_spec_baseline(source: &str, var_doc: &VarDoc, plan: &ExecutionPlan) -> SpecBaseline {
103    SpecBaseline {
104        source_hash: hash_source(source),
105        examples: live_examples(var_doc, plan),
106    }
107}
108
109/// Paragraphs the baseline recorded as examples that now match zero steps.
110pub fn detect_drift(
111    baseline: Option<&SpecBaseline>,
112    var_doc: &VarDoc,
113    plan: &ExecutionPlan,
114) -> Vec<Drifted> {
115    let Some(baseline) = baseline else {
116        return Vec::new();
117    };
118    let candidates = &var_doc.examples;
119    let n = candidates.len();
120    let tokens: Vec<HashSet<String>> = candidates
121        .iter()
122        .map(|c| tokenize(&derive_example_name(&c.body)))
123        .collect();
124    let live: Vec<bool> = candidates.iter().map(|c| is_live(c.span, plan)).collect();
125
126    let mut drifts = Vec::new();
127    for b in &baseline.examples {
128        let b_tokens = tokenize(&b.name);
129        let mut best_idx: Option<usize> = None;
130        let mut best_score = 0.0f64;
131        for i in 0..n {
132            let score = similarity(&b_tokens, &tokens[i]);
133            if score < SIMILARITY_THRESHOLD {
134                continue;
135            }
136            let line = candidates[i].span.start_line as isize;
137            let best_line = best_idx.map_or(0, |bi| candidates[bi].span.start_line as isize);
138            let b_line = b.line as isize;
139            if best_idx.is_none()
140                || score > best_score
141                || (score == best_score && (line - b_line).abs() < (best_line - b_line).abs())
142            {
143                best_idx = Some(i);
144                best_score = score;
145            }
146        }
147        if let Some(bi) = best_idx {
148            if !live[bi] {
149                let cand = &candidates[bi];
150                drifts.push(Drifted {
151                    name: b.name.clone(),
152                    line: cand.span.start_line,
153                    span: cand.span,
154                });
155            }
156        }
157    }
158    drifts
159}
160
161/// The human-readable message for a drift.
162pub fn message(drifted: &Drifted) -> String {
163    format!(
164        "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).",
165        drifted.name
166    )
167}
168
169/// One spec's baseline reconciliation against a [`BaselineStore`]. `update`
170/// accepts all drift; otherwise detect drift and rewrite the baseline only on a
171/// clean run.
172pub fn reconcile_drift(
173    store: &mut dyn BaselineStore,
174    spec_path: &str,
175    source: &str,
176    var_doc: &VarDoc,
177    plan: &ExecutionPlan,
178    update: bool,
179) -> Vec<Drifted> {
180    let lock = store.read().as_deref().and_then(parse_var_lock);
181    let drifts = if update {
182        Vec::new()
183    } else {
184        detect_drift(lock.as_ref().and_then(|l| l.specs.get(spec_path)), var_doc, plan)
185    };
186    if update || drifts.is_empty() {
187        let next = derive_spec_baseline(source, var_doc, plan);
188        let mut specs = lock.map_or_else(BTreeMap::new, |l| l.specs);
189        specs.insert(spec_path.to_string(), next);
190        store.write(&stringify_var_lock(&VarLock { version: 1, specs }));
191    }
192    drifts
193}
194
195/// Serializes `varar.lock.json` deterministically (fixed field order, sorted spec
196/// paths, two-space indent, trailing newline) — NOT [`crate::canonical_json`].
197pub fn stringify_var_lock(lock: &VarLock) -> String {
198    let mut sb = String::new();
199    sb.push_str("{\n  \"version\": 1,\n  \"specs\": ");
200    if lock.specs.is_empty() {
201        sb.push_str("{}");
202    } else {
203        sb.push_str("{\n");
204        let n = lock.specs.len();
205        // `BTreeMap` iterates spec paths in sorted order.
206        for (p, (path, baseline)) in lock.specs.iter().enumerate() {
207            sb.push_str("    ");
208            write_json_string(&mut sb, path);
209            sb.push_str(": {\n      \"sourceHash\": ");
210            write_json_string(&mut sb, &baseline.source_hash);
211            sb.push_str(",\n      \"examples\": ");
212            if baseline.examples.is_empty() {
213                sb.push_str("[]");
214            } else {
215                sb.push_str("[\n");
216                let en = baseline.examples.len();
217                for (e, ex) in baseline.examples.iter().enumerate() {
218                    sb.push_str("        {\n          \"name\": ");
219                    write_json_string(&mut sb, &ex.name);
220                    sb.push_str(",\n          \"line\": ");
221                    sb.push_str(&ex.line.to_string());
222                    sb.push_str("\n        }");
223                    if e + 1 < en {
224                        sb.push(',');
225                    }
226                    sb.push('\n');
227                }
228                sb.push_str("      ]");
229            }
230            sb.push_str("\n    }");
231            if p + 1 < n {
232                sb.push(',');
233            }
234            sb.push('\n');
235        }
236        sb.push_str("  }");
237    }
238    sb.push_str("\n}\n");
239    sb
240}
241
242fn write_json_string(sb: &mut String, s: &str) {
243    use std::fmt::Write;
244    sb.push('"');
245    for c in s.chars() {
246        match c {
247            '"' => sb.push_str("\\\""),
248            '\\' => sb.push_str("\\\\"),
249            '\n' => sb.push_str("\\n"),
250            '\r' => sb.push_str("\\r"),
251            '\t' => sb.push_str("\\t"),
252            '\u{0008}' => sb.push_str("\\b"),
253            '\u{000c}' => sb.push_str("\\f"),
254            c if (c as u32) < 0x20 => {
255                let _ = write!(sb, "\\u{:04x}", c as u32);
256            }
257            c => sb.push(c),
258        }
259    }
260    sb.push('"');
261}
262
263/// Parses `varar.lock.json`; `None` on malformed input (treated as no baseline).
264pub fn parse_var_lock(text: &str) -> Option<VarLock> {
265    let parsed = JsonReader::new(text).parse_whole()?;
266    let Value::Map(obj) = parsed else { return None };
267    if !matches!(obj.get("version"), Some(Value::Int(1))) {
268        return None;
269    }
270    let Some(Value::Map(specs_raw)) = obj.get("specs") else {
271        return None;
272    };
273    let mut specs = BTreeMap::new();
274    for (k, v) in specs_raw {
275        specs.insert(k.clone(), parse_spec_baseline(v)?);
276    }
277    Some(VarLock { version: 1, specs })
278}
279
280fn parse_spec_baseline(value: &Value) -> Option<SpecBaseline> {
281    let Value::Map(map) = value else { return None };
282    let Some(Value::String(source_hash)) = map.get("sourceHash") else {
283        return None;
284    };
285    let Some(Value::List(examples_raw)) = map.get("examples") else {
286        return None;
287    };
288    let mut examples = Vec::new();
289    for item in examples_raw {
290        let Value::Map(e) = item else { return None };
291        let Some(Value::String(name)) = e.get("name") else {
292            return None;
293        };
294        let Some(Value::Int(line)) = e.get("line") else {
295            return None;
296        };
297        examples.push(BaselineExample {
298            name: name.clone(),
299            line: *line as usize,
300        });
301    }
302    Some(SpecBaseline {
303        source_hash: source_hash.clone(),
304        examples,
305    })
306}
307
308/// A tiny recursive-descent JSON reader — enough for `varar.lock.json`, returning
309/// `None` on malformed input (Java's caught-exception → null).
310struct JsonReader {
311    chars: Vec<char>,
312    i: usize,
313}
314
315impl JsonReader {
316    fn new(text: &str) -> JsonReader {
317        JsonReader {
318            chars: text.chars().collect(),
319            i: 0,
320        }
321    }
322
323    fn parse_whole(&mut self) -> Option<Value> {
324        let v = self.value()?;
325        self.skip_ws();
326        if self.i != self.chars.len() {
327            return None;
328        }
329        Some(v)
330    }
331
332    fn value(&mut self) -> Option<Value> {
333        self.skip_ws();
334        match self.peek()? {
335            '{' => self.object(),
336            '[' => self.array(),
337            '"' => self.string().map(Value::String),
338            't' | 'f' => self.boolean(),
339            'n' => self.null(),
340            _ => self.number(),
341        }
342    }
343
344    fn object(&mut self) -> Option<Value> {
345        self.expect('{')?;
346        let mut map = BTreeMap::new();
347        self.skip_ws();
348        if self.peek()? == '}' {
349            self.i += 1;
350            return Some(Value::Map(map));
351        }
352        loop {
353            self.skip_ws();
354            let key = self.string()?;
355            self.skip_ws();
356            self.expect(':')?;
357            map.insert(key, self.value()?);
358            self.skip_ws();
359            match self.next()? {
360                '}' => return Some(Value::Map(map)),
361                ',' => {}
362                _ => return None,
363            }
364        }
365    }
366
367    fn array(&mut self) -> Option<Value> {
368        self.expect('[')?;
369        let mut list = Vec::new();
370        self.skip_ws();
371        if self.peek()? == ']' {
372            self.i += 1;
373            return Some(Value::List(list));
374        }
375        loop {
376            list.push(self.value()?);
377            self.skip_ws();
378            match self.next()? {
379                ']' => return Some(Value::List(list)),
380                ',' => {}
381                _ => return None,
382            }
383        }
384    }
385
386    fn string(&mut self) -> Option<String> {
387        self.expect('"')?;
388        let mut out = String::new();
389        loop {
390            match self.next()? {
391                '"' => return Some(out),
392                '\\' => match self.next()? {
393                    '"' => out.push('"'),
394                    '\\' => out.push('\\'),
395                    '/' => out.push('/'),
396                    'n' => out.push('\n'),
397                    'r' => out.push('\r'),
398                    't' => out.push('\t'),
399                    'b' => out.push('\u{0008}'),
400                    'f' => out.push('\u{000c}'),
401                    'u' => {
402                        let code = self.hex4()?;
403                        out.push(char::from_u32(code)?);
404                    }
405                    _ => return None,
406                },
407                c => out.push(c),
408            }
409        }
410    }
411
412    fn hex4(&mut self) -> Option<u32> {
413        if self.i + 4 > self.chars.len() {
414            return None;
415        }
416        let slice: String = self.chars[self.i..self.i + 4].iter().collect();
417        self.i += 4;
418        u32::from_str_radix(&slice, 16).ok()
419    }
420
421    fn number(&mut self) -> Option<Value> {
422        let start = self.i;
423        while self.i < self.chars.len() && "-+.eE0123456789".contains(self.chars[self.i]) {
424            self.i += 1;
425        }
426        if self.i == start {
427            return None;
428        }
429        let num: String = self.chars[start..self.i].iter().collect();
430        if num.contains(['.', 'e', 'E']) {
431            num.parse::<f64>().ok().map(Value::Float)
432        } else {
433            num.parse::<i64>().ok().map(Value::Int)
434        }
435    }
436
437    fn boolean(&mut self) -> Option<Value> {
438        if self.starts_with("true") {
439            self.i += 4;
440            Some(Value::Bool(true))
441        } else if self.starts_with("false") {
442            self.i += 5;
443            Some(Value::Bool(false))
444        } else {
445            None
446        }
447    }
448
449    fn null(&mut self) -> Option<Value> {
450        if self.starts_with("null") {
451            self.i += 4;
452            Some(Value::Null)
453        } else {
454            None
455        }
456    }
457
458    fn starts_with(&self, lit: &str) -> bool {
459        let lit: Vec<char> = lit.chars().collect();
460        self.i + lit.len() <= self.chars.len() && self.chars[self.i..self.i + lit.len()] == lit[..]
461    }
462
463    fn skip_ws(&mut self) {
464        while self.i < self.chars.len() && matches!(self.chars[self.i], ' ' | '\n' | '\r' | '\t') {
465            self.i += 1;
466        }
467    }
468
469    fn peek(&self) -> Option<char> {
470        self.chars.get(self.i).copied()
471    }
472
473    fn next(&mut self) -> Option<char> {
474        let c = self.chars.get(self.i).copied()?;
475        self.i += 1;
476        Some(c)
477    }
478
479    fn expect(&mut self, c: char) -> Option<()> {
480        if self.next()? == c { Some(()) } else { None }
481    }
482}