Skip to main content

tldr_cli/commands/
fix.rs

1//! Fix command -- diagnose and auto-fix errors from compiler/runtime output.
2//!
3//! # Subcommands
4//!
5//! - `tldr fix diagnose` -- parse error text and produce a structured diagnosis
6//! - `tldr fix apply` -- apply a fix to source code, writing the patched output
7//! - `tldr fix check` -- run test command, diagnose failures, apply fixes in a loop
8//!
9//! # Examples
10//!
11//! ```sh
12//! # Pipe a Python traceback from clipboard or file
13//! tldr fix diagnose --error-file traceback.txt --source app.py
14//!
15//! # Inline error text
16//! tldr fix diagnose --error "NameError: name 'json' is not defined" --source app.py
17//!
18//! # Apply fix to source (writes to stdout or --output)
19//! tldr fix apply --error-file traceback.txt --source app.py
20//!
21//! # Run test, diagnose, fix, repeat loop
22//! tldr fix check --file src/app.py --test-cmd "pytest tests/test_app.py"
23//! ```
24
25use 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/// Diagnose and auto-fix errors from compiler/runtime output.
37///
38/// Accepts (auto-detected) error text from any of:
39///   - Rust:        cargo build / cargo check / rustc errors (E0xxx codes)
40///   - C/C++:       gcc / clang diagnostics (`file:line:col: error: ...`)
41///   - Python:      tracebacks (NameError, AttributeError, ImportError, ...)
42///   - JS/TS:       jest / mocha test output, tsc errors (TS2xxx codes)
43///   - Linters:     eslint --format json, ruff, pylint
44///
45/// The format is auto-detected from the error text — pass it via
46/// `--error "..."`, `--error-file path`, or `--stdin`.
47#[derive(Debug, Args)]
48pub struct FixArgs {
49    /// Fix subcommand
50    #[command(subcommand)]
51    pub command: FixCommand,
52}
53
54/// Fix subcommands
55#[derive(Debug, Subcommand)]
56pub enum FixCommand {
57    /// Parse error output and produce a structured diagnosis with optional fix
58    Diagnose(FixDiagnoseArgs),
59    /// Apply fix edits to source code and write the patched result
60    Apply(FixApplyArgs),
61    /// Run test command, diagnose failures, apply fixes, and re-run in a loop
62    Check(FixCheckArgs),
63}
64
65/// Arguments for `tldr fix check`
66#[derive(Debug, Args)]
67pub struct FixCheckArgs {
68    /// Source file to fix
69    #[arg(long, short = 'f')]
70    pub file: PathBuf,
71
72    /// Test command to run (e.g., "pytest tests/test_app.py")
73    #[arg(long, short = 't')]
74    pub test_cmd: String,
75
76    /// Maximum number of fix attempts (default: 5)
77    #[arg(long, default_value = "5")]
78    pub max_attempts: usize,
79}
80
81/// Arguments for `tldr fix diagnose`
82#[derive(Debug, Args)]
83pub struct FixDiagnoseArgs {
84    /// Source file to analyze (required for tree-sitter based analysis)
85    #[arg(long, short = 's')]
86    pub source: PathBuf,
87
88    /// Inline error text (mutually exclusive with --error-file)
89    #[arg(long, short = 'e', conflicts_with = "error_file")]
90    pub error: Option<String>,
91
92    /// File containing error text (mutually exclusive with --error)
93    #[arg(long, conflicts_with = "error")]
94    pub error_file: Option<PathBuf>,
95
96    /// Read error text from stdin (when neither --error nor --error-file is given)
97    #[arg(long)]
98    pub stdin: bool,
99
100    /// Path to API surface JSON file for enhanced analysis (e.g., TS2339 property suggestions)
101    #[arg(long)]
102    pub api_surface: Option<PathBuf>,
103}
104
105/// Arguments for `tldr fix apply`
106#[derive(Debug, Args)]
107pub struct FixApplyArgs {
108    /// Source file to patch
109    #[arg(long, short = 's')]
110    pub source: PathBuf,
111
112    /// Inline error text
113    #[arg(long, short = 'e', conflicts_with = "error_file")]
114    pub error: Option<String>,
115
116    /// File containing error text
117    #[arg(long, conflicts_with = "error")]
118    pub error_file: Option<PathBuf>,
119
120    /// Output file for the patched source (stdout if not specified)
121    #[arg(long, short = 'o')]
122    pub output: Option<PathBuf>,
123
124    /// Read error text from stdin
125    #[arg(long)]
126    pub stdin: bool,
127
128    /// Write the patched source back to the original file (in-place fix)
129    #[arg(long, short = 'i')]
130    pub in_place: bool,
131
132    /// Show a unified diff instead of the full patched source
133    #[arg(long, short = 'd')]
134    pub diff: bool,
135
136    /// Path to API surface JSON file for enhanced analysis (e.g., TS2339 property suggestions)
137    #[arg(long)]
138    pub api_surface: Option<PathBuf>,
139}
140
141impl FixArgs {
142    /// Run the fix command
143    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
153/// Read error text from one of: --error, --error-file, --stdin, or fallback to stdin
154fn 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
187/// Compute a minimal unified diff between two strings, line by line.
188///
189/// Returns a string with lines prefixed by ` ` (unchanged), `-` (removed), or `+` (added).
190fn 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    // Walk both sides; emit context / remove / add markers.
197    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                // Lines differ: emit removal then addition
207                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
224/// Run the diagnose subcommand
225fn 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
257/// Run the apply subcommand
258fn 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                // Show unified diff instead of full patched source
286                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                // Write to stdout
324                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                        // Text mode: just print the patched source
335                        print!("{}", patched);
336                    }
337                }
338                Ok(())
339            }
340        }
341        None => {
342            // No fix available -- print the diagnosis as advisory
343            eprintln!(
344                "No auto-fix available (confidence: {:?}). Diagnosis:",
345                diagnosis.confidence
346            );
347            let writer = OutputWriter::new(format, false);
348            writer.write(&diagnosis)?;
349            // Exit with non-zero to indicate no fix was applied
350            Err(anyhow!(
351                "No deterministic fix available for this error. Escalate to a model."
352            ))
353        }
354    }
355}
356
357/// Run the check subcommand: test -> diagnose -> fix -> repeat loop.
358fn 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    // Report results
378    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        // Cleanup
423        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    // ---- Check subcommand tests ----
437
438    #[test]
439    fn test_fix_check_args_defaults() {
440        // Verify the FixCheckArgs struct has correct field types
441        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        // Ensure the Check variant exists on FixCommand
464        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        // Verify Debug representation contains "Check"
471        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    // ---- Diff flag tests ----
515
516    #[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        // Write a source file with a missing import that fix can handle
536        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        // Should succeed (produces diff output to stdout)
549        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    // ---- API surface flag tests ----
558
559    #[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}