Skip to main content

spec_drift/
auto_fix.rs

1//! Auto-fix suggestions for deterministic drift rules.
2//!
3//! Some rules are mechanically fixable:
4//! - `symbol_absence`: doc references a renamed/missing symbol → suggest the new name
5//! - `ghost_command`: CI references a deleted crate/binary → suggest removal
6//! - `compile_failure`: example doesn't compile → surface the rustc diagnostic
7//!
8//! This module produces `AutoFix` structs that either auto-apply
9//! (with `--fix`) or are surfaced as suggestions in the report.
10
11use crate::domain::{Divergence, RuleId};
12use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
13use std::path::Path;
14
15/// A suggested fix for a divergence.
16#[derive(Debug, Clone)]
17pub struct AutoFix {
18    /// File to modify
19    pub file: std::path::PathBuf,
20    /// Line number where the fix applies
21    pub line: u32,
22    /// Description of what to change
23    pub description: String,
24    /// The old text to replace (if a simple substitution)
25    pub old_text: Option<String>,
26    /// The replacement text
27    pub new_text: Option<String>,
28    /// Whether this fix can be auto-applied safely
29    pub auto_applicable: bool,
30}
31
32/// Try to generate an auto-fix for a divergence.
33///
34/// Only deterministic rules with mechanical fixes are supported.
35pub fn suggest_fix(divergence: &Divergence, workspace_root: &Path) -> Option<AutoFix> {
36    match divergence.rule {
37        RuleId::SymbolAbsence => suggest_symbol_fix(divergence, workspace_root),
38        RuleId::GhostCommand => suggest_ghost_command_fix(divergence),
39        RuleId::CompileFailure => suggest_compile_fix(divergence),
40        _ => None,
41    }
42}
43
44fn suggest_symbol_fix(divergence: &Divergence, workspace_root: &Path) -> Option<AutoFix> {
45    // The `stated` field renders the old name claimed in docs, e.g.
46    // "`Client::new` exists in the codebase".
47    // The `reality` field explains it doesn't exist.
48    // Try to find a similar name in the codebase.
49    let old_name = extract_stated_symbol(&divergence.stated)?;
50    if old_name.is_empty() {
51        return None;
52    }
53
54    // Search for similar symbols (simple fuzzy match)
55    let candidates = find_similar_symbols(workspace_root, &old_name);
56    let replacement = candidates.first()?;
57
58    Some(AutoFix {
59        file: divergence.location.file.clone(),
60        line: divergence.location.line,
61        description: format!(
62            "Replace `{}` with `{}` in documentation",
63            old_name, replacement
64        ),
65        old_text: Some(old_name),
66        new_text: Some(replacement.clone()),
67        auto_applicable: candidates.len() == 1,
68    })
69}
70
71fn extract_stated_symbol(stated: &str) -> Option<String> {
72    let raw = stated
73        .split_once('`')
74        .and_then(|(_, rest)| rest.split_once('`').map(|(symbol, _)| symbol))
75        .unwrap_or(stated)
76        .trim();
77    let symbol = raw.strip_suffix("()").unwrap_or(raw).trim();
78    (!symbol.is_empty()).then(|| symbol.to_string())
79}
80
81fn suggest_ghost_command_fix(divergence: &Divergence) -> Option<AutoFix> {
82    // stated: "cargo --package old-crate" or "cargo --bin old-bin"
83    // reality: "package/bin not found in workspace"
84    let stated = &divergence.stated;
85
86    // Extract the package/bin name from the command
87    let is_package = stated.contains("--package");
88    let prefix = if is_package { "--package" } else { "--bin" };
89
90    // Find what comes after --package or --bin
91    let cmd = stated
92        .split_whitespace()
93        .skip_while(|w| *w != prefix)
94        .nth(1)?;
95
96    Some(AutoFix {
97        file: divergence.location.file.clone(),
98        line: divergence.location.line,
99        description: format!(
100            "Remove reference to {prefix} `{cmd}` which no longer exists in the workspace"
101        ),
102        old_text: Some(format!("{prefix} {cmd}")),
103        new_text: None, // Can't auto-determine replacement
104        auto_applicable: false,
105    })
106}
107
108fn suggest_compile_fix(divergence: &Divergence) -> Option<AutoFix> {
109    // Reality contains the rustc error message
110    let reality = &divergence.reality;
111    if reality.is_empty() {
112        return None;
113    }
114
115    Some(AutoFix {
116        file: divergence.location.file.clone(),
117        line: divergence.location.line,
118        description: format!("Fix compilation error: {}", reality),
119        old_text: None,
120        new_text: None,
121        auto_applicable: false,
122    })
123}
124
125/// Find Rust symbols in the workspace that are similar to `name`.
126fn find_similar_symbols(root: &Path, name: &str) -> Vec<String> {
127    use std::collections::BTreeSet;
128    let mut candidates = BTreeSet::new();
129    let lower = name.to_lowercase();
130
131    // Walk workspace .rs files looking for similar function/struct/enum names
132    if let Ok(entries) = std::fs::read_dir(root.join("src")) {
133        for entry in entries.flatten() {
134            let path = entry.path();
135            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
136                continue;
137            }
138            if let Ok(contents) = std::fs::read_to_string(&path) {
139                for token in tokenize_rust(&contents) {
140                    if token == name {
141                        continue; // Skip exact match (it's the old name)
142                    }
143                    if similarity(&token.to_lowercase(), &lower) > 0.6 {
144                        candidates.insert(token);
145                    }
146                }
147            }
148        }
149    }
150
151    candidates.into_iter().collect()
152}
153
154/// Simple tokenizer for Rust identifiers.
155fn tokenize_rust(src: &str) -> Vec<String> {
156    let mut tokens = Vec::new();
157    let mut current = String::new();
158    for ch in src.chars() {
159        if ch.is_alphanumeric() || ch == '_' {
160            current.push(ch);
161        } else {
162            if !current.is_empty()
163                && current
164                    .chars()
165                    .next()
166                    .is_some_and(|c| c.is_alphabetic() || c == '_')
167            {
168                tokens.push(current.clone());
169            }
170            current.clear();
171        }
172    }
173    if !current.is_empty()
174        && current
175            .chars()
176            .next()
177            .is_some_and(|c| c.is_alphabetic() || c == '_')
178    {
179        tokens.push(current);
180    }
181    tokens
182}
183
184/// Identifier similarity after normalizing separators.
185fn similarity(a: &str, b: &str) -> f64 {
186    let a = normalize_identifier(a);
187    let b = normalize_identifier(b);
188    let max_len = a.chars().count().max(b.chars().count());
189    if max_len == 0 {
190        return 0.0;
191    }
192    if a == b {
193        return 1.0;
194    }
195    let distance = levenshtein(&a, &b);
196    let edit_score = 1.0 - (distance as f64 / max_len as f64);
197    let common_prefix = a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count();
198    let prefix_score = common_prefix as f64 / max_len as f64;
199    edit_score * prefix_score
200}
201
202fn normalize_identifier(s: &str) -> String {
203    s.chars()
204        .filter(|c| c.is_alphanumeric())
205        .flat_map(char::to_lowercase)
206        .collect()
207}
208
209fn levenshtein(a: &str, b: &str) -> usize {
210    let a: Vec<char> = a.chars().collect();
211    let b: Vec<char> = b.chars().collect();
212    let mut prev: Vec<usize> = (0..=b.len()).collect();
213    let mut curr = vec![0; b.len() + 1];
214
215    for (i, ca) in a.iter().enumerate() {
216        curr[0] = i + 1;
217        for (j, cb) in b.iter().enumerate() {
218            let cost = usize::from(ca != cb);
219            curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
220        }
221        std::mem::swap(&mut prev, &mut curr);
222    }
223
224    prev[b.len()]
225}
226
227/// Auto-fix runner: apply all auto-applicable fixes.
228///
229/// Returns the number of fixes applied. Prints changes to stderr.
230pub fn apply_fixes(divergences: &[Divergence], workspace_root: &Path) -> u32 {
231    let mut applied = 0u32;
232    for d in divergences {
233        if let Some(fix) = suggest_fix(d, workspace_root) {
234            if !fix.auto_applicable {
235                continue;
236            }
237            if let (Some(old), Some(new)) = (&fix.old_text, &fix.new_text)
238                && apply_text_fix(workspace_root, &fix.file, fix.line, old, new)
239            {
240                eprintln!(
241                    "  Fixed: {} (line {}) — {}",
242                    fix.file.display(),
243                    fix.line,
244                    fix.description
245                );
246                applied += 1;
247            }
248        }
249    }
250    applied
251}
252
253fn apply_text_fix(workspace_root: &Path, file: &Path, line: u32, old: &str, new: &str) -> bool {
254    let path = resolve_fix_path(workspace_root, file);
255    let Ok(contents) = std::fs::read_to_string(&path) else {
256        return false;
257    };
258    let mut lines: Vec<String> = contents.lines().map(str::to_string).collect();
259    let idx = (line as usize).saturating_sub(1);
260    if idx >= lines.len() {
261        return false;
262    }
263    let Some(pos) = lines[idx].find(old) else {
264        return false;
265    };
266    lines[idx].replace_range(pos..pos + old.len(), new);
267    let mut output = lines.join("\n");
268    if contents.ends_with('\n') {
269        output.push('\n');
270    }
271    std::fs::write(path, output).is_ok()
272}
273
274fn resolve_fix_path(workspace_root: &Path, file: &Path) -> std::path::PathBuf {
275    if file.is_absolute() {
276        file.to_path_buf()
277    } else {
278        workspace_root.join(file)
279    }
280}
281
282pub fn slice_markdown_section(
283    file_path: &Path,
284    start_line: u32,
285) -> Option<(String, std::ops::Range<usize>)> {
286    let source = std::fs::read_to_string(file_path).ok()?;
287
288    let mut sections = Vec::new();
289    let mut current_line = 1;
290    let mut current_offset = 0;
291    let mut current_text = String::new();
292
293    let parser = Parser::new_ext(&source, Options::all()).into_offset_iter();
294    let mut in_heading = false;
295    let mut heading_level = 0u8;
296
297    for (event, range) in parser {
298        match event {
299            Event::Start(Tag::Heading { level, .. }) => {
300                let lvl = match level {
301                    pulldown_cmark::HeadingLevel::H2 => 2,
302                    pulldown_cmark::HeadingLevel::H3 => 3,
303                    _ => 0,
304                };
305                if lvl == 2 || lvl == 3 {
306                    if !current_text.trim().is_empty() {
307                        sections.push((current_line, current_offset..range.start));
308                        current_line = line_of_offset(&source, range.start);
309                        current_offset = range.start;
310                        current_text.clear();
311                    } else {
312                        current_line = line_of_offset(&source, range.start);
313                        current_offset = range.start;
314                    }
315                    in_heading = true;
316                    heading_level = lvl;
317                }
318            }
319            Event::End(TagEnd::Heading(_)) => {
320                in_heading = false;
321                heading_level = 0;
322            }
323            Event::Text(t) => {
324                if in_heading && (heading_level == 2 || heading_level == 3) {
325                    current_text.push_str(&t);
326                    current_text.push('\n');
327                } else if !in_heading {
328                    current_text.push_str(&t);
329                    current_text.push(' ');
330                }
331            }
332            Event::Code(t) => {
333                current_text.push('`');
334                current_text.push_str(&t);
335                current_text.push('`');
336                current_text.push(' ');
337            }
338            _ => {}
339        }
340    }
341    if !current_text.trim().is_empty() || current_offset < source.len() {
342        sections.push((current_line, current_offset..source.len()));
343    }
344
345    for (line, range) in sections {
346        if line == start_line {
347            let text = source[range.clone()].to_string();
348            return Some((text, range));
349        }
350    }
351
352    None
353}
354
355fn line_of_offset(src: &str, offset: usize) -> u32 {
356    let end = offset.min(src.len());
357    (src[..end].bytes().filter(|&b| b == b'\n').count() as u32) + 1
358}
359
360pub fn slice_example_narrative(file_path: &Path) -> Option<(String, std::ops::Range<usize>)> {
361    let src = std::fs::read_to_string(file_path).ok()?;
362    let mut start_offset = None;
363    let mut current_offset = 0;
364
365    let mut narrative_text = String::new();
366    let mut temp_empty_lines = String::new();
367    let mut last_comment_offset = 0;
368
369    for line in src.split_inclusive('\n') {
370        let trimmed = line.trim_start();
371        if let Some(rest) = trimmed.strip_prefix("//!") {
372            if start_offset.is_none() {
373                start_offset = Some(current_offset);
374            }
375            if !temp_empty_lines.is_empty() {
376                narrative_text.push_str(&temp_empty_lines);
377                temp_empty_lines.clear();
378            }
379            narrative_text.push_str(rest.trim());
380            narrative_text.push('\n');
381            last_comment_offset = current_offset + line.len();
382        } else if let Some(rest) = trimmed.strip_prefix("//") {
383            if start_offset.is_none() {
384                start_offset = Some(current_offset);
385            }
386            if !temp_empty_lines.is_empty() {
387                narrative_text.push_str(&temp_empty_lines);
388                temp_empty_lines.clear();
389            }
390            narrative_text.push_str(rest.trim());
391            narrative_text.push('\n');
392            last_comment_offset = current_offset + line.len();
393        } else if trimmed.is_empty() || trimmed == "\r" {
394            if start_offset.is_some() {
395                temp_empty_lines.push('\n');
396            }
397        } else {
398            break;
399        }
400        current_offset += line.len();
401    }
402
403    let trimmed_narrative = narrative_text.trim().to_string();
404    if trimmed_narrative.len() < 16 {
405        return None;
406    }
407
408    let start = start_offset?;
409    let end = last_comment_offset;
410    if start >= end {
411        return None;
412    }
413
414    Some((trimmed_narrative, start..end))
415}
416
417pub fn build_markdown_correction_prompt(
418    original_section: &str,
419    code_context: &str,
420    reality: &str,
421) -> (String, String) {
422    let system = "You are an expert technical writer and programmer. Your task is to update a specific Markdown section of documentation so that it accurately describes the current implementation in the source code.\n\
423                  You MUST return ONLY the updated Markdown section (including its heading). Do not include any explanation, conversational text, or markdown code block wrapper (like ```markdown ... ```).\n\
424                  Preserve the original formatting, headings, style, and tone as much as possible, only editing the parts that are outdated.".to_string();
425
426    let user = format!(
427        "Here is the original Markdown section:\n\
428         ---\n\
429         {}\n\
430         ---\n\n\
431         Here is the current implementation in the source code:\n\
432         ---\n\
433         {}\n\
434         ---\n\n\
435         According to our analysis, this documentation section has drifted from reality because:\n\
436         {}\n\n\
437         Please write the corrected Markdown section. Remember to return ONLY the raw corrected Markdown (including its heading, if it had one), with no conversational wrapper.",
438        original_section, code_context, reality
439    );
440    (system, user)
441}
442
443pub fn build_example_narrative_prompt(
444    original_narrative: &str,
445    public_signatures: &str,
446    reality: &str,
447) -> (String, String) {
448    let system = "You are an expert technical writer and programmer. Your task is to update the narrative comment block at the top of a Rust example file so that it accurately describes the current public API of the library.\n\
449                  You MUST return ONLY the updated narrative text. Do not include any comment prefixes (like //! or //), conversational text, explanations, or code blocks.\n\
450                  Preserve the original style and tone as much as possible, only editing the parts that are outdated.".to_string();
451
452    let user = format!(
453        "Here is the original example narrative (excluding comment prefixes):\n\
454         ---\n\
455         {}\n\
456         ---\n\n\
457         Here are the signatures of currently-public functions in the library:\n\
458         ---\n\
459         {}\n\
460         ---\n\n\
461         According to our analysis, this narrative has drifted from the public API because:\n\
462         {}\n\n\
463         Please write the corrected narrative text (without comment prefixes). Remember to return ONLY the raw corrected narrative, with no conversational wrapper.",
464        original_narrative, public_signatures, reality
465    );
466    (system, user)
467}
468
469pub fn format_as_comments(text: &str, prefix: &str) -> String {
470    let mut out = String::new();
471    for line in text.lines() {
472        if line.trim().is_empty() {
473            out.push_str(prefix);
474            out.push('\n');
475        } else {
476            out.push_str(prefix);
477            out.push(' ');
478            out.push_str(line);
479            out.push('\n');
480        }
481    }
482    out
483}
484
485pub fn collect_public_signatures(ctx: &crate::context::ProjectContext) -> String {
486    let mut names: Vec<String> = ctx
487        .code_facts
488        .iter()
489        .filter(|f| {
490            matches!(f.kind, crate::domain::FactKind::Function)
491                && !f
492                    .location
493                    .file
494                    .components()
495                    .any(|c| c.as_os_str() == "tests" || c.as_os_str() == "examples")
496        })
497        .map(|f| f.name.clone())
498        .collect();
499    names.sort();
500    names.dedup();
501    names.join("\n")
502}
503
504pub fn build_outdated_logic_context(
505    ctx: &crate::context::ProjectContext,
506    section_text: &str,
507) -> String {
508    let mentioned = mentioned_identifiers(section_text);
509    let bodies: Vec<(String, String)> = mentioned
510        .iter()
511        .filter_map(|name| fetch_fn_body(ctx, name).map(|b| (name.clone(), b)))
512        .collect();
513
514    let mut context = String::new();
515    for (name, body) in bodies {
516        context.push_str(&format!("### fn {name}\n```rust\n{body}\n```\n"));
517    }
518    context
519}
520
521fn mentioned_identifiers(text: &str) -> Vec<String> {
522    let mut out = Vec::new();
523    for part in text.split('`') {
524        let trimmed = part.trim().trim_end_matches("()");
525        if is_ident(trimmed) {
526            out.push(trimmed.to_string());
527        }
528        // Handle qualified paths: grab the leaf.
529        if let Some(leaf) = trimmed.rsplit("::").next()
530            && is_ident(leaf)
531            && leaf != trimmed
532        {
533            out.push(leaf.to_string());
534        }
535    }
536    out.sort();
537    out.dedup();
538    out
539}
540
541fn is_ident(s: &str) -> bool {
542    if s.is_empty() {
543        return false;
544    }
545    let mut chars = s.chars();
546    let first = chars.next().unwrap();
547    (first.is_ascii_alphabetic() || first == '_')
548        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
549}
550
551fn fetch_fn_body(ctx: &crate::context::ProjectContext, name: &str) -> Option<String> {
552    let fact = ctx
553        .facts_named(name)
554        .find(|f| matches!(f.kind, crate::domain::FactKind::Function))?;
555    let src = std::fs::read_to_string(&fact.location.file).ok()?;
556    const WINDOW: usize = 50;
557    let lines: Vec<&str> = src.lines().collect();
558    let start = fact.location.line.saturating_sub(1) as usize;
559    let end = (start + WINDOW).min(lines.len());
560    Some(lines[start..end].join("\n"))
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    #[test]
568    fn similarity_scores() {
569        assert!(similarity("connect_to_db", "connect_to_db") > 0.9);
570        assert!(similarity("init_connection", "connect_to_db") < 0.5);
571        assert!(similarity("login", "log_in") > 0.5);
572    }
573
574    #[test]
575    fn extracts_symbol_from_rendered_stated_text() {
576        assert_eq!(
577            extract_stated_symbol("`Client::new` exists in the codebase").as_deref(),
578            Some("Client::new")
579        );
580        assert_eq!(
581            extract_stated_symbol("`connect_to_db()` exists in the codebase").as_deref(),
582            Some("connect_to_db")
583        );
584    }
585
586    #[test]
587    fn apply_text_fix_resolves_relative_path_against_workspace_root() {
588        let tmp = tempfile::tempdir().unwrap();
589        let readme = tmp.path().join("README.md");
590        std::fs::write(&readme, "Use `old_name()`.\n").unwrap();
591
592        assert!(apply_text_fix(
593            tmp.path(),
594            Path::new("README.md"),
595            1,
596            "old_name",
597            "new_name",
598        ));
599
600        let out = std::fs::read_to_string(readme).unwrap();
601        assert_eq!(out, "Use `new_name()`.\n");
602    }
603
604    #[test]
605    fn tokenize_rust_extracts_identifiers() {
606        let tokens = tokenize_rust("pub fn connect_to_db() -> Result<()> { let x = 1; }");
607        assert!(tokens.contains(&"connect_to_db".to_string()));
608        assert!(tokens.contains(&"Result".to_string()));
609    }
610
611    #[test]
612    fn ghost_command_fix_extracts_package_name() {
613        let d = Divergence {
614            rule: RuleId::GhostCommand,
615            severity: crate::domain::Severity::Warning,
616            location: crate::domain::Location::new(".github/workflows/ci.yml", 10),
617            stated: "cargo test --package old-crate --all-features".into(),
618            reality: "package 'old-crate' not found in workspace".into(),
619            risk: "CI jobs may silently skip or fail".into(),
620            attribution: None,
621        };
622        let fix = suggest_ghost_command_fix(&d).unwrap();
623        assert!(fix.description.contains("old-crate"));
624    }
625
626    #[test]
627    fn test_slice_markdown_section() {
628        let tmp = tempfile::tempdir().unwrap();
629        let file_path = tmp.path().join("README.md");
630        let content = "\
631# Title
632Intro text is here.
633
634## Section 1
635This is the text for section 1.
636
637### Sub Section 2
638And text for section 2.
639";
640        std::fs::write(&file_path, content).unwrap();
641
642        // Slice Section 1 (starts on line 4, line counting of ## Section 1)
643        let slice1 = slice_markdown_section(&file_path, 4).unwrap();
644        assert!(slice1.0.contains("## Section 1"));
645        assert!(slice1.0.contains("This is the text for section 1."));
646        assert_eq!(
647            &content[slice1.1],
648            "## Section 1\nThis is the text for section 1.\n\n"
649        );
650
651        // Slice Sub Section 2 (starts on line 7)
652        let slice2 = slice_markdown_section(&file_path, 7).unwrap();
653        assert!(slice2.0.contains("### Sub Section 2"));
654        assert!(slice2.0.contains("And text for section 2."));
655        assert_eq!(
656            &content[slice2.1],
657            "### Sub Section 2\nAnd text for section 2.\n"
658        );
659    }
660
661    #[test]
662    fn test_slice_example_narrative() {
663        let tmp = tempfile::tempdir().unwrap();
664        let file_path = tmp.path().join("demo.rs");
665        let content = "\
666//! This example demonstrates a legacy connection.
667//! And this is the second line of the narrative.
668
669fn main() {}
670";
671        std::fs::write(&file_path, content).unwrap();
672
673        let slice = slice_example_narrative(&file_path).unwrap();
674        assert_eq!(
675            slice.0,
676            "This example demonstrates a legacy connection.\nAnd this is the second line of the narrative."
677        );
678        assert_eq!(
679            &content[slice.1],
680            "//! This example demonstrates a legacy connection.\n//! And this is the second line of the narrative.\n"
681        );
682    }
683}