Skip to main content

nu_command/filesystem/
save.rs

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