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)
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
242/// Run the apply subcommand
243fn 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                // Show unified diff instead of full patched source
267                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                // Write to stdout
305                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                        // Text mode: just print the patched source
316                        print!("{}", patched);
317                    }
318                }
319                Ok(())
320            }
321        }
322        None => {
323            // No fix available -- print the diagnosis as advisory
324            eprintln!(
325                "No auto-fix available (confidence: {:?}). Diagnosis:",
326                diagnosis.confidence
327            );
328            let writer = OutputWriter::new(format, false);
329            writer.write(&diagnosis)?;
330            // Exit with non-zero to indicate no fix was applied
331            Err(anyhow!(
332                "No deterministic fix available for this error. Escalate to a model."
333            ))
334        }
335    }
336}
337
338/// Run the check subcommand: test -> diagnose -> fix -> repeat loop.
339fn 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    // Report results
359    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        // Cleanup
404        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    // ---- Check subcommand tests ----
418
419    #[test]
420    fn test_fix_check_args_defaults() {
421        // Verify the FixCheckArgs struct has correct field types
422        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        // Ensure the Check variant exists on FixCommand
445        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        // Verify Debug representation contains "Check"
452        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    // ---- Diff flag tests ----
489
490    #[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        // Write a source file with a missing import that fix can handle
510        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        // Should succeed (produces diff output to stdout)
523        let result = run_apply(&args, OutputFormat::Text, Some("python"));
524        assert!(result.is_ok(), "run_apply with --diff should succeed: {:?}", result);
525    }
526
527    // ---- API surface flag tests ----
528
529    #[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}