1use crate::compiler::diagnostic::{CompileDiagnostic, Suggestion};
8use crate::edit::Edit;
9use cargo_metadata::diagnostic::Applicability;
10use std::path::Path;
11use thiserror::Error;
12
13#[derive(Error, Debug)]
14pub enum AutofixError {
15 #[error("Cannot auto-fix: {0}")]
16 CannotFix(String),
17
18 #[error("Edit error: {0}")]
19 EditError(#[from] crate::edit::EditError),
20
21 #[error("IO error: {0}")]
22 Io(#[from] std::io::Error),
23
24 #[error("Parse error: {0}")]
25 ParseError(String),
26}
27
28#[derive(Debug)]
30pub enum AutofixResult {
31 Fixed(Vec<Edit>),
33 CannotFix { reason: String },
35}
36
37#[must_use]
41pub fn try_autofix(diag: &CompileDiagnostic, workspace: &Path) -> AutofixResult {
42 let machine_fixes = diag.machine_applicable_suggestions();
44 if !machine_fixes.is_empty() {
45 let edits = machine_fixes
46 .iter()
47 .filter_map(|s| suggestion_to_edit(s, workspace))
48 .collect::<Vec<_>>();
49
50 if !edits.is_empty() {
51 return AutofixResult::Fixed(edits);
52 }
53 }
54
55 match diag.code.as_deref() {
57 Some("E0063") => fix_missing_field(diag, workspace),
58 Some("E0433") => fix_unresolved_module(diag, workspace),
59 _ => AutofixResult::CannotFix {
60 reason: format!("No auto-fix strategy for error code {:?}", diag.code),
61 },
62 }
63}
64
65fn suggestion_to_edit(suggestion: &Suggestion, workspace: &Path) -> Option<Edit> {
67 if !suggestion.file.starts_with(workspace) {
69 return None;
70 }
71
72 let content = std::fs::read_to_string(&suggestion.file).ok()?;
74
75 if suggestion.byte_end > content.len() {
77 return None;
78 }
79
80 let expected = &content[suggestion.byte_start..suggestion.byte_end];
81
82 Some(Edit::new(
83 suggestion.file.clone(),
84 suggestion.byte_start,
85 suggestion.byte_end,
86 suggestion.replacement.clone(),
87 expected,
88 ))
89}
90
91fn fix_missing_field(diag: &CompileDiagnostic, workspace: &Path) -> AutofixResult {
96 let Some((field_name, _struct_name)) = parse_missing_field_message(&diag.message) else {
98 return AutofixResult::CannotFix {
99 reason: format!("Could not parse E0063 message: {}", diag.message),
100 };
101 };
102
103 let Some(span) = diag.spans.first() else {
105 return AutofixResult::CannotFix {
106 reason: "No source span in diagnostic".to_string(),
107 };
108 };
109
110 if span.is_macro_expansion {
112 return AutofixResult::CannotFix {
113 reason: "Cannot auto-fix inside macro expansion".to_string(),
114 };
115 }
116
117 let content = match std::fs::read_to_string(&span.file) {
119 Ok(c) => c,
120 Err(e) => {
121 return AutofixResult::CannotFix {
122 reason: format!("Cannot read file: {}", e),
123 };
124 }
125 };
126
127 let Some(insert_info) =
130 find_struct_initializer_insert_point(&content, span.byte_start, span.byte_end)
131 else {
132 return AutofixResult::CannotFix {
133 reason: "Cannot find struct initializer closing brace".to_string(),
134 };
135 };
136
137 let default_value = infer_default_value(&field_name);
139
140 let field_init = if insert_info.needs_comma_before {
142 format!(
143 ",\n{}{}: {}",
144 insert_info.field_indent, field_name, default_value
145 )
146 } else if insert_info.is_empty_struct {
147 format!(
148 "\n{}{}: {},\n{}",
149 insert_info.field_indent, field_name, default_value, insert_info.closing_brace_indent
150 )
151 } else {
152 format!(
153 "\n{}{}: {},",
154 insert_info.field_indent, field_name, default_value
155 )
156 };
157
158 let expected = &content[insert_info.insert_at..insert_info.insert_at];
160
161 let edit = Edit::new(
162 span.file.clone(),
163 insert_info.insert_at,
164 insert_info.insert_at,
165 field_init,
166 expected,
167 );
168
169 if !span.file.starts_with(workspace) {
171 return AutofixResult::CannotFix {
172 reason: "File is outside workspace".to_string(),
173 };
174 }
175
176 AutofixResult::Fixed(vec![edit])
177}
178
179fn fix_unresolved_module(diag: &CompileDiagnostic, workspace: &Path) -> AutofixResult {
184 let mut candidates: Vec<&Suggestion> = diag
185 .suggestions
186 .iter()
187 .filter(|suggestion| {
188 suggestion.file.starts_with(workspace)
189 && matches!(
190 suggestion.applicability,
191 Applicability::MachineApplicable | Applicability::MaybeIncorrect
192 )
193 && is_safe_path_replacement(&suggestion.replacement)
194 })
195 .collect();
196
197 if candidates.is_empty() {
198 return AutofixResult::CannotFix {
199 reason: "No safe suggestion candidates for E0433".to_string(),
200 };
201 }
202
203 let has_machine_applicable = candidates
205 .iter()
206 .any(|s| s.applicability == Applicability::MachineApplicable);
207 if has_machine_applicable {
208 candidates.retain(|s| s.applicability == Applicability::MachineApplicable);
209 }
210
211 let mut edits = Vec::new();
212 for suggestion in candidates {
213 if let Some(edit) = suggestion_to_edit(suggestion, workspace) {
214 if edits.iter().any(|existing: &Edit| {
215 existing.file == edit.file
216 && existing.byte_start == edit.byte_start
217 && existing.byte_end == edit.byte_end
218 && existing.new_text == edit.new_text
219 }) {
220 continue;
221 }
222 edits.push(edit);
223 }
224 }
225
226 if edits.len() != 1 {
227 return AutofixResult::CannotFix {
228 reason: format!("Ambiguous E0433 suggestions: {} candidates", edits.len()),
229 };
230 }
231
232 AutofixResult::Fixed(edits)
233}
234
235fn is_safe_path_replacement(replacement: &str) -> bool {
236 let replacement = replacement.trim();
237 !replacement.is_empty()
238 && replacement
239 .chars()
240 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
241}
242
243fn parse_missing_field_message(message: &str) -> Option<(String, String)> {
247 let field_start = message.find("missing field `")? + "missing field `".len();
249 let field_end = message[field_start..].find('`')? + field_start;
250 let field_name = message[field_start..field_end].to_string();
251
252 let struct_start = message.find("in initializer of `")? + "in initializer of `".len();
253 let struct_end = message[struct_start..].find('`')? + struct_start;
254 let struct_name = message[struct_start..struct_end].to_string();
255
256 Some((field_name, struct_name))
257}
258
259#[derive(Debug)]
261struct InsertPoint {
262 insert_at: usize,
264 needs_comma_before: bool,
266 is_empty_struct: bool,
268 field_indent: String,
270 closing_brace_indent: String,
272}
273
274fn find_struct_initializer_insert_point(
278 content: &str,
279 span_start: usize,
280 span_end: usize,
281) -> Option<InsertPoint> {
282 let search_start = span_start.saturating_sub(50); let search_end = (span_end + 500).min(content.len()); let search_region = &content[search_start..search_end];
288
289 let mut brace_depth = 0;
291 let mut in_string = false;
292 let mut escape_next = false;
293 let mut last_closing_brace = None;
294 let mut first_opening_brace = None;
295
296 for (i, c) in search_region.char_indices() {
297 if escape_next {
298 escape_next = false;
299 continue;
300 }
301
302 match c {
303 '\\' if in_string => escape_next = true,
304 '"' => in_string = !in_string,
305 '{' if !in_string => {
306 if first_opening_brace.is_none() {
307 first_opening_brace = Some(search_start + i);
308 }
309 brace_depth += 1;
310 }
311 '}' if !in_string => {
312 brace_depth -= 1;
313 if brace_depth == 0 {
314 last_closing_brace = Some(search_start + i);
315 break;
316 }
317 }
318 _ => {}
319 }
320 }
321
322 let closing_brace = last_closing_brace?;
323 let opening_brace = first_opening_brace?;
324
325 let between_braces = &content[opening_brace + 1..closing_brace];
327 let is_empty = between_braces.trim().is_empty();
328
329 let before_brace = &content[opening_brace + 1..closing_brace];
331 let last_content_char = before_brace.trim_end().chars().last();
332
333 let needs_comma = !is_empty && last_content_char != Some(',');
335
336 let (field_indent, closing_brace_indent) =
338 detect_indentation(content, opening_brace, closing_brace);
339
340 Some(InsertPoint {
341 insert_at: closing_brace,
342 needs_comma_before: needs_comma,
343 is_empty_struct: is_empty,
344 field_indent,
345 closing_brace_indent,
346 })
347}
348
349fn detect_indentation(
353 content: &str,
354 opening_brace: usize,
355 closing_brace: usize,
356) -> (String, String) {
357 let closing_brace_indent = get_line_indent(content, closing_brace);
359
360 let between_braces = &content[opening_brace + 1..closing_brace];
362
363 for line in between_braces.lines() {
365 if line.contains(':') && !line.trim().is_empty() {
366 let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect();
368 if !indent.is_empty() {
369 return (indent, closing_brace_indent);
370 }
371 }
372 }
373
374 let field_indent = format!("{} ", closing_brace_indent);
376 (field_indent, closing_brace_indent)
377}
378
379fn get_line_indent(content: &str, byte_offset: usize) -> String {
381 let line_start = content[..byte_offset]
383 .rfind('\n')
384 .map(|i| i + 1)
385 .unwrap_or(0);
386
387 content[line_start..]
389 .chars()
390 .take_while(|c| *c == ' ' || *c == '\t')
391 .collect()
392}
393
394fn infer_default_value(field_name: &str) -> &'static str {
399 if field_name.ends_with("_level")
401 || field_name.ends_with("_limit")
402 || field_name.ends_with("_timeout")
403 || field_name.ends_with("_override")
404 || field_name.ends_with("_config")
405 || field_name.ends_with("_policy")
406 || field_name.starts_with("optional_")
407 || field_name.starts_with("maybe_")
408 || field_name.contains("sandbox")
409 {
410 return "None";
411 }
412
413 if field_name.starts_with("is_")
415 || field_name.starts_with("has_")
416 || field_name.starts_with("can_")
417 || field_name.starts_with("should_")
418 || field_name.starts_with("enable")
419 || field_name.starts_with("disable")
420 || field_name.ends_with("_enabled")
421 || field_name.ends_with("_disabled")
422 || field_name.ends_with("_allowed")
423 {
424 return "false";
425 }
426
427 if field_name.ends_with("s") && !field_name.ends_with("ss") {
429 return "Vec::new()";
431 }
432
433 if field_name.ends_with("_count")
435 || field_name.ends_with("_size")
436 || field_name.ends_with("_index")
437 {
438 return "0";
439 }
440
441 if field_name.ends_with("_name")
443 || field_name.ends_with("_path")
444 || field_name.ends_with("_url")
445 || field_name.ends_with("_message")
446 {
447 return "String::new()";
448 }
449
450 "None"
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use cargo_metadata::diagnostic::{Applicability, DiagnosticLevel};
459 use std::path::PathBuf;
460 use tempfile::TempDir;
461
462 #[test]
463 fn test_parse_missing_field_message() {
464 let msg = "missing field `windows_sandbox_level` in initializer of `SandboxPolicy`";
465 let (field, struct_name) = parse_missing_field_message(msg).unwrap();
466 assert_eq!(field, "windows_sandbox_level");
467 assert_eq!(struct_name, "SandboxPolicy");
468 }
469
470 #[test]
471 fn test_parse_missing_field_message_complex() {
472 let msg = "missing field `foo_bar` in initializer of `some::module::MyStruct`";
473 let (field, struct_name) = parse_missing_field_message(msg).unwrap();
474 assert_eq!(field, "foo_bar");
475 assert_eq!(struct_name, "some::module::MyStruct");
476 }
477
478 #[test]
479 fn test_infer_default_value() {
480 assert_eq!(infer_default_value("windows_sandbox_level"), "None");
481 assert_eq!(infer_default_value("is_enabled"), "false");
482 assert_eq!(infer_default_value("items"), "Vec::new()");
483 assert_eq!(infer_default_value("retry_count"), "0");
484 assert_eq!(infer_default_value("file_name"), "String::new()");
485 assert_eq!(infer_default_value("unknown_field"), "None");
486 }
487
488 #[test]
489 fn test_find_insert_point_simple() {
490 let content = r#"let x = MyStruct {
491 field1: 1,
492 field2: 2,
493 };"#;
494
495 let insert = find_struct_initializer_insert_point(content, 8, 60).unwrap();
496 assert!(!insert.is_empty_struct);
497 assert!(!insert.needs_comma_before); assert_eq!(insert.field_indent, " "); assert_eq!(insert.closing_brace_indent, " "); }
501
502 #[test]
503 fn test_find_insert_point_no_trailing_comma() {
504 let content = r#"let x = MyStruct {
505 field1: 1,
506 field2: 2
507 };"#;
508
509 let insert = find_struct_initializer_insert_point(content, 8, 60).unwrap();
510 assert!(!insert.is_empty_struct);
511 assert!(insert.needs_comma_before); assert_eq!(insert.field_indent, " "); }
514
515 #[test]
516 fn test_find_insert_point_empty() {
517 let content = "let x = MyStruct { };";
518 let insert = find_struct_initializer_insert_point(content, 8, 20).unwrap();
519 assert!(insert.is_empty_struct);
520 }
521
522 #[test]
523 fn test_detect_indentation_tabs() {
524 let content = "let x = MyStruct {\n\t\tfield1: 1,\n\t}";
525 let insert = find_struct_initializer_insert_point(content, 8, 35).unwrap();
526 assert_eq!(insert.field_indent, "\t\t"); assert_eq!(insert.closing_brace_indent, "\t"); }
529
530 #[test]
531 fn test_detect_indentation_real_world() {
532 let content = r#" self.app_event_tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
534 sandbox_policy: SandboxPolicy::new_read_only_policy(),
535 model: self.model.clone(),
536 }));"#;
537
538 let insert = find_struct_initializer_insert_point(content, 50, 180).unwrap();
539 assert_eq!(insert.field_indent, " "); assert_eq!(insert.closing_brace_indent, " "); }
542
543 #[test]
544 fn test_fix_e0433_with_single_safe_suggestion() {
545 let workspace = TempDir::new().unwrap();
546 let file = workspace.path().join("mod.rs");
547 let source = "use codex_common::approval_presets::builtin_approval_presets;\n";
548 std::fs::write(&file, source).unwrap();
549
550 let needle = "codex_common";
551 let byte_start = source.find(needle).unwrap();
552 let byte_end = byte_start + needle.len();
553
554 let diag = CompileDiagnostic {
555 code: Some("E0433".to_string()),
556 message: "failed to resolve: use of unresolved module or unlinked crate `codex_common`"
557 .to_string(),
558 level: DiagnosticLevel::Error,
559 spans: vec![],
560 suggestions: vec![Suggestion {
561 file: PathBuf::from(&file),
562 byte_start,
563 byte_end,
564 replacement: "codex_utils_approval_presets".to_string(),
565 applicability: Applicability::MaybeIncorrect,
566 message: "there is a crate with a similar name".to_string(),
567 }],
568 rendered: None,
569 };
570
571 let result = try_autofix(&diag, workspace.path());
572 match result {
573 AutofixResult::Fixed(edits) => {
574 assert_eq!(edits.len(), 1);
575 assert_eq!(edits[0].byte_start, byte_start);
576 assert_eq!(edits[0].byte_end, byte_end);
577 assert_eq!(edits[0].new_text, "codex_utils_approval_presets");
578 }
579 AutofixResult::CannotFix { reason } => panic!("expected fix, got: {reason}"),
580 }
581 }
582
583 #[test]
584 fn test_fix_e0433_rejects_ambiguous_suggestions() {
585 let workspace = TempDir::new().unwrap();
586 let file = workspace.path().join("mod.rs");
587 let source = "use codex_common::foo;\nuse codex_common::bar;\n";
588 std::fs::write(&file, source).unwrap();
589
590 let first = source.find("codex_common").unwrap();
591 let second = source[first + 1..].find("codex_common").unwrap() + first + 1;
592 let end = first + "codex_common".len();
593 let second_end = second + "codex_common".len();
594
595 let diag = CompileDiagnostic {
596 code: Some("E0433".to_string()),
597 message: "failed to resolve".to_string(),
598 level: DiagnosticLevel::Error,
599 spans: vec![],
600 suggestions: vec![
601 Suggestion {
602 file: PathBuf::from(&file),
603 byte_start: first,
604 byte_end: end,
605 replacement: "codex_core".to_string(),
606 applicability: Applicability::MaybeIncorrect,
607 message: "hint".to_string(),
608 },
609 Suggestion {
610 file: PathBuf::from(&file),
611 byte_start: second,
612 byte_end: second_end,
613 replacement: "codex_utils".to_string(),
614 applicability: Applicability::MaybeIncorrect,
615 message: "hint".to_string(),
616 },
617 ],
618 rendered: None,
619 };
620
621 let result = try_autofix(&diag, workspace.path());
622 match result {
623 AutofixResult::CannotFix { reason } => {
624 assert!(reason.contains("Ambiguous E0433 suggestions"));
625 }
626 AutofixResult::Fixed(_) => panic!("expected ambiguous result to be rejected"),
627 }
628 }
629}