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, span)?)
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)?, 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/// Actionable error for when `save` receives structured data (a record, table,
332/// or other non-string value) but the destination has no matching
333/// `to <extension>` serializer, so the value can't be written as-is.
334///
335/// Filenames are only hints. Mirrors how `to json`/`to toml` report
336/// unserializable input: the failure is that `save` doesn't support this input
337/// for this file, not a low-level string coercion, so we raise
338/// [`ShellError::UnsupportedInput`] pointing at the explicit conversions rather
339/// than a bare "can't convert to string".
340fn cant_serialize_to_file(from_type: Type, value_span: Span, call_span: Span) -> ShellError {
341    ShellError::UnsupportedInput {
342        msg: format!(
343            "cannot save {from_type} to this file: no `to` converter matches the file's \
344             extension. Serialize it first, e.g. `... | to json | save <file>`, or save \
345             the rendered table with ansi escape sequences with `... | table | save <file>`"
346        ),
347        input: "value originates from here".into(),
348        msg_span: call_span,
349        input_span: value_span,
350    }
351}
352
353/// Convert [`Value::String`] [`Value::Binary`] or [`Value::List`] into [`Vec`] of bytes
354///
355/// Propagates [`Value::Error`] and, for structured values that can't be
356/// coerced to text, returns an actionable [`cant_serialize_to_file`] error
357/// pointing at `span` (the `save` invocation).
358fn value_to_bytes(value: Value, span: Span) -> Result<Vec<u8>, ShellError> {
359    match value {
360        Value::String { val, .. } => Ok(val.into_bytes()),
361        Value::Binary { val, .. } => Ok(val.into_owned()),
362        Value::List { vals, .. } => {
363            let val = vals
364                .into_iter()
365                .map(|val| {
366                    let (ty, val_span) = (val.get_type(), val.span());
367                    val.coerce_into_string()
368                        .map_err(|_| cant_serialize_to_file(ty, val_span, span))
369                })
370                .collect::<Result<Vec<String>, ShellError>>()?
371                .join("\n")
372                + "\n";
373
374            Ok(val.into_bytes())
375        }
376        // Propagate errors by explicitly matching them before the final case.
377        Value::Error { error, .. } => Err(*error),
378        other => {
379            let (ty, val_span) = (other.get_type(), other.span());
380            other
381                .coerce_into_string()
382                .map(String::into_bytes)
383                .map_err(|_| cant_serialize_to_file(ty, val_span, span))
384        }
385    }
386}
387
388/// Convert string path to [`Path`] and [`Span`] and check if this path
389/// can be used with given flags
390fn prepare_path(
391    path: &Spanned<PathBuf>,
392    append: bool,
393    force: bool,
394) -> Result<(&Path, Span), ShellError> {
395    let span = path.span;
396    let path = &path.item;
397
398    if !(force || append) && path.exists() {
399        Err(ShellError::Generic(
400            GenericError::new(
401                "Destination file already exists",
402                format!(
403                    "Destination file '{}' already exists",
404                    path.to_string_lossy()
405                ),
406                span,
407            )
408            .with_help("you can use -f, --force to force overwriting the destination"),
409        ))
410    } else {
411        Ok((path, span))
412    }
413}
414
415fn open_file(
416    engine_state: &EngineState,
417    path: &Path,
418    span: Span,
419    append: bool,
420) -> Result<File, ShellError> {
421    let file: std::io::Result<File> = match (append, path.exists() || is_windows_device_path(path))
422    {
423        (true, true) => std::fs::OpenOptions::new().append(true).open(path),
424        _ => {
425            // This is a temporary solution until `std::fs::File::create` is fixed on Windows (rust-lang/rust#134893)
426            // A TOCTOU problem exists here, which may cause wrong error message to be shown
427            #[cfg(target_os = "windows")]
428            if path.is_dir() {
429                #[allow(
430                    deprecated,
431                    reason = "we don't get a IsADirectory error, so we need to provide it"
432                )]
433                Err(std::io::ErrorKind::IsADirectory.into())
434            } else {
435                std::fs::File::create(path)
436            }
437            #[cfg(not(target_os = "windows"))]
438            std::fs::File::create(path)
439        }
440    };
441
442    match file {
443        Ok(file) => Ok(file),
444        Err(err) => {
445            // In caase of NotFound, search for the missing parent directory.
446            // This also presents a TOCTOU (or TOUTOC, technically?)
447            if err.kind() == std::io::ErrorKind::NotFound
448                && let Some(missing_component) =
449                    path.ancestors().skip(1).filter(|dir| !dir.exists()).last()
450            {
451                // By looking at the postfix to remove, rather than the prefix
452                // to keep, we are able to handle relative paths too.
453                let components_to_remove = path
454                    .strip_prefix(missing_component)
455                    .expect("Stripping ancestor from a path should never fail")
456                    .as_os_str()
457                    .as_encoded_bytes();
458
459                return Err(ShellError::Io(IoError::new(
460                    ErrorKind::DirectoryNotFound,
461                    engine_state
462                        .span_match_postfix(span, components_to_remove)
463                        .map(|(pre, _post)| pre)
464                        .unwrap_or(span),
465                    PathBuf::from(missing_component),
466                )));
467            }
468
469            Err(ShellError::Io(IoError::new(err, span, PathBuf::from(path))))
470        }
471    }
472}
473
474/// Get output file and optional stderr file
475fn get_files(
476    engine_state: &EngineState,
477    path: &Spanned<PathBuf>,
478    stderr_path: Option<&Spanned<PathBuf>>,
479    append: bool,
480    force: bool,
481) -> Result<(File, Option<File>), ShellError> {
482    // First check both paths
483    let (path, path_span) = prepare_path(path, append, force)?;
484    let stderr_path_and_span = stderr_path
485        .as_ref()
486        .map(|stderr_path| prepare_path(stderr_path, append, force))
487        .transpose()?;
488
489    // Only if both files can be used open and possibly truncate them
490    let file = open_file(engine_state, path, path_span, append)?;
491
492    let stderr_file = stderr_path_and_span
493        .map(|(stderr_path, stderr_path_span)| {
494            if path == stderr_path {
495                Err(ShellError::Generic(
496                    GenericError::new(
497                        "input and stderr input to same file",
498                        "can't save both input and stderr input to the same file",
499                        stderr_path_span,
500                    )
501                    .with_help("you should use `o+e> file` instead"),
502                ))
503            } else {
504                open_file(engine_state, stderr_path, stderr_path_span, append)
505            }
506        })
507        .transpose()?;
508
509    Ok((file, stderr_file))
510}
511
512fn write_or_consume_stderr(
513    stderr: ChildPipe,
514    file: Option<File>,
515    span: Span,
516    signals: &Signals,
517    progress: bool,
518) -> Result<(), ShellError> {
519    if let Some(file) = file {
520        match stderr {
521            ChildPipe::Pipe(pipe) => stream_to_file(pipe, None, signals, file, span, progress),
522            ChildPipe::Tee(tee) => stream_to_file(tee, None, signals, file, span, progress),
523        }?
524    } else {
525        match stderr {
526            ChildPipe::Pipe(mut pipe) => io::copy(&mut pipe, &mut io::stderr()),
527            ChildPipe::Tee(mut tee) => io::copy(&mut tee, &mut io::stderr()),
528        }
529        .map_err(|err| IoError::new(err, span, None))?;
530    }
531    Ok(())
532}
533
534struct ByteStreamSaveContext<'a> {
535    metadata: Option<&'a PipelineMetadata>,
536    path: &'a Spanned<PathBuf>,
537    stderr_path: Option<&'a Spanned<PathBuf>>,
538    engine_state: &'a EngineState,
539    append: bool,
540    force: bool,
541    span: Span,
542    progress: bool,
543}
544
545fn stream_byte_stream_to_file(
546    stream: ByteStream,
547    context: ByteStreamSaveContext<'_>,
548) -> Result<PipelineData, ShellError> {
549    let from_io_error = IoError::factory(context.span, context.path.item.as_path());
550    let span = context.span;
551    let progress = context.progress;
552
553    check_saving_to_source_file(context.metadata, context.path, context.stderr_path)?;
554
555    let (file, stderr_file) = get_files(
556        context.engine_state,
557        context.path,
558        context.stderr_path,
559        context.append,
560        context.force,
561    )?;
562
563    let size = stream.known_size();
564    let signals = context.engine_state.signals();
565
566    match stream.into_source() {
567        ByteStreamSource::Read(read) => {
568            stream_to_file(read, size, signals, file, span, progress)?;
569        }
570        ByteStreamSource::File(source) => {
571            stream_to_file(source, size, signals, file, span, progress)?;
572        }
573        #[cfg(feature = "os")]
574        ByteStreamSource::Child(mut child) => {
575            match (child.stdout.take(), child.stderr.take()) {
576                (Some(stdout), stderr) => {
577                    let handler = stderr
578                        .map(|stderr| {
579                            let signals = signals.clone();
580                            thread::Builder::new()
581                                .name("stderr saver".into())
582                                .spawn(move || {
583                                    write_or_consume_stderr(
584                                        stderr,
585                                        stderr_file,
586                                        span,
587                                        &signals,
588                                        progress,
589                                    )
590                                })
591                        })
592                        .transpose()
593                        .map_err(&from_io_error)?;
594
595                    let res = match stdout {
596                        ChildPipe::Pipe(pipe) => {
597                            stream_to_file(pipe, None, signals, file, span, progress)
598                        }
599                        ChildPipe::Tee(tee) => {
600                            stream_to_file(tee, None, signals, file, span, progress)
601                        }
602                    };
603                    if let Some(h) = handler {
604                        h.join().map_err(|err| ShellError::ExternalCommand {
605                            label: "Fail to receive external commands stderr message".to_string(),
606                            help: format!("{err:?}"),
607                            span,
608                        })??;
609                    }
610                    res?;
611                }
612                (None, Some(stderr)) => {
613                    write_or_consume_stderr(stderr, stderr_file, span, signals, progress)?;
614                }
615                (None, None) => {}
616            };
617
618            child.wait()?;
619        }
620    }
621
622    Ok(PipelineData::empty())
623}
624
625fn stream_to_file(
626    source: impl Read,
627    known_size: Option<u64>,
628    signals: &Signals,
629    mut file: File,
630    span: Span,
631    progress: bool,
632) -> Result<(), ShellError> {
633    // TODO: maybe we can get a path in here
634    let from_io_error = IoError::factory(span, None);
635
636    // https://github.com/nushell/nushell/pull/9377 contains the reason for not using `BufWriter`
637    if progress {
638        let mut bytes_processed = 0;
639
640        let mut bar = progress_bar::NuProgressBar::new(known_size);
641
642        let mut last_update = Instant::now();
643
644        let mut reader = BufReader::new(source);
645
646        let res = loop {
647            if let Err(err) = signals.check(&span) {
648                bar.abandoned_msg("# Cancelled #");
649                return Err(err);
650            }
651
652            match reader.fill_buf() {
653                Ok(&[]) => break Ok(()),
654                Ok(buf) => {
655                    file.write_all(buf).map_err(&from_io_error)?;
656                    let len = buf.len();
657                    reader.consume(len);
658                    bytes_processed += len as u64;
659                    if last_update.elapsed() >= Duration::from_millis(75) {
660                        bar.update_bar(bytes_processed);
661                        last_update = Instant::now();
662                    }
663                }
664                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
665                Err(e) => break Err(e),
666            }
667        };
668
669        // If the process failed, stop the progress bar with an error message.
670        if let Err(err) = res {
671            let _ = file.flush();
672            bar.abandoned_msg("# Error while saving #");
673            Err(from_io_error(err).into())
674        } else {
675            file.flush().map_err(&from_io_error)?;
676            Ok(())
677        }
678    } else {
679        copy_with_signals(source, file, span, signals)?;
680        Ok(())
681    }
682}