1use 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 #[arg(short = 'a', long = "append")]
26 pub append: bool,
27
28 #[arg(long, conflicts_with = "no_strict")]
31 pub strict: bool,
32
33 #[arg(long = "no-strict")]
36 pub no_strict: bool,
37
38 #[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 pub target: Option<PathBuf>,
49
50 #[command(subcommand)]
52 pub command: Option<Subcommand>,
53}
54
55#[derive(clap::Subcommand, Debug)]
56pub enum Subcommand {
57 Completions {
59 shell: clap_complete::Shell,
61 },
62}
63
64pub 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
79pub 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 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}