Skip to main content

rusty_bubbles/
filepicker.rs

1//! Cleanroom Rust port of upstream Go source file: `filepicker/filepicker.go`
2//! Cleanroom Rust port of upstream Go source file: `filepicker/hidden_unix.go`
3//! Upstream Target Tag / Version: `v2.1.0`
4//!
5//! <public-docs>
6//! # FilePicker
7//!
8//! A file picker component for Bubble Tea applications.
9//!
10//! The humanized byte formatting is an inline port of
11//! `github.com/dustin/go-humanize`'s `Bytes` function, and the permission
12//! string is a port of Go's `os.FileMode::String`.
13//! </public-docs>
14
15use crate::key::{self, Binding};
16use rusty_bubbletea::key::KeyPressMsg;
17use rusty_bubbletea::model::{Cmd, Msg};
18use rusty_lipgloss::{self, Style, RIGHT};
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicI64, Ordering};
21
22static LAST_ID: AtomicI64 = AtomicI64::new(0);
23
24fn next_id() -> i32 {
25    (LAST_ID.fetch_add(1, Ordering::SeqCst)) as i32
26}
27
28/// New returns a new filepicker model with default styling and key bindings.
29pub fn new() -> Model {
30    Model {
31        id: next_id(),
32        current_directory: ".".to_string(),
33        cursor: ">".to_string(),
34        allowed_types: vec![],
35        selected: 0,
36        show_permissions: true,
37        show_size: true,
38        show_hidden: false,
39        dir_allowed: false,
40        file_allowed: true,
41        auto_height: true,
42        height: 0,
43        max_idx: 0,
44        min_idx: 0,
45        selected_stack: Stack::new(),
46        min_stack: Stack::new(),
47        max_stack: Stack::new(),
48        key_map: default_key_map(),
49        styles: default_styles(),
50        path: String::new(),
51        files: vec![],
52        file_selected: String::new(),
53    }
54}
55
56/// errorMsg is sent when reading a directory fails.
57#[derive(Debug)]
58pub struct ErrorMsg(pub String);
59
60/// readDirMsg is sent after a directory has been read.
61#[derive(Debug)]
62pub struct ReadDirMsg {
63    id: i32,
64    /// The entries of the directory.
65    pub entries: Vec<DirEntry>,
66}
67
68/// DirEntry mirrors a single entry in a directory listing.
69#[derive(Debug, Clone)]
70pub struct DirEntry {
71    /// The name of the entry.
72    pub name: String,
73    /// Whether the entry is a directory.
74    pub is_dir: bool,
75    /// The size of the entry in bytes.
76    pub size: u64,
77    /// The Go-style permission string, e.g. "-rw-r--r--".
78    pub mode_string: String,
79    /// Whether the entry is a symlink.
80    pub is_symlink: bool,
81}
82
83const MARGIN_BOTTOM: usize = 5;
84const FILE_SIZE_WIDTH: usize = 7;
85const PADDING_LEFT: usize = 2;
86
87/// KeyMap defines key bindings for each user action.
88#[derive(Debug, Clone)]
89pub struct KeyMap {
90    /// GoToTop binding.
91    pub go_to_top: Binding,
92    /// GoToLast binding.
93    pub go_to_last: Binding,
94    /// Down binding.
95    pub down: Binding,
96    /// Up binding.
97    pub up: Binding,
98    /// PageUp binding.
99    pub page_up: Binding,
100    /// PageDown binding.
101    pub page_down: Binding,
102    /// Back binding.
103    pub back: Binding,
104    /// Open binding.
105    pub open: Binding,
106    /// Select binding.
107    pub select: Binding,
108}
109
110/// DefaultKeyMap defines the default keybindings.
111pub fn default_key_map() -> KeyMap {
112    KeyMap {
113        go_to_top: key::new_binding(vec![key::with_keys(&["g"]), key::with_help("g", "first")]),
114        go_to_last: key::new_binding(vec![key::with_keys(&["G"]), key::with_help("G", "last")]),
115        down: key::new_binding(vec![
116            key::with_keys(&["j", "down", "ctrl+n"]),
117            key::with_help("j", "down"),
118        ]),
119        up: key::new_binding(vec![
120            key::with_keys(&["k", "up", "ctrl+p"]),
121            key::with_help("k", "up"),
122        ]),
123        page_up: key::new_binding(vec![
124            key::with_keys(&["K", "pgup"]),
125            key::with_help("pgup", "page up"),
126        ]),
127        page_down: key::new_binding(vec![
128            key::with_keys(&["J", "pgdown"]),
129            key::with_help("pgdown", "page down"),
130        ]),
131        back: key::new_binding(vec![
132            key::with_keys(&["h", "backspace", "left", "esc"]),
133            key::with_help("h", "back"),
134        ]),
135        open: key::new_binding(vec![
136            key::with_keys(&["l", "right", "enter"]),
137            key::with_help("l", "open"),
138        ]),
139        select: key::new_binding(vec![
140            key::with_keys(&["enter"]),
141            key::with_help("enter", "select"),
142        ]),
143    }
144}
145
146/// Styles defines the possible customizations for styles in the file picker.
147#[derive(Debug, Clone)]
148pub struct Styles {
149    /// Style for the disabled cursor.
150    pub disabled_cursor: Style,
151    /// Style for the cursor.
152    pub cursor: Style,
153    /// Style for symlinks.
154    pub symlink: Style,
155    /// Style for directories.
156    pub directory: Style,
157    /// Style for files.
158    pub file: Style,
159    /// Style for disabled files.
160    pub disabled_file: Style,
161    /// Style for permissions.
162    pub permission: Style,
163    /// Style for the selected item.
164    pub selected: Style,
165    /// Style for disabled selected items.
166    pub disabled_selected: Style,
167    /// Style for file sizes.
168    pub file_size: Style,
169    /// Style for the empty directory view.
170    pub empty_directory: Style,
171}
172
173/// DefaultStyles defines the default styling for the file picker.
174pub fn default_styles() -> Styles {
175    Styles {
176        disabled_cursor: rusty_lipgloss::new_style().foreground("247"),
177        cursor: rusty_lipgloss::new_style().foreground("212"),
178        symlink: rusty_lipgloss::new_style().foreground("36"),
179        directory: rusty_lipgloss::new_style().foreground("99"),
180        file: rusty_lipgloss::new_style(),
181        disabled_file: rusty_lipgloss::new_style().foreground("243"),
182        disabled_selected: rusty_lipgloss::new_style().foreground("247"),
183        permission: rusty_lipgloss::new_style().foreground("244"),
184        selected: rusty_lipgloss::new_style().foreground("212").bold(true),
185        file_size: rusty_lipgloss::new_style()
186            .foreground("240")
187            .width(FILE_SIZE_WIDTH)
188            .align(&[RIGHT]),
189        empty_directory: rusty_lipgloss::new_style()
190            .foreground("240")
191            .padding_left(PADDING_LEFT)
192            .set_string(&["Bummer. No Files Found."]),
193    }
194}
195
196/// Model represents a file picker.
197#[derive(Debug)]
198pub struct Model {
199    id: i32,
200
201    /// Path is the path which the user has selected with the file picker.
202    pub path: String,
203
204    /// CurrentDirectory is the directory that the user is currently in.
205    pub current_directory: String,
206
207    /// AllowedTypes specifies which file types the user may select.
208    /// If empty the user may select any file.
209    pub allowed_types: Vec<String>,
210
211    /// The key bindings for the file picker.
212    pub key_map: KeyMap,
213    files: Vec<DirEntry>,
214    /// Whether to show permissions.
215    pub show_permissions: bool,
216    /// Whether to show file sizes.
217    pub show_size: bool,
218    /// Whether to show hidden files.
219    pub show_hidden: bool,
220    /// Whether directories can be selected.
221    pub dir_allowed: bool,
222    /// Whether files can be selected.
223    pub file_allowed: bool,
224
225    /// The currently selected file.
226    pub file_selected: String,
227    selected: usize,
228    selected_stack: Stack,
229
230    min_idx: usize,
231    max_idx: usize,
232    max_stack: Stack,
233    min_stack: Stack,
234
235    height: usize,
236    /// Whether the height is automatically managed.
237    pub auto_height: bool,
238
239    /// The cursor string.
240    pub cursor: String,
241    /// The styles of the file picker.
242    pub styles: Styles,
243}
244
245/// Stack is a simple LIFO stack of indices used to remember navigation
246/// history.
247#[derive(Debug, Default)]
248pub struct Stack {
249    slice: Vec<usize>,
250}
251
252impl Stack {
253    fn new() -> Stack {
254        Stack { slice: vec![] }
255    }
256
257    fn push(&mut self, i: usize) {
258        self.slice.push(i);
259    }
260
261    fn pop(&mut self) -> usize {
262        let res = self.slice[self.slice.len() - 1];
263        self.slice.pop();
264        res
265    }
266
267    fn length(&self) -> usize {
268        self.slice.len()
269    }
270}
271
272impl Model {
273    fn push_view(&mut self, selected: usize, minimum: usize, maximum: usize) {
274        self.selected_stack.push(selected);
275        self.min_stack.push(minimum);
276        self.max_stack.push(maximum);
277    }
278
279    fn pop_view(&mut self) -> (usize, usize, usize) {
280        (
281            self.selected_stack.pop(),
282            self.min_stack.pop(),
283            self.max_stack.pop(),
284        )
285    }
286
287    fn read_dir_cmd(&self, path: &str, show_hidden: bool) -> Cmd {
288        let path = path.to_string();
289        let id = self.id;
290        Some(Box::new(move || {
291            let mut entries: Vec<DirEntry> = vec![];
292            if let Ok(rd) = std::fs::read_dir(&path) {
293                for entry in rd.flatten() {
294                    let name = entry.file_name().to_string_lossy().to_string();
295                    let metadata = entry.metadata();
296                    let (is_dir, size, mode_string, is_symlink) = match metadata {
297                        Ok(md) => {
298                            let is_symlink = md.file_type().is_symlink();
299                            (md.is_dir(), md.len(), file_mode_string(&md), is_symlink)
300                        }
301                        Err(_) => (false, 0, "----------".to_string(), false),
302                    };
303                    entries.push(DirEntry {
304                        name,
305                        is_dir,
306                        size,
307                        mode_string,
308                        is_symlink,
309                    });
310                }
311            }
312            entries.sort_by(|a, b| {
313                if a.is_dir == b.is_dir {
314                    a.name.cmp(&b.name)
315                } else {
316                    b.is_dir.cmp(&a.is_dir)
317                }
318            });
319
320            if show_hidden {
321                return Some(Box::new(ReadDirMsg { id, entries }));
322            }
323
324            let mut sanitized: Vec<DirEntry> = vec![];
325            for dir_entry in entries {
326                let is_hidden = is_hidden(&dir_entry.name);
327                if is_hidden {
328                    continue;
329                }
330                sanitized.push(dir_entry);
331            }
332            Some(Box::new(ReadDirMsg {
333                id,
334                entries: sanitized,
335            }))
336        }))
337    }
338
339    /// SetHeight sets the height of the file picker.
340    pub fn set_height(&mut self, h: usize) {
341        self.height = h;
342        if self.max_idx > self.height.saturating_sub(1) {
343            self.max_idx = self.min_idx + self.height - 1;
344        }
345    }
346
347    /// Height returns the height of the file picker.
348    pub fn height(&self) -> usize {
349        self.height
350    }
351
352    /// Init initializes the file picker model.
353    pub fn init(&self) -> Cmd {
354        self.read_dir_cmd(&self.current_directory.clone(), self.show_hidden)
355    }
356
357    /// Update handles user interactions within the file picker model.
358    pub fn update(&mut self, msg: &dyn Msg) -> Cmd {
359        if let Some(m) = msg.as_any().downcast_ref::<ReadDirMsg>() {
360            if m.id != self.id {
361                return None;
362            }
363            self.files = m.entries.clone();
364            self.max_idx = self.max_idx.max(self.height().saturating_sub(1));
365            return None;
366        }
367
368        if msg.as_any().downcast_ref::<ErrorMsg>().is_some() {
369            return None;
370        }
371
372        if let Some(m) = msg
373            .as_any()
374            .downcast_ref::<rusty_bubbletea::screen::WindowSizeMsg>()
375        {
376            if self.auto_height {
377                self.set_height(m.height - MARGIN_BOTTOM);
378            }
379            self.max_idx = self.height() - 1;
380            return None;
381        }
382
383        if let Some(m) = msg.as_any().downcast_ref::<KeyPressMsg>() {
384            let k = &m.0;
385            if key::matches(k, std::slice::from_ref(&self.key_map.go_to_top)) {
386                self.selected = 0;
387                self.min_idx = 0;
388                self.max_idx = self.height() - 1;
389            } else if key::matches(k, std::slice::from_ref(&self.key_map.go_to_last)) {
390                self.selected = self.files.len().saturating_sub(1);
391                self.min_idx = self.files.len() - self.height();
392                self.max_idx = self.files.len() - 1;
393            } else if key::matches(k, std::slice::from_ref(&self.key_map.down)) {
394                self.selected += 1;
395                if self.selected >= self.files.len() {
396                    self.selected = self.files.len().saturating_sub(1);
397                }
398                if self.selected > self.max_idx {
399                    self.min_idx += 1;
400                    self.max_idx += 1;
401                }
402            } else if key::matches(k, std::slice::from_ref(&self.key_map.up)) {
403                self.selected = self.selected.saturating_sub(1);
404                if self.selected < self.min_idx {
405                    self.min_idx = self.min_idx.saturating_sub(1);
406                    self.max_idx = self.max_idx.saturating_sub(1);
407                }
408            } else if key::matches(k, std::slice::from_ref(&self.key_map.page_down)) {
409                self.selected += self.height();
410                if self.selected >= self.files.len() {
411                    self.selected = self.files.len().saturating_sub(1);
412                }
413                self.min_idx += self.height();
414                self.max_idx += self.height();
415
416                if self.max_idx >= self.files.len() {
417                    self.max_idx = self.files.len() - 1;
418                    self.min_idx = self.max_idx - self.height();
419                }
420            } else if key::matches(k, std::slice::from_ref(&self.key_map.page_up)) {
421                self.selected = self.selected.saturating_sub(self.height());
422                self.min_idx = self.min_idx.saturating_sub(self.height());
423                self.max_idx = self.max_idx.saturating_sub(self.height());
424
425                if self.min_idx == 0 {
426                    // minIdx < 0 => 0; maxIdx = minIdx + Height
427                    self.max_idx = self.min_idx + self.height();
428                }
429            } else if key::matches(k, std::slice::from_ref(&self.key_map.back)) {
430                self.current_directory = Path::new(&self.current_directory)
431                    .parent()
432                    .map(|p| p.to_string_lossy().to_string())
433                    .unwrap_or_else(|| "/".to_string());
434                if self.selected_stack.length() > 0 {
435                    let (s, mn, mx) = self.pop_view();
436                    self.selected = s;
437                    self.min_idx = mn;
438                    self.max_idx = mx;
439                } else {
440                    self.selected = 0;
441                    self.min_idx = 0;
442                    self.max_idx = self.height() - 1;
443                }
444                return self.read_dir_cmd(&self.current_directory.clone(), self.show_hidden);
445            } else if key::matches(k, std::slice::from_ref(&self.key_map.open)) {
446                if self.files.is_empty() {
447                    return None;
448                }
449
450                let f = self.files[self.selected].clone();
451                let is_symlink = f.is_symlink;
452                let mut is_dir = f.is_dir;
453
454                if is_symlink {
455                    let symlink_path = PathBuf::from(&self.current_directory)
456                        .join(&f.name)
457                        .canonicalize()
458                        .unwrap_or_default();
459                    if symlink_path.is_dir() {
460                        is_dir = true;
461                    }
462                }
463
464                if ((!is_dir && self.file_allowed) || (is_dir && self.dir_allowed))
465                    && key::matches(k, std::slice::from_ref(&self.key_map.select))
466                {
467                    // Select the current path as the selection
468                    self.path = PathBuf::from(&self.current_directory)
469                        .join(&f.name)
470                        .to_string_lossy()
471                        .to_string();
472                }
473
474                if !is_dir {
475                    return None;
476                }
477
478                self.current_directory = PathBuf::from(&self.current_directory)
479                    .join(&f.name)
480                    .to_string_lossy()
481                    .to_string();
482                self.push_view(self.selected, self.min_idx, self.max_idx);
483                self.selected = 0;
484                self.min_idx = 0;
485                self.max_idx = self.height() - 1;
486                return self.read_dir_cmd(&self.current_directory.clone(), self.show_hidden);
487            }
488        }
489        None
490    }
491
492    /// View returns the view of the file picker.
493    pub fn view(&self) -> String {
494        if self.files.is_empty() {
495            let v = self
496                .styles
497                .empty_directory
498                .clone()
499                .set_string(&["Bummer. No Files Found."])
500                .height(self.height())
501                .max_height(self.height())
502                .render("");
503            return v;
504        }
505        let mut s = String::new();
506
507        for (i, f) in self.files.iter().enumerate() {
508            if i < self.min_idx || i > self.max_idx {
509                continue;
510            }
511
512            let mut symlink_path = String::new();
513            let is_symlink = f.is_symlink;
514            let size = humanize_bytes(f.size);
515            let name = &f.name;
516
517            if is_symlink {
518                symlink_path = PathBuf::from(&self.current_directory)
519                    .join(name)
520                    .canonicalize()
521                    .map(|p| p.to_string_lossy().to_string())
522                    .unwrap_or_default();
523            }
524
525            let disabled = !self.can_select(name) && !f.is_dir;
526
527            if self.selected == i {
528                let mut selected = String::new();
529                if self.show_permissions {
530                    selected += " ";
531                    selected += &f.mode_string;
532                }
533                if self.show_size {
534                    selected += &format!(
535                        "{:>width$}",
536                        size,
537                        width = self.styles.file_size.get_width()
538                    );
539                }
540                selected += " ";
541                selected += name;
542                if is_symlink {
543                    selected += " → ";
544                    selected += &symlink_path;
545                }
546                if disabled {
547                    s += &self.styles.disabled_cursor.clone().render(&self.cursor);
548                    s += &self.styles.disabled_selected.clone().render(&selected);
549                } else {
550                    s += &self.styles.cursor.clone().render(&self.cursor);
551                    s += &self.styles.selected.clone().render(&selected);
552                }
553                s += "\n";
554                continue;
555            }
556
557            let mut style = self.styles.file.clone();
558            if f.is_dir {
559                style = self.styles.directory.clone();
560            } else if is_symlink {
561                style = self.styles.symlink.clone();
562            } else if disabled {
563                style = self.styles.disabled_file.clone();
564            }
565
566            let mut file_name = style.render(name);
567            s += &self.styles.cursor.clone().render(" ");
568            if is_symlink {
569                file_name += " → ";
570                file_name += &symlink_path;
571            }
572            if self.show_permissions {
573                s += " ";
574                s += &self.styles.permission.clone().render(&f.mode_string);
575            }
576            if self.show_size {
577                s += &self.styles.file_size.clone().render(&size);
578            }
579            s += " ";
580            s += &file_name;
581            s += "\n";
582        }
583
584        for _ in rusty_lipgloss::size::height(&s)..=self.height() {
585            s += "\n";
586        }
587
588        s
589    }
590
591    /// DidSelectFile returns whether a user has selected a file (on this
592    /// msg).
593    pub fn did_select_file(&self, msg: &dyn Msg) -> (bool, String) {
594        let (did_select, path) = self.did_select_file_inner(msg);
595        if did_select && self.can_select(&path) {
596            return (true, path);
597        }
598        (false, String::new())
599    }
600
601    /// DidSelectDisabledFile returns whether a user tried to select a
602    /// disabled file (on this msg).
603    pub fn did_select_disabled_file(&self, msg: &dyn Msg) -> (bool, String) {
604        let (did_select, path) = self.did_select_file_inner(msg);
605        if did_select && !self.can_select(&path) {
606            return (true, path);
607        }
608        (false, String::new())
609    }
610
611    fn did_select_file_inner(&self, msg: &dyn Msg) -> (bool, String) {
612        if self.files.is_empty() {
613            return (false, String::new());
614        }
615        let m = msg.as_any().downcast_ref::<KeyPressMsg>();
616        match m {
617            Some(m) => {
618                // If the msg does not match the Select keymap then this
619                // could not have been a selection.
620                if !key::matches(&m.0, std::slice::from_ref(&self.key_map.select)) {
621                    return (false, String::new());
622                }
623
624                // The key press was a selection, let's confirm whether the
625                // current file could be selected or used for navigating
626                // deeper into the stack.
627                let f = &self.files[self.selected];
628                let is_symlink = f.is_symlink;
629                let mut is_dir = f.is_dir;
630
631                if is_symlink {
632                    let symlink_path = PathBuf::from(&self.current_directory)
633                        .join(&f.name)
634                        .canonicalize()
635                        .unwrap_or_default();
636                    if symlink_path.is_dir() {
637                        is_dir = true;
638                    }
639                }
640
641                if ((!is_dir && self.file_allowed) || (is_dir && self.dir_allowed))
642                    && !self.path.is_empty()
643                {
644                    return (true, self.path.clone());
645                }
646
647                (false, String::new())
648            }
649            None => (false, String::new()),
650        }
651    }
652
653    fn can_select(&self, file: &str) -> bool {
654        if self.allowed_types.is_empty() {
655            return true;
656        }
657
658        for ext in &self.allowed_types {
659            if file.ends_with(ext.as_str()) {
660                return true;
661            }
662        }
663        false
664    }
665
666    /// HighlightedPath returns the path of the currently highlighted file
667    /// or directory.
668    pub fn highlighted_path(&self) -> String {
669        if self.files.is_empty() || self.selected >= self.files.len() {
670            return String::new();
671        }
672        PathBuf::from(&self.current_directory)
673            .join(&self.files[self.selected].name)
674            .to_string_lossy()
675            .to_string()
676    }
677}
678
679/// isHidden returns whether the given name is hidden: a leading dot on Unix
680/// (port of `hidden_unix.go`), or the `FILE_ATTRIBUTE_HIDDEN` attribute on
681/// Windows (port of `hidden_windows.go`). The name is resolved relative to
682/// the current working directory on Windows, mirroring the upstream call
683/// `IsHidden(dirEntry.Name())`.
684#[cfg(not(windows))]
685fn is_hidden(name: &str) -> bool {
686    name.starts_with('.')
687}
688
689/// Windows port of `hidden_windows.go`: `GetFileAttributes` + the
690/// `FILE_ATTRIBUTE_HIDDEN` flag.
691#[cfg(windows)]
692fn is_hidden(name: &str) -> bool {
693    use std::os::windows::ffi::OsStrExt;
694
695    #[link(name = "kernel32")]
696    extern "system" {
697        fn GetFileAttributesW(lp_file_name: *const u16) -> u32;
698    }
699
700    const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
701    const INVALID_FILE_ATTRIBUTES: u32 = u32::MAX;
702
703    let wide: Vec<u16> = std::ffi::OsStr::new(name)
704        .encode_wide()
705        .chain(std::iter::once(0))
706        .collect();
707    unsafe {
708        let attributes = GetFileAttributesW(wide.as_ptr());
709        attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_HIDDEN) != 0
710    }
711}
712
713/// humanizeBytes formats a byte count the way Go's go-humanize `Bytes` does
714/// (with the space removed, matching the filepicker's `Replace(..., " ", "")`).
715fn humanize_bytes(size: u64) -> String {
716    if size < 10 {
717        return format!("{} B", size);
718    }
719    let base = 1000.0f64;
720    let sizes = ["B", "kB", "MB", "GB", "TB", "PB", "EB"];
721    let n = (size as f64).log(base).floor() as usize;
722    let suffix = sizes[n.min(sizes.len() - 1)];
723    let val = (size as f64 / base.powi(n as i32) * 10.0 + 0.5).floor() / 10.0;
724    if val < 10.0 {
725        return format!("{:.1} {}", val, suffix).replace(' ', "");
726    }
727    format!("{:.0} {}", val, suffix).replace(' ', "")
728}
729
730/// fileModeString renders Go's `os.FileMode::String()` permission string,
731/// e.g. "drwxr-xr-x" or "-rw-r--r--".
732#[cfg(unix)]
733fn file_mode_string(md: &std::fs::Metadata) -> String {
734    use std::os::unix::fs::{FileTypeExt, MetadataExt};
735
736    let mut s = String::with_capacity(10);
737    let mode = md.mode();
738
739    // File type bit.
740    let kind = md.file_type();
741    if kind.is_dir() {
742        s.push('d');
743    } else if kind.is_symlink() {
744        s.push('L');
745    } else if kind.is_char_device() {
746        s.push('c');
747    } else if kind.is_block_device() {
748        s.push('b');
749    } else if kind.is_fifo() {
750        s.push('p');
751    } else if kind.is_socket() {
752        s.push('S');
753    } else {
754        s.push('-');
755    }
756
757    // Permission bits.
758    let perm = mode & 0o7777;
759    let chars = ['r', 'w', 'x'];
760    for i in 0..9 {
761        if perm & (0o400 >> i) != 0 {
762            s.push(chars[i % 3]);
763        } else {
764            s.push('-');
765        }
766        if i == 2 {
767            if perm & 0o4000 != 0 {
768                let c = s.pop().unwrap();
769                s.push(if c == 'x' { 's' } else { 'S' });
770            }
771        } else if i == 5 {
772            if perm & 0o2000 != 0 {
773                let c = s.pop().unwrap();
774                s.push(if c == 'x' { 's' } else { 'S' });
775            }
776        } else if i == 8 && perm & 0o1000 != 0 {
777            let c = s.pop().unwrap();
778            s.push(if c == 'x' { 't' } else { 'T' });
779        }
780    }
781    s
782}
783
784#[cfg(not(unix))]
785fn file_mode_string(_md: &std::fs::Metadata) -> String {
786    "-rw-r--r--".to_string()
787}