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"],