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