tldr-core 0.1.4

Core analysis engine for TLDR code analysis tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
//! Fix check loop -- run command, diagnose, fix, repeat.
//!
//! This module implements the `tldr fix check` core logic: a loop that
//! runs a test command, parses any errors from its output, diagnoses them,
//! applies fixes, and repeats until the test passes or the maximum number
//! of attempts is reached.
//!
//! # Usage
//!
//! ```rust,ignore
//! use tldr_core::fix::check::{run_check_loop, CheckConfig};
//! use std::path::Path;
//!
//! let config = CheckConfig {
//!     file: Path::new("src/app.py"),
//!     test_cmd: "pytest tests/test_app.py",
//!     lang: None,
//!     max_attempts: 5,
//! };
//! let result = run_check_loop(&config);
//! if result.final_pass {
//!     println!("All errors fixed in {} iterations!", result.iterations);
//! }
//! ```

use std::path::Path;
use std::process::Command;

use super::error_parser::parse_error;
use super::patch::apply_fix;
use super::types::Diagnosis;
use super::diagnose;

/// Result of a single fix attempt in the check loop.
#[derive(Debug, Clone, serde::Serialize)]
pub struct FixAttempt {
    /// Which iteration of the loop this attempt was (1-indexed).
    pub iteration: usize,
    /// The error code that was diagnosed (e.g., "NameError", "E0599").
    pub error_code: String,
    /// The error message that was diagnosed.
    pub message: String,
    /// Whether this attempt successfully produced and applied a fix.
    pub fixed: bool,
    /// Description of the fix that was applied, if any.
    pub description: Option<String>,
}

/// Result of the entire check loop.
#[derive(Debug, Clone, serde::Serialize)]
pub struct CheckResult {
    /// The file that was being fixed.
    pub file: String,
    /// The test command that was run.
    pub test_cmd: String,
    /// Details of each fix attempt.
    pub attempts: Vec<FixAttempt>,
    /// Whether the test command passed after all attempts.
    pub final_pass: bool,
    /// Total number of iterations performed.
    pub iterations: usize,
}

/// Configuration for the check loop.
pub struct CheckConfig<'a> {
    /// Path to the source file to fix.
    pub file: &'a Path,
    /// Shell command to run as the test (e.g., "pytest tests/test_app.py").
    pub test_cmd: &'a str,
    /// Optional language hint; auto-detected from file extension if `None`.
    pub lang: Option<&'a str>,
    /// Maximum number of fix attempts before giving up (default: 5).
    pub max_attempts: usize,
}

/// Detect language from a file extension.
///
/// Returns a language string suitable for the `fix::diagnose` system,
/// or `None` if the extension is not recognized.
fn detect_lang_from_extension(path: &Path) -> Option<&'static str> {
    let ext = path.extension()?.to_str()?;
    match ext {
        "py" => Some("python"),
        "rs" => Some("rust"),
        "ts" | "tsx" => Some("typescript"),
        "go" => Some("go"),
        "js" | "mjs" => Some("javascript"),
        _ => None,
    }
}

/// Run a shell command and capture its output.
///
/// Returns `(exit_success, combined_error_output)` where the error output
/// is stderr if non-empty, otherwise stdout (some tools write errors to stdout).
fn run_command(cmd: &str) -> (bool, String) {
    let output = Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .output();

    match output {
        Ok(out) => {
            let success = out.status.success();
            let stderr = String::from_utf8_lossy(&out.stderr).to_string();
            let stdout = String::from_utf8_lossy(&out.stdout).to_string();
            // Prefer stderr for error output; fall back to stdout
            let error_output = if stderr.trim().is_empty() {
                stdout
            } else {
                stderr
            };
            (success, error_output)
        }
        Err(e) => (false, format!("Failed to execute command: {}", e)),
    }
}

