1use 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
16pub const SIMILARITY_THRESHOLD: f64 = 0.5;
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct BaselineExample {
22 pub name: String,
23 pub line: usize,
24}
25
26#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct OathBaseline {
29 pub source_hash: String,
30 pub examples: Vec<BaselineExample>,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct LockFile {
36 pub version: u32,
37 pub oaths: BTreeMap<String, OathBaseline>,
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct Drifted {
43 pub name: String,
44 pub line: usize,
45 pub span: Span,
46}
47
48pub trait BaselineStore {
51 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
59fn overlaps(a: Span, b: Span) -> bool {
64 a.start_offset < b.end_offset && b.start_offset < a.end_offset
65}
66
67fn 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
97pub 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
109pub 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
117pub 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
169pub 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
177pub 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
203pub 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
218pub 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
250pub 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 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
318pub 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}