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::new(input, stderr, 130);
118 }
119
120 let resolved_path = VirtualUtils::resolve_path(file, Some(&cwd));
121 trace_lazy("VirtualCommand", || {
122 format!(
123 "tee: writing file | file={:?}, append={}, bytes={}",
124 resolved_path,
125 parsed.append,
126 input.len()
127 )
128 });
129
130 let write_result = OpenOptions::new()
131 .write(true)
132 .create(true)
133 .append(parsed.append)
134 .truncate(!parsed.append)
135 .open(&resolved_path)
136 .and_then(|mut handle| handle.write_all(input.as_bytes()));
137
138 if let Err(write_error) = write_result {
139 stderr.push_str(&file_error_message(file, &write_error));
142 code = 1;
143 }
144 }
145
146 trace_lazy("VirtualCommand", || {
147 format!(
148 "tee: finished | files_written={}, code={}, stdout_bytes={}",
149 parsed.files.len(),
150 code,
151 input.len()
152 )
153 });
154
155 CommandResult::new(input, stderr, code)
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 fn args(list: &[&str]) -> Vec<String> {
163 list.iter().map(|s| s.to_string()).collect()
164 }
165
166 #[test]
167 fn test_parse_args_defaults() {
168 let parsed = parse_args(&args(&["a.txt", "b.txt"]));
169 assert!(!parsed.append);
170 assert!(!parsed.ignore_interrupts);
171 assert_eq!(parsed.files, vec!["a.txt", "b.txt"]);
172 assert!(parsed.error.is_none());
173 }
174
175 #[test]
176 fn test_parse_args_short_and_long_flags() {
177 let parsed = parse_args(&args(&["-a", "--ignore-interrupts", "out.txt"]));
178 assert!(parsed.append);
179 assert!(parsed.ignore_interrupts);
180 assert_eq!(parsed.files, vec!["out.txt"]);
181 }
182
183 #[test]
184 fn test_parse_args_clustered_flags() {
185 let parsed = parse_args(&args(&["-ai", "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_double_dash_ends_options() {
193 let parsed = parse_args(&args(&["--", "-a"]));
194 assert!(!parsed.append);
195 assert_eq!(parsed.files, vec!["-a"]);
196 }
197
198 #[test]
199 fn test_parse_args_bare_dash_is_a_file() {
200 let parsed = parse_args(&args(&["-"]));
202 assert_eq!(parsed.files, vec!["-"]);
203 assert!(parsed.error.is_none());
204 }
205
206 #[test]
207 fn test_parse_args_unrecognized_long_option() {
208 let parsed = parse_args(&args(&["--unknown-option", "out.txt"]));
209 assert_eq!(
210 parsed.error,
211 Some("tee: unrecognized option \'--unknown-option\'\n".to_string())
212 );
213 }
214
215 #[test]
216 fn test_parse_args_invalid_short_option() {
217 let parsed = parse_args(&args(&["-z", "out.txt"]));
218 assert_eq!(
219 parsed.error,
220 Some("tee: invalid option -- \'z\'\n".to_string())
221 );
222 }
223
224 #[test]
225 fn test_file_error_messages() {
226 let not_found = std::io::Error::new(ErrorKind::NotFound, "nope");
227 assert_eq!(
228 file_error_message("missing.txt", ¬_found),
229 "tee: missing.txt: No such file or directory\n"
230 );
231
232 let denied = std::io::Error::new(ErrorKind::PermissionDenied, "nope");
233 assert_eq!(
234 file_error_message("locked.txt", &denied),
235 "tee: locked.txt: Permission denied\n"
236 );
237
238 let is_dir = std::io::Error::new(ErrorKind::IsADirectory, "nope");
239 assert_eq!(
240 file_error_message("adir", &is_dir),
241 "tee: adir: Is a directory\n"
242 );
243 }
244
245 #[tokio::test]
246 async fn test_tee_cancellation_returns_sigint_code() {
247 let dir = tempfile::tempdir().unwrap();
248 let file = dir.path().join("out.txt");
249
250 let mut ctx = CommandContext::new(vec![file.to_string_lossy().to_string()]);
251 ctx.stdin = Some("payload".to_string());
252 ctx.is_cancelled = Some(Box::new(|| true));
253
254 let result = tee(ctx).await;
255
256 assert_eq!(result.code, 130);
257 assert_eq!(result.stdout, "payload");
258 assert!(!file.exists());
259 }
260
261 #[tokio::test]
262 async fn test_tee_ignore_interrupts_keeps_writing() {
263 let dir = tempfile::tempdir().unwrap();
264 let file = dir.path().join("out.txt");
265
266 let mut ctx =
267 CommandContext::new(vec!["-i".to_string(), file.to_string_lossy().to_string()]);
268 ctx.stdin = Some("payload".to_string());
269 ctx.is_cancelled = Some(Box::new(|| true));
270
271 let result = tee(ctx).await;
272
273 assert!(result.is_success());
274 assert_eq!(std::fs::read_to_string(&file).unwrap(), "payload");
275 }
276}