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)
226 .map_err(|e| anyhow!("Failed to read source file '{}': {}", args.source.display(), e))?;
227
228 let diagnosis = fix::diagnose(&error_text, &source, lang, None);
229
230 match diagnosis {
231 Some(diag) => {
232 let writer = OutputWriter::new(format, false);
233 writer.write(&diag)?;
234 Ok(())
235 }
236 None => Err(anyhow!(
237 "Could not parse or diagnose the error. The error format may not be supported yet."
238 )),
239 }
240}
241
242fn run_apply(args: &FixApplyArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
244 let error_text = read_error_text(&args.error, &args.error_file, args.stdin)?;
245
246 if let Some(surface_path) = &args.api_surface {
247 eprintln!(
248 "Note: API surface enrichment available from '{}'",
249 surface_path.display()
250 );
251 }
252
253 let source = std::fs::read_to_string(&args.source)
254 .map_err(|e| anyhow!("Failed to read source file '{}': {}", args.source.display(), e))?;
255
256 let diagnosis = fix::diagnose(&error_text, &source, lang, None)
257 .ok_or_else(|| {
258 anyhow!("Could not parse or diagnose the error. The error format may not be supported.")
259 })?;
260
261 match &diagnosis.fix {
262 Some(fix_data) => {
263 let patched = fix::apply_fix(&source, fix_data);
264
265 if args.diff {
266 match format {
268 OutputFormat::Json | OutputFormat::Compact => {
269 let diff_text = compute_line_diff(&source, &patched);
270 let result = serde_json::json!({
271 "diagnosis": diagnosis,
272 "diff": diff_text,
273 });
274 let writer = OutputWriter::new(format, false);
275 writer.write(&result)?;
276 }
277 _ => {
278 let diff_text = compute_line_diff(&source, &patched);
279 print!("{}", diff_text);
280 }
281 }
282 Ok(())
283 } else if args.in_place {
284 std::fs::write(&args.source, &patched).map_err(|e| {
285 anyhow!(
286 "Failed to write patched source to '{}': {}",
287 args.source.display(),
288 e
289 )
290 })?;
291 eprintln!("Fixed: {}", diagnosis.message);
292 Ok(())
293 } else if let Some(output_path) = &args.output {
294 std::fs::write(output_path, &patched).map_err(|e| {
295 anyhow!(
296 "Failed to write patched source to '{}': {}",
297 output_path.display(),
298 e
299 )
300 })?;
301 eprintln!("Fixed: {}", diagnosis.message);
302 Ok(())
303 } else {
304 match format {
306 OutputFormat::Json | OutputFormat::Compact => {
307 let result = serde_json::json!({
308 "diagnosis": diagnosis,
309 "patched_source": patched,
310 });
311 let writer = OutputWriter::new(format, false);
312 writer.write(&result)?;
313 }
314 _ => {
315 print!("{}", patched);
317 }
318 }
319 Ok(())
320 }
321 }
322 None => {
323 eprintln!(
325 "No auto-fix available (confidence: {:?}). Diagnosis:",
326 diagnosis.confidence
327 );
328 let writer = OutputWriter::new(format, false);
329 writer.write(&diagnosis)?;
330 Err(anyhow!(
332 "No deterministic fix available for this error. Escalate to a model."
333 ))
334 }
335 }
336}
337
338fn run_check(args: &FixCheckArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
340 use fix::{run_check_loop, CheckConfig};
341
342 if !args.file.exists() {
343 return Err(anyhow!(
344 "Source file '{}' does not exist.",
345 args.file.display()
346 ));
347 }
348
349 let config = CheckConfig {
350 file: &args.file,
351 test_cmd: &args.test_cmd,
352 lang,
353 max_attempts: args.max_attempts,
354 };
355
356 let result = run_check_loop(&config);
357
358 let writer = OutputWriter::new(format, false);
360 writer.write(&result)?;
361
362 if result.final_pass {
363 eprintln!(
364 "All errors fixed in {} iteration{}.",
365 result.iterations,
366 if result.iterations == 1 { "" } else { "s" }
367 );
368 Ok(())
369 } else {
370 Err(anyhow!(
371 "Some errors could not be fixed after {} attempt{}.",
372 result.iterations,
373 if result.iterations == 1 { "" } else { "s" }
374 ))
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn test_read_error_text_inline() {
384 let text = read_error_text(
385 &Some("NameError: name 'x' is not defined".to_string()),
386 &None,
387 false,
388 )
389 .unwrap();
390 assert_eq!(text, "NameError: name 'x' is not defined");
391 }
392
393 #[test]
394 fn test_read_error_text_file() {
395 let dir = std::env::temp_dir().join("tldr_fix_test");
396 std::fs::create_dir_all(&dir).unwrap();
397 let err_file = dir.join("test_error.txt");
398 std::fs::write(&err_file, "KeyError: 'name'").unwrap();
399
400 let text = read_error_text(&None, &Some(err_file.clone()), false).unwrap();
401 assert_eq!(text, "KeyError: 'name'");
402
403 let _ = std::fs::remove_dir_all(&dir);
405 }
406
407 #[test]
408 fn test_read_error_text_missing_file() {
409 let result = read_error_text(
410 &None,
411 &Some(PathBuf::from("/nonexistent/path/error.txt")),
412 false,
413 );
414 assert!(result.is_err());
415 }
416
417 #[test]
420 fn test_fix_check_args_defaults() {
421 let args = FixCheckArgs {
423 file: PathBuf::from("app.py"),
424 test_cmd: "pytest tests/".to_string(),
425 max_attempts: 5,
426 };
427 assert_eq!(args.file, PathBuf::from("app.py"));
428 assert_eq!(args.test_cmd, "pytest tests/");
429 assert_eq!(args.max_attempts, 5);
430 }
431
432 #[test]
433 fn test_fix_check_args_with_max_attempts() {
434 let args = FixCheckArgs {
435 file: PathBuf::from("main.rs"),
436 test_cmd: "cargo test".to_string(),
437 max_attempts: 10,
438 };
439 assert_eq!(args.max_attempts, 10);
440 }
441
442 #[test]
443 fn test_fix_command_check_variant_exists() {
444 let args = FixCheckArgs {
446 file: PathBuf::from("app.py"),
447 test_cmd: "pytest".to_string(),
448 max_attempts: 5,
449 };
450 let cmd = FixCommand::Check(args);
451 let debug = format!("{:?}", cmd);
453 assert!(debug.contains("Check"), "FixCommand should have Check variant");
454 }
455
456 #[test]
457 fn test_run_check_missing_file() {
458 let args = FixCheckArgs {
459 file: PathBuf::from("/nonexistent/file.py"),
460 test_cmd: "true".to_string(),
461 max_attempts: 5,
462 };
463 let result = run_check(&args, OutputFormat::Json, None);
464 assert!(result.is_err(), "Should error on missing file");
465 let err_msg = result.unwrap_err().to_string();
466 assert!(
467 err_msg.contains("does not exist"),
468 "Error should mention missing file: {}",
469 err_msg
470 );
471 }
472
473 #[test]
474 fn test_run_check_succeeds_on_passing_test() {
475 let dir = tempfile::tempdir().expect("create temp dir");
476 let source_path = dir.path().join("app.py");
477 std::fs::write(&source_path, "x = 1\n").expect("write source");
478
479 let args = FixCheckArgs {
480 file: source_path,
481 test_cmd: "true".to_string(),
482 max_attempts: 5,
483 };
484 let result = run_check(&args, OutputFormat::Json, Some("python"));
485 assert!(result.is_ok(), "Should succeed when test passes: {:?}", result);
486 }
487
488 #[test]
491 fn test_fix_apply_args_has_diff_field() {
492 let args = FixApplyArgs {
493 source: PathBuf::from("app.py"),
494 error: Some("NameError: name 'x' is not defined".to_string()),
495 error_file: None,
496 output: None,
497 stdin: false,
498 in_place: false,
499 diff: true,
500 api_surface: None,
501 };
502 assert!(args.diff);
503 }
504
505 #[test]
506 fn test_run_apply_diff_flag() {
507 let dir = tempfile::tempdir().expect("create temp dir");
508 let source_path = dir.path().join("app.py");
509 std::fs::write(&source_path, "import os\nx = json.loads('{}')\n").expect("write source");
511
512 let args = FixApplyArgs {
513 source: source_path,
514 error: Some("NameError: name 'json' is not defined".to_string()),
515 error_file: None,
516 output: None,
517 stdin: false,
518 in_place: false,
519 diff: true,
520 api_surface: None,
521 };
522 let result = run_apply(&args, OutputFormat::Text, Some("python"));
524 assert!(result.is_ok(), "run_apply with --diff should succeed: {:?}", result);
525 }
526
527 #[test]
530 fn test_fix_diagnose_args_has_api_surface_field() {
531 let args = FixDiagnoseArgs {
532 source: PathBuf::from("app.py"),
533 error: Some("error".to_string()),
534 error_file: None,
535 stdin: false,
536 api_surface: Some(PathBuf::from("surface.json")),
537 };
538 assert_eq!(args.api_surface, Some(PathBuf::from("surface.json")));
539 }
540
541 #[test]
542 fn test_fix_apply_args_has_api_surface_field() {
543 let args = FixApplyArgs {
544 source: PathBuf::from("app.py"),
545 error: Some("error".to_string()),
546 error_file: None,
547 output: None,
548 stdin: false,
549 in_place: false,
550 diff: false,
551 api_surface: Some(PathBuf::from("surface.json")),
552 };
553 assert_eq!(args.api_surface, Some(PathBuf::from("surface.json")));
554 }
555
556 #[test]
557 fn test_run_check_fails_on_unfixable_error() {
558 let dir = tempfile::tempdir().expect("create temp dir");
559 let source_path = dir.path().join("app.py");
560 let script_path = dir.path().join("test.sh");
561
562 std::fs::write(&source_path, "x = 1\n").expect("write source");
563 std::fs::write(
564 &script_path,
565 "#!/bin/sh\necho 'just random junk' >&2\nexit 1\n",
566 )
567 .expect("write script");
568
569 #[cfg(unix)]
570 {
571 use std::os::unix::fs::PermissionsExt;
572 std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
573 .expect("chmod script");
574 }
575
576 let cmd = script_path.display().to_string();
577 let args = FixCheckArgs {
578 file: source_path,
579 test_cmd: cmd,
580 max_attempts: 3,
581 };
582 let result = run_check(&args, OutputFormat::Json, Some("python"));
583 assert!(result.is_err(), "Should fail when error is unfixable");
584 }
585}