command_stream/commands/
tee.rs1use crate::commands::CommandContext;
4use crate::utils::{trace_lazy, CommandResult, VirtualUtils};
5use std::fs::OpenOptions;
6use std::io::{ErrorKind, Write};
7
8fn file_error_message(file: &str, error: &std::io::Error) -> String {
10 match error.kind() {
11 ErrorKind::NotFound => format!("tee: {}: No such file or directory\n", file),
12 ErrorKind::IsADirectory => format!("tee: {}: Is a directory\n", file),
13 ErrorKind::PermissionDenied => format!("tee: {}: Permission denied\n", file),
14 _ if error.to_string().contains("directory") => {
15 format!("tee: {}: Is a directory\n", file)
16 }
17 _ => format!("tee: {}: {}\n", file, error),
18 }
19}
20
21#[derive(Debug, Default, PartialEq)]
23struct ParsedArgs {
24 append: bool,
25 ignore_interrupts: bool,
26 files: Vec<String>,
27 error: Option<String>,
28}
29
30fn parse_args(args: &[String]) -> ParsedArgs {
36 let mut parsed = ParsedArgs::default();
37 let mut options_ended = false;
38
39 for arg in args {
40 if options_ended || arg == "-" || !arg.starts_with('-') {
41 parsed.files.push(arg.clone());
42 continue;
43 }
44
45 if arg == "--" {
46 options_ended = true;
47 continue;
48 }
49
50 if arg == "--append" {
51 parsed.append = true;
52 continue;
53 }
54
55 if arg == "--ignore-interrupts" {
56 parsed.ignore_interrupts = true;
57 continue;
58 }
59
60 if arg.starts_with("--") {
61 parsed.error = Some(format!("tee: unrecognized option '{}'\n", arg));
62 return parsed;
63 }
64
65 for flag in arg.chars().skip(1) {
66 match flag {
67 'a' => parsed.append = true,
68 'i' => parsed.ignore_interrupts = true,
69 _ => {
70 parsed.error = Some(format!("tee: invalid option -- '{}'\n", flag));
71 return parsed;
72 }
73 }
74 }
75 }
76
77 parsed
78}
79
80pub async fn tee(ctx: CommandContext) -> CommandResult {
88 let parsed = parse_args(&ctx.args);
89
90 if let Some(error) = parsed.error {
91 trace_lazy("VirtualCommand", || format!("tee: {}", error.trim_end()));
92 return VirtualUtils::error(error);
93 }
94
95 let input = ctx.stdin.clone().unwrap_or_default();
96
97 trace_lazy("VirtualCommand", || {
98 format!(
99 "tee: starting | append={}, ignore_interrupts={}, files={:?}, stdin_length={}",
100 parsed.append,
101 parsed.ignore_interrupts,
102 parsed.files,
103 input.len()
104 )
105 });
106
107 let cwd = ctx.get_cwd();
108 let mut stderr = String::new();
109 let mut code = 0;
110
111 for file in &parsed.files {
112 if !parsed.ignore_interrupts && ctx.is_cancelled() {
113 trace_lazy("VirtualCommand", || {
114 "tee: cancelled while writing files".to_string()
115 });
116 return CommandResult {
118 stdout: input,
119 stderr,
120 code: 130,
121 };
122 }
123
124 let resolved_path = VirtualUtils::resolve_path(file, Some(&cwd));
125 trace_lazy("VirtualCommand", || {
126 format!(
127 "tee: writing file | file={:?}, append={}, bytes={}",
128 resolved_path,
129 parsed.append,
130 input.len()
131 )
132 });
133
134 let write_result = OpenOptions::new()
135 .write(true)
136 .create(true)
137 .append(parsed.append)
138 .truncate(!parsed.append)
139 .open(&resolved_path)
140 .and_then(|mut handle| handle.write_all(input.as_bytes()));
141
142 if let Err(write_error) = write_result {
143 stderr.push_str(&file_error_message(file, &write_error));
146 code = 1;
147 }
148 }
149
150 trace_lazy("VirtualCommand", || {
151 format!(
152 "tee: finished | files_written={}, code={}, stdout_bytes={}",
153 parsed.files.len(),
154 code,
155 input.len()
156 )
157 });
158
159 CommandResult {
160 stdout: input,
161 stderr,
162 code,
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn args(list: &[&str]) -> Vec<String> {
171 list.iter().map(|s| s.to_string()).collect()
172 }
173
174 #[test]
175 fn test_parse_args_defaults() {
176 let parsed = parse_args(&args(&["a.txt", "b.txt"]));
177 assert!(!parsed.append);
178 assert!(!parsed.ignore_interrupts);
179 assert_eq!(parsed.files, vec!["a.txt", "b.txt"]);
180 assert!(parsed.error.is_none());
181 }
182
183 #[test]
184 fn test_parse_args_short_and_long_flags() {
185 let parsed = parse_args(&args(&["-a", "--ignore-interrupts", "out.txt"]));
186 assert!(parsed.append);
187 assert!(parsed.ignore_interrupts);
188 assert_eq!(parsed.files, vec!["out.txt"]);
189 }
190
191 #[test]
192 fn test_parse_args_clustered_flags() {
193 let parsed = parse_args(&args(&["-ai", "out.txt"]));
194 assert!(parsed.append);
195 assert!(parsed.ignore_interrupts);
196 assert_eq!(parsed.files, vec!["out.txt"]);
197 }
198
199 #[test]
200 fn test_parse_args_double_dash_ends_options() {
201 let parsed = parse_args(&args(&["--", "-a"]));
202 assert!(!parsed.append);
203 assert_eq!(parsed.files, vec!["-a"]);
204 }
205
206 #[test]
207 fn test_parse_args_bare_dash_is_a_file() {
208 let parsed = parse_args(&args(&["-"]));
210 assert_eq!(parsed.files, vec!["-"]);
211 assert!(parsed.error.is_none());
212 }
213
214 #[test]
215 fn test_parse_args_unrecognized_long_option() {
216 let parsed = parse_args(&args(&["--unknown-option", "out.txt"]));
217 assert_eq!(
218 parsed.error,
219 Some("tee: unrecognized option \'--unknown-option\'\n".to_string())
220 );
221 }
222
223 #[test]
224 fn test_parse_args_invalid_short_option() {
225 let parsed = parse_args(&args(&["-z", "out.txt"]));
226 assert_eq!(
227 parsed.error,
228 Some("tee: invalid option -- \'z\'\n".to_string())
229 );
230 }
231
232 #[test]
233 fn test_file_error_messages() {
234 let not_found = std::io::Error::new(ErrorKind::NotFound, "nope");
235 assert_eq!(
236 file_error_message("missing.txt", ¬_found),
237 "tee: missing.txt: No such file or directory\n"
238 );
239
240 let denied = std::io::Error::new(ErrorKind::PermissionDenied, "nope");
241 assert_eq!(
242 file_error_message("locked.txt", &denied),
243 "tee: locked.txt: Permission denied\n"
244 );
245
246 let is_dir = std::io::Error::new(ErrorKind::IsADirectory, "nope");
247 assert_eq!(
248 file_error_message("adir", &is_dir),
249 "tee: adir: Is a directory\n"
250 );
251 }
252
253 #[tokio::test]
254 async fn test_tee_cancellation_returns_sigint_code() {
255 let dir = tempfile::tempdir().unwrap();
256 let file = dir.path().join("out.txt");
257
258 let mut ctx = CommandContext::new(vec![file.to_string_lossy().to_string()]);
259 ctx.stdin = Some("payload".to_string());
260 ctx.is_cancelled = Some(Box::new(|| true));
261
262 let result = tee(ctx).await;
263
264 assert_eq!(result.code, 130);
265 assert_eq!(result.stdout, "payload");
266 assert!(!file.exists());
267 }
268
269 #[tokio::test]
270 async fn test_tee_ignore_interrupts_keeps_writing() {
271 let dir = tempfile::tempdir().unwrap();
272 let file = dir.path().join("out.txt");
273
274 let mut ctx =
275 CommandContext::new(vec!["-i".to_string(), file.to_string_lossy().to_string()]);
276 ctx.stdin = Some("payload".to_string());
277 ctx.is_cancelled = Some(Box::new(|| true));
278
279 let result = tee(ctx).await;
280
281 assert!(result.is_success());
282 assert_eq!(std::fs::read_to_string(&file).unwrap(), "payload");
283 }
284}