/// Run the fix check loop.
///
/// Executes `config.test_cmd`, and if it fails:
/// 1. Parses the error output
/// 2. Reads the source file
/// 3. Diagnoses the error
/// 4. Applies the fix (if available) and writes it back
/// 5. Repeats until the test passes or `max_attempts` is reached
///
/// Returns a `CheckResult` with details of each attempt.
pub fn run_check_loop(config: &CheckConfig) -> CheckResult {
    let lang = config
        .lang
        .or_else(|| detect_lang_from_extension(config.file));

    let file_str = config.file.display().to_string();
    let mut attempts: Vec<FixAttempt> = Vec::new();
    let mut iteration = 0;
    let mut final_pass = false;

    loop {
        iteration += 1;

        // Step 1: Run the test command
        let (success, error_output) = run_command(config.test_cmd);

        if success {
            final_pass = true;
            break;
        }

        // Stop if we have exhausted attempts
        if iteration > config.max_attempts {
            break;
        }

        // Step 2: Parse the error output
        let parsed = match parse_error(&error_output, lang) {
            Some(p) => p,
            None => {
                // Could not parse the error -- nothing we can fix
                attempts.push(FixAttempt {
                    iteration,
                    error_code: "unparseable".to_string(),
                    message: truncate_output(&error_output, 200),
                    fixed: false,
                    description: None,
                });
                break;
            }
        };

        // Step 3: Read the source file
        let source = match std::fs::read_to_string(config.file) {
            Ok(s) => s,
            Err(e) => {
                attempts.push(FixAttempt {
                    iteration,
                    error_code: parsed.error_type.clone(),
                    message: format!("Cannot read source file: {}", e),
                    fixed: false,
                    description: None,
                });
                break;
            }
        };

        // Step 4: Diagnose
        let diagnosis: Option<Diagnosis> = diagnose(&error_output, &source, lang, None);

        match diagnosis {
            Some(diag) if diag.fix.is_some() => {
                let fix = diag.fix.as_ref().unwrap();
                let patched = apply_fix(&source, fix);

                // Step 5: Write the patched source back
                match std::fs::write(config.file, &patched) {
                    Ok(()) => {
                        attempts.push(FixAttempt {
                            iteration,
                            error_code: diag.error_code.clone(),
                            message: diag.message.clone(),
                            fixed: true,
                            description: Some(fix.description.clone()),
                        });
                    }
                    Err(e) => {
                        attempts.push(FixAttempt {
                            iteration,
                            error_code: diag.error_code.clone(),
                            message: format!("Failed to write patched source: {}", e),
                            fixed: false,
                            description: None,
                        });
                        break;
                    }
                }
            }
            Some(diag) => {
                // Diagnosis exists but no fix available
                attempts.push(FixAttempt {
                    iteration,
                    error_code: diag.error_code.clone(),
                    message: diag.message.clone(),
                    fixed: false,
                    description: None,
                });
                break;
            }
            None => {
                // Could not diagnose
                attempts.push(FixAttempt {
                    iteration,
                    error_code: parsed.error_type.clone(),
                    message: format!("Could not diagnose: {}", parsed.message),
                    fixed: false,
                    description: None,
                });
                break;
            }
        }
    }

    CheckResult {
        file: file_str,
        test_cmd: config.test_cmd.to_string(),
        attempts,
        final_pass,
        iterations: iteration,
    }
}

