use std::fs;
use std::io::Write;
use std::time::Instant;
use anyhow::{Context, Result};
use crate::binary_detect;
use crate::checksum;
use crate::cli::{GlobalArgs, OutputFormat, ReadArgs};
use crate::error::AtomwriteError;
use crate::ndjson_types::{LineRange, ReadOutput};
use crate::output::NdjsonWriter;
#[tracing::instrument(skip_all, fields(command = "read"))]
pub fn cmd_read(
args: &ReadArgs,
global: &GlobalArgs,
writer: &mut NdjsonWriter<impl Write>,
verb: &str,
) -> Result<()> {
let start = Instant::now();
let workspace = global.resolve_workspace()?;
let targets = resolve_targets(&args.paths, &workspace)?;
if matches!(args.format, OutputFormat::Raw) {
return write_raw_targets(&targets, args, global, &workspace, writer, verb);
}
if let Some(pattern) = targets.empty_patterns.first() {
return Err(AtomwriteError::GlobNoMatches {
pattern: pattern.clone(),
}
.into());
}
if targets.paths.len() != 1 {
return Err(AtomwriteError::InvalidInput {
reason: format!(
"{verb} takes one target per invocation, and {} were named",
targets.paths.len()
),
}
.into());
}
let path = crate::path_safety::validate_path(&targets.paths[0], &workspace)?;
if !path.exists() {
return Err(AtomwriteError::NotFound { path }.into());
}
if let Some(pattern) = args.grep.as_deref() {
regex::Regex::new(pattern).map_err(|e| {
crate::error::AtomwriteError::invalid(&format!("invalid --grep pattern '{pattern}'"), e)
})?;
}
let metadata =
fs::metadata(&path).with_context(|| format!("cannot stat {}", path.display()))?;
let max_size = global.effective_max_filesize();
let is_binary = detect_binary_prefix(&path)?;
let (hash, bytes) = checksum::hash_file_streaming(&path, max_size)?;
if let Some(ref expected) = args.verify_checksum
&& &hash != expected
{
return Err(AtomwriteError::ChecksumVerifyFailed {
path,
expected: expected.clone(),
}
.into());
}
let permissions_str = format_permissions(&metadata);
let modified_str = format_modified(&metadata);
let (content_str, lines_total) = if is_binary {
(None, 0u64)
} else if args.stat {
(None, count_lines_streaming(&path)?)
} else {
let (content, total) = scan_lines_streaming(&path, args)?;
(Some(content), total)
};
let line_count = content_str
.as_deref()
.map(|s| s.lines().count() as u64)
.unwrap_or(lines_total);
let range = if is_binary {
None
} else {
parse_line_range(args, lines_total)
};
let mode = if args.stat {
"stat"
} else if args.line.is_some() {
"line"
} else if args.lines.is_some() {
"lines"
} else if args.head.is_some() {
"head"
} else if args.tail.is_some() {
"tail"
} else if args.grep.is_some() {
"grep"
} else {
"full"
};
let output = ReadOutput {
r#type: "read",
path: path.display().to_string(),
content: content_str,
lines: line_count,
lines_total: if lines_total != line_count {
Some(lines_total)
} else {
None
},
bytes,
checksum: hash,
permissions: permissions_str,
modified: modified_str,
kind: if is_binary {
"binary".into()
} else {
"text".into()
},
binary: is_binary,
range,
verified: args.verify_checksum.as_ref().map(|_| true),
mode: mode.to_string(),
};
writer.write_event(&output)?;
tracing::debug!(path = %path.display(), elapsed_ms = start.elapsed().as_millis() as u64, "read complete");
Ok(())
}
fn write_raw(writer: &mut NdjsonWriter<impl Write>, data: &[u8], args: &ReadArgs) -> Result<()> {
writer.flush()?;
let inner = std::io::stdout();
let mut lock = inner.lock();
let filtered = apply_line_filters(data, args);
let written = lock.write_all(&filtered);
written.and_then(|()| lock.flush()).map_err(|e| {
if e.kind() == std::io::ErrorKind::BrokenPipe {
crate::error::AtomwriteError::BrokenPipe.into()
} else {
anyhow::Error::from(e)
}
})
}
fn write_raw_targets(
targets: &ResolvedTargets,
args: &ReadArgs,
global: &GlobalArgs,
workspace: &std::path::Path,
writer: &mut NdjsonWriter<impl Write>,
verb: &str,
) -> Result<()> {
if targets.paths.len() == 1 && targets.empty_patterns.is_empty() {
let path = crate::path_safety::validate_path(&targets.paths[0], workspace)?;
if !path.exists() {
return Err(AtomwriteError::NotFound { path }.into());
}
let raw_bytes = crate::file_io::read_file_bytes(&path, global.effective_max_filesize())?;
let hash = checksum::hash_bytes(&raw_bytes);
if let Some(ref expected) = args.verify_checksum
&& &hash != expected
{
return Err(AtomwriteError::ChecksumVerifyFailed {
path,
expected: expected.clone(),
}
.into());
}
return write_raw(writer, &raw_bytes, args);
}
refuse_batch_flags(args, verb)?;
stream_raw_targets(targets, global, workspace, writer)
}
fn refuse_batch_flags(args: &ReadArgs, verb: &str) -> Result<()> {
let flag = if args.head.is_some() {
"--head"
} else if args.tail.is_some() {
"--tail"
} else if args.lines.is_some() {
"--lines"
} else if args.line.is_some() {
"--line"
} else if args.context != 0 {
"--context"
} else if args.stat && verb != "stat" {
"--stat"
} else if args.grep.is_some() {
"--grep"
} else if args.verify_checksum.is_some() {
"--verify-checksum"
} else {
return Ok(());
};
Err(AtomwriteError::InvalidInput {
reason: format!(
"{verb} {flag} expects a single target, and several were named; run the verb once per file"
),
}
.into())
}
fn stream_raw_targets(
targets: &ResolvedTargets,
global: &GlobalArgs,
workspace: &std::path::Path,
writer: &mut NdjsonWriter<impl Write>,
) -> Result<()> {
writer.flush()?;
let max_size = global.effective_max_filesize();
let shutdown = crate::signal::get_or_install_handlers()?;
let mut cancel = crate::signal::CancelCheck::for_bytes(shutdown.flag());
let mut out = std::io::stdout().lock();
let mut buf = vec![0u8; crate::constants::RAW_COPY_BUF_BYTES];
let mut first_error: Option<AtomwriteError> = None;
let mut bytes_total: u64 = 0;
for path in &targets.paths {
let checked = match crate::path_safety::validate_path(path, workspace) {
Ok(checked) => checked,
Err(e) => {
let failure = e.downcast::<AtomwriteError>().unwrap_or_else(|other| {
AtomwriteError::InternalError {
reason: format!("{other:#}"),
}
});
record_failure(failure, &mut first_error);
continue;
}
};
match copy_one_target(
&checked,
&mut out,
&mut buf,
max_size,
&mut cancel,
&mut bytes_total,
) {
Ok(()) => {}
Err(e) if is_fatal(&e) => return Err(e.into()),
Err(e) => record_failure(e, &mut first_error),
}
}
for pattern in &targets.empty_patterns {
record_failure(
AtomwriteError::GlobNoMatches {
pattern: pattern.clone(),
},
&mut first_error,
);
}
out.flush()
.map_err(|e| write_error_at(std::path::Path::new(""), e))?;
tracing::debug!(
targets = targets.paths.len(),
bytes = bytes_total,
"read raw concatenation complete"
);
match first_error {
Some(e) => Err(anyhow::Error::from(e).context(crate::output::VerdictPublished)),
None => Ok(()),
}
}
fn record_failure(e: AtomwriteError, first: &mut Option<AtomwriteError>) {
report_target_error(&e);
if first.is_none() {
*first = Some(e);
}
}
fn is_fatal(e: &AtomwriteError) -> bool {
matches!(e, AtomwriteError::BrokenPipe) || crate::signal::is_global_shutdown()
}
fn copy_one_target(
path: &std::path::Path,
out: &mut impl Write,
buf: &mut [u8],
max_size: u64,
cancel: &mut crate::signal::CancelCheck,
bytes_total: &mut u64,
) -> std::result::Result<(), AtomwriteError> {
use std::io::Read;
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(AtomwriteError::NotFound {
path: path.to_path_buf(),
});
}
Err(e) => return Err(AtomwriteError::from_io_at(path, e)),
};
let size = file
.metadata()
.map_err(|e| AtomwriteError::from_io_at(path, e))?
.len();
if max_size > 0 && size > max_size {
return Err(AtomwriteError::FileTooLarge {
path: path.to_path_buf(),
size,
max_size,
});
}
loop {
let read = file
.read(buf)
.map_err(|e| AtomwriteError::from_io_at(path, e))?;
if read == 0 {
return Ok(());
}
cancel.advance(read as u64)?;
out.write_all(&buf[..read])
.map_err(|e| write_error_at(path, e))?;
*bytes_total += read as u64;
}
}
fn write_error_at(path: &std::path::Path, e: std::io::Error) -> AtomwriteError {
if e.kind() == std::io::ErrorKind::BrokenPipe {
AtomwriteError::BrokenPipe
} else {
AtomwriteError::from_io_at(path, e)
}
}
fn report_target_error(e: &AtomwriteError) {
crate::best_effort::best_effort(
writeln!(std::io::stderr().lock(), "atomwrite: {e}"),
"report read batch error on stderr",
);
}
fn detect_binary_prefix(path: &std::path::Path) -> Result<bool> {
use std::io::Read;
let mut file =
fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut head = [0u8; crate::constants::BINARY_DETECT_SIZE];
let read = file
.read(&mut head)
.with_context(|| format!("cannot read {}", path.display()))?;
Ok(binary_detect::is_binary(&head[..read]))
}
fn apply_line_filters(data: &[u8], args: &ReadArgs) -> Vec<u8> {
let ranges = line_spans(data);
let total = ranges.len();
if let Some(ref range_str) = args.lines {
if let Some((start, end)) = parse_range_str(range_str, total) {
if start >= end {
return Vec::new();
}
return concat_spans(data, &ranges[start..end]);
}
}
if let Some(line_num) = args.line {
let idx = line_num.saturating_sub(1);
if idx >= total {
return Vec::new();
}
let ctx = args.context;
let start = idx.saturating_sub(ctx);
let end = (idx + ctx + 1).min(total);
return concat_spans(data, &ranges[start..end]);
}
if let Some(n) = args.head {
let end = n.min(total);
if end == 0 {
return Vec::new();
}
return concat_spans(data, &ranges[..end]);
}
if let Some(n) = args.tail {
if n == 0 {
return Vec::new();
}
let start = total.saturating_sub(n);
return concat_spans(data, &ranges[start..]);
}
if let Some(ref pattern) = args.grep {
if let Ok(re) = regex::Regex::new(pattern) {
let matched: Vec<(usize, usize)> = ranges
.iter()
.copied()
.filter(|span| re.is_match(&String::from_utf8_lossy(&data[span.0..span.1])))
.collect();
return concat_spans(data, &matched);
}
}
data.to_vec()
}
fn line_spans(data: &[u8]) -> Vec<(usize, usize)> {
let mut spans = Vec::new();
let mut start = 0;
for (index, byte) in data.iter().enumerate() {
if *byte == b'\n' {
spans.push((start, index + 1));
start = index + 1;
}
}
if start < data.len() {
spans.push((start, data.len()));
}
spans
}
fn concat_spans(data: &[u8], spans: &[(usize, usize)]) -> Vec<u8> {
let mut out = Vec::with_capacity(spans.iter().map(|(a, b)| b - a).sum());
for (start, end) in spans {
out.extend_from_slice(&data[*start..*end]);
}
out
}
fn parse_range_str(s: &str, total: usize) -> Option<(usize, usize)> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 2 {
return None;
}
let start = parts[0].parse::<usize>().ok()?.saturating_sub(1).min(total);
let end = parts[1].parse::<usize>().ok()?.min(total);
if start >= end {
return Some((0, 0));
}
Some((start, end))
}
fn parse_line_range(args: &ReadArgs, total: u64) -> Option<LineRange> {
if let Some(ref range_str) = args.lines {
if let Some((start, end)) = parse_range_str(range_str, total as usize) {
return Some(LineRange {
start: start + 1,
end,
});
}
}
if let Some(line_num) = args.line {
let ctx = args.context;
let start = line_num.saturating_sub(ctx);
let end = (line_num + ctx).min(total as usize);
return Some(LineRange { start, end });
}
None
}
fn format_permissions(metadata: &fs::Metadata) -> String {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
format!(
"0o{:o}",
metadata.permissions().mode() & crate::constants::UNIX_MODE_PERMISSION_MASK
)
}
#[cfg(not(unix))]
{
if metadata.permissions().readonly() {
"readonly".into()
} else {
"readwrite".into()
}
}
}
fn format_modified(metadata: &fs::Metadata) -> String {
match metadata.modified() {
Ok(time) => {
let secs = time
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let (year, month, day, hour, min, sec) = crate::atomic::epoch_to_utc(secs);
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
}
Err(_) => "unknown".into(),
}
}
include!("read_globs.inc.rs");
include!("read_lines.inc.rs");