Skip to main content

rusty_sponge/
cli.rs

1//! Command-line interface for `rusty-sponge`.
2//!
3//! The parsed `Cli` struct is consumed by `lib.rs::run()`. Mode resolution
4//! happens *after* parse (see [`crate::mode::resolve`]) so the precedence
5//! ladder can consider the CLI flag, the env var, and `argv[0]` together.
6
7use clap::Parser;
8use std::path::{Path, PathBuf};
9
10use crate::Error;
11
12#[derive(Parser, Debug)]
13#[command(
14    name = "rusty-sponge",
15    version,
16    about = "Soak up all of stdin and write it atomically to a file.",
17    long_about = "A Rust port of moreutils `sponge`. Buffers all of stdin (in memory \
18                  up to a configurable threshold, then spills to a tempfile) before \
19                  writing the buffered bytes atomically to the target file via a \
20                  sibling tempfile + rename. Without a file argument, writes to stdout."
21)]
22pub struct Cli {
23    /// Append to the target instead of replacing it (reads the existing file
24    /// first, then concatenates stdin).
25    #[arg(short = 'a', long = "append")]
26    pub append: bool,
27
28    /// Enable strict moreutils-compat mode. Rejects every Default-mode
29    /// extension and emits byte-equal usage/error text vs moreutils sponge.
30    #[arg(long, conflicts_with = "no_strict")]
31    pub strict: bool,
32
33    /// Explicitly disable strict mode (overrides `RUSTY_SPONGE_STRICT` env
34    /// var and `argv[0] = sponge` auto-detect). Highest precedence.
35    #[arg(long = "no-strict")]
36    pub no_strict: bool,
37
38    /// Override the in-memory spill threshold (Default mode only; ignored
39    /// in Strict mode). Default: 128 MiB.
40    #[arg(
41        long = "spill-mb",
42        env = "RUSTY_SPONGE_SPILL_MB",
43        hide_env_values = false
44    )]
45    pub spill_mb: Option<String>,
46
47    /// The file to write to. Omit to write to stdout (pipeline-batching mode).
48    pub target: Option<PathBuf>,
49
50    /// Subcommand (currently only `completions`).
51    #[command(subcommand)]
52    pub command: Option<Subcommand>,
53}
54
55#[derive(clap::Subcommand, Debug)]
56pub enum Subcommand {
57    /// Emit shell completion scripts (Default mode only).
58    Completions {
59        /// Shell name: bash, zsh, fish, powershell, elvish.
60        shell: clap_complete::Shell,
61    },
62}
63
64/// Resolve the explicit `--strict` / `--no-strict` flag value the user supplied.
65/// Returns:
66/// - `Some(true)` if `--strict` was given
67/// - `Some(false)` if `--no-strict` was given
68/// - `None` if neither was given (mode resolution falls through to env / argv[0])
69pub fn strict_flag(cli: &Cli) -> Option<bool> {
70    if cli.strict {
71        Some(true)
72    } else if cli.no_strict {
73        Some(false)
74    } else {
75        None
76    }
77}
78
79/// Pre-write validation: fail fast (before reading any stdin) if the target
80/// is an existing directory. Per FR-014 + HINT-002, this MUST run before any
81/// expensive IO.
82pub fn validate_target(target: &Path) -> Result<(), Error> {
83    if let Ok(meta) = std::fs::symlink_metadata(target) {
84        if meta.is_dir() {
85            return Err(Error::TargetIsDirectory(target.to_path_buf()));
86        }
87    }
88    Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use clap::CommandFactory;
95
96    #[test]
97    fn cli_command_factory_compiles() {
98        // Smoke test: clap can build the command tree from the derive.
99        let cmd = Cli::command();
100        assert_eq!(cmd.get_name(), "rusty-sponge");
101    }
102
103    #[test]
104    fn parse_no_args_means_stdout_target() {
105        let cli = Cli::try_parse_from(["rusty-sponge"]).expect("parse should succeed");
106        assert!(cli.target.is_none());
107        assert!(!cli.append);
108        assert!(!cli.strict);
109    }
110
111    #[test]
112    fn parse_target_file() {
113        let cli = Cli::try_parse_from(["rusty-sponge", "out.txt"]).expect("parse should succeed");
114        assert_eq!(cli.target, Some(PathBuf::from("out.txt")));
115    }
116
117    #[test]
118    fn parse_append_flag() {
119        let cli =
120            Cli::try_parse_from(["rusty-sponge", "-a", "out.txt"]).expect("parse should succeed");
121        assert!(cli.append);
122    }
123
124    #[test]
125    fn parse_long_append_flag() {
126        let cli = Cli::try_parse_from(["rusty-sponge", "--append", "out.txt"])
127            .expect("parse should succeed");
128        assert!(cli.append);
129    }
130
131    #[test]
132    fn parse_strict_flag() {
133        let cli = Cli::try_parse_from(["rusty-sponge", "--strict", "out.txt"])
134            .expect("parse should succeed");
135        assert!(cli.strict);
136        assert_eq!(strict_flag(&cli), Some(true));
137    }
138
139    #[test]
140    fn parse_no_strict_flag() {
141        let cli = Cli::try_parse_from(["rusty-sponge", "--no-strict", "out.txt"])
142            .expect("parse should succeed");
143        assert!(cli.no_strict);
144        assert_eq!(strict_flag(&cli), Some(false));
145    }
146
147    #[test]
148    fn parse_strict_conflicts_with_no_strict() {
149        let result = Cli::try_parse_from(["rusty-sponge", "--strict", "--no-strict", "out.txt"]);
150        assert!(result.is_err(), "--strict and --no-strict must conflict");
151    }
152
153    #[test]
154    fn parse_spill_mb_via_flag() {
155        let cli = Cli::try_parse_from(["rusty-sponge", "--spill-mb", "16", "out.txt"])
156            .expect("parse should succeed");
157        assert_eq!(cli.spill_mb.as_deref(), Some("16"));
158    }
159
160    #[test]
161    fn validate_target_rejects_directory() {
162        let tmpdir = tempfile::tempdir().unwrap();
163        let result = validate_target(tmpdir.path());
164        assert!(matches!(result, Err(Error::TargetIsDirectory(_))));
165    }
166
167    #[test]
168    fn validate_target_accepts_nonexistent_file() {
169        let tmpdir = tempfile::tempdir().unwrap();
170        let nonexistent = tmpdir.path().join("nope.txt");
171        assert!(validate_target(&nonexistent).is_ok());
172    }
173
174    #[test]
175    fn validate_target_accepts_regular_file() {
176        let tmpdir = tempfile::tempdir().unwrap();
177        let f = tmpdir.path().join("regular.txt");
178        std::fs::write(&f, b"hi").unwrap();
179        assert!(validate_target(&f).is_ok());
180    }
181}