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#[derive(Debug, Args)]
38pub struct FixArgs {
39    /// Fix subcommand
40    #[command(subcommand)]
41    pub command: FixCommand,
42}
43
44/// Fix subcommands
45#[derive(Debug, Subcommand)]
46pub enum FixCommand {
47    /// Parse error output and produce a structured diagnosis with optional fix
48    Diagnose(FixDiagnoseArgs),
49    /// Apply fix edits to source code and write the patched result
50    Apply(FixApplyArgs),
51    /// Run test command, diagnose failures, apply fixes, and re-run in a loop
52    Check(FixCheckArgs),
53}
54
55/// Arguments for `tldr fix check`
56#[derive(Debug, Args)]
57pub struct FixCheckArgs {
58    /// Source file to fix
59    #[arg(long, short = 'f')]
60    pub file: PathBuf,
61
62    /// Test command to run (e.g., "pytest tests/test_app.py")
63    #[arg(long, short = 't')]
64    pub test_cmd: String,
65
66    /// Maximum number of fix attempts (default: 5)
67    #[arg(long, default_value = "5")]
68    pub max_attempts: usize,
69}
70
71/// Arguments for `tldr fix diagnose`
72#[derive(Debug, Args)]
73pub struct FixDiagnoseArgs {
74    /// Source file to analyze (required for tree-sitter based analysis)
75    #[arg(long, short = 's')]
76    pub source: PathBuf,
77
78    /// Inline error text (mutually exclusive with --error-file)
79    #[arg(long, short = 'e', conflicts_with = "error_file")]
80    pub error: Option<String>,
81
82    /// File containing error text (mutually exclusive with --error)
83    #[arg(long, conflicts_with = "error")]
84    pub error_file: Option<PathBuf>,
85
86    /// Read error text from stdin (when neither --error nor --error-file is given)
87    #[arg(long)]
88    pub stdin: bool,
89
90    /// Path to API surface JSON file for enhanced analysis (e.g., TS2339 property suggestions)
91    #[arg(long)]
92    pub api_surface: Option<PathBuf>,
93}
94
95/// Arguments for `tldr fix apply`
96#[derive(Debug, Args)]
97pub struct FixApplyArgs {
98    /// Source file to patch
99    #[arg(long, short = 's')]
100    pub source: PathBuf,
101
102    /// Inline error text
103    #[arg(long, short = 'e', conflicts_with = "error_file")]
104    pub error: Option<String>,
105
106    /// File containing error text
107    #[arg(long, conflicts_with = "error")]
108    pub error_file: Option<PathBuf>,
109
110    /// Output file for the patched source (stdout if not specified)
111    #[arg(long, short = 'o')]
112    pub output: Option<PathBuf>,
113
114    /// Read error text from stdin
115    #[arg(long)]
116    pub stdin: bool,
117
118    /// Write the patched source back to the original file (in-place fix)
119    #[arg(long, short = 'i')]
120    pub in_place: bool,
121
122    /// Show a unified diff instead of the full patched source
123    #[arg(long, short = 'd')]
124    pub diff: bool,
125
126    /// Path to API surface JSON file for enhanced analysis (e.g., TS2339 property suggestions)
127    #[arg(long)]
128    pub api_surface: Option<PathBuf>,
129}
130
131impl FixArgs {
132    /// Run the fix command
133    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
143/// Read error text from one of: --error, --error-file, --stdin, or fallback to stdin
144fn 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
177/// Compute a minimal unified diff between two strings, line by line.
178///
179/// Returns a string with lines prefixed by ` ` (unchanged), `-` (removed), or `+` (added).
180fn 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    // Walk both sides; emit context / remove / add markers.
187    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                // Lines differ: emit removal then addition
197                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
214/// Run the diagnose subcommand
215fn 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
247/// Run the apply subcommand
248fn 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                // Show unified diff instead of full patched source
276                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                // Write to stdout
314                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                        // Text mode: just print the patched source
325                        print!("{}", patched);
326                    }
327                }
328                Ok(())
329            }
330        }
331        None => {
332            // No fix available -- print the diagnosis as advisory
333            eprintln!(
334                "No auto-fix available (confidence: {:?}). Diagnosis:",
335                diagnosis.confidence
336            );
337            let writer = OutputWriter::new(format, false);
338            writer.write(&diagnosis)?;
339            // Exit with non-zero to indicate no fix was applied
340            Err(anyhow!(
341                "No deterministic fix available for this error. Escalate to a model."
342            ))
343        }
344    }
345}
346
347/// Run the check subcommand: test -> diagnose -> fix -> repeat loop.
348fn 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    // Report results
368    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        // Cleanup
413        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    // ---- Check subcommand tests ----
427
428    #[test]
429    fn test_fix_check_args_defaults() {
430        // Verify the FixCheckArgs struct has correct field types
431        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        // Ensure the Check variant exists on FixCommand
454        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        // Verify Debug representation contains "Check"
461        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    // ---- Diff flag tests ----
505
506    #[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        // Write a source file with a missing import that fix can handle
526        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        // Should succeed (produces diff output to stdout)
539        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    // ---- API surface flag tests ----
548
549    #[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}