nu_command/filesystem/
save.rs

1use crate::progress_bar;
2use nu_engine::get_eval_block;
3#[allow(deprecated)]
4use nu_engine::{command_prelude::*, current_dir};
5use nu_path::expand_path_with;
6use nu_protocol::{
7    ByteStreamSource, DataSource, OutDest, PipelineMetadata, Signals, ast,
8    byte_stream::copy_with_signals, process::ChildPipe, shell_error::io::IoError,
9};
10use std::{
11    fs::File,
12    io::{self, BufRead, BufReader, Read, Write},
13    path::{Path, PathBuf},
14    thread,
15    time::{Duration, Instant},
16};
17
18#[derive(Clone)]
19pub struct Save;
20
21impl Command for Save {
22    fn name(&self) -> &str {
23        "save"
24    }
25
26    fn description(&self) -> &str {
27        "Save a file."
28    }
29
30    fn search_terms(&self) -> Vec<&str> {
31        vec![
32            "write",
33            "write_file",
34            "append",
35            "redirection",
36            "file",
37            "io",
38            ">",
39            ">>",
40        ]
41    }
42
43    fn signature(&self) -> nu_protocol::Signature {
44        Signature::build("save")
45            .input_output_types(vec![(Type::Any, Type::Nothing)])
46            .required("filename", SyntaxShape::Filepath, "The filename to use.")
47            .named(
48                "stderr",
49                SyntaxShape::Filepath,
50                "the filename used to save stderr, only works with `-r` flag",
51                Some('e'),
52            )
53            .switch("raw", "save file as raw binary", Some('r'))
54            .switch("append", "append input to the end of the file", Some('a'))
55            .switch("force", "overwrite the destination", Some('f'))
56            .switch("progress", "enable progress bar", Some('p'))
57            .category(Category::FileSystem)
58    }
59
60    fn run(
61        &self,
62        engine_state: &EngineState,
63        stack: &mut Stack,
64        call: &Call,
65        input: PipelineData,
66    ) -> Result<PipelineData, ShellError> {
67        let raw = call.has_flag(engine_state, stack, "raw")?;
68        let append = call.has_flag(engine_state, stack, "append")?;
69        let force = call.has_flag(engine_state, stack, "force")?;
70        let progress = call.has_flag(engine_state, stack, "progress")?;
71
72        let span = call.head;
73        #[allow(deprecated)]
74        let cwd = current_dir(engine_state, stack)?;
75
76        let path_arg = call.req::<Spanned<PathBuf>>(engine_state, stack, 0)?;
77        let path = Spanned {
78            item: expand_path_with(path_arg.item, &cwd, true),
79            span: path_arg.span,
80        };
81
82        let stderr_path = call
83            .get_flag::<Spanned<PathBuf>>(engine_state, stack, "stderr")?
84            .map(|arg| Spanned {
85                item: expand_path_with(arg.item, cwd, true),
86                span: arg.span,
87            });
88
89        let from_io_error = IoError::factory(span, path.item.as_path());
90        match input {
91            PipelineData::ByteStream(stream, metadata) => {
92                check_saving_to_source_file(metadata.as_ref(), &path, stderr_path.as_ref())?;
93
94                let (file, stderr_file) =
95                    get_files(engine_state, &path, stderr_path.as_ref(), append, force)?;
96
97                let size = stream.known_size();
98                let signals = engine_state.signals();
99
100                match stream.into_source() {
101                    ByteStreamSource::Read(read) => {
102                        stream_to_file(read, size, signals, file, span, progress)?;
103                    }
104                    ByteStreamSource::File(source) => {
105                        stream_to_file(source, size, signals, file, span, progress)?;
106                    }
107                    #[cfg(feature = "os")]
108                    ByteStreamSource::Child(mut child) => {
109                        fn write_or_consume_stderr(
110                            stderr: ChildPipe,
111                            file: Option<File>,
112                            span: Span,
113                            signals: &Signals,
114                            progress: bool,
115                        ) -> Result<(), ShellError> {
116                            if let Some(file) = file {
117                                match stderr {
118                                    ChildPipe::Pipe(pipe) => {
119                                        stream_to_file(pipe, None, signals, file, span, progress)
120                                    }
121                                    ChildPipe::Tee(tee) => {
122                                        stream_to_file(tee, None, signals, file, span, progress)
123                                    }
124                                }?
125                            } else {
126                                match stderr {
127                                    ChildPipe::Pipe(mut pipe) => {
128                                        io::copy(&mut pipe, &mut io::stderr())
129                                    }
130                                    ChildPipe::Tee(mut tee) => {
131                                        io::copy(&mut tee, &mut io::stderr())
132                                    }
133                                }
134                                .map_err(|err| IoError::new(err, span, None))?;
135                            }
136                            Ok(())
137                        }
138
139                        match (child.stdout.take(), child.stderr.take()) {
140                            (Some(stdout), stderr) => {
141                                // delegate a thread to redirect stderr to result.
142                                let handler = stderr
143                                    .map(|stderr| {
144                                        let signals = signals.clone();
145                                        thread::Builder::new().name("stderr saver".into()).spawn(
146                                            move || {
147                                                write_or_consume_stderr(
148                                                    stderr,
149                                                    stderr_file,
150                                                    span,
151                                                    &signals,
152                                                    progress,
153                                                )
154                                            },
155                                        )
156                                    })
157                                    .transpose()
158                                    .map_err(&from_io_error)?;
159
160                                let res = match stdout {
161                                    ChildPipe::Pipe(pipe) => {
162                                        stream_to_file(pipe, None, signals, file, span, progress)
163                                    }
164                                    ChildPipe::Tee(tee) => {
165                                        stream_to_file(tee, None, signals, file, span, progress)
166                                    }
167                                };
168                                if let Some(h) = handler {
169                                    h.join().map_err(|err| ShellError::ExternalCommand {
170                                        label: "Fail to receive external commands stderr message"
171                                            .to_string(),
172                                        help: format!("{err:?}"),
173                                        span,
174                                    })??;
175                                }
176                                res?;
177                            }
178                            (None, Some(stderr)) => {
179                                write_or_consume_stderr(
180                                    stderr,
181                                    stderr_file,
182                                    span,
183                                    signals,
184                                    progress,
185                                )?;
186                            }
187                            (None, None) => {}
188                        };
189
190                        child.wait()?;
191                    }
192                }
193
194                Ok(PipelineData::Empty)
195            }
196            PipelineData::ListStream(ls, pipeline_metadata)
197                if raw || prepare_path(&path, append, force)?.0.extension().is_none() =>
198            {
199                check_saving_to_source_file(
200                    pipeline_metadata.as_ref(),
201                    &path,
202                    stderr_path.as_ref(),
203                )?;
204
205                let (mut file, _) =
206                    get_files(engine_state, &path, stderr_path.as_ref(), append, force)?;
207                for val in ls {
208                    file.write_all(&value_to_bytes(val)?)
209                        .map_err(&from_io_error)?;
210                    file.write_all("\n".as_bytes()).map_err(&from_io_error)?;
211                }
212                file.flush().map_err(&from_io_error)?;
213
214                Ok(PipelineData::empty())
215            }
216            input => {
217                // It's not necessary to check if we are saving to the same file if this is a
218                // collected value, and not a stream
219                if !matches!(input, PipelineData::Value(..) | PipelineData::Empty) {
220                    check_saving_to_source_file(
221                        input.metadata().as_ref(),
222                        &path,
223                        stderr_path.as_ref(),
224                    )?;
225                }
226
227                let bytes =
228                    input_to_bytes(input, Path::new(&path.item), raw, engine_state, stack, span)?;
229
230                // Only open file after successful conversion
231                let (mut file, _) =
232                    get_files(engine_state, &path, stderr_path.as_ref(), append, force)?;
233
234                file.write_all(&bytes).map_err(&from_io_error)?;
235                file.flush().map_err(&from_io_error)?;
236
237                Ok(PipelineData::empty())
238            }
239        }
240    }
241
242    fn examples(&self) -> Vec<Example> {
243        vec![
244            Example {
245                description: "Save a string to foo.txt in the current directory",
246                example: r#"'save me' | save foo.txt"#,
247                result: None,
248            },
249            Example {
250                description: "Append a string to the end of foo.txt",
251                example: r#"'append me' | save --append foo.txt"#,
252                result: None,
253            },
254            Example {
255                description: "Save a record to foo.json in the current directory",
256                example: r#"{ a: 1, b: 2 } | save foo.json"#,
257                result: None,
258            },
259            Example {
260                description: "Save a running program's stderr to foo.txt",
261                example: r#"do -i {} | save foo.txt --stderr foo.txt"#,
262                result: None,
263            },
264            Example {
265                description: "Save a running program's stderr to separate file",
266                example: r#"do -i {} | save foo.txt --stderr bar.txt"#,
267                result: None,
268            },
269            Example {
270                description: "Show the extensions for which the `save` command will automatically serialize",
271                example: r#"scope commands
272    | where name starts-with "to "
273    | insert extension { get name | str replace -r "^to " "" | $"*.($in)" }
274    | select extension name
275    | rename extension command
276"#,
277                result: None,
278            },
279        ]
280    }
281
282    fn pipe_redirection(&self) -> (Option<OutDest>, Option<OutDest>) {
283        (Some(OutDest::PipeSeparate), Some(OutDest::PipeSeparate))
284    }
285}
286
287fn saving_to_source_file_error(dest: &Spanned<PathBuf>) -> ShellError {
288    ShellError::GenericError {
289        error: "pipeline input and output are the same file".into(),
290        msg: format!(
291            "can't save output to '{}' while it's being read",
292            dest.item.display()
293        ),
294        span: Some(dest.span),
295        help: Some(
296            "insert a `collect` command in the pipeline before `save` (see `help collect`).".into(),
297        ),
298        inner: vec![],
299    }
300}
301
302fn check_saving_to_source_file(
303    metadata: Option<&PipelineMetadata>,
304    dest: &Spanned<PathBuf>,
305    stderr_dest: Option<&Spanned<PathBuf>>,
306) -> Result<(), ShellError> {
307    let Some(DataSource::FilePath(source)) = metadata.map(|meta| &meta.data_source) else {
308        return Ok(());
309    };
310
311    if &dest.item == source {
312        return Err(saving_to_source_file_error(dest));
313    }
314
315    if let Some(dest) = stderr_dest {
316        if &dest.item == source {
317            return Err(saving_to_source_file_error(dest));
318        }
319    }
320
321    Ok(())
322}
323
324/// Convert [`PipelineData`] bytes to write in file, possibly converting
325/// to format of output file
326fn input_to_bytes(
327    input: PipelineData,
328    path: &Path,
329    raw: bool,
330    engine_state: &EngineState,
331    stack: &mut Stack,
332    span: Span,
333) -> Result<Vec<u8>, ShellError> {
334    let ext = if raw {
335        None
336    } else if let PipelineData::ByteStream(..) = input {
337        None
338    } else if let PipelineData::Value(Value::String { .. }, ..) = input {
339        None
340    } else {
341        path.extension()
342            .map(|name| name.to_string_lossy().to_string())
343    };
344
345    let input = if let Some(ext) = ext {
346        convert_to_extension(engine_state, &ext, stack, input, span)?
347    } else {
348        input
349    };
350
351    value_to_bytes(input.into_value(span)?)
352}
353
354/// Convert given data into content of file of specified extension if
355/// corresponding `to` command exists. Otherwise attempt to convert
356/// data to bytes as is
357fn convert_to_extension(
358    engine_state: &EngineState,
359    extension: &str,
360    stack: &mut Stack,
361    input: PipelineData,
362    span: Span,
363) -> Result<PipelineData, ShellError> {
364    if let Some(decl_id) = engine_state.find_decl(format!("to {extension}").as_bytes(), &[]) {
365        let decl = engine_state.get_decl(decl_id);
366        if let Some(block_id) = decl.block_id() {
367            let block = engine_state.get_block(block_id);
368            let eval_block = get_eval_block(engine_state);
369            eval_block(engine_state, stack, block, input)
370        } else {
371            let call = ast::Call::new(span);
372            decl.run(engine_state, stack, &(&call).into(), input)
373        }
374    } else {
375        Ok(input)
376    }
377}
378
379/// Convert [`Value::String`] [`Value::Binary`] or [`Value::List`] into [`Vec`] of bytes
380///
381/// Propagates [`Value::Error`] and creates error otherwise
382fn value_to_bytes(value: Value) -> Result<Vec<u8>, ShellError> {
383    match value {
384        Value::String { val, .. } => Ok(val.into_bytes()),
385        Value::Binary { val, .. } => Ok(val),
386        Value::List { vals, .. } => {
387            let val = vals
388                .into_iter()
389                .map(Value::coerce_into_string)
390                .collect::<Result<Vec<String>, ShellError>>()?
391                .join("\n")
392                + "\n";
393
394            Ok(val.into_bytes())
395        }
396        // Propagate errors by explicitly matching them before the final case.
397        Value::Error { error, .. } => Err(*error),
398        other => Ok(other.coerce_into_string()?.into_bytes()),
399    }
400}
401
402/// Convert string path to [`Path`] and [`Span`] and check if this path
403/// can be used with given flags
404fn prepare_path(
405    path: &Spanned<PathBuf>,
406    append: bool,
407    force: bool,
408) -> Result<(&Path, Span), ShellError> {
409    let span = path.span;
410    let path = &path.item;
411
412    if !(force || append) && path.exists() {
413        Err(ShellError::GenericError {
414            error: "Destination file already exists".into(),
415            msg: format!(
416                "Destination file '{}' already exists",
417                path.to_string_lossy()
418            ),
419            span: Some(span),
420            help: Some("you can use -f, --force to force overwriting the destination".into()),
421            inner: vec![],
422        })
423    } else {
424        Ok((path, span))
425    }
426}
427
428fn open_file(
429    engine_state: &EngineState,
430    path: &Path,
431    span: Span,
432    append: bool,
433) -> Result<File, ShellError> {
434    let file: std::io::Result<File> = match (append, path.exists()) {
435        (true, true) => std::fs::OpenOptions::new().append(true).open(path),
436        _ => {
437            // This is a temporary solution until `std::fs::File::create` is fixed on Windows (rust-lang/rust#134893)
438            // A TOCTOU problem exists here, which may cause wrong error message to be shown
439            #[cfg(target_os = "windows")]
440            if path.is_dir() {
441                #[allow(
442                    deprecated,
443                    reason = "we don't get a IsADirectory error, so we need to provide it"
444                )]
445                Err(std::io::ErrorKind::IsADirectory.into())
446            } else {
447                std::fs::File::create(path)
448            }
449            #[cfg(not(target_os = "windows"))]
450            std::fs::File::create(path)
451        }
452    };
453
454    match file {
455        Ok(file) => Ok(file),
456        Err(err) => {
457            // In caase of NotFound, search for the missing parent directory.
458            // This also presents a TOCTOU (or TOUTOC, technically?)
459            if err.kind() == std::io::ErrorKind::NotFound {
460                if let Some(missing_component) =
461                    path.ancestors().skip(1).filter(|dir| !dir.exists()).last()
462                {
463                    // By looking at the postfix to remove, rather than the prefix
464                    // to keep, we are able to handle relative paths too.
465                    let components_to_remove = path
466                        .strip_prefix(missing_component)
467                        .expect("Stripping ancestor from a path should never fail")
468                        .as_os_str()
469                        .as_encoded_bytes();
470
471                    return Err(ShellError::Io(IoError::new(
472                        ErrorKind::DirectoryNotFound,
473                        engine_state
474                            .span_match_postfix(span, components_to_remove)
475                            .map(|(pre, _post)| pre)
476                            .unwrap_or(span),
477                        PathBuf::from(missing_component),
478                    )));
479                }
480            }
481
482            Err(ShellError::Io(IoError::new(err, span, PathBuf::from(path))))
483        }
484    }
485}
486
487/// Get output file and optional stderr file
488fn get_files(
489    engine_state: &EngineState,
490    path: &Spanned<PathBuf>,
491    stderr_path: Option<&Spanned<PathBuf>>,
492    append: bool,
493    force: bool,
494) -> Result<(File, Option<File>), ShellError> {
495    // First check both paths
496    let (path, path_span) = prepare_path(path, append, force)?;
497    let stderr_path_and_span = stderr_path
498        .as_ref()
499        .map(|stderr_path| prepare_path(stderr_path, append, force))
500        .transpose()?;
501
502    // Only if both files can be used open and possibly truncate them
503    let file = open_file(engine_state, path, path_span, append)?;
504
505    let stderr_file = stderr_path_and_span
506        .map(|(stderr_path, stderr_path_span)| {
507            if path == stderr_path {
508                Err(ShellError::GenericError {
509                    error: "input and stderr input to same file".into(),
510                    msg: "can't save both input and stderr input to the same file".into(),
511                    span: Some(stderr_path_span),
512                    help: Some("you should use `o+e> file` instead".into()),
513                    inner: vec![],
514                })
515            } else {
516                open_file(engine_state, stderr_path, stderr_path_span, append)
517            }
518        })
519        .transpose()?;
520
521    Ok((file, stderr_file))
522}
523
524fn stream_to_file(
525    source: impl Read,
526    known_size: Option<u64>,
527    signals: &Signals,
528    mut file: File,
529    span: Span,
530    progress: bool,
531) -> Result<(), ShellError> {
532    // TODO: maybe we can get a path in here
533    let from_io_error = IoError::factory(span, None);
534
535    // https://github.com/nushell/nushell/pull/9377 contains the reason for not using `BufWriter`
536    if progress {
537        let mut bytes_processed = 0;
538
539        let mut bar = progress_bar::NuProgressBar::new(known_size);
540
541        let mut last_update = Instant::now();
542
543        let mut reader = BufReader::new(source);
544
545        let res = loop {
546            if let Err(err) = signals.check(&span) {
547                bar.abandoned_msg("# Cancelled #".to_owned());
548                return Err(err);
549            }
550
551            match reader.fill_buf() {
552                Ok(&[]) => break Ok(()),
553                Ok(buf) => {
554                    file.write_all(buf).map_err(&from_io_error)?;
555                    let len = buf.len();
556                    reader.consume(len);
557                    bytes_processed += len as u64;
558                    if last_update.elapsed() >= Duration::from_millis(75) {
559                        bar.update_bar(bytes_processed);
560                        last_update = Instant::now();
561                    }
562                }
563                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
564                Err(e) => break Err(e),
565            }
566        };
567
568        // If the process failed, stop the progress bar with an error message.
569        if let Err(err) = res {
570            let _ = file.flush();
571            bar.abandoned_msg("# Error while saving #".to_owned());
572            Err(from_io_error(err).into())
573        } else {
574            file.flush().map_err(&from_io_error)?;
575            Ok(())
576        }
577    } else {
578        copy_with_signals(source, file, span, signals)?;
579        Ok(())
580    }
581}