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