use crate::cli::global::GlobalFlags;
use crate::exit;
use clap::Args;
use serde::Serialize;
use std::fs;
#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
patchloom read src/main.rs
patchloom read src/main.rs --lines 1-10
patchloom read src/main.rs src/lib.rs --json")]
pub struct ReadArgs {
#[arg(required = true, num_args = 1..)]
pub files: Vec<String>,
#[arg(long)]
pub lines: Option<String>,
}
#[derive(Debug, Serialize)]
struct ReadOutput {
ok: bool,
path: String,
start_line: usize,
end_line: usize,
total_lines: usize,
content: String,
}
#[allow(unused_imports)]
pub(crate) use crate::ops::read::{LineRange, SelectedLines, parse_line_range, select_lines};
fn read_one_file(path: &str, lines: Option<LineRange>) -> Result<ReadOutput, String> {
let content = fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
if lines.is_none() {
let total_lines = content.lines().count();
let start_line = if total_lines == 0 { 0 } else { 1 };
return Ok(ReadOutput {
ok: true,
path: path.to_string(),
start_line,
end_line: total_lines,
total_lines,
content,
});
}
let selected = select_lines(&content, lines.expect("checked is_none above"));
Ok(ReadOutput {
ok: true,
path: path.to_string(),
start_line: selected.start_line,
end_line: selected.end_line,
total_lines: selected.total_lines,
content: selected.content,
})
}
pub fn run(args: ReadArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!("read: files={:?}, lines={:?}", args.files, args.lines);
let cwd = global.resolve_cwd()?;
let structured = global.json || global.jsonl;
let parsed_lines = if let Some(spec) = &args.lines {
match parse_line_range(spec) {
Ok(range) => Some(range),
Err(err) => {
if structured {
anyhow::bail!("invalid --lines value '{spec}': {err}");
}
eprintln!("read: {err}");
return Ok(exit::FAILURE);
}
}
} else {
None
};
let multi = args.files.len() > 1;
let mut outputs: Vec<ReadOutput> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for (i, path) in args.files.iter().enumerate() {
let resolved = cwd.join(path);
let resolved_str = resolved.to_string_lossy();
match read_one_file(&resolved_str, parsed_lines) {
Ok(output) => {
if global.jsonl {
global.emit_json(&output)?;
} else if global.json {
outputs.push(output);
} else if !global.quiet {
if multi && i > 0 {
println!();
}
if multi {
println!("==> {} <==", path);
}
print!("{}", output.content);
}
}
Err(e) => {
if !structured {
eprintln!("read: {e}");
}
errors.push(e);
}
}
}
if errors.len() == args.files.len() {
if structured {
anyhow::bail!(errors.join("\n"));
}
return Ok(exit::FAILURE);
}
if global.json {
if !multi && outputs.len() == 1 {
global.emit_json(&outputs[0])?;
} else {
global.emit_json(&outputs)?;
}
}
if structured {
for error in &errors {
eprintln!("read: {error}");
}
}
if !errors.is_empty() {
return Ok(exit::FAILURE);
}
Ok(exit::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_range_start_end() {
assert_eq!(parse_line_range("5:10").unwrap(), (5, Some(10)));
}
#[test]
fn parse_range_start_end_dash() {
assert_eq!(parse_line_range("5-10").unwrap(), (5, Some(10)));
}
#[test]
fn parse_range_single_line() {
assert_eq!(parse_line_range("7").unwrap(), (7, Some(7)));
}
#[test]
fn parse_range_zero_fails() {
assert!(parse_line_range("0:5").is_err());
}
#[test]
fn parse_range_end_before_start_fails() {
assert!(parse_line_range("10:5").is_err());
}
#[test]
fn parse_range_missing_start_fails() {
let err = parse_line_range(":10").unwrap_err();
assert!(
err.to_string().contains("missing start line"),
"expected 'missing start line', got: {err}"
);
}
#[test]
fn parse_range_open_ended() {
assert_eq!(parse_line_range("5:").unwrap(), (5, None));
}
#[test]
fn parse_range_non_numeric_single_fails() {
let err = parse_line_range("abc").unwrap_err();
assert!(
err.to_string().contains("invalid line number"),
"expected 'invalid line number', got: {err}"
);
}
#[test]
fn parse_range_single_zero_fails() {
let err = parse_line_range("0").unwrap_err();
assert!(
err.to_string().contains("1-based"),
"expected '1-based', got: {err}"
);
}
#[test]
fn parse_range_non_numeric_start_fails() {
let err = parse_line_range("abc:10").unwrap_err();
assert!(
err.to_string().contains("invalid start line"),
"expected 'invalid start line', got: {err}"
);
}
#[test]
fn parse_range_non_numeric_end_fails() {
let err = parse_line_range("5:xyz").unwrap_err();
assert!(
err.to_string().contains("invalid end line"),
"expected 'invalid end line', got: {err}"
);
}
#[test]
fn read_one_file_empty_file() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("empty.txt");
fs::write(&file, "").unwrap();
let result = read_one_file(file.to_str().unwrap(), None).unwrap();
assert_eq!(result.content, "");
assert_eq!(result.total_lines, 0);
assert_eq!(result.start_line, 0);
assert_eq!(result.end_line, 0);
}
#[test]
fn read_one_file_line_range_clamped_to_file_length() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("short.txt");
fs::write(&file, "line1\nline2\nline3\n").unwrap();
let lines = Some((2, Some(100)));
let result = read_one_file(file.to_str().unwrap(), lines).unwrap();
assert_eq!(result.content, "line2\nline3\n");
assert_eq!(result.start_line, 2);
assert_eq!(result.end_line, 3);
assert_eq!(result.total_lines, 3);
}
#[test]
fn read_one_file_no_lines_spec_preserves_trailing_newline() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("trail.txt");
fs::write(&file, "hello\n").unwrap();
let result = read_one_file(file.to_str().unwrap(), None).unwrap();
assert_eq!(result.content, "hello\n");
assert_eq!(result.total_lines, 1);
}
#[test]
fn read_one_file_no_trailing_newline() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("notrail.txt");
fs::write(&file, "hello").unwrap();
let result = read_one_file(file.to_str().unwrap(), None).unwrap();
assert_eq!(result.content, "hello");
assert_eq!(result.total_lines, 1);
}
#[test]
fn read_one_file_with_lines_preserves_trailing_newline() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("trail.txt");
fs::write(&file, "a\nb\nc\n").unwrap();
let lines = Some((2, Some(3)));
let result = read_one_file(file.to_str().unwrap(), lines).unwrap();
assert_eq!(result.content, "b\nc\n");
}
#[test]
fn read_one_file_mid_range_no_trailing_newline() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("mid.txt");
fs::write(&file, "a\nb\nc\nd\n").unwrap();
let lines = Some((2, Some(3)));
let result = read_one_file(file.to_str().unwrap(), lines).unwrap();
assert_eq!(result.content, "b\nc");
}
#[test]
fn read_one_file_empty_file_with_line_range() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("empty.txt");
fs::write(&file, "").unwrap();
let lines = Some((1, Some(5)));
let result = read_one_file(file.to_str().unwrap(), lines).unwrap();
assert_eq!(result.content, "");
assert_eq!(result.total_lines, 0);
}
#[test]
fn read_one_file_nonexistent_returns_error() {
let result = read_one_file("/tmp/does-not-exist-patchloom-test.txt", None);
assert!(result.is_err());
}
#[test]
fn select_lines_start_beyond_file_returns_empty() {
let content = "line1\nline2\n";
let result = select_lines(content, (100, Some(200)));
assert_eq!(result, SelectedLines::empty(2));
}
#[test]
fn select_lines_crlf_content_normalizes_to_lf() {
let content = "alpha\r\nbeta\r\ngamma\r\n";
let result = select_lines(content, (2, Some(3)));
assert_eq!(result.content, "beta\ngamma\n");
assert_eq!(result.start_line, 2);
assert_eq!(result.end_line, 3);
}
#[test]
fn partial_read_failure_returns_failure() {
let dir = tempfile::TempDir::new().unwrap();
let exists = dir.path().join("exists.txt");
std::fs::write(&exists, "hello\n").unwrap();
let missing = dir.path().join("missing.txt");
let args = ReadArgs {
files: vec![
exists.to_string_lossy().into_owned(),
missing.to_string_lossy().into_owned(),
],
lines: None,
};
let global = GlobalFlags::test_default();
let code = run(args, &global).unwrap();
assert_eq!(code, exit::FAILURE);
}
#[test]
fn quiet_mode_still_returns_failure_for_missing_file() {
let dir = tempfile::TempDir::new().unwrap();
let missing = dir.path().join("does-not-exist.txt");
let args = ReadArgs {
files: vec![missing.to_string_lossy().into_owned()],
lines: None,
};
let mut global = GlobalFlags::test_default();
global.quiet = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::FAILURE);
}
#[test]
fn quiet_mode_still_returns_failure_for_bad_lines() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("f.txt");
std::fs::write(&file, "hello\n").unwrap();
let args = ReadArgs {
files: vec![file.to_string_lossy().into_owned()],
lines: Some("abc".to_string()),
};
let mut global = GlobalFlags::test_default();
global.quiet = true;
let code = run(args, &global).unwrap();
assert_eq!(code, exit::FAILURE);
}
}