Skip to main content

command_stream/commands/
tee.rs

1//! Virtual `tee` command implementation
2
3use crate::commands::CommandContext;
4use crate::utils::{trace_lazy, CommandResult, VirtualUtils};
5use std::fs::OpenOptions;
6use std::io::{ErrorKind, Write};
7
8/// Translate a file system error into the message GNU tee prints.
9fn 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/// Parsed `tee` operands
22#[derive(Debug, Default, PartialEq)]
23struct ParsedArgs {
24    append: bool,
25    ignore_interrupts: bool,
26    files: Vec<String>,
27    error: Option<String>,
28}
29
30/// Parse tee operands.
31///
32/// Supports `-a`/`--append`, `-i`/`--ignore-interrupts`, clustered short
33/// options such as `-ai`, and `--` to end option parsing. Everything else is an
34/// operand, including a bare `-`, which GNU tee treats as a file named `-`.
35fn 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
80/// Execute the tee command
81///
82/// Reads stdin, copies it to stdout so the pipeline keeps flowing, and writes
83/// the same bytes to every file operand. File operands are truncated unless
84/// `-a` is given. A file that cannot be written reports an error and sets the
85/// exit code to 1, but the remaining files and stdout are still written, which
86/// is what GNU tee does.
87pub 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            // SIGINT exit code, with the input still forwarded to stdout.
117            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            // GNU tee keeps copying to the remaining files and to stdout after
140            // a failed target, and exits with 1 at the end.
141            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        // GNU tee treats a lone `-` as a file named `-`, not as stdout.
201        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", &not_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}