diff --git a/Cargo.lock b/Cargo.lock
index bae7e95..c83786a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -466,6 +466,7 @@ dependencies = [
"miette",
"paste",
"quick-xml",
+ "sarge",
"serde",
"serde_ini",
"serde_json",
@@ -676,6 +677,12 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "sarge"
+version = "9.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "065234e2bf7d8a6781ccd8bb0cc4721ddbca95094bcd4405724189b2fb384b79"
+
[[package]]
name = "serde"
version = "1.0.228"
diff --git a/Cargo.toml b/Cargo.toml
index 70eb2fd..df9d9ce 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ custom = ["json"]
async = ["tokio", "async-trait", "futures"]
miette = ["dep:miette"]
+sarge = ["dep:sarge"]
full = [
"json",
"yaml",
@@ -71,6 +72,7 @@ futures = { version = "0.3", optional = true }
# Error reporting (optional)
miette = { version = "7", optional = true }
+sarge = { version = "9", optional = true }
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
@@ -101,6 +103,11 @@ name = "multiio_records_demo"
path = "src/bin/multiio_records_demo.rs"
required-features = ["csv"]
+[[bin]]
+name = "multiio_sarge"
+path = "src/bin/multiio_sarge.rs"
+required-features = ["json", "sarge"]
+
[[example]]
name = "basic_sync"
path = "examples/basic_sync.rs"
diff --git a/e2e/data/input/xml_roundtrip/input.xml b/e2e/data/input/xml_roundtrip/input.xml
new file mode 100644
index 0000000..64ed9bb
--- /dev/null
+++ b/e2e/data/input/xml_roundtrip/input.xml
@@ -0,0 +1,4 @@
+
+ 1
+ two
+
diff --git a/e2e/data/output/baseline/xml_roundtrip/output.json b/e2e/data/output/baseline/xml_roundtrip/output.json
new file mode 100644
index 0000000..fa8ec66
--- /dev/null
+++ b/e2e/data/output/baseline/xml_roundtrip/output.json
@@ -0,0 +1,8 @@
+{
+ "a": {
+ "$text": "1"
+ },
+ "b": {
+ "$text": "two"
+ }
+}
diff --git a/e2e/tests/conftest.py b/e2e/tests/conftest.py
index d26bde6..2c09eba 100644
--- a/e2e/tests/conftest.py
+++ b/e2e/tests/conftest.py
@@ -102,6 +102,25 @@ def multiio_records_demo_bin() -> Path:
return bin_path
+@pytest.fixture(scope="session")
+def multiio_sarge_bin() -> Path:
+ """Ensure the multiio_sarge binary is built once per test session (sarge CLI demo)."""
+ root = project_root()
+ result = subprocess.run(
+ ["cargo", "build", "--quiet", "--bin", "multiio_sarge", "--features", "full,sarge"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"cargo build sarge failed: {result.stderr}"
+
+ bin_path = root / "target" / "debug" / "multiio_sarge"
+ assert bin_path.exists(), f"built sarge binary not found at {bin_path}"
+
+ return bin_path
+
+
def run_pipeline(
multiio_bin: Path,
pipeline_yaml_content: str,
diff --git a/e2e/tests/test_features_matrix.py b/e2e/tests/test_features_matrix.py
index 261dd63..a4b57c0 100644
--- a/e2e/tests/test_features_matrix.py
+++ b/e2e/tests/test_features_matrix.py
@@ -27,6 +27,7 @@ def project_root() -> Path:
"csv",
"xml",
"plaintext",
+ "sarge",
"async",
"miette",
"custom",
diff --git a/e2e/tests/test_manual_cli.py b/e2e/tests/test_manual_cli.py
index 853075b..f71693d 100644
--- a/e2e/tests/test_manual_cli.py
+++ b/e2e/tests/test_manual_cli.py
@@ -142,3 +142,56 @@ def test_manual_multi_in_one_out_json(tmp_path: Path, multiio_manual_bin: Path)
baseline_file = e2e / "data" / "output" / "baseline" / scenario / "output.json"
compare_json_files(output_file, baseline_file)
+
+
+def test_manual_stdin_stdout_alias_tokens(tmp_path: Path, multiio_manual_bin: Path) -> None:
+ """stdin/stdout aliases should behave like '-'."""
+ root = project_root()
+
+ stdin_values = [
+ {"name": "alice", "age": 30},
+ {"name": "bob", "age": 25},
+ ]
+ stdin_data = json.dumps(stdin_values)
+
+ result = subprocess.run(
+ [str(multiio_manual_bin), "stdin", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ input=stdin_data,
+ )
+
+ assert (
+ result.returncode == 0
+ ), f"multiio_manual stdin/stdout aliases failed: {result.stderr}\nStdout: {result.stdout}"
+
+ output = json.loads(result.stdout)
+ assert output == stdin_values
+
+
+def test_manual_writes_to_stderr(tmp_path: Path, multiio_manual_bin: Path) -> None:
+ """stderr output token should write engine output to stderr."""
+ root = project_root()
+
+ stdin_values = [
+ {"name": "alice", "age": 30},
+ {"name": "bob", "age": 25},
+ ]
+ stdin_data = json.dumps(stdin_values)
+
+ result = subprocess.run(
+ [str(multiio_manual_bin), "stdin", "stderr"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ input=stdin_data,
+ )
+
+ assert (
+ result.returncode == 0
+ ), f"multiio_manual ->stderr failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stdout == ""
+
+ output = json.loads(result.stderr)
+ assert output == stdin_values
diff --git a/e2e/tests/test_sarge_cli.py b/e2e/tests/test_sarge_cli.py
new file mode 100644
index 0000000..ffdb28a
--- /dev/null
+++ b/e2e/tests/test_sarge_cli.py
@@ -0,0 +1,247 @@
+"""E2E tests for the sarge-based CLI demo (multiio_sarge)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+from conftest import project_root
+
+
+def _baseline_json(scenario: str, filename: str = "output.json") -> object:
+ root = project_root()
+ path = root / "e2e" / "data" / "output" / "baseline" / scenario / filename
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def test_sarge_inline_json_to_stdout(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_token = '{"a":1}'
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", input_token, "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+
+ output = json.loads(result.stdout)
+ assert output == [{"a": 1}]
+
+
+def test_sarge_stdin_alias_to_stdout(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ stdin_data = '{"a":1}'
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", "stdin", "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ input=stdin_data,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge stdin->stdout failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+
+ output = json.loads(result.stdout)
+ assert output == [{"a": 1}]
+
+
+def test_sarge_writes_to_stderr(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_token = '{"a":1}'
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", input_token, "--output", "stderr"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge ->stderr failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stdout == ""
+
+ output = json.loads(result.stderr)
+ assert output == [{"a": 1}]
+
+
+def test_sarge_forced_path_creates_file(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ output_path = tmp_path / "stderr"
+ input_token = '{"a":1}'
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", input_token, "--output", f"@{output_path}"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge forced-path failed: {result.stderr}\nStdout: {result.stdout}"
+ assert output_path.exists(), "expected forced-path output file to be created"
+
+ output = json.loads(output_path.read_text(encoding="utf-8"))
+ assert output == [{"a": 1}]
+
+
+def test_sarge_repeatable_outputs_stdout_and_stderr(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_token = '{"a":1}'
+
+ result = subprocess.run(
+ [
+ str(multiio_sarge_bin),
+ "--input",
+ input_token,
+ "--output",
+ "stdout",
+ "--output",
+ "stderr",
+ ],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge repeatable outputs failed: {result.stderr}\nStdout: {result.stdout}"
+
+ out = json.loads(result.stdout)
+ err = json.loads(result.stderr)
+ assert out == [{"a": 1}]
+ assert err == [{"a": 1}]
+
+
+def test_sarge_repeatable_inputs_with_inline_json_commas(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+
+ # First token includes a comma, which used to break naive comma-splitting.
+ in1 = '{"a":1,"b":2}'
+ in2 = '{"c":3}'
+
+ result = subprocess.run(
+ [
+ str(multiio_sarge_bin),
+ "--input",
+ in1,
+ "--input",
+ in2,
+ "--output",
+ "stdout",
+ ],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge repeatable inputs failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+
+ output = json.loads(result.stdout)
+ assert output == [{"a": 1, "b": 2}, {"c": 3}]
+
+
+def test_sarge_reads_yaml_file_and_outputs_json(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_file = root / "e2e" / "data" / "input" / "yaml_roundtrip" / "input.yaml"
+ expected = _baseline_json("yaml_roundtrip")
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", str(input_file), "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge yaml->json failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+ assert json.loads(result.stdout) == [expected]
+
+
+def test_sarge_reads_toml_file_and_outputs_json(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_file = root / "e2e" / "data" / "input" / "toml_roundtrip" / "input.toml"
+ expected = _baseline_json("toml_roundtrip")
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", str(input_file), "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge toml->json failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+ assert json.loads(result.stdout) == [expected]
+
+
+def test_sarge_reads_ini_file_and_outputs_json(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_file = root / "e2e" / "data" / "input" / "ini_roundtrip" / "input.ini"
+ expected = _baseline_json("ini_roundtrip")
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", str(input_file), "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge ini->json failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+ assert json.loads(result.stdout) == [expected]
+
+
+def test_sarge_reads_csv_file_and_outputs_json(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_file = root / "e2e" / "data" / "input" / "csv_roundtrip" / "input.csv"
+ expected = _baseline_json("csv_roundtrip")
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", str(input_file), "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge csv->json failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+ assert json.loads(result.stdout) == [expected]
+
+
+def test_sarge_reads_plaintext_file_and_outputs_json(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_file = root / "e2e" / "data" / "input" / "plaintext_lines" / "input.txt"
+ expected = _baseline_json("plaintext_lines")
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", str(input_file), "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert (
+ result.returncode == 0
+ ), f"multiio_sarge plaintext->json failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+ assert json.loads(result.stdout) == [expected]
+
+
+def test_sarge_reads_xml_file_and_outputs_json(tmp_path: Path, multiio_sarge_bin: Path) -> None:
+ root = project_root()
+ input_file = root / "e2e" / "data" / "input" / "xml_roundtrip" / "input.xml"
+ expected = _baseline_json("xml_roundtrip")
+
+ result = subprocess.run(
+ [str(multiio_sarge_bin), "--input", str(input_file), "--output", "stdout"],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, f"multiio_sarge xml->json failed: {result.stderr}\nStdout: {result.stdout}"
+ assert result.stderr == ""
+ assert json.loads(result.stdout) == [expected]
diff --git a/src/bin/multiio_sarge.rs b/src/bin/multiio_sarge.rs
new file mode 100644
index 0000000..a881c4a
--- /dev/null
+++ b/src/bin/multiio_sarge.rs
@@ -0,0 +1,68 @@
+use std::error::Error;
+
+use multiio::cli::{InputArgs, OutputArgs};
+use multiio::format::default_registry;
+use multiio::{ErrorPolicy, MultiioBuilder};
+use sarge::prelude::*;
+
+fn print_usage() {
+ eprintln!("Usage:");
+ eprintln!(
+ " multiio_sarge --input [--input ...] --output [--output ...]"
+ );
+ eprintln!();
+ eprintln!("Input tokens:");
+ eprintln!(" - | stdin Read from stdin");
+ eprintln!(" = Inline content (in-memory input)");
+ eprintln!(" @ Force treating value as a file path");
+ eprintln!();
+ eprintln!("Output tokens:");
+ eprintln!(" - | stdout Write to stdout");
+ eprintln!(" stderr Write to stderr");
+ eprintln!(" @ Force treating value as a file path");
+}
+
+fn run() -> Result<(), Box> {
+ let mut reader = ArgumentReader::new();
+
+ let input_ref = reader.add::(tag::both('i', "input"));
+ let output_ref = reader.add::(tag::both('o', "output"));
+
+ let args = reader.parse()?;
+
+ let input = match input_ref.get(&args) {
+ Some(Ok(v)) => v,
+ Some(Err(_)) => unreachable!("InputArgs parsing is infallible"),
+ None => InputArgs::default(),
+ };
+
+ let output = match output_ref.get(&args) {
+ Some(Ok(v)) => v,
+ Some(Err(_)) => unreachable!("OutputArgs parsing is infallible"),
+ None => OutputArgs::default(),
+ };
+
+ if input.is_empty() || output.is_empty() {
+ return Err("missing --input/--output".into());
+ }
+
+ let registry = default_registry();
+ let engine = MultiioBuilder::new(registry)
+ .with_mode(ErrorPolicy::FastFail)
+ .with_input_args(&input)
+ .with_output_args(&output)
+ .build()?;
+
+ let values: Vec = engine.read_all()?;
+ engine.write_all(&values)?;
+
+ Ok(())
+}
+
+fn main() {
+ if let Err(e) = run() {
+ eprintln!("multiio_sarge error: {e}");
+ print_usage();
+ std::process::exit(1);
+ }
+}
diff --git a/src/builder.rs b/src/builder.rs
index 1990afb..de390f1 100644
--- a/src/builder.rs
+++ b/src/builder.rs
@@ -12,6 +12,7 @@ use crate::error::{AggregateError, ErrorPolicy, SingleIoError, Stage};
use crate::format::CustomFormat;
use crate::format::{DEFAULT_FORMAT_ORDER, FormatKind, FormatRegistry};
use crate::io::{FileInput, FileOutput, InputProvider, OutputTarget, StdinInput, StdoutOutput};
+use crate::io::{InMemorySource, StderrOutput};
pub struct MultiioBuilder {
input_args: Vec,
@@ -51,11 +52,19 @@ impl MultiioBuilder {
self
}
+ pub fn with_input_args(self, args: &crate::cli::InputArgs) -> Self {
+ self.inputs_from_args(args.as_slice())
+ }
+
pub fn outputs_from_args(mut self, args: &[String]) -> Self {
self.output_args = args.to_vec();
self
}
+ pub fn with_output_args(self, args: &crate::cli::OutputArgs) -> Self {
+ self.outputs_from_args(args.as_slice())
+ }
+
pub fn add_input(mut self, arg: impl Into) -> Self {
self.input_args.push(arg.into());
self
@@ -142,18 +151,61 @@ impl MultiioBuilder {
}
fn resolve_single_input(&self, raw: &str) -> Result {
- if raw == "-" {
+ let raw = raw.trim();
+
+ if let Some(path) = raw.strip_prefix('@') {
+ if path.is_empty() {
+ return Err(SingleIoError {
+ stage: Stage::ResolveInput,
+ target: raw.to_string(),
+ error: Box::new(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "expected a path after '@'",
+ )),
+ });
+ }
+
+ let path = PathBuf::from(path);
+ let provider: Arc = Arc::new(FileInput::new(path.clone()));
+ let explicit = self.infer_format_from_path(&path);
+
return Ok(InputSpec {
- raw: raw.to_string(),
+ raw: path.to_string_lossy().into_owned(),
+ provider,
+ explicit_format: explicit,
+ format_candidates: self.default_input_formats.clone(),
+ });
+ }
+
+ if raw == "-" || raw.eq_ignore_ascii_case("stdin") {
+ return Ok(InputSpec {
+ raw: "-".to_string(),
provider: Arc::new(StdinInput::new()),
explicit_format: None,
format_candidates: self.default_input_formats.clone(),
});
}
+ if let Some(content) = raw.strip_prefix('=') {
+ use std::hash::{Hash, Hasher};
+
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ content.hash(&mut hasher);
+ let id = format!("inline:{:016x}", hasher.finish());
+
+ let provider: Arc =
+ Arc::new(InMemorySource::from_string(id.clone(), content.to_string()));
+
+ return Ok(InputSpec {
+ raw: id,
+ provider,
+ explicit_format: None,
+ format_candidates: self.default_input_formats.clone(),
+ });
+ }
+
let path = PathBuf::from(raw);
let provider: Arc = Arc::new(FileInput::new(path.clone()));
-
let explicit = self.infer_format_from_path(&path);
Ok(InputSpec {
@@ -188,9 +240,36 @@ impl MultiioBuilder {
}
fn resolve_single_output(&self, raw: &str) -> Result {
- if raw == "-" {
+ let raw = raw.trim();
+
+ if let Some(path) = raw.strip_prefix('@') {
+ if path.is_empty() {
+ return Err(SingleIoError {
+ stage: Stage::ResolveOutput,
+ target: raw.to_string(),
+ error: Box::new(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "expected a path after '@'",
+ )),
+ });
+ }
+
+ let path = PathBuf::from(path);
+ let target: Arc = Arc::new(FileOutput::new(path.clone()));
+ let explicit = self.infer_format_from_path(&path);
+
return Ok(OutputSpec {
- raw: raw.to_string(),
+ raw: path.to_string_lossy().into_owned(),
+ target,
+ explicit_format: explicit,
+ format_candidates: self.default_output_formats.clone(),
+ file_exists_policy: self.file_exists_policy,
+ });
+ }
+
+ if raw == "-" || raw.eq_ignore_ascii_case("stdout") {
+ return Ok(OutputSpec {
+ raw: "-".to_string(),
target: Arc::new(StdoutOutput::new()),
explicit_format: None,
format_candidates: self.default_output_formats.clone(),
@@ -198,6 +277,16 @@ impl MultiioBuilder {
});
}
+ if raw.eq_ignore_ascii_case("stderr") {
+ return Ok(OutputSpec {
+ raw: "stderr".to_string(),
+ target: Arc::new(StderrOutput::new()),
+ explicit_format: None,
+ format_candidates: self.default_output_formats.clone(),
+ file_exists_policy: self.file_exists_policy,
+ });
+ }
+
let path = PathBuf::from(raw);
let target: Arc = Arc::new(FileOutput::new(path.clone()));
@@ -370,7 +459,7 @@ impl Default for MultiioBuilder {
#[cfg(test)]
mod tests {
use super::*;
- use crate::format::{DEFAULT_FORMAT_ORDER, default_registry};
+ use crate::format::{DEFAULT_FORMAT_ORDER, FormatKind, default_registry};
#[test]
fn builder_defaults_match_default_format_order() {
@@ -378,4 +467,52 @@ mod tests {
assert_eq!(builder.default_input_formats, DEFAULT_FORMAT_ORDER);
assert_eq!(builder.default_output_formats, DEFAULT_FORMAT_ORDER);
}
+
+ #[test]
+ fn resolve_single_input_supports_stdin_alias_and_inline_content() {
+ let builder = MultiioBuilder::default();
+
+ let stdin = builder.resolve_single_input("stdin").expect("stdin spec");
+ assert_eq!(stdin.raw, "-");
+ assert_eq!(stdin.provider.id(), "-");
+ assert!(stdin.explicit_format.is_none());
+
+ let inline = builder.resolve_single_input("=hello").expect("inline spec");
+ assert!(inline.raw.starts_with("inline:"));
+ assert_eq!(inline.provider.id(), inline.raw);
+ assert!(inline.explicit_format.is_none());
+
+ let forced_path = builder
+ .resolve_single_input("@file.txt")
+ .expect("forced path spec");
+ assert_eq!(forced_path.raw, "file.txt");
+ assert_eq!(forced_path.provider.id(), "file.txt");
+ assert_eq!(forced_path.explicit_format, Some(FormatKind::Plaintext));
+ }
+
+ #[test]
+ fn resolve_single_output_supports_stdout_alias_stderr_and_forced_path() {
+ let builder = MultiioBuilder::default();
+
+ let stdout = builder
+ .resolve_single_output("stdout")
+ .expect("stdout spec");
+ assert_eq!(stdout.raw, "-");
+ assert_eq!(stdout.target.id(), "-");
+ assert!(stdout.explicit_format.is_none());
+
+ let stderr = builder
+ .resolve_single_output("stderr")
+ .expect("stderr spec");
+ assert_eq!(stderr.raw, "stderr");
+ assert_eq!(stderr.target.id(), "stderr");
+ assert!(stderr.explicit_format.is_none());
+
+ let forced_path = builder
+ .resolve_single_output("@out.txt")
+ .expect("forced path spec");
+ assert_eq!(forced_path.raw, "out.txt");
+ assert_eq!(forced_path.target.id(), "out.txt");
+ assert_eq!(forced_path.explicit_format, Some(FormatKind::Plaintext));
+ }
}
diff --git a/src/builder_async.rs b/src/builder_async.rs
index 770dc76..5451e75 100644
--- a/src/builder_async.rs
+++ b/src/builder_async.rs
@@ -15,8 +15,8 @@ use crate::format::{
default_registry,
};
use crate::io::{
- AsyncFileInput, AsyncFileOutput, AsyncInputProvider, AsyncOutputTarget, AsyncStdinInput,
- AsyncStdoutOutput,
+ AsyncFileInput, AsyncFileOutput, AsyncInMemorySource, AsyncInputProvider, AsyncOutputTarget,
+ AsyncStderrOutput, AsyncStdinInput, AsyncStdoutOutput,
};
pub struct MultiioAsyncBuilder {
@@ -86,11 +86,19 @@ impl MultiioAsyncBuilder {
self
}
+ pub fn with_input_args(self, args: &crate::cli::InputArgs) -> Self {
+ self.inputs_from_args(args.as_slice())
+ }
+
pub fn outputs_from_args(mut self, args: &[String]) -> Self {
self.output_args = args.to_vec();
self
}
+ pub fn with_output_args(self, args: &crate::cli::OutputArgs) -> Self {
+ self.outputs_from_args(args.as_slice())
+ }
+
pub fn add_input(mut self, arg: impl Into) -> Self {
self.input_args.push(arg.into());
self
@@ -178,18 +186,63 @@ impl MultiioAsyncBuilder {
}
fn resolve_single_input(&self, raw: &str) -> Result {
- if raw == "-" {
+ let raw = raw.trim();
+
+ if let Some(path) = raw.strip_prefix('@') {
+ if path.is_empty() {
+ return Err(SingleIoError {
+ stage: Stage::ResolveInput,
+ target: raw.to_string(),
+ error: Box::new(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "expected a path after '@'",
+ )),
+ });
+ }
+
+ let path = PathBuf::from(path);
+ let provider: Arc = Arc::new(AsyncFileInput::new(path.clone()));
+ let explicit = self.infer_format_from_path(&path);
+
return Ok(AsyncInputSpec {
- raw: raw.to_string(),
+ raw: path.to_string_lossy().into_owned(),
+ provider,
+ explicit_format: explicit,
+ format_candidates: self.default_input_formats.clone(),
+ });
+ }
+
+ if raw == "-" || raw.eq_ignore_ascii_case("stdin") {
+ return Ok(AsyncInputSpec {
+ raw: "-".to_string(),
provider: Arc::new(AsyncStdinInput::new()),
explicit_format: None,
format_candidates: self.default_input_formats.clone(),
});
}
+ if let Some(content) = raw.strip_prefix('=') {
+ use std::hash::{Hash, Hasher};
+
+ let mut hasher = std::collections::hash_map::DefaultHasher::new();
+ content.hash(&mut hasher);
+ let id = format!("inline:{:016x}", hasher.finish());
+
+ let provider: Arc = Arc::new(AsyncInMemorySource::from_string(
+ id.clone(),
+ content.to_string(),
+ ));
+
+ return Ok(AsyncInputSpec {
+ raw: id,
+ provider,
+ explicit_format: None,
+ format_candidates: self.default_input_formats.clone(),
+ });
+ }
+
let path = PathBuf::from(raw);
let provider: Arc = Arc::new(AsyncFileInput::new(path.clone()));
-
let explicit = self.infer_format_from_path(&path);
Ok(AsyncInputSpec {
@@ -224,9 +277,36 @@ impl MultiioAsyncBuilder {
}
fn resolve_single_output(&self, raw: &str) -> Result {
- if raw == "-" {
+ let raw = raw.trim();
+
+ if let Some(path) = raw.strip_prefix('@') {
+ if path.is_empty() {
+ return Err(SingleIoError {
+ stage: Stage::ResolveOutput,
+ target: raw.to_string(),
+ error: Box::new(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "expected a path after '@'",
+ )),
+ });
+ }
+
+ let path = PathBuf::from(path);
+ let target: Arc = Arc::new(AsyncFileOutput::new(path.clone()));
+ let explicit = self.infer_format_from_path(&path);
+
return Ok(AsyncOutputSpec {
- raw: raw.to_string(),
+ raw: path.to_string_lossy().into_owned(),
+ target,
+ explicit_format: explicit,
+ format_candidates: self.default_output_formats.clone(),
+ file_exists_policy: self.file_exists_policy,
+ });
+ }
+
+ if raw == "-" || raw.eq_ignore_ascii_case("stdout") {
+ return Ok(AsyncOutputSpec {
+ raw: "-".to_string(),
target: Arc::new(AsyncStdoutOutput::new()),
explicit_format: None,
format_candidates: self.default_output_formats.clone(),
@@ -234,6 +314,16 @@ impl MultiioAsyncBuilder {
});
}
+ if raw.eq_ignore_ascii_case("stderr") {
+ return Ok(AsyncOutputSpec {
+ raw: "stderr".to_string(),
+ target: Arc::new(AsyncStderrOutput::new()),
+ explicit_format: None,
+ format_candidates: self.default_output_formats.clone(),
+ file_exists_policy: self.file_exists_policy,
+ });
+ }
+
let path = PathBuf::from(raw);
let target: Arc = Arc::new(AsyncFileOutput::new(path.clone()));
@@ -353,6 +443,7 @@ impl MultiioAsyncBuilder {
fn output_from_config(&self, cfg: &OutputConfig) -> Result {
let target: Arc = match cfg.kind.as_str() {
"stdout" | "-" => Arc::new(AsyncStdoutOutput::new()),
+ "stderr" => Arc::new(AsyncStderrOutput::new()),
"file" => {
let path = cfg.path.as_ref().ok_or_else(|| SingleIoError {
stage: Stage::ResolveOutput,
@@ -400,7 +491,7 @@ impl MultiioAsyncBuilder {
#[cfg(test)]
mod tests {
use super::*;
- use crate::format::{DEFAULT_FORMAT_ORDER, default_async_registry};
+ use crate::format::{DEFAULT_FORMAT_ORDER, FormatKind, default_async_registry};
#[test]
fn async_builder_defaults_match_default_format_order() {
@@ -408,4 +499,52 @@ mod tests {
assert_eq!(builder.default_input_formats, DEFAULT_FORMAT_ORDER);
assert_eq!(builder.default_output_formats, DEFAULT_FORMAT_ORDER);
}
+
+ #[test]
+ fn resolve_single_input_supports_stdin_alias_and_inline_content() {
+ let builder = MultiioAsyncBuilder::default();
+
+ let stdin = builder.resolve_single_input("stdin").expect("stdin spec");
+ assert_eq!(stdin.raw, "-");
+ assert_eq!(stdin.provider.id(), "-");
+ assert!(stdin.explicit_format.is_none());
+
+ let inline = builder.resolve_single_input("=hello").expect("inline spec");
+ assert!(inline.raw.starts_with("inline:"));
+ assert_eq!(inline.provider.id(), inline.raw);
+ assert!(inline.explicit_format.is_none());
+
+ let forced_path = builder
+ .resolve_single_input("@file.txt")
+ .expect("forced path spec");
+ assert_eq!(forced_path.raw, "file.txt");
+ assert_eq!(forced_path.provider.id(), "file.txt");
+ assert_eq!(forced_path.explicit_format, Some(FormatKind::Plaintext));
+ }
+
+ #[test]
+ fn resolve_single_output_supports_stdout_alias_stderr_and_forced_path() {
+ let builder = MultiioAsyncBuilder::default();
+
+ let stdout = builder
+ .resolve_single_output("stdout")
+ .expect("stdout spec");
+ assert_eq!(stdout.raw, "-");
+ assert_eq!(stdout.target.id(), "-");
+ assert!(stdout.explicit_format.is_none());
+
+ let stderr = builder
+ .resolve_single_output("stderr")
+ .expect("stderr spec");
+ assert_eq!(stderr.raw, "stderr");
+ assert_eq!(stderr.target.id(), "stderr");
+ assert!(stderr.explicit_format.is_none());
+
+ let forced_path = builder
+ .resolve_single_output("@out.txt")
+ .expect("forced path spec");
+ assert_eq!(forced_path.raw, "out.txt");
+ assert_eq!(forced_path.target.id(), "out.txt");
+ assert_eq!(forced_path.explicit_format, Some(FormatKind::Plaintext));
+ }
}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 47fd5b8..1a4e8ab 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -1,44 +1,82 @@
//! CLI integration helpers for multiio.
//!
-//! This module provides utilities to integrate multiio with CLI argument parsers
-//! like `clap`. It helps convert CLI arguments into multiio configurations.
+//! This module keeps CLI-related types intentionally lightweight so callers can
+//! integrate with any argument parser (clap/sarge/argh/…).
//!
-//! # Example with clap
+//! # Token conventions
+//!
+//! multiio intentionally supports a small set of conventional "special" tokens
+//! for CLI ergonomics:
+//!
+//! - Inputs:
+//! - `-` or `stdin` => stdin
+//! - `=` => inline content (in-memory input)
+//! - `@` => force treating the value as a file path (useful for
+//! disambiguating reserved tokens)
+//! - Outputs:
+//! - `-` or `stdout` => stdout
+//! - `stderr` => stderr
+//! - `@` => force treating the value as a file path (e.g. `@stderr`)
+//!
+//! # Example
//!
//! ```rust,ignore
-//! use clap::Parser;
+//! use multiio::{default_registry, MultiioBuilder};
//! use multiio::cli::{InputArgs, OutputArgs};
//!
-//! #[derive(Parser)]
-//! struct Cli {
-//! #[clap(flatten)]
-//! input: InputArgs,
-//!
-//! #[clap(flatten)]
-//! output: OutputArgs,
-//! }
+//! fn run(inputs: Vec, outputs: Vec) -> Result<(), Box> {
+//! let input = InputArgs::from(inputs);
+//! let output = OutputArgs::from(outputs);
//!
-//! fn main() {
-//! let cli = Cli::parse();
+//! let engine = MultiioBuilder::new(default_registry())
+//! .with_input_args(&input)
+//! .with_output_args(&output)
+//! .build()?;
//!
-//! let builder = MultiioBuilder::new(default_registry())
-//! .with_input_args(&cli.input)
-//! .with_output_args(&cli.output);
+//! let values: Vec = engine.read_all()?;
+//! engine.write_all(&values)?;
+//! Ok(())
//! }
//! ```
-use crate::format::FormatKind;
+#[cfg(feature = "sarge")]
+mod sarge;
+
+macro_rules! impls_for {
+ (
+ $name:ident => $type:path
+ ) => {
+ impl Deref for $name {
+ type Target = $type;
+
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+ }
+
+ impl DerefMut for $name {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.0
+ }
+ }
+
+ impl From<$type> for $name {
+ fn from(inputs: $type) -> Self {
+ Self(inputs)
+ }
+ }
+
+ impl From<$name> for $type {
+ fn from(args: $name) -> Self {
+ args.0
+ }
+ }
+ };
+}
/// Common input arguments for CLI applications.
-///
-/// Can be used with `#[clap(flatten)]` to add standard input options.
#[derive(Debug, Clone, Default)]
-pub struct InputArgs {
- /// Input file paths. Use "-" for stdin.
- pub inputs: Vec,
- /// Explicit input format (overrides auto-detection).
- pub input_format: Option,
-}
+pub struct InputArgs(Vec);
impl InputArgs {
pub fn new() -> Self {
@@ -46,37 +84,18 @@ impl InputArgs {
}
pub fn with_input(mut self, path: impl Into) -> Self {
- self.inputs.push(path.into());
- self
- }
-
- pub fn with_format(mut self, format: impl Into) -> Self {
- self.input_format = Some(format.into());
+ self.push(path.into());
self
}
- pub fn format_kind(&self) -> Option {
- self.input_format
- .as_ref()
- .and_then(|s| s.parse::().ok())
- }
-
pub fn is_stdin(&self) -> bool {
- self.inputs.iter().any(|s| s == "-")
+ self.iter()
+ .any(|s| s == "-" || s.eq_ignore_ascii_case("stdin"))
}
}
#[derive(Debug, Clone, Default)]
-pub struct OutputArgs {
- /// Output file paths. Use "-" for stdout.
- pub outputs: Vec,
- /// Explicit output format (overrides auto-detection).
- pub output_format: Option,
- /// What to do if output file exists.
- pub overwrite: bool,
- /// Append to existing file instead of overwriting.
- pub append: bool,
-}
+pub struct OutputArgs(Vec);
impl OutputArgs {
pub fn new() -> Self {
@@ -84,52 +103,29 @@ impl OutputArgs {
}
pub fn with_output(mut self, path: impl Into) -> Self {
- self.outputs.push(path.into());
- self
- }
-
- /// Set explicit output format.
- pub fn with_format(mut self, format: impl Into) -> Self {
- self.output_format = Some(format.into());
- self
- }
-
- /// Enable overwrite mode.
- pub fn with_overwrite(mut self) -> Self {
- self.overwrite = true;
+ self.push(path.into());
self
}
- /// Enable append mode.
- pub fn with_append(mut self) -> Self {
- self.append = true;
- self
- }
-
- /// Parse the format string into FormatKind.
- pub fn format_kind(&self) -> Option {
- self.output_format
- .as_ref()
- .and_then(|s| s.parse::().ok())
- }
-
/// Check if writing to stdout.
pub fn is_stdout(&self) -> bool {
- self.outputs.iter().any(|s| s == "-")
+ self.iter()
+ .any(|s| s == "-" || s.eq_ignore_ascii_case("stdout"))
}
- /// Get the file exists policy based on flags.
- pub fn file_exists_policy(&self) -> crate::config::FileExistsPolicy {
- if self.append {
- crate::config::FileExistsPolicy::Append
- } else if self.overwrite {
- crate::config::FileExistsPolicy::Overwrite
- } else {
- crate::config::FileExistsPolicy::Error
- }
+ /// Check if writing to stderr.
+ pub fn is_stderr(&self) -> bool {
+ self.iter().any(|s| s.eq_ignore_ascii_case("stderr"))
}
}
+impls_for!(InputArgs => Vec);
+impls_for!(OutputArgs => Vec);
+
+use std::ops::{Deref, DerefMut};
+
+use crate::format::FormatKind;
+
/// Parse a format string into a FormatKind.
///
/// Supports common format names and aliases.
diff --git a/src/cli/sarge.rs b/src/cli/sarge.rs
new file mode 100644
index 0000000..22b89c9
--- /dev/null
+++ b/src/cli/sarge.rs
@@ -0,0 +1,173 @@
+use std::convert::Infallible;
+
+use sarge::ArgumentType;
+
+use crate::cli::{InputArgs, OutputArgs};
+
+fn split_repeatable_values(val: &str) -> Vec {
+ let mut tokens = Vec::new();
+ let mut buf = String::new();
+
+ let mut in_quote: Option = None;
+ let mut depth_brace: usize = 0;
+ let mut depth_bracket: usize = 0;
+ let mut depth_paren: usize = 0;
+
+ let mut it = val.chars().peekable();
+ while let Some(ch) = it.next() {
+ if let Some(q) = in_quote {
+ match ch {
+ '\\' => {
+ buf.push(ch);
+ if let Some(next) = it.next() {
+ buf.push(next);
+ }
+ }
+ _ => {
+ buf.push(ch);
+ if ch == q {
+ in_quote = None;
+ }
+ }
+ }
+ continue;
+ }
+
+ match ch {
+ '"' | '\'' => {
+ in_quote = Some(ch);
+ buf.push(ch);
+ }
+ '\\' => {
+ if matches!(it.peek(), Some(',')) {
+ it.next();
+ buf.push(',');
+ } else {
+ buf.push('\\');
+ }
+ }
+ '{' => {
+ depth_brace = depth_brace.saturating_add(1);
+ buf.push(ch);
+ }
+ '}' => {
+ depth_brace = depth_brace.saturating_sub(1);
+ buf.push(ch);
+ }
+ '[' => {
+ depth_bracket = depth_bracket.saturating_add(1);
+ buf.push(ch);
+ }
+ ']' => {
+ depth_bracket = depth_bracket.saturating_sub(1);
+ buf.push(ch);
+ }
+ '(' => {
+ depth_paren = depth_paren.saturating_add(1);
+ buf.push(ch);
+ }
+ ')' => {
+ depth_paren = depth_paren.saturating_sub(1);
+ buf.push(ch);
+ }
+ ',' if depth_brace == 0 && depth_bracket == 0 && depth_paren == 0 => {
+ let token = buf.trim();
+ if !token.is_empty() {
+ tokens.push(token.to_string());
+ }
+ buf.clear();
+ }
+ _ => buf.push(ch),
+ }
+ }
+
+ let token = buf.trim();
+ if !token.is_empty() {
+ tokens.push(token.to_string());
+ }
+
+ tokens
+}
+
+impl ArgumentType for InputArgs {
+ type Error = Infallible;
+
+ const REPEATABLE: bool = true;
+
+ fn from_value(val: Option<&str>) -> sarge::ArgResult {
+ fn normalize(token: &str) -> String {
+ // Preserve explicit prefixes so callers can disambiguate.
+ if token.starts_with('@') || token.starts_with('=') {
+ return token.to_string();
+ }
+
+ if token == "-" || token.eq_ignore_ascii_case("stdin") {
+ return "-".to_string();
+ }
+
+ // Auto-detect: existing path => file, otherwise treat as inline content.
+ if std::fs::metadata(token).is_ok() {
+ token.to_string()
+ } else {
+ format!("={token}")
+ }
+ }
+
+ let mut inputs = Vec::new();
+ match val {
+ None => inputs.push("-".to_string()),
+ Some(v) => {
+ for token in split_repeatable_values(v) {
+ inputs.push(normalize(token.trim()));
+ }
+ }
+ }
+
+ Some(Ok(InputArgs(inputs)))
+ }
+
+ fn default_value() -> Option {
+ Some(InputArgs::default())
+ }
+}
+
+impl ArgumentType for OutputArgs {
+ type Error = Infallible;
+
+ const REPEATABLE: bool = true;
+
+ fn from_value(val: Option<&str>) -> sarge::ArgResult {
+ fn normalize(token: &str) -> String {
+ // Preserve explicit prefixes so callers can disambiguate.
+ if token.starts_with('@') {
+ return token.to_string();
+ }
+
+ if token == "-" || token.eq_ignore_ascii_case("stdout") {
+ return "-".to_string();
+ }
+
+ if token.eq_ignore_ascii_case("stderr") {
+ return "stderr".to_string();
+ }
+
+ token.to_string()
+ }
+
+ let mut outputs = Vec::new();
+ match val {
+ None => outputs.push("-".to_string()),
+ Some(v) => {
+ for token in split_repeatable_values(v) {
+ outputs.push(normalize(token.trim()));
+ }
+ }
+ }
+
+ Some(Ok(OutputArgs(outputs)))
+ }
+
+ fn default_value() -> Option {
+ Some(OutputArgs::default())
+ }
+}
diff --git a/src/io/async_std_io.rs b/src/io/async_std_io.rs
index 2a4315d..309d022 100644
--- a/src/io/async_std_io.rs
+++ b/src/io/async_std_io.rs
@@ -96,6 +96,40 @@ impl AsyncOutputTarget for AsyncStdoutOutput {
}
}
+#[derive(Debug, Clone)]
+pub struct AsyncStderrOutput {
+ id: String,
+}
+
+impl AsyncStderrOutput {
+ pub fn new() -> Self {
+ Self {
+ id: "stderr".into(),
+ }
+ }
+}
+
+impl Default for AsyncStderrOutput {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl AsyncOutputTarget for AsyncStderrOutput {
+ fn id(&self) -> &str {
+ &self.id
+ }
+
+ async fn open_overwrite(&self) -> std::io::Result> {
+ Ok(Box::new(BufWriter::new(tokio::io::stderr())))
+ }
+
+ async fn open_append(&self) -> std::io::Result> {
+ Ok(Box::new(BufWriter::new(tokio::io::stderr())))
+ }
+}
+
#[derive(Debug, Clone)]
pub struct AsyncFileOutput {
id: String,
diff --git a/src/io/memory.rs b/src/io/memory.rs
index b0facbf..75bf2f4 100644
--- a/src/io/memory.rs
+++ b/src/io/memory.rs
@@ -3,7 +3,13 @@
use std::io::{self, Cursor, Read, Write};
use std::sync::{Arc, Mutex};
+#[cfg(feature = "async")]
+use super::AsyncInputProvider;
use super::{InputProvider, OutputTarget};
+#[cfg(feature = "async")]
+use async_trait::async_trait;
+#[cfg(feature = "async")]
+use tokio::io::{AsyncRead, BufReader};
/// In-memory input source for testing.
#[derive(Debug, Clone)]
@@ -95,6 +101,42 @@ impl OutputTarget for InMemorySink {
}
}
+/// Async in-memory input source (useful for tests and CLI inline content).
+#[cfg(feature = "async")]
+#[derive(Debug, Clone)]
+pub struct AsyncInMemorySource {
+ id: String,
+ data: Arc>,
+}
+
+#[cfg(feature = "async")]
+impl AsyncInMemorySource {
+ pub fn new(id: impl Into, data: Vec) -> Self {
+ Self {
+ id: id.into(),
+ data: Arc::new(data),
+ }
+ }
+
+ pub fn from_string(id: impl Into, data: impl Into) -> Self {
+ Self::new(id, data.into().into_bytes())
+ }
+}
+
+#[cfg(feature = "async")]
+#[async_trait]
+impl AsyncInputProvider for AsyncInMemorySource {
+ fn id(&self) -> &str {
+ &self.id
+ }
+
+ async fn open(&self) -> io::Result> {
+ Ok(Box::new(BufReader::new(Cursor::new(
+ self.data.as_ref().clone(),
+ ))))
+ }
+}
+
/// Write handle for in-memory sink.
struct InMemoryWriteHandle {
buf: Arc>>,
diff --git a/src/io/mod.rs b/src/io/mod.rs
index 6f7adee..fd037b3 100644
--- a/src/io/mod.rs
+++ b/src/io/mod.rs
@@ -12,6 +12,8 @@ mod output;
mod std_io;
pub use input::InputProvider;
+#[cfg(feature = "async")]
+pub use memory::AsyncInMemorySource;
pub use memory::{InMemorySink, InMemorySource};
pub use output::OutputTarget;
pub use std_io::{FileInput, FileOutput, StderrOutput, StdinInput, StdoutOutput};
@@ -29,4 +31,6 @@ pub use async_input::AsyncInputProvider;
#[cfg(feature = "async")]
pub use async_output::AsyncOutputTarget;
#[cfg(feature = "async")]
-pub use async_std_io::{AsyncFileInput, AsyncFileOutput, AsyncStdinInput, AsyncStdoutOutput};
+pub use async_std_io::{
+ AsyncFileInput, AsyncFileOutput, AsyncStderrOutput, AsyncStdinInput, AsyncStdoutOutput,
+};
diff --git a/src/lib.rs b/src/lib.rs
index ce5c18b..c554d9a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -127,8 +127,8 @@ pub use engine_async::AsyncIoEngine;
pub use format::{AsyncFormatRegistry, default_async_registry};
#[cfg(feature = "async")]
pub use io::{
- AsyncFileInput, AsyncFileOutput, AsyncInputProvider, AsyncOutputTarget, AsyncStdinInput,
- AsyncStdoutOutput,
+ AsyncFileInput, AsyncFileOutput, AsyncInMemorySource, AsyncInputProvider, AsyncOutputTarget,
+ AsyncStderrOutput, AsyncStdinInput, AsyncStdoutOutput,
};
/// Build a synchronous IoEngine from a PipelineConfig using the default
diff --git a/src/tests/cli/mod.rs b/src/tests/cli/mod.rs
index fb9d80f..547ffa0 100644
--- a/src/tests/cli/mod.rs
+++ b/src/tests/cli/mod.rs
@@ -1,3 +1,6 @@
//! CLI-related tests.
mod parse_tests;
+
+#[cfg(feature = "sarge")]
+mod sarge_tests;
diff --git a/src/tests/cli/sarge_tests.rs b/src/tests/cli/sarge_tests.rs
new file mode 100644
index 0000000..327ae5e
--- /dev/null
+++ b/src/tests/cli/sarge_tests.rs
@@ -0,0 +1,129 @@
+use crate::cli::{InputArgs, OutputArgs};
+use sarge::ArgumentType;
+
+#[test]
+fn input_args_autodetects_path_vs_inline_content() {
+ let dir = tempfile::tempdir().expect("tempdir");
+
+ let existing = dir.path().join("in.txt");
+ std::fs::write(&existing, "hello").expect("write");
+ let existing = existing.to_string_lossy().to_string();
+
+ let parsed = ::from_value(Some(&existing))
+ .expect("some")
+ .expect("ok");
+ assert_eq!(parsed.as_slice(), &[existing.clone()]);
+
+ let missing = dir.path().join("missing.txt").to_string_lossy().to_string();
+ let parsed = ::from_value(Some(&missing))
+ .expect("some")
+ .expect("ok");
+ assert_eq!(parsed.as_slice(), &[format!("={missing}")]);
+}
+
+#[test]
+fn output_args_normalizes_stdout_stderr_and_forced_path() {
+ let stdout = ::from_value(Some("stdout"))
+ .expect("some")
+ .expect("ok");
+ assert_eq!(stdout.as_slice(), &["-".to_string()]);
+
+ let stderr = ::from_value(Some("stderr"))
+ .expect("some")
+ .expect("ok");
+ assert_eq!(stderr.as_slice(), &["stderr".to_string()]);
+
+ let forced_path = ::from_value(Some("@stderr"))
+ .expect("some")
+ .expect("ok");
+ assert_eq!(forced_path.as_slice(), &["@stderr".to_string()]);
+}
+
+#[test]
+fn input_args_repeatable_split_allows_inline_json_with_commas() {
+ // This string mirrors how sarge joins repeatable values: it inserts a top-level comma
+ // between occurrences. The first JSON value itself contains commas.
+ let merged = r#"{"a":1,"b":2},{"c":3}"#;
+
+ let parsed = ::from_value(Some(merged))
+ .expect("some")
+ .expect("ok");
+
+ assert_eq!(
+ parsed.as_slice(),
+ &[r#"={"a":1,"b":2}"#.to_string(), r#"={"c":3}"#.to_string()]
+ );
+}
+
+#[test]
+fn input_args_repeatable_split_allows_inline_yaml_flow_mapping_with_commas() {
+ let merged = r#"{a:1,b:2},{c:3}"#;
+
+ let parsed = ::from_value(Some(merged))
+ .expect("some")
+ .expect("ok");
+
+ assert_eq!(
+ parsed.as_slice(),
+ &[r#"={a:1,b:2}"#.to_string(), r#"={c:3}"#.to_string()]
+ );
+}
+
+#[test]
+fn input_args_repeatable_split_allows_inline_toml_inline_table_with_commas() {
+ let merged = r#"{a=1,b=2},{c=3}"#;
+
+ let parsed = ::from_value(Some(merged))
+ .expect("some")
+ .expect("ok");
+
+ assert_eq!(
+ parsed.as_slice(),
+ &[r#"={a=1,b=2}"#.to_string(), r#"={c=3}"#.to_string()]
+ );
+}
+
+#[test]
+fn input_args_repeatable_split_allows_inline_xml_with_commas_in_quoted_attrs() {
+ let merged = r#","#;
+
+ let parsed = ::from_value(Some(merged))
+ .expect("some")
+ .expect("ok");
+
+ assert_eq!(
+ parsed.as_slice(),
+ &[
+ r#"="#.to_string(),
+ r#"="#.to_string()
+ ]
+ );
+}
+
+#[test]
+fn input_args_repeatable_split_supports_escaped_commas_for_plaintext_like_tokens() {
+ let merged = r#"=a\,b,=c"#;
+
+ let parsed = ::from_value(Some(merged))
+ .expect("some")
+ .expect("ok");
+
+ assert_eq!(
+ parsed.as_slice(),
+ &[r#"=a,b"#.to_string(), r#"=c"#.to_string()]
+ );
+}
+
+#[test]
+fn output_args_repeatable_split_allows_escaped_commas() {
+ let merged = r#"@a\,b.txt,stderr"#;
+
+ let parsed = ::from_value(Some(merged))
+ .expect("some")
+ .expect("ok");
+
+ assert_eq!(
+ parsed.as_slice(),
+ &["@a,b.txt".to_string(), "stderr".to_string()]
+ );
+}
diff --git a/src/tests/feature_matrix.rs b/src/tests/feature_matrix.rs
index dac2529..7e01c56 100644
--- a/src/tests/feature_matrix.rs
+++ b/src/tests/feature_matrix.rs
@@ -66,6 +66,10 @@ fn feature_matrix_compiles() {
no_default: true,
features: vec!["plaintext"],
},
+ Case {
+ no_default: true,
+ features: vec!["sarge"],
+ },
Case {
no_default: true,
features: vec!["yaml"],
@@ -95,6 +99,10 @@ fn feature_matrix_compiles() {
features: vec!["custom"],
}, // pulls json transitively
// Multi-feature and umbrella sets.
+ Case {
+ no_default: true,
+ features: vec!["plaintext", "sarge"],
+ },
Case {
no_default: true,
features: vec!["json", "yaml"],