nu_command/filesystem/
ls.rs

1use crate::{DirBuilder, DirInfo};
2use chrono::{DateTime, Local, LocalResult, TimeZone, Utc};
3use nu_engine::glob_from;
4#[allow(deprecated)]
5use nu_engine::{command_prelude::*, env::current_dir};
6use nu_glob::MatchOptions;
7use nu_path::{expand_path_with, expand_to_real_path};
8use nu_protocol::{
9    DataSource, NuGlob, PipelineMetadata, Signals,
10    shell_error::{self, io::IoError},
11};
12use pathdiff::diff_paths;
13use rayon::prelude::*;
14#[cfg(unix)]
15use std::os::unix::fs::PermissionsExt;
16use std::{
17    cmp::Ordering,
18    path::PathBuf,
19    sync::{Arc, Mutex, mpsc},
20    time::{SystemTime, UNIX_EPOCH},
21};
22
23#[derive(Clone)]
24pub struct Ls;
25
26#[derive(Clone, Copy)]
27struct Args {
28    all: bool,
29    long: bool,
30    short_names: bool,
31    full_paths: bool,
32    du: bool,
33    directory: bool,
34    use_mime_type: bool,
35    use_threads: bool,
36    call_span: Span,
37}
38
39impl Command for Ls {
40    fn name(&self) -> &str {
41        "ls"
42    }
43
44    fn description(&self) -> &str {
45        "List the filenames, sizes, and modification times of items in a directory."
46    }
47
48    fn search_terms(&self) -> Vec<&str> {
49        vec!["dir"]
50    }
51
52    fn signature(&self) -> nu_protocol::Signature {
53        Signature::build("ls")
54            .input_output_types(vec![(Type::Nothing, Type::table())])
55            // LsGlobPattern is similar to string, it won't auto-expand
56            // and we use it to track if the user input is quoted.
57            .rest("pattern", SyntaxShape::OneOf(vec![SyntaxShape::GlobPattern, SyntaxShape::String]), "The glob pattern to use.")
58            .switch("all", "Show hidden files", Some('a'))
59            .switch(
60                "long",
61                "Get all available columns for each entry (slower; columns are platform-dependent)",
62                Some('l'),
63            )
64            .switch(
65                "short-names",
66                "Only print the file names, and not the path",
67                Some('s'),
68            )
69            .switch("full-paths", "display paths as absolute paths", Some('f'))
70            .switch(
71                "du",
72                "Display the apparent directory size (\"disk usage\") in place of the directory metadata size",
73                Some('d'),
74            )
75            .switch(
76                "directory",
77                "List the specified directory itself instead of its contents",
78                Some('D'),
79            )
80            .switch("mime-type", "Show mime-type in type column instead of 'file' (based on filenames only; files' contents are not examined)", Some('m'))
81            .switch("threads", "Use multiple threads to list contents. Output will be non-deterministic.", Some('t'))
82            .category(Category::FileSystem)
83    }
84
85    fn run(
86        &self,
87        engine_state: &EngineState,
88        stack: &mut Stack,
89        call: &Call,
90        _input: PipelineData,
91    ) -> Result<PipelineData, ShellError> {
92        let all = call.has_flag(engine_state, stack, "all")?;
93        let long = call.has_flag(engine_state, stack, "long")?;
94        let short_names = call.has_flag(engine_state, stack, "short-names")?;
95        let full_paths = call.has_flag(engine_state, stack, "full-paths")?;
96        let du = call.has_flag(engine_state, stack, "du")?;
97        let directory = call.has_flag(engine_state, stack, "directory")?;
98        let use_mime_type = call.has_flag(engine_state, stack, "mime-type")?;
99        let use_threads = call.has_flag(engine_state, stack, "threads")?;
100        let call_span = call.head;
101        #[allow(deprecated)]
102        let cwd = current_dir(engine_state, stack)?;
103
104        let args = Args {
105            all,
106            long,
107            short_names,
108            full_paths,
109            du,
110            directory,
111            use_mime_type,
112            use_threads,
113            call_span,
114        };
115
116        let pattern_arg = call.rest::<Spanned<NuGlob>>(engine_state, stack, 0)?;
117        let input_pattern_arg = if !call.has_positional_args(stack, 0) {
118            None
119        } else {
120            Some(pattern_arg)
121        };
122        match input_pattern_arg {
123            None => Ok(
124                ls_for_one_pattern(None, args, engine_state.signals().clone(), cwd)?
125                    .into_pipeline_data_with_metadata(
126                        call_span,
127                        engine_state.signals().clone(),
128                        PipelineMetadata {
129                            data_source: DataSource::Ls,
130                            content_type: None,
131                        },
132                    ),
133            ),
134            Some(pattern) => {
135                let mut result_iters = vec![];
136                for pat in pattern {
137                    result_iters.push(ls_for_one_pattern(
138                        Some(pat),
139                        args,
140                        engine_state.signals().clone(),
141                        cwd.clone(),
142                    )?)
143                }
144
145                // Here nushell needs to use
146                // use `flatten` to chain all iterators into one.
147                Ok(result_iters
148                    .into_iter()
149                    .flatten()
150                    .into_pipeline_data_with_metadata(
151                        call_span,
152                        engine_state.signals().clone(),
153                        PipelineMetadata {
154                            data_source: DataSource::Ls,
155                            content_type: None,
156                        },
157                    ))
158            }
159        }
160    }
161
162    fn examples(&self) -> Vec<Example> {
163        vec![
164            Example {
165                description: "List visible files in the current directory",
166                example: "ls",
167                result: None,
168            },
169            Example {
170                description: "List visible files in a subdirectory",
171                example: "ls subdir",
172                result: None,
173            },
174            Example {
175                description: "List visible files with full path in the parent directory",
176                example: "ls -f ..",
177                result: None,
178            },
179            Example {
180                description: "List Rust files",
181                example: "ls *.rs",
182                result: None,
183            },
184            Example {
185                description: "List files and directories whose name do not contain 'bar'",
186                example: "ls | where name !~ bar",
187                result: None,
188            },
189            Example {
190                description: "List the full path of all dirs in your home directory",
191                example: "ls -a ~ | where type == dir",
192                result: None,
193            },
194            Example {
195                description: "List only the names (not paths) of all dirs in your home directory which have not been modified in 7 days",
196                example: "ls -as ~ | where type == dir and modified < ((date now) - 7day)",
197                result: None,
198            },
199            Example {
200                description: "Recursively list all files and subdirectories under the current directory using a glob pattern",
201                example: "ls -a **/*",
202                result: None,
203            },
204            Example {
205                description: "Recursively list *.rs and *.toml files using the glob command",
206                example: "ls ...(glob **/*.{rs,toml})",
207                result: None,
208            },
209            Example {
210                description: "List given paths and show directories themselves",
211                example: "['/path/to/directory' '/path/to/file'] | each {|| ls -D $in } | flatten",
212                result: None,
213            },
214        ]
215    }
216}
217
218fn ls_for_one_pattern(
219    pattern_arg: Option<Spanned<NuGlob>>,
220    args: Args,
221    signals: Signals,
222    cwd: PathBuf,
223) -> Result<PipelineData, ShellError> {
224    fn create_pool(num_threads: usize) -> Result<rayon::ThreadPool, ShellError> {
225        match rayon::ThreadPoolBuilder::new()
226            .num_threads(num_threads)
227            .build()
228        {
229            Err(e) => Err(e).map_err(|e| ShellError::GenericError {
230                error: "Error creating thread pool".into(),
231                msg: e.to_string(),
232                span: Some(Span::unknown()),
233                help: None,
234                inner: vec![],
235            }),
236            Ok(pool) => Ok(pool),
237        }
238    }
239
240    let (tx, rx) = mpsc::channel();
241
242    let Args {
243        all,
244        long,
245        short_names,
246        full_paths,
247        du,
248        directory,
249        use_mime_type,
250        use_threads,
251        call_span,
252    } = args;
253    let pattern_arg = {
254        if let Some(path) = pattern_arg {
255            // it makes no sense to list an empty string.
256            if path.item.as_ref().is_empty() {
257                return Err(ShellError::Io(IoError::new_with_additional_context(
258                    shell_error::io::ErrorKind::from_std(std::io::ErrorKind::NotFound),
259                    path.span,
260                    PathBuf::from(path.item.to_string()),
261                    "empty string('') directory or file does not exist",
262                )));
263            }
264            match path.item {
265                NuGlob::DoNotExpand(p) => Some(Spanned {
266                    item: NuGlob::DoNotExpand(nu_utils::strip_ansi_string_unlikely(p)),
267                    span: path.span,
268                }),
269                NuGlob::Expand(p) => Some(Spanned {
270                    item: NuGlob::Expand(nu_utils::strip_ansi_string_unlikely(p)),
271                    span: path.span,
272                }),
273            }
274        } else {
275            pattern_arg
276        }
277    };
278
279    let mut just_read_dir = false;
280    let p_tag: Span = pattern_arg.as_ref().map(|p| p.span).unwrap_or(call_span);
281    let (pattern_arg, absolute_path) = match pattern_arg {
282        Some(pat) => {
283            // expand with cwd here is only used for checking
284            let tmp_expanded =
285                nu_path::expand_path_with(pat.item.as_ref(), &cwd, pat.item.is_expand());
286            // Avoid checking and pushing "*" to the path when directory (do not show contents) flag is true
287            if !directory && tmp_expanded.is_dir() {
288                if read_dir(tmp_expanded, p_tag, use_threads, signals.clone())?
289                    .next()
290                    .is_none()
291                {
292                    return Ok(Value::test_nothing().into_pipeline_data());
293                }
294                just_read_dir = !(pat.item.is_expand() && nu_glob::is_glob(pat.item.as_ref()));
295            }
296
297            // it's absolute path if:
298            // 1. pattern is absolute.
299            // 2. pattern can be expanded, and after expands to real_path, it's absolute.
300            //    here `expand_to_real_path` call is required, because `~/aaa` should be absolute
301            //    path.
302            let absolute_path = Path::new(pat.item.as_ref()).is_absolute()
303                || (pat.item.is_expand() && expand_to_real_path(pat.item.as_ref()).is_absolute());
304            (pat.item, absolute_path)
305        }
306        None => {
307            // Avoid pushing "*" to the default path when directory (do not show contents) flag is true
308            if directory {
309                (NuGlob::Expand(".".to_string()), false)
310            } else if read_dir(cwd.clone(), p_tag, use_threads, signals.clone())?
311                .next()
312                .is_none()
313            {
314                return Ok(Value::test_nothing().into_pipeline_data());
315            } else {
316                (NuGlob::Expand("*".to_string()), false)
317            }
318        }
319    };
320
321    let hidden_dir_specified = is_hidden_dir(pattern_arg.as_ref());
322
323    let path = pattern_arg.into_spanned(p_tag);
324    let (prefix, paths) = if just_read_dir {
325        let expanded = nu_path::expand_path_with(path.item.as_ref(), &cwd, path.item.is_expand());
326        let paths = read_dir(expanded.clone(), p_tag, use_threads, signals.clone())?;
327        // just need to read the directory, so prefix is path itself.
328        (Some(expanded), paths)
329    } else {
330        let glob_options = if all {
331            None
332        } else {
333            let glob_options = MatchOptions {
334                recursive_match_hidden_dir: false,
335                ..Default::default()
336            };
337            Some(glob_options)
338        };
339        glob_from(&path, &cwd, call_span, glob_options, signals.clone())?
340    };
341
342    let mut paths_peek = paths.peekable();
343    let no_matches = paths_peek.peek().is_none();
344    signals.check(call_span)?;
345    if no_matches {
346        return Err(ShellError::GenericError {
347            error: format!("No matches found for {:?}", path.item),
348            msg: "Pattern, file or folder not found".into(),
349            span: Some(p_tag),
350            help: Some("no matches found".into()),
351            inner: vec![],
352        });
353    }
354
355    let hidden_dirs = Arc::new(Mutex::new(Vec::new()));
356
357    let signals_clone = signals.clone();
358
359    let pool = if use_threads {
360        let count = std::thread::available_parallelism()
361            .map_err(|err| {
362                IoError::new_with_additional_context(
363                    err,
364                    call_span,
365                    None,
366                    "Could not get available parallelism",
367                )
368            })?
369            .get();
370        create_pool(count)?
371    } else {
372        create_pool(1)?
373    };
374
375    pool.install(|| {
376        rayon::spawn(move || {
377            let result = paths_peek
378                .par_bridge()
379                .filter_map(move |x| match x {
380                    Ok(path) => {
381                        let metadata = std::fs::symlink_metadata(&path).ok();
382                        let hidden_dir_clone = Arc::clone(&hidden_dirs);
383                        let mut hidden_dir_mutex = hidden_dir_clone
384                            .lock()
385                            .expect("Unable to acquire lock for hidden_dirs");
386                        if path_contains_hidden_folder(&path, &hidden_dir_mutex) {
387                            return None;
388                        }
389
390                        if !all && !hidden_dir_specified && is_hidden_dir(&path) {
391                            if path.is_dir() {
392                                hidden_dir_mutex.push(path);
393                                drop(hidden_dir_mutex);
394                            }
395                            return None;
396                        }
397
398                        let display_name = if short_names {
399                            path.file_name().map(|os| os.to_string_lossy().to_string())
400                        } else if full_paths || absolute_path {
401                            Some(path.to_string_lossy().to_string())
402                        } else if let Some(prefix) = &prefix {
403                            if let Ok(remainder) = path.strip_prefix(prefix) {
404                                if directory {
405                                    // When the path is the same as the cwd, path_diff should be "."
406                                    let path_diff = if let Some(path_diff_not_dot) =
407                                        diff_paths(&path, &cwd)
408                                    {
409                                        let path_diff_not_dot = path_diff_not_dot.to_string_lossy();
410                                        if path_diff_not_dot.is_empty() {
411                                            ".".to_string()
412                                        } else {
413                                            path_diff_not_dot.to_string()
414                                        }
415                                    } else {
416                                        path.to_string_lossy().to_string()
417                                    };
418
419                                    Some(path_diff)
420                                } else {
421                                    let new_prefix = if let Some(pfx) = diff_paths(prefix, &cwd) {
422                                        pfx
423                                    } else {
424                                        prefix.to_path_buf()
425                                    };
426
427                                    Some(new_prefix.join(remainder).to_string_lossy().to_string())
428                                }
429                            } else {
430                                Some(path.to_string_lossy().to_string())
431                            }
432                        } else {
433                            Some(path.to_string_lossy().to_string())
434                        }
435                        .ok_or_else(|| ShellError::GenericError {
436                            error: format!("Invalid file name: {:}", path.to_string_lossy()),
437                            msg: "invalid file name".into(),
438                            span: Some(call_span),
439                            help: None,
440                            inner: vec![],
441                        });
442
443                        match display_name {
444                            Ok(name) => {
445                                let entry = dir_entry_dict(
446                                    &path,
447                                    &name,
448                                    metadata.as_ref(),
449                                    call_span,
450                                    long,
451                                    du,
452                                    &signals_clone,
453                                    use_mime_type,
454                                    args.full_paths,
455                                );
456                                match entry {
457                                    Ok(value) => Some(value),
458                                    Err(err) => Some(Value::error(err, call_span)),
459                                }
460                            }
461                            Err(err) => Some(Value::error(err, call_span)),
462                        }
463                    }
464                    Err(err) => Some(Value::error(err, call_span)),
465                })
466                .try_for_each(|stream| {
467                    tx.send(stream).map_err(|e| ShellError::GenericError {
468                        error: "Error streaming data".into(),
469                        msg: e.to_string(),
470                        span: Some(call_span),
471                        help: None,
472                        inner: vec![],
473                    })
474                })
475                .map_err(|err| ShellError::GenericError {
476                    error: "Unable to create a rayon pool".into(),
477                    msg: err.to_string(),
478                    span: Some(call_span),
479                    help: None,
480                    inner: vec![],
481                });
482
483            if let Err(error) = result {
484                let _ = tx.send(Value::error(error, call_span));
485            }
486        });
487    });
488
489    Ok(rx
490        .into_iter()
491        .into_pipeline_data(call_span, signals.clone()))
492}
493
494fn is_hidden_dir(dir: impl AsRef<Path>) -> bool {
495    #[cfg(windows)]
496    {
497        use std::os::windows::fs::MetadataExt;
498
499        if let Ok(metadata) = dir.as_ref().metadata() {
500            let attributes = metadata.file_attributes();
501            // https://docs.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants
502            (attributes & 0x2) != 0
503        } else {
504            false
505        }
506    }
507
508    #[cfg(not(windows))]
509    {
510        dir.as_ref()
511            .file_name()
512            .map(|name| name.to_string_lossy().starts_with('.'))
513            .unwrap_or(false)
514    }
515}
516
517fn path_contains_hidden_folder(path: &Path, folders: &[PathBuf]) -> bool {
518    if folders.iter().any(|p| path.starts_with(p.as_path())) {
519        return true;
520    }
521    false
522}
523
524#[cfg(unix)]
525use std::os::unix::fs::FileTypeExt;
526use std::path::Path;
527
528pub fn get_file_type(md: &std::fs::Metadata, display_name: &str, use_mime_type: bool) -> String {
529    let ft = md.file_type();
530    let mut file_type = "unknown";
531    if ft.is_dir() {
532        file_type = "dir";
533    } else if ft.is_file() {
534        file_type = "file";
535    } else if ft.is_symlink() {
536        file_type = "symlink";
537    } else {
538        #[cfg(unix)]
539        {
540            if ft.is_block_device() {
541                file_type = "block device";
542            } else if ft.is_char_device() {
543                file_type = "char device";
544            } else if ft.is_fifo() {
545                file_type = "pipe";
546            } else if ft.is_socket() {
547                file_type = "socket";
548            }
549        }
550    }
551    if use_mime_type {
552        let guess = mime_guess::from_path(display_name);
553        let mime_guess = match guess.first() {
554            Some(mime_type) => mime_type.essence_str().to_string(),
555            None => "unknown".to_string(),
556        };
557        if file_type == "file" {
558            mime_guess
559        } else {
560            file_type.to_string()
561        }
562    } else {
563        file_type.to_string()
564    }
565}
566
567#[allow(clippy::too_many_arguments)]
568pub(crate) fn dir_entry_dict(
569    filename: &std::path::Path, // absolute path
570    display_name: &str,         // file name to be displayed
571    metadata: Option<&std::fs::Metadata>,
572    span: Span,
573    long: bool,
574    du: bool,
575    signals: &Signals,
576    use_mime_type: bool,
577    full_symlink_target: bool,
578) -> Result<Value, ShellError> {
579    #[cfg(windows)]
580    if metadata.is_none() {
581        return Ok(windows_helper::dir_entry_dict_windows_fallback(
582            filename,
583            display_name,
584            span,
585            long,
586        ));
587    }
588
589    let mut record = Record::new();
590    let mut file_type = "unknown".to_string();
591
592    record.push("name", Value::string(display_name, span));
593
594    if let Some(md) = metadata {
595        file_type = get_file_type(md, display_name, use_mime_type);
596        record.push("type", Value::string(file_type.clone(), span));
597    } else {
598        record.push("type", Value::nothing(span));
599    }
600
601    if long {
602        if let Some(md) = metadata {
603            record.push(
604                "target",
605                if md.file_type().is_symlink() {
606                    if let Ok(path_to_link) = filename.read_link() {
607                        // Actually `filename` should always have a parent because it's a symlink.
608                        // But for safety, we check `filename.parent().is_some()` first.
609                        if full_symlink_target && filename.parent().is_some() {
610                            Value::string(
611                                expand_path_with(
612                                    path_to_link,
613                                    filename
614                                        .parent()
615                                        .expect("already check the filename have a parent"),
616                                    true,
617                                )
618                                .to_string_lossy(),
619                                span,
620                            )
621                        } else {
622                            Value::string(path_to_link.to_string_lossy(), span)
623                        }
624                    } else {
625                        Value::string("Could not obtain target file's path", span)
626                    }
627                } else {
628                    Value::nothing(span)
629                },
630            )
631        }
632    }
633
634    if long {
635        if let Some(md) = metadata {
636            record.push("readonly", Value::bool(md.permissions().readonly(), span));
637
638            #[cfg(unix)]
639            {
640                use nu_utils::filesystem::users;
641                use std::os::unix::fs::MetadataExt;
642
643                let mode = md.permissions().mode();
644                record.push(
645                    "mode",
646                    Value::string(umask::Mode::from(mode).to_string(), span),
647                );
648
649                let nlinks = md.nlink();
650                record.push("num_links", Value::int(nlinks as i64, span));
651
652                let inode = md.ino();
653                record.push("inode", Value::int(inode as i64, span));
654
655                record.push(
656                    "user",
657                    if let Some(user) = users::get_user_by_uid(md.uid().into()) {
658                        Value::string(user.name, span)
659                    } else {
660                        Value::int(md.uid().into(), span)
661                    },
662                );
663
664                record.push(
665                    "group",
666                    if let Some(group) = users::get_group_by_gid(md.gid().into()) {
667                        Value::string(group.name, span)
668                    } else {
669                        Value::int(md.gid().into(), span)
670                    },
671                );
672            }
673        }
674    }
675
676    record.push(
677        "size",
678        if let Some(md) = metadata {
679            let zero_sized = file_type == "pipe"
680                || file_type == "socket"
681                || file_type == "char device"
682                || file_type == "block device";
683
684            if md.is_dir() {
685                if du {
686                    let params = DirBuilder::new(Span::new(0, 2), None, false, None, false);
687                    let dir_size = DirInfo::new(filename, &params, None, span, signals)?.get_size();
688
689                    Value::filesize(dir_size as i64, span)
690                } else {
691                    let dir_size: u64 = md.len();
692
693                    Value::filesize(dir_size as i64, span)
694                }
695            } else if md.is_file() {
696                Value::filesize(md.len() as i64, span)
697            } else if md.file_type().is_symlink() {
698                if let Ok(symlink_md) = filename.symlink_metadata() {
699                    Value::filesize(symlink_md.len() as i64, span)
700                } else {
701                    Value::nothing(span)
702                }
703            } else if zero_sized {
704                Value::filesize(0, span)
705            } else {
706                Value::nothing(span)
707            }
708        } else {
709            Value::nothing(span)
710        },
711    );
712
713    if let Some(md) = metadata {
714        if long {
715            record.push("created", {
716                let mut val = Value::nothing(span);
717                if let Ok(c) = md.created() {
718                    if let Some(local) = try_convert_to_local_date_time(c) {
719                        val = Value::date(local.with_timezone(local.offset()), span);
720                    }
721                }
722                val
723            });
724
725            record.push("accessed", {
726                let mut val = Value::nothing(span);
727                if let Ok(a) = md.accessed() {
728                    if let Some(local) = try_convert_to_local_date_time(a) {
729                        val = Value::date(local.with_timezone(local.offset()), span)
730                    }
731                }
732                val
733            });
734        }
735
736        record.push("modified", {
737            let mut val = Value::nothing(span);
738            if let Ok(m) = md.modified() {
739                if let Some(local) = try_convert_to_local_date_time(m) {
740                    val = Value::date(local.with_timezone(local.offset()), span);
741                }
742            }
743            val
744        })
745    } else {
746        if long {
747            record.push("created", Value::nothing(span));
748            record.push("accessed", Value::nothing(span));
749        }
750
751        record.push("modified", Value::nothing(span));
752    }
753
754    Ok(Value::record(record, span))
755}
756
757// TODO: can we get away from local times in `ls`? internals might be cleaner if we worked in UTC
758// and left the conversion to local time to the display layer
759fn try_convert_to_local_date_time(t: SystemTime) -> Option<DateTime<Local>> {
760    // Adapted from https://github.com/chronotope/chrono/blob/v0.4.19/src/datetime.rs#L755-L767.
761    let (sec, nsec) = match t.duration_since(UNIX_EPOCH) {
762        Ok(dur) => (dur.as_secs() as i64, dur.subsec_nanos()),
763        Err(e) => {
764            // unlikely but should be handled
765            let dur = e.duration();
766            let (sec, nsec) = (dur.as_secs() as i64, dur.subsec_nanos());
767            if nsec == 0 {
768                (-sec, 0)
769            } else {
770                (-sec - 1, 1_000_000_000 - nsec)
771            }
772        }
773    };
774
775    const NEG_UNIX_EPOCH: i64 = -11644473600; // t was invalid 0, UNIX_EPOCH subtracted above.
776    if sec == NEG_UNIX_EPOCH {
777        // do not tz lookup invalid SystemTime
778        return None;
779    }
780    match Utc.timestamp_opt(sec, nsec) {
781        LocalResult::Single(t) => Some(t.with_timezone(&Local)),
782        _ => None,
783    }
784}
785
786// #[cfg(windows)] is just to make Clippy happy, remove if you ever want to use this on other platforms
787#[cfg(windows)]
788fn unix_time_to_local_date_time(secs: i64) -> Option<DateTime<Local>> {
789    match Utc.timestamp_opt(secs, 0) {
790        LocalResult::Single(t) => Some(t.with_timezone(&Local)),
791        _ => None,
792    }
793}
794
795#[cfg(windows)]
796mod windows_helper {
797    use super::*;
798
799    use nu_protocol::shell_error;
800    use std::os::windows::prelude::OsStrExt;
801    use windows::Win32::Foundation::FILETIME;
802    use windows::Win32::Storage::FileSystem::{
803        FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_READONLY, FILE_ATTRIBUTE_REPARSE_POINT, FindClose,
804        FindFirstFileW, WIN32_FIND_DATAW,
805    };
806    use windows::Win32::System::SystemServices::{
807        IO_REPARSE_TAG_MOUNT_POINT, IO_REPARSE_TAG_SYMLINK,
808    };
809
810    /// A secondary way to get file info on Windows, for when std::fs::symlink_metadata() fails.
811    /// dir_entry_dict depends on metadata, but that can't be retrieved for some Windows system files:
812    /// https://github.com/rust-lang/rust/issues/96980
813    pub fn dir_entry_dict_windows_fallback(
814        filename: &Path,
815        display_name: &str,
816        span: Span,
817        long: bool,
818    ) -> Value {
819        let mut record = Record::new();
820
821        record.push("name", Value::string(display_name, span));
822
823        let find_data = match find_first_file(filename, span) {
824            Ok(fd) => fd,
825            Err(e) => {
826                // Sometimes this happens when the file name is not allowed on Windows (ex: ends with a '.', pipes)
827                // For now, we just log it and give up on returning metadata columns
828                // TODO: find another way to get this data (like cmd.exe, pwsh, and MINGW bash can)
829                log::error!("ls: '{}' {}", filename.to_string_lossy(), e);
830                return Value::record(record, span);
831            }
832        };
833
834        record.push(
835            "type",
836            Value::string(get_file_type_windows_fallback(&find_data), span),
837        );
838
839        if long {
840            record.push(
841                "target",
842                if is_symlink(&find_data) {
843                    if let Ok(path_to_link) = filename.read_link() {
844                        Value::string(path_to_link.to_string_lossy(), span)
845                    } else {
846                        Value::string("Could not obtain target file's path", span)
847                    }
848                } else {
849                    Value::nothing(span)
850                },
851            );
852
853            record.push(
854                "readonly",
855                Value::bool(
856                    find_data.dwFileAttributes & FILE_ATTRIBUTE_READONLY.0 != 0,
857                    span,
858                ),
859            );
860        }
861
862        let file_size = ((find_data.nFileSizeHigh as u64) << 32) | find_data.nFileSizeLow as u64;
863        record.push("size", Value::filesize(file_size as i64, span));
864
865        if long {
866            record.push("created", {
867                let mut val = Value::nothing(span);
868                let seconds_since_unix_epoch = unix_time_from_filetime(&find_data.ftCreationTime);
869                if let Some(local) = unix_time_to_local_date_time(seconds_since_unix_epoch) {
870                    val = Value::date(local.with_timezone(local.offset()), span);
871                }
872                val
873            });
874
875            record.push("accessed", {
876                let mut val = Value::nothing(span);
877                let seconds_since_unix_epoch = unix_time_from_filetime(&find_data.ftLastAccessTime);
878                if let Some(local) = unix_time_to_local_date_time(seconds_since_unix_epoch) {
879                    val = Value::date(local.with_timezone(local.offset()), span);
880                }
881                val
882            });
883        }
884
885        record.push("modified", {
886            let mut val = Value::nothing(span);
887            let seconds_since_unix_epoch = unix_time_from_filetime(&find_data.ftLastWriteTime);
888            if let Some(local) = unix_time_to_local_date_time(seconds_since_unix_epoch) {
889                val = Value::date(local.with_timezone(local.offset()), span);
890            }
891            val
892        });
893
894        Value::record(record, span)
895    }
896
897    fn unix_time_from_filetime(ft: &FILETIME) -> i64 {
898        /// January 1, 1970 as Windows file time
899        const EPOCH_AS_FILETIME: u64 = 116444736000000000;
900        const HUNDREDS_OF_NANOSECONDS: u64 = 10000000;
901
902        let time_u64 = ((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64);
903        if time_u64 > 0 {
904            let rel_to_linux_epoch = time_u64.saturating_sub(EPOCH_AS_FILETIME);
905            let seconds_since_unix_epoch = rel_to_linux_epoch / HUNDREDS_OF_NANOSECONDS;
906            return seconds_since_unix_epoch as i64;
907        }
908        0
909    }
910
911    // wrapper around the FindFirstFileW Win32 API
912    fn find_first_file(filename: &Path, span: Span) -> Result<WIN32_FIND_DATAW, ShellError> {
913        unsafe {
914            let mut find_data = WIN32_FIND_DATAW::default();
915            // The windows crate really needs a nicer way to do string conversions
916            let filename_wide: Vec<u16> = filename
917                .as_os_str()
918                .encode_wide()
919                .chain(std::iter::once(0))
920                .collect();
921
922            match FindFirstFileW(
923                windows::core::PCWSTR(filename_wide.as_ptr()),
924                &mut find_data,
925            ) {
926                Ok(handle) => {
927                    // Don't forget to close the Find handle
928                    // https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findfirstfilew#remarks
929                    // Assumption: WIN32_FIND_DATAW is a pure data struct, so we can let our
930                    // find_data outlive the handle.
931                    let _ = FindClose(handle);
932                    Ok(find_data)
933                }
934                Err(e) => Err(ShellError::Io(IoError::new_with_additional_context(
935                    shell_error::io::ErrorKind::from_std(std::io::ErrorKind::Other),
936                    span,
937                    PathBuf::from(filename),
938                    format!("Could not read metadata: {e}"),
939                ))),
940            }
941        }
942    }
943
944    fn get_file_type_windows_fallback(find_data: &WIN32_FIND_DATAW) -> String {
945        if find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY.0 != 0 {
946            return "dir".to_string();
947        }
948
949        if is_symlink(find_data) {
950            return "symlink".to_string();
951        }
952
953        "file".to_string()
954    }
955
956    fn is_symlink(find_data: &WIN32_FIND_DATAW) -> bool {
957        if find_data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 {
958            // Follow Golang's lead in treating mount points as symlinks.
959            // https://github.com/golang/go/blob/016d7552138077741a9c3fdadc73c0179f5d3ff7/src/os/types_windows.go#L104-L105
960            if find_data.dwReserved0 == IO_REPARSE_TAG_SYMLINK
961                || find_data.dwReserved0 == IO_REPARSE_TAG_MOUNT_POINT
962            {
963                return true;
964            }
965        }
966        false
967    }
968}
969
970#[allow(clippy::type_complexity)]
971fn read_dir(
972    f: PathBuf,
973    span: Span,
974    use_threads: bool,
975    signals: Signals,
976) -> Result<Box<dyn Iterator<Item = Result<PathBuf, ShellError>> + Send>, ShellError> {
977    let signals_clone = signals.clone();
978    let items = f
979        .read_dir()
980        .map_err(|err| IoError::new(err, span, f.clone()))?
981        .map(move |d| {
982            signals_clone.check(span)?;
983            d.map(|r| r.path())
984                .map_err(|err| IoError::new(err, span, f.clone()))
985                .map_err(ShellError::from)
986        });
987    if !use_threads {
988        let mut collected = items.collect::<Vec<_>>();
989        signals.check(span)?;
990        collected.sort_by(|a, b| match (a, b) {
991            (Ok(a), Ok(b)) => a.cmp(b),
992            (Ok(_), Err(_)) => Ordering::Greater,
993            (Err(_), Ok(_)) => Ordering::Less,
994            (Err(_), Err(_)) => Ordering::Equal,
995        });
996        return Ok(Box::new(collected.into_iter()));
997    }
998    Ok(Box::new(items))
999}