1use std::io::Read;
26use std::path::PathBuf;
27
28use anyhow::{anyhow, Result};
29use clap::{Args, Subcommand};
30
31use tldr_core::fix;
32use tldr_core::Language;
33
34use crate::output::{OutputFormat, OutputWriter};
35
36#[derive(Debug, Args)]
48pub struct FixArgs {
49 #[command(subcommand)]
51 pub command: FixCommand,
52}
53
54#[derive(Debug, Subcommand)]
56pub enum FixCommand {
57 Diagnose(FixDiagnoseArgs),
59 Apply(FixApplyArgs),
61 Check(FixCheckArgs),
63}
64
65#[derive(Debug, Args)]
67pub struct FixCheckArgs {
68 #[arg(long, short = 'f')]
70 pub file: PathBuf,
71
72 #[arg(long, short = 't')]
74 pub test_cmd: String,
75
76 #[arg(long, default_value = "5")]
78 pub max_attempts: usize,
79}
80
81#[derive(Debug, Args)]
83pub struct FixDiagnoseArgs {
84 #[arg(long, short = 's')]
86 pub source: PathBuf,
87
88 #[arg(long, short = 'e', conflicts_with = "error_file")]
90 pub error: Option<String>,
91
92 #[arg(long, conflicts_with = "error")]
94 pub error_file: Option<PathBuf>,
95
96 #[arg(long)]
98 pub stdin: bool,
99
100 #[arg(long)]
102 pub api_surface: Option<PathBuf>,
103}
104
105#[derive(Debug, Args)]
107pub struct FixApplyArgs {
108 #[arg(long, short = 's')]
110 pub source: PathBuf,
111
112 #[arg(long, short = 'e', conflicts_with = "error_file")]
114 pub error: Option<String>,
115
116 #[arg(long, conflicts_with = "error")]
118 pub error_file: Option<PathBuf>,
119
120 #[arg(long, short = 'o')]
122 pub output: Option<PathBuf>,
123
124 #[arg(long)]
126 pub stdin: bool,
127
128 #[arg(long, short = 'i')]
130 pub in_place: bool,
131
132 #[arg(long, short = 'd')]
134 pub diff: bool,
135
136 #[arg(long)]
138 pub api_surface: Option<PathBuf>,
139}
140
141impl FixArgs {
142 pub fn run(&self, format: OutputFormat, _quiet: bool, lang: Option<Language>) -> Result<()> {
144 let lang_str = lang.as_ref().map(Language::as_str);
145 match &self.command {
146 FixCommand::Diagnose(args) => run_diagnose(args, format, lang_str),
147 FixCommand::Apply(args) => run_apply(args, format, lang_str),
148 FixCommand::Check(args) => run_check(args, format, lang_str),
149 }
150 }
151}
152
153fn read_error_text(
155 error: &Option<String>,
156 error_file: &Option<PathBuf>,
157 use_stdin: bool,
158) -> Result<String> {
159 if let Some(text) = error {
160 return Ok(text.clone());
161 }
162
163 if let Some(path) = error_file {
164 let text = std::fs::read_to_string(path)
165 .map_err(|e| anyhow!("Failed to read error file '{}': {}", path.display(), e))?;
166 return Ok(text);
167 }
168
169 if use_stdin || (error.is_none() && error_file.is_none()) {
170 let mut buf = String::new();
171 std::io::stdin()
172 .read_to_string(&mut buf)
173 .map_err(|e| anyhow!("Failed to read from stdin: {}", e))?;
174 if buf.is_empty() {
175 return Err(anyhow!(
176 "No error text provided. Use --error, --error-file, or pipe to stdin."
177 ));
178 }
179 return Ok(buf);
180 }
181
182 Err(anyhow!(
183 "No error text provided. Use --error, --error-file, --stdin, or pipe to stdin."
184 ))
185}
186
187fn compute_line_diff(old: &str, new: &str) -> String {
191 let old_lines: Vec<&str> = old.lines().collect();
192 let new_lines: Vec<&str> = new.lines().collect();
193
194 let mut output = String::new();
195
196 let mut oi = 0;
198 let mut ni = 0;
199 while oi < old_lines.len() || ni < new_lines.len() {
200 if oi < old_lines.len() && ni < new_lines.len() {
201 if old_lines[oi] == new_lines[ni] {
202 output.push_str(&format!(" {}\n", old_lines[oi]));
203 oi += 1;
204 ni += 1;
205 } else {
206 output.push_str(&format!("-{}\n", old_lines[oi]));
208 output.push_str(&format!("+{}\n", new_lines[ni]));
209 oi += 1;
210 ni += 1;
211 }
212 } else if oi < old_lines.len() {
213 output.push_str(&format!("-{}\n", old_lines[oi]));
214 oi += 1;
215 } else {
216 output.push_str(&format!("+{}\n", new_lines[ni]));
217 ni += 1;
218 }
219 }
220
221 output
222}
223
224fn run_diagnose(args: &FixDiagnoseArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
226 let error_text = read_error_text(&args.error, &args.error_file, args.stdin)?;
227
228 if let Some(surface_path) = &args.api_surface {
229 eprintln!(
230 "Note: API surface enrichment available from '{}'",
231 surface_path.display()
232 );
233 }
234
235 let source = std::fs::read_to_string(&args.source).map_err(|e| {
236 anyhow!(
237 "Failed to read source file '{}': {}",
238 args.source.display(),
239 e
240 )
241 })?;
242
243 let diagnosis = fix::diagnose(&error_text, &source, lang, None);
244
245 match diagnosis {
246 Some(diag) => {
247 let writer = OutputWriter::new(format, false);
248 writer.write(&diag)?;
249 Ok(())
250 }
251 None => Err(anyhow!(
252 "Could not parse or diagnose the error. The error format may not be supported yet."
253 )),
254 }
255}
256
257fn run_apply(args: &FixApplyArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
259 let error_text = read_error_text(&args.error, &args.error_file, args.stdin)?;
260
261 if let Some(surface_path) = &args.api_surface {
262 eprintln!(
263 "Note: API surface enrichment available from '{}'",
264 surface_path.display()
265 );
266 }
267
268 let source = std::fs::read_to_string(&args.source).map_err(|e| {
269 anyhow!(
270 "Failed to read source file '{}': {}",
271 args.source.display(),
272 e
273 )
274 })?;
275
276 let diagnosis = fix::diagnose(&error_text, &source, lang, None).ok_or_else(|| {
277 anyhow!("Could not parse or diagnose the error. The error format may not be supported.")
278 })?;
279
280 match &diagnosis.fix {
281 Some(fix_data) => {
282 let patched = fix::apply_fix(&source, fix_data);
283
284 if args.diff {
285 match format {
287 OutputFormat::Json | OutputFormat::Compact => {
288 let diff_text = compute_line_diff(&source, &patched);
289 let result = serde_json::json!({
290 "diagnosis": diagnosis,
291 "diff": diff_text,
292 });
293 let writer = OutputWriter::new(format, false);
294 writer.write(&result)?;
295 }
296 _ => {
297 let diff_text = compute_line_diff(&source, &patched);
298 print!("{}", diff_text);
299 }
300 }
301 Ok(())
302 } else if args.in_place {
303 std::fs::write(&args.source, &patched).map_err(|e| {
304 anyhow!(
305 "Failed to write patched source to '{}': {}",
306 args.source.display(),
307 e
308 )
309 })?;
310 eprintln!("Fixed: {}", diagnosis.message);
311 Ok(())
312 } else if let Some(output_path) = &args.output {
313 std::fs::write(output_path, &patched).map_err(|e| {
314 anyhow!(
315 "Failed to write patched source to '{}': {}",
316 output_path.display(),
317 e
318 )
319 })?;
320 eprintln!("Fixed: {}", diagnosis.message);
321 Ok(())
322 } else {
323 match format {
325 OutputFormat::Json | OutputFormat::Compact => {
326 let result = serde_json::json!({
327 "diagnosis": diagnosis,
328 "patched_source": patched,
329 });
330 let writer = OutputWriter::new(format, false);
331 writer.write(&result)?;
332 }
333 _ => {
334 print!("{}", patched);
336 }
337 }
338 Ok(())
339 }
340 }
341 None => {
342 eprintln!(
344 "No auto-fix available (confidence: {:?}). Diagnosis:",
345 diagnosis.confidence
346 );
347 let writer = OutputWriter::new(format, false);
348 writer.write(&diagnosis)?;
349 Err(anyhow!(
351 "No deterministic fix available for this error. Escalate to a model."
352 ))
353 }
354 }
355}
356
357fn run_check(args: &FixCheckArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
359 use fix::{run_check_loop, CheckConfig};
360
361 if !args.file.exists() {
362 return Err(anyhow!(
363 "Source file '{}' does not exist.",
364 args.file.display()
365 ));
366 }
367
368 let config = CheckConfig {
369 file: &args.file,
370 test_cmd: &args.test_cmd,
371 lang,
372 max_attempts: args.max_attempts,
373 };
374
375 let result = run_check_loop(&config);
376
377 let writer = OutputWriter::new(format, false);
379 writer.write(&result)?;
380
381 if result.final_pass {
382 eprintln!(
383 "All errors fixed in {} iteration{}.",
384 result.iterations,
385 if result.iterations == 1 { "" } else { "s" }
386 );
387 Ok(())
388 } else {
389 Err(anyhow!(
390 "Some errors could not be fixed after {} attempt{}.",
391 result.iterations,
392 if result.iterations == 1 { "" } else { "s" }
393 ))
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn test_read_error_text_inline() {
403 let text = read_error_text(
404 &Some("NameError: name 'x' is not defined".to_string()),
405 &None,
406 false,
407 )
408 .unwrap();
409 assert_eq!(text, "NameError: name 'x' is not defined");
410 }
411
412 #[test]
413 fn test_read_error_text_file() {
414 let dir = std::env::temp_dir().join("tldr_fix_test");
415 std::fs::create_dir_all(&dir).unwrap();
416 let err_file = dir.join("test_error.txt");
417 std::fs::write(&err_file, "KeyError: 'name'").unwrap();
418
419 let text = read_error_text(&None, &Some(err_file.clone()), false).unwrap();
420 assert_eq!(text, "KeyError: 'name'");
421
422 let _ = std::fs::remove_dir_all(&dir);
424 }
425
426 #[test]
427 fn test_read_error_text_missing_file() {
428 let result = read_error_text(
429 &None,
430 &Some(PathBuf::from("/nonexistent/path/error.txt")),
431 false,
432 );
433 assert!(result.is_err());
434 }
435
436 #[test]
439 fn test_fix_check_args_defaults() {
440 let args = FixCheckArgs {
442 file: PathBuf::from("app.py"),
443 test_cmd: "pytest tests/".to_string(),
444 max_attempts: 5,
445 };
446 assert_eq!(args.file, PathBuf::from("app.py"));
447 assert_eq!(args.test_cmd, "pytest tests/");
448 assert_eq!(args.max_attempts, 5);
449 }
450
451 #[test]
452 fn test_fix_check_args_with_max_attempts() {
453 let args = FixCheckArgs {
454 file: PathBuf::from("main.rs"),
455 test_cmd: "cargo test".to_string(),
456 max_attempts: 10,
457 };
458 assert_eq!(args.max_attempts, 10);
459 }
460
461 #[test]
462 fn test_fix_command_check_variant_exists() {
463 let args = FixCheckArgs {
465 file: PathBuf::from("app.py"),
466 test_cmd: "pytest".to_string(),
467 max_attempts: 5,
468 };
469 let cmd = FixCommand::Check(args);
470 let debug = format!("{:?}", cmd);
472 assert!(
473 debug.contains("Check"),
474 "FixCommand should have Check variant"
475 );
476 }
477
478 #[test]
479 fn test_run_check_missing_file() {
480 let args = FixCheckArgs {
481 file: PathBuf::from("/nonexistent/file.py"),
482 test_cmd: "true".to_string(),
483 max_attempts: 5,
484 };
485 let result = run_check(&args, OutputFormat::Json, None);
486 assert!(result.is_err(), "Should error on missing file");
487 let err_msg = result.unwrap_err().to_string();
488 assert!(
489 err_msg.contains("does not exist"),
490 "Error should mention missing file: {}",
491 err_msg
492 );
493 }
494
495 #[test]
496 fn test_run_check_succeeds_on_passing_test() {
497 let dir = tempfile::tempdir().expect("create temp dir");
498 let source_path = dir.path().join("app.py");
499 std::fs::write(&source_path, "x = 1\n").expect("write source");
500
501 let args = FixCheckArgs {
502 file: source_path,
503 test_cmd: "true".to_string(),
504 max_attempts: 5,
505 };
506 let result = run_check(&args, OutputFormat::Json, Some("python"));
507 assert!(
508 result.is_ok(),
509 "Should succeed when test passes: {:?}",
510 result
511 );
512 }
513
514 #[test]
517 fn test_fix_apply_args_has_diff_field() {
518 let args = FixApplyArgs {
519 source: PathBuf::from("app.py"),
520 error: Some("NameError: name 'x' is not defined".to_string()),
521 error_file: None,
522 output: None,
523 stdin: false,
524 in_place: false,
525 diff: true,
526 api_surface: None,
527 };
528 assert!(args.diff);
529 }
530
531 #[test]
532 fn test_run_apply_diff_flag() {
533 let dir = tempfile::tempdir().expect("create temp dir");
534 let source_path = dir.path().join("app.py");
535 std::fs::write(&source_path, "import os\nx = json.loads('{}')\n").expect("write source");
537
538 let args = FixApplyArgs {
539 source: source_path,
540 error: Some("NameError: name 'json' is not defined".to_string()),
541 error_file: None,
542 output: None,
543 stdin: false,
544 in_place: false,
545 diff: true,
546 api_surface: None,
547 };
548 let result = run_apply(&args, OutputFormat::Text, Some("python"));
550 assert!(
551 result.is_ok(),
552 "run_apply with --diff should succeed: {:?}",
553 result
554 );
555 }
556
557 #[test]
560 fn test_fix_diagnose_args_has_api_surface_field() {
561 let args = FixDiagnoseArgs {
562 source: PathBuf::from("app.py"),
563 error: Some("error".to_string()),
564 error_file: None,
565 stdin: false,
566 api_surface: Some(PathBuf::from("surface.json")),
567 };
568 assert_eq!(args.api_surface, Some(PathBuf::from("surface.json")));
569 }
570
571 #[test]
572 fn test_fix_apply_args_has_api_surface_field() {
573 let args = FixApplyArgs {
574 source: PathBuf::from("app.py"),
575 error: Some("error".to_string()),
576 error_file: None,
577 output: None,
578 stdin: false,
579 in_place: false,
580 diff: false,
581 api_surface: Some(PathBuf::from("surface.json")),
582 };
583 assert_eq!(args.api_surface, Some(PathBuf::from("surface.json")));
584 }
585
586 #[test]
587 fn test_run_check_fails_on_unfixable_error() {
588 let dir = tempfile::tempdir().expect("create temp dir");
589 let source_path = dir.path().join("app.py");
590 let script_path = dir.path().join("test.sh");
591
592 std::fs::write(&source_path, "x = 1\n").expect("write source");
593 std::fs::write(
594 &script_path,
595 "#!/bin/sh\necho 'just random junk' >&2\nexit 1\n",
596 )
597 .expect("write script");
598
599 #[cfg(unix)]
600 {
601 use std::os::unix::fs::PermissionsExt;
602 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
603 .expect("chmod script");
604 }
605
606 let cmd = script_path.display().to_string();
607 let args = FixCheckArgs {
608 file: source_path,
609 test_cmd: cmd,
610 max_attempts: 3,
611 };
612 let result = run_check(&args, OutputFormat::Json, Some("python"));
613 assert!(result.is_err(), "Should fail when error is unfixable");
614 }
615}