1use crate::domain::{Divergence, RuleId};
12use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
13use std::path::Path;
14
15#[derive(Debug, Clone)]
17pub struct AutoFix {
18 pub file: std::path::PathBuf,
20 pub line: u32,
22 pub description: String,
24 pub old_text: Option<String>,
26 pub new_text: Option<String>,
28 pub auto_applicable: bool,
30}
31
32pub 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 let old_name = extract_stated_symbol(&divergence.stated)?;
50 if old_name.is_empty() {
51 return None;
52 }
53
54 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 let stated = &divergence.stated;
85
86 let is_package = stated.contains("--package");
88 let prefix = if is_package { "--package" } else { "--bin" };
89
90 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, auto_applicable: false,
105 })
106}
107
108fn suggest_compile_fix(divergence: &Divergence) -> Option<AutoFix> {
109 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
125fn 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 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; }
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
154fn 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
184fn 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
227pub 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 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 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 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}