use std::io::Read;
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use clap::{Args, Subcommand};
use tldr_core::fix;
use tldr_core::Language;
use crate::output::{OutputFormat, OutputWriter};
#[derive(Debug, Args)]
pub struct FixArgs {
#[command(subcommand)]
pub command: FixCommand,
}
#[derive(Debug, Subcommand)]
pub enum FixCommand {
Diagnose(FixDiagnoseArgs),
Apply(FixApplyArgs),
Check(FixCheckArgs),
}
#[derive(Debug, Args)]
pub struct FixCheckArgs {
#[arg(long, short = 'f')]
pub file: PathBuf,
#[arg(long, short = 't')]
pub test_cmd: String,
#[arg(long, default_value = "5")]
pub max_attempts: usize,
}
#[derive(Debug, Args)]
pub struct FixDiagnoseArgs {
#[arg(long, short = 's')]
pub source: PathBuf,
#[arg(long, short = 'e', conflicts_with = "error_file")]
pub error: Option<String>,
#[arg(long, conflicts_with = "error")]
pub error_file: Option<PathBuf>,
#[arg(long)]
pub stdin: bool,
#[arg(long)]
pub api_surface: Option<PathBuf>,
}
#[derive(Debug, Args)]
pub struct FixApplyArgs {
#[arg(long, short = 's')]
pub source: PathBuf,
#[arg(long, short = 'e', conflicts_with = "error_file")]
pub error: Option<String>,
#[arg(long, conflicts_with = "error")]
pub error_file: Option<PathBuf>,
#[arg(long, short = 'o')]
pub output: Option<PathBuf>,
#[arg(long)]
pub stdin: bool,
#[arg(long, short = 'i')]
pub in_place: bool,
#[arg(long, short = 'd')]
pub diff: bool,
#[arg(long)]
pub api_surface: Option<PathBuf>,
}
impl FixArgs {
pub fn run(&self, format: OutputFormat, _quiet: bool, lang: Option<Language>) -> Result<()> {
let lang_str = lang.as_ref().map(Language::as_str);
match &self.command {
FixCommand::Diagnose(args) => run_diagnose(args, format, lang_str),
FixCommand::Apply(args) => run_apply(args, format, lang_str),
FixCommand::Check(args) => run_check(args, format, lang_str),
}
}
}
fn read_error_text(
error: &Option<String>,
error_file: &Option<PathBuf>,
use_stdin: bool,
) -> Result<String> {
if let Some(text) = error {
return Ok(text.clone());
}
if let Some(path) = error_file {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow!("Failed to read error file '{}': {}", path.display(), e))?;
return Ok(text);
}
if use_stdin || (error.is_none() && error_file.is_none()) {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| anyhow!("Failed to read from stdin: {}", e))?;
if buf.is_empty() {
return Err(anyhow!(
"No error text provided. Use --error, --error-file, or pipe to stdin."
));
}
return Ok(buf);
}
Err(anyhow!(
"No error text provided. Use --error, --error-file, --stdin, or pipe to stdin."
))
}
fn compute_line_diff(old: &str, new: &str) -> String {
let old_lines: Vec<&str> = old.lines().collect();
let new_lines: Vec<&str> = new.lines().collect();
let mut output = String::new();
let mut oi = 0;
let mut ni = 0;
while oi < old_lines.len() || ni < new_lines.len() {
if oi < old_lines.len() && ni < new_lines.len() {
if old_lines[oi] == new_lines[ni] {
output.push_str(&format!(" {}\n", old_lines[oi]));
oi += 1;
ni += 1;
} else {
output.push_str(&format!("-{}\n", old_lines[oi]));
output.push_str(&format!("+{}\n", new_lines[ni]));
oi += 1;
ni += 1;
}
} else if oi < old_lines.len() {
output.push_str(&format!("-{}\n", old_lines[oi]));
oi += 1;
} else {
output.push_str(&format!("+{}\n", new_lines[ni]));
ni += 1;
}
}
output
}
fn run_diagnose(args: &FixDiagnoseArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
let error_text = read_error_text(&args.error, &args.error_file, args.stdin)?;
if let Some(surface_path) = &args.api_surface {
eprintln!(
"Note: API surface enrichment available from '{}'",
surface_path.display()
);
}
let source = std::fs::read_to_string(&args.source)
.map_err(|e| anyhow!("Failed to read source file '{}': {}", args.source.display(), e))?;
let diagnosis = fix::diagnose(&error_text, &source, lang, None);
match diagnosis {
Some(diag) => {
let writer = OutputWriter::new(format, false);
writer.write(&diag)?;
Ok(())
}
None => Err(anyhow!(
"Could not parse or diagnose the error. The error format may not be supported yet."
)),
}
}
fn run_apply(args: &FixApplyArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
let error_text = read_error_text(&args.error, &args.error_file, args.stdin)?;
if let Some(surface_path) = &args.api_surface {
eprintln!(
"Note: API surface enrichment available from '{}'",
surface_path.display()
);
}
let source = std::fs::read_to_string(&args.source)
.map_err(|e| anyhow!("Failed to read source file '{}': {}", args.source.display(), e))?;
let diagnosis = fix::diagnose(&error_text, &source, lang, None)
.ok_or_else(|| {
anyhow!("Could not parse or diagnose the error. The error format may not be supported.")
})?;
match &diagnosis.fix {
Some(fix_data) => {
let patched = fix::apply_fix(&source, fix_data);
if args.diff {
match format {
OutputFormat::Json | OutputFormat::Compact => {
let diff_text = compute_line_diff(&source, &patched);
let result = serde_json::json!({
"diagnosis": diagnosis,
"diff": diff_text,
});
let writer = OutputWriter::new(format, false);
writer.write(&result)?;
}
_ => {
let diff_text = compute_line_diff(&source, &patched);
print!("{}", diff_text);
}
}
Ok(())
} else if args.in_place {
std::fs::write(&args.source, &patched).map_err(|e| {
anyhow!(
"Failed to write patched source to '{}': {}",
args.source.display(),
e
)
})?;
eprintln!("Fixed: {}", diagnosis.message);
Ok(())
} else if let Some(output_path) = &args.output {
std::fs::write(output_path, &patched).map_err(|e| {
anyhow!(
"Failed to write patched source to '{}': {}",
output_path.display(),
e
)
})?;
eprintln!("Fixed: {}", diagnosis.message);
Ok(())
} else {
match format {
OutputFormat::Json | OutputFormat::Compact => {
let result = serde_json::json!({
"diagnosis": diagnosis,
"patched_source": patched,
});
let writer = OutputWriter::new(format, false);
writer.write(&result)?;
}
_ => {
print!("{}", patched);
}
}
Ok(())
}
}
None => {
eprintln!(
"No auto-fix available (confidence: {:?}). Diagnosis:",
diagnosis.confidence
);
let writer = OutputWriter::new(format, false);
writer.write(&diagnosis)?;
Err(anyhow!(
"No deterministic fix available for this error. Escalate to a model."
))
}
}
}
fn run_check(args: &FixCheckArgs, format: OutputFormat, lang: Option<&str>) -> Result<()> {
use fix::{run_check_loop, CheckConfig};
if !args.file.exists() {
return Err(anyhow!(
"Source file '{}' does not exist.",
args.file.display()
));
}
let config = CheckConfig {
file: &args.file,
test_cmd: &args.test_cmd,
lang,
max_attempts: args.max_attempts,
};
let result = run_check_loop(&config);
let writer = OutputWriter::new(format, false);
writer.write(&result)?;
if result.final_pass {
eprintln!(
"All errors fixed in {} iteration{}.",
result.iterations,
if result.iterations == 1 { "" } else { "s" }
);
Ok(())
} else {
Err(anyhow!(
"Some errors could not be fixed after {} attempt{}.",
result.iterations,
if result.iterations == 1 { "" } else { "s" }
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_error_text_inline() {
let text = read_error_text(
&Some("NameError: name 'x' is not defined".to_string()),
&None,
false,
)
.unwrap();
assert_eq!(text, "NameError: name 'x' is not defined");
}
#[test]
fn test_read_error_text_file() {
let dir = std::env::temp_dir().join("tldr_fix_test");
std::fs::create_dir_all(&dir).unwrap();
let err_file = dir.join("test_error.txt");
std::fs::write(&err_file, "KeyError: 'name'").unwrap();
let text = read_error_text(&None, &Some(err_file.clone()), false).unwrap();
assert_eq!(text, "KeyError: 'name'");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_read_error_text_missing_file() {
let result = read_error_text(
&None,
&Some(PathBuf::from("/nonexistent/path/error.txt")),
false,
);
assert!(result.is_err());
}
#[test]
fn test_fix_check_args_defaults() {
let args = FixCheckArgs {
file: PathBuf::from("app.py"),
test_cmd: "pytest tests/".to_string(),
max_attempts: 5,
};
assert_eq!(args.file, PathBuf::from("app.py"));
assert_eq!(args.test_cmd, "pytest tests/");
assert_eq!(args.max_attempts, 5);
}
#[test]
fn test_fix_check_args_with_max_attempts() {
let args = FixCheckArgs {
file: PathBuf::from("main.rs"),
test_cmd: "cargo test".to_string(),
max_attempts: 10,
};
assert_eq!(args.max_attempts, 10);
}
#[test]
fn test_fix_command_check_variant_exists() {
let args = FixCheckArgs {
file: PathBuf::from("app.py"),
test_cmd: "pytest".to_string(),
max_attempts: 5,
};
let cmd = FixCommand::Check(args);
let debug = format!("{:?}", cmd);
assert!(debug.contains("Check"), "FixCommand should have Check variant");
}
#[test]
fn test_run_check_missing_file() {
let args = FixCheckArgs {
file: PathBuf::from("/nonexistent/file.py"),
test_cmd: "true".to_string(),
max_attempts: 5,
};
let result = run_check(&args, OutputFormat::Json, None);
assert!(result.is_err(), "Should error on missing file");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("does not exist"),
"Error should mention missing file: {}",
err_msg
);
}
#[test]
fn test_run_check_succeeds_on_passing_test() {
let dir = tempfile::tempdir().expect("create temp dir");
let source_path = dir.path().join("app.py");
std::fs::write(&source_path, "x = 1\n").expect("write source");
let args = FixCheckArgs {
file: source_path,
test_cmd: "true".to_string(),
max_attempts: 5,
};
let result = run_check(&args, OutputFormat::Json, Some("python"));
assert!(result.is_ok(), "Should succeed when test passes: {:?}", result);
}
#[test]
fn test_fix_apply_args_has_diff_field() {
let args = FixApplyArgs {
source: PathBuf::from("app.py"),
error: Some("NameError: name 'x' is not defined".to_string()),
error_file: None,
output: None,
stdin: false,
in_place: false,
diff: true,
api_surface: None,
};
assert!(args.diff);
}
#[test]
fn test_run_apply_diff_flag() {
let dir = tempfile::tempdir().expect("create temp dir");
let source_path = dir.path().join("app.py");
std::fs::write(&source_path, "import os\nx = json.loads('{}')\n").expect("write source");
let args = FixApplyArgs {
source: source_path,
error: Some("NameError: name 'json' is not defined".to_string()),
error_file: None,
output: None,
stdin: false,
in_place: false,
diff: true,
api_surface: None,
};
let result = run_apply(&args, OutputFormat::Text, Some("python"));
assert!(result.is_ok(), "run_apply with --diff should succeed: {:?}", result);
}
#[test]
fn test_fix_diagnose_args_has_api_surface_field() {
let args = FixDiagnoseArgs {
source: PathBuf::from("app.py"),
error: Some("error".to_string()),
error_file: None,
stdin: false,
api_surface: Some(PathBuf::from("surface.json")),
};
assert_eq!(args.api_surface, Some(PathBuf::from("surface.json")));
}
#[test]
fn test_fix_apply_args_has_api_surface_field() {
let args = FixApplyArgs {
source: PathBuf::from("app.py"),
error: Some("error".to_string()),
error_file: None,
output: None,
stdin: false,
in_place: false,
diff: false,
api_surface: Some(PathBuf::from("surface.json")),
};
assert_eq!(args.api_surface, Some(PathBuf::from("surface.json")));
}
#[test]
fn test_run_check_fails_on_unfixable_error() {
let dir = tempfile::tempdir().expect("create temp dir");
let source_path = dir.path().join("app.py");
let script_path = dir.path().join("test.sh");
std::fs::write(&source_path, "x = 1\n").expect("write source");
std::fs::write(
&script_path,
"#!/bin/sh\necho 'just random junk' >&2\nexit 1\n",
)
.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 args = FixCheckArgs {
file: source_path,
test_cmd: cmd,
max_attempts: 3,
};
let result = run_check(&args, OutputFormat::Json, Some("python"));
assert!(result.is_err(), "Should fail when error is unfixable");
}
}