use anyhow::{Result, anyhow};
use std::io::{self, BufRead};
pub(crate) const MAX_LINE_LEN: usize = 16 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedLine {
pub line_no: usize,
pub raw: String,
pub kind: ParsedLineKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedLineKind {
Ok(Vec<String>),
TokenizeError(String),
}
pub fn tokenize_line(line: &str) -> Result<Option<Vec<String>>> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return Ok(None);
}
let tokens = shlex::split(trimmed).ok_or_else(|| anyhow!("malformed quoting"))?;
if tokens.is_empty() {
return Ok(None);
}
let mut argv = Vec::with_capacity(tokens.len() + 1);
argv.push("s7cmd".to_string());
argv.extend(tokens);
Ok(Some(argv))
}
pub fn read_all<R: BufRead>(mut reader: R) -> Result<Vec<ParsedLine>> {
let mut out = Vec::new();
let mut line_no: usize = 0;
loop {
line_no += 1;
let line = match read_line_capped(&mut reader) {
Ok(Some(s)) => s,
Ok(None) => break,
Err(e) => return Err(anyhow!("read error at line {line_no}: {e}")),
};
match tokenize_line(&line) {
Ok(Some(argv)) => out.push(ParsedLine {
line_no,
raw: line,
kind: ParsedLineKind::Ok(argv),
}),
Ok(None) => continue,
Err(e) => {
out.push(ParsedLine {
line_no,
raw: line,
kind: ParsedLineKind::TokenizeError(e.to_string()),
});
}
}
}
Ok(out)
}
pub(crate) fn read_line_capped<R: BufRead>(reader: &mut R) -> io::Result<Option<String>> {
let mut buf: Vec<u8> = Vec::new();
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return if buf.is_empty() {
Ok(None)
} else {
String::from_utf8(buf)
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
};
}
match available.iter().position(|&b| b == b'\n') {
Some(idx) => {
buf.extend_from_slice(&available[..idx]);
reader.consume(idx + 1);
if buf.len() > MAX_LINE_LEN {
return Err(too_long_err());
}
return String::from_utf8(buf)
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e));
}
None => {
buf.extend_from_slice(available);
let n = available.len();
reader.consume(n);
if buf.len() > MAX_LINE_LEN {
return Err(too_long_err());
}
}
}
}
}
pub(crate) fn too_long_err() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidData,
format!("line exceeds {}-byte limit (16 KiB)", MAX_LINE_LEN),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenize_simple_command() {
let argv = tokenize_line("ls s3://bucket").unwrap().unwrap();
assert_eq!(argv, vec!["s7cmd", "ls", "s3://bucket"]);
}
#[test]
fn tokenize_quoted_argument_with_spaces() {
let argv = tokenize_line(r#"cp "s3://b/with spaces/key" /tmp/dst"#)
.unwrap()
.unwrap();
assert_eq!(
argv,
vec!["s7cmd", "cp", "s3://b/with spaces/key", "/tmp/dst"]
);
}
#[test]
fn skip_blank_line() {
assert!(tokenize_line("").unwrap().is_none());
assert!(tokenize_line(" ").unwrap().is_none());
}
#[test]
fn skip_comment_line() {
assert!(tokenize_line("# this is a comment").unwrap().is_none());
assert!(tokenize_line(" # leading spaces ok").unwrap().is_none());
}
#[test]
fn malformed_quoting_errors() {
let err = tokenize_line(r#"cp "unterminated"#).unwrap_err();
assert!(err.to_string().contains("malformed"));
}
#[test]
fn read_all_collects_with_line_numbers_and_skips_blanks() {
let input = "ls s3://b1\n\n# skipme\ncp /a s3://b2/k\n";
let lines = read_all(input.as_bytes()).unwrap();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].line_no, 1);
assert!(
matches!(&lines[0].kind, ParsedLineKind::Ok(a) if a == &vec!["s7cmd".to_string(), "ls".to_string(), "s3://b1".to_string()])
);
assert_eq!(lines[1].line_no, 4);
assert!(
matches!(&lines[1].kind, ParsedLineKind::Ok(a) if a == &vec!["s7cmd".to_string(), "cp".to_string(), "/a".to_string(), "s3://b2/k".to_string()])
);
}
#[test]
fn read_all_surfaces_tokenize_error_as_kind_and_continues() {
let input = "ls s3://b1\ncp \"oops\nls s3://b2\n";
let lines = read_all(input.as_bytes()).unwrap();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].line_no, 1);
assert!(matches!(lines[0].kind, ParsedLineKind::Ok(_)));
assert_eq!(lines[1].line_no, 2);
match &lines[1].kind {
ParsedLineKind::TokenizeError(msg) => {
assert!(msg.contains("malformed"), "msg: {msg}");
assert!(
!msg.starts_with("line "),
"reason must not bake line prefix: {msg}"
);
}
other => panic!("expected TokenizeError, got {other:?}"),
}
assert_eq!(lines[2].line_no, 3);
assert!(matches!(lines[2].kind, ParsedLineKind::Ok(_)));
}
#[test]
fn read_all_empty_input_returns_empty() {
let lines = read_all("".as_bytes()).unwrap();
assert!(lines.is_empty());
}
#[test]
fn tokenize_unicode_argument() {
let argv = tokenize_line("rm s3://b/файл").unwrap().unwrap();
assert_eq!(argv, vec!["s7cmd", "rm", "s3://b/файл"]);
}
#[test]
fn read_all_accepts_line_at_max_len() {
let mut s = String::with_capacity(MAX_LINE_LEN + 1);
s.push('#');
s.extend(std::iter::repeat_n('x', MAX_LINE_LEN - 1));
s.push('\n');
assert_eq!(s.len(), MAX_LINE_LEN + 1);
let lines = read_all(s.as_bytes()).unwrap();
assert!(lines.is_empty(), "comment line should be skipped");
}
#[test]
fn read_all_rejects_line_over_max_len() {
let mut s = String::with_capacity(MAX_LINE_LEN + 3);
s.push('#');
s.extend(std::iter::repeat_n('x', MAX_LINE_LEN));
s.push('\n');
let err = read_all(s.as_bytes()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("line 1"), "msg: {msg}");
assert!(msg.contains("exceeds"), "msg: {msg}");
}
#[test]
fn read_all_rejects_long_line_without_terminator() {
let mut s = String::with_capacity(MAX_LINE_LEN + 1);
s.push('#');
s.extend(std::iter::repeat_n('x', MAX_LINE_LEN));
let err = read_all(s.as_bytes()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("line 1"), "msg: {msg}");
assert!(msg.contains("exceeds"), "msg: {msg}");
}
#[test]
fn read_all_long_line_after_short_lines_reports_correct_line_no() {
let mut s = String::new();
s.push_str("# ok\n");
s.push_str("# also ok\n");
s.push('#');
s.extend(std::iter::repeat_n('x', MAX_LINE_LEN));
s.push('\n');
let err = read_all(s.as_bytes()).unwrap_err();
assert!(err.to_string().contains("line 3"));
}
#[test]
fn read_line_capped_returns_none_at_eof() {
let mut r = io::Cursor::new(b"" as &[u8]);
assert!(read_line_capped(&mut r).unwrap().is_none());
}
#[test]
fn read_line_capped_handles_final_line_without_newline() {
let mut r = io::Cursor::new(b"hello" as &[u8]);
assert_eq!(read_line_capped(&mut r).unwrap().unwrap(), "hello");
assert!(read_line_capped(&mut r).unwrap().is_none());
}
#[test]
fn read_line_capped_rejects_invalid_utf8() {
let mut r = io::Cursor::new(&[0xff, 0xfe, b'\n'][..]);
let err = read_line_capped(&mut r).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
}
}