/// Truncate output to a maximum length for display in attempt messages.
fn truncate_output(s: &str, max_len: usize) -> String {
    if s.len() <= max_len {
        s.trim().to_string()
    } else {
        format!("{}...", &s[..max_len].trim())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    /// Helper: create a temp directory with a source file and a test script.
    /// Returns (temp_dir, source_path, test_script_path).
    fn setup_temp_env(
        source_name: &str,
        source_content: &str,
        script_content: &str,
    ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
        let dir = tempfile::tempdir().expect("create temp dir");
        let source_path = dir.path().join(source_name);
        let script_path = dir.path().join("test.sh");

        std::fs::write(&source_path, source_content).expect("write source");

        let mut script = std::fs::File::create(&script_path).expect("create script");
        script
            .write_all(script_content.as_bytes())
            .expect("write script");

        // Make script executable
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
                .expect("chmod script");
        }

        (dir, source_path, script_path)
    }

    // ---- Language detection tests ----

    #[test]
    fn test_detect_lang_python() {
        assert_eq!(
            detect_lang_from_extension(Path::new("app.py")),
            Some("python")
        );
    }

    #[test]
    fn test_detect_lang_rust() {
        assert_eq!(
            detect_lang_from_extension(Path::new("main.rs")),
            Some("rust")
        );
    }

    #[test]
    fn test_detect_lang_typescript() {
        assert_eq!(
            detect_lang_from_extension(Path::new("app.ts")),
            Some("typescript")
        );
        assert_eq!(
            detect_lang_from_extension(Path::new("App.tsx")),
            Some("typescript")
        );
    }

    #[test]
    fn test_detect_lang_go() {
        assert_eq!(
            detect_lang_from_extension(Path::new("main.go")),
            Some("go")
        );
    }

    #[test]
    fn test_detect_lang_javascript() {
        assert_eq!(
            detect_lang_from_extension(Path::new("app.js")),
            Some("javascript")
        );
        assert_eq!(
            detect_lang_from_extension(Path::new("module.mjs")),
            Some("javascript")
        );
    }

    #[test]
    fn test_detect_lang_unknown() {
        assert_eq!(detect_lang_from_extension(Path::new("file.rb")), None);
        assert_eq!(detect_lang_from_extension(Path::new("no_ext")), None);
    }

    // ---- Loop termination tests ----

    #[test]
    fn test_check_loop_terminates_on_success() {
        // Test command that succeeds immediately
        let (_dir, source_path, _script_path) =
            setup_temp_env("app.py", "x = 1\n", "");

        let config = CheckConfig {
            file: &source_path,
            test_cmd: "true",  // always succeeds
            lang: Some("python"),
            max_attempts: 5,
        };

        let result = run_check_loop(&config);
        assert!(result.final_pass, "Should pass immediately");
        assert_eq!(result.iterations, 1);
        assert!(result.attempts.is_empty(), "No fix attempts needed");
    }

    #[test]
    fn test_check_loop_terminates_on_no_fix_available() {
        // Test command that fails with an error we can parse but not fix
        let (_dir, source_path, script_path) = setup_temp_env(
            "app.py",
            "x = 1\n",
            "#!/bin/sh\necho 'RecursionError: maximum recursion depth exceeded' >&2\nexit 1\n",
        );

        let cmd = script_path.display().to_string();
        let config = CheckConfig {
            file: &source_path,
            test_cmd: &cmd,
            lang: Some("python"),
            max_attempts: 5,
        };

        let result = run_check_loop(&config);
        assert!(!result.final_pass, "Should not pass -- no fix available");
        assert_eq!(result.attempts.len(), 1, "Should try once then stop");
        assert!(!result.attempts[0].fixed, "Attempt should not be fixed");
    }

    #[test]
    fn test_check_loop_terminates_on_unparseable_error() {
        // Test command that fails with output we cannot parse
        let (_dir, source_path, script_path) = setup_temp_env(
            "app.py",
            "x = 1\n",
            "#!/bin/sh\necho 'just some random junk' >&2\nexit 1\n",
        );

        let cmd = script_path.display().to_string();
        let config = CheckConfig {
            file: &source_path,
            test_cmd: &cmd,
            lang: Some("python"),
            max_attempts: 5,
        };

        let result = run_check_loop(&config);
        assert!(!result.final_pass, "Should not pass -- unparseable error");
        assert_eq!(result.attempts.len(), 1);
        assert_eq!(result.attempts[0].error_code, "unparseable");
    }

    #[test]
    fn test_check_loop_respects_max_attempts() {
        // Script that always fails with a fixable error, but the fix
        // doesn't actually resolve the issue (so the loop runs until max_attempts).
        // We use a NameError that diagnoses to "add import json",
        // but the test script always fails regardless.
        let (_dir, source_path, script_path) = setup_temp_env(
            "app.py",
            "def f():\n    data = json.loads('{}')\n",
            "#!/bin/sh\necho \"NameError: name 'json' is not defined\" >&2\nexit 1\n",
        );

        let cmd = script_path.display().to_string();
        let config = CheckConfig {
            file: &source_path,
            test_cmd: &cmd,
            lang: Some("python"),
            max_attempts: 3,
        };

        let result = run_check_loop(&config);
        assert!(!result.final_pass, "Should not pass -- always fails");
        // First iteration applies a fix (import json), subsequent iterations
        // may or may not parse (the error stays the same but source changed).
        // The important thing is we don't exceed max_attempts.
        assert!(
            result.iterations <= 4, // max_attempts + 1 for final check
            "Should not exceed max_attempts + 1: got {}",
            result.iterations
        );
    }

    #[test]
    fn test_check_loop_applies_fix_and_passes() {
        // Create a script that fails on first run then succeeds.
        // We use a marker file to track state.
        let dir = tempfile::tempdir().expect("create temp dir");
        let source_path = dir.path().join("app.py");
        let marker_path = dir.path().join("marker");
        let script_path = dir.path().join("test.sh");

        // Source with a NameError-triggering pattern
        std::fs::write(&source_path, "def f():\n    data = json.loads('{}')\n")
            .expect("write source");

        // Script: fail on first run (no marker), succeed on subsequent runs
        let script = format!(
            "#!/bin/sh\nif [ -f \"{}\" ]; then\n  exit 0\nelse\n  touch \"{}\"\n  echo \"NameError: name 'json' is not defined\" >&2\n  exit 1\nfi\n",
            marker_path.display(),
            marker_path.display()
        );
        let mut f = std::fs::File::create(&script_path).expect("create script");
        f.write_all(script.as_bytes()).expect("write script");

        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))
                .expect("chmod script");
        }

        let cmd = script_path.display().to_string();
        let config = CheckConfig {
            file: &source_path,
            test_cmd: &cmd,
            lang: Some("python"),
            max_attempts: 5,
        };

        let result = run_check_loop(&config);
        assert!(result.final_pass, "Should pass after fix");
        assert_eq!(result.iterations, 2, "Should take 2 iterations");
        assert_eq!(result.attempts.len(), 1, "One fix attempt");
        assert!(result.attempts[0].fixed, "Fix should have been applied");

        // Verify the source file was actually modified
        let patched = std::fs::read_to_string(&source_path).expect("read patched");
        assert!(
            patched.contains("import json"),
            "Source should contain the fix: got {:?}",
            patched
        );
    }

    #[test]
    fn test_check_result_serialization() {
        let result = CheckResult {
            file: "app.py".to_string(),
            test_cmd: "pytest".to_string(),
            attempts: vec![FixAttempt {
                iteration: 1,
                error_code: "NameError".to_string(),
                message: "name 'json' is not defined".to_string(),
                fixed: true,
                description: Some("Add import json".to_string()),
            }],
            final_pass: true,
            iterations: 2,
        };

        let json = serde_json::to_string(&result).expect("serialize");
        assert!(json.contains("NameError"));
        assert!(json.contains("final_pass"));
        assert!(json.contains("\"iterations\":2"));
    }

    #[test]
    fn test_fix_attempt_serialization() {
        let attempt = FixAttempt {
            iteration: 1,
            error_code: "E0599".to_string(),
            message: "no method found".to_string(),
            fixed: false,
            description: None,
        };

        let json = serde_json::to_string(&attempt).expect("serialize");
        assert!(json.contains("E0599"));
        assert!(json.contains("\"fixed\":false"));
    }

    #[test]
    fn test_truncate_output_short() {
        assert_eq!(truncate_output("hello", 10), "hello");
    }

    #[test]
    fn test_truncate_output_long() {
        let long = "a".repeat(300);
        let result = truncate_output(&long, 200);
        assert!(result.ends_with("..."));
        assert!(result.len() <= 204); // 200 chars + "..."
    }

    #[test]
    fn test_run_command_success() {
        let (success, _output) = run_command("true");
        assert!(success);
    }

    #[test]
    fn test_run_command_failure() {
        let (success, _output) = run_command("false");
        assert!(!success);
    }

    #[test]
    fn test_run_command_captures_stderr() {
        let (success, output) = run_command("echo 'error text' >&2; exit 1");
        assert!(!success);
        assert!(
            output.contains("error text"),
            "Should capture stderr: got {:?}",
            output
        );
    }

    #[test]
    fn test_run_command_falls_back_to_stdout() {
        let (_, output) = run_command("echo 'stdout error'; exit 1");
        assert!(
            output.contains("stdout error"),
            "Should fall back to stdout when stderr is empty: got {:?}",
            output
        );
    }

    #[test]
    fn test_check_loop_file_display_in_result() {
        let (_dir, source_path, _) = setup_temp_env("app.py", "x = 1\n", "");

        let config = CheckConfig {
            file: &source_path,
            test_cmd: "true",
            lang: Some("python"),
            max_attempts: 5,
        };

        let result = run_check_loop(&config);
        assert!(
            result.file.contains("app.py"),
            "Result should contain file path"
        );
        assert_eq!(result.test_cmd, "true");
    }
}