Skip to main content

tree_space/
config.rs

1//! Configuration and XDG persistence.
2//!
3//! This module owns two concerns:
4//!
5//! * **`Config`** — the user-facing TOML file at
6//!   `$XDG_CONFIG_HOME/tree-space/config.toml` (`~/.config/tree-space/config.toml`).
7//!   Every field has a sane default via `#[serde(default)]`, so a minimal file
8//!   (or none at all) is valid. Those defaults are not written here: they are
9//!   parsed from the shipped `default-config.toml` (see [`builtin`]), which is
10//!   the single source of truth for every default value.
11//! * **`SessionState`** — small runtime state (last opened root) kept in
12//!   `$XDG_STATE_HOME/tree-space/state.toml`. Kept separate from the config so
13//!   that re-exporting the config as a "default" for users stays clean.
14//!
15//! The XDG resolution helpers are pure functions over environment-style inputs
16//! so they can be tested without mutating process-global environment variables.
17
18use std::env;
19use std::path::{Path, PathBuf};
20use std::sync::OnceLock;
21
22use regex::Regex;
23use serde::{Deserialize, Serialize};
24
25/// The directory name used below all XDG base dirs.
26pub const APP_DIR: &str = "tree-space";
27/// Name of the config file inside [`APP_DIR`].
28pub const CONFIG_FILE: &str = "config.toml";
29/// Name of the state file inside [`APP_DIR`].
30pub const STATE_FILE: &str = "state.toml";
31/// Name of the user stylesheet inside [`APP_DIR`].
32pub const STYLE_FILE: &str = "main.css";
33
34/// The shipping default config, written out on first launch. This is the
35/// **single source of truth** for every default value: [`Config::default`] and
36/// each section's `Default` are parsed from it (see [`builtin`]). It is kept as
37/// a real file (rather than generated by serialization) so it can be commented
38/// and laid out for humans.
39const DEFAULT_CONFIG: &str = include_str!("../default-config.toml");
40
41/// The shipped default stylesheet, written out beside the config on first
42/// launch and used as a fallback when the user's own is missing or unreadable.
43pub const DEFAULT_STYLESHEET: &str = include_str!("../style/main.css");
44
45/// The parsed `default-config.toml`, built once. Every `Default` impl delegates
46/// here so the file, not this module, decides the defaults.
47///
48/// Parsing uses [`ShippedRaw`], whose section fields are `Option`s with
49/// *(field-level)* `#[serde(default)]`. That keeps the parse free of any call
50/// back into a section's own `Default` — and therefore out of [`builtin`]
51/// itself — because the shipped file always populates every section. A missing
52/// section (or scalar) falls back to the literal schema default on its type,
53/// which likewise never re-enters [`builtin`].
54fn builtin() -> &'static Config {
55    static ONCE: OnceLock<Config> = OnceLock::new();
56    ONCE.get_or_init(|| {
57        let raw: ShippedRaw = toml::from_str(DEFAULT_CONFIG)
58            .expect("default-config.toml must be valid TOML");
59        Config {
60            panel: raw.panel.unwrap_or_default(),
61            tree: raw.tree.unwrap_or_default(),
62            startup: raw.startup.unwrap_or_default(),
63            context_menu: raw.context_menu.unwrap_or_default(),
64            pane_menu: raw.pane_menu.unwrap_or_default(),
65        }
66    })
67}
68
69/// A recursion-free view of `default-config.toml`: every section is optional
70/// and defaults to `None`, so deserializing the shipped file never touches a
71/// section's `Default` (which would re-enter [`builtin`]).
72#[derive(Deserialize)]
73struct ShippedRaw {
74    #[serde(default)]
75    panel: Option<PanelConfig>,
76    #[serde(default)]
77    tree: Option<TreeConfig>,
78    #[serde(default)]
79    startup: Option<StartupRoot>,
80    #[serde(default)]
81    context_menu: Option<ContextMenu>,
82    #[serde(default)]
83    pane_menu: Option<PaneMenu>,
84}
85
86// ---------------------------------------------------------------------------
87// XDG path resolution
88// ---------------------------------------------------------------------------
89
90/// Resolve `$XDG_CONFIG_HOME` / `$HOME/.config` from explicit inputs.
91///
92/// `xdg` is the value of `XDG_CONFIG_HOME`, `home` the value of `HOME`. When
93/// neither is present the XDG spec mandates falling back to `~/.config`.
94fn xdg_config_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
95    match xdg.filter(|s| !s.is_empty()) {
96        Some(dir) => PathBuf::from(dir),
97        None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".config"),
98    }
99}
100
101/// Resolve `$XDG_STATE_HOME` / `$HOME/.local/state` from explicit inputs.
102fn xdg_state_home(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
103    match xdg.filter(|s| !s.is_empty()) {
104        Some(dir) => PathBuf::from(dir),
105        None => home.map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/")).join(".local/state"),
106    }
107}
108
109/// Path of `<app dir>/<file>` below an XDG base directory.
110fn app_path(base: &Path, file: &str) -> PathBuf {
111    base.join(APP_DIR).join(file)
112}
113
114/// Read `XDG_CONFIG_HOME`/`HOME` from the environment. Replaced by
115/// [`xdg_config_home`] in tests where determinism matters.
116fn config_home_from_env() -> PathBuf {
117    let xdg = env::var_os("XDG_CONFIG_HOME").map(|v| v.to_string_lossy().into_owned());
118    let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
119    xdg_config_home(xdg.as_deref(), home.as_deref())
120}
121
122/// Read `XDG_STATE_HOME`/`HOME` from the environment.
123fn state_home_from_env() -> PathBuf {
124    let xdg = env::var_os("XDG_STATE_HOME").map(|v| v.to_string_lossy().into_owned());
125    let home = env::var_os("HOME").map(|v| v.to_string_lossy().into_owned());
126    xdg_state_home(xdg.as_deref(), home.as_deref())
127}
128
129/// Absolute path of the config file for the current environment.
130pub fn config_file() -> PathBuf {
131    app_path(&config_home_from_env(), CONFIG_FILE)
132}
133
134/// Absolute path of the state file for the current environment.
135pub fn state_file() -> PathBuf {
136    app_path(&state_home_from_env(), STATE_FILE)
137}
138
139/// Absolute path of the user stylesheet for the current environment.
140pub fn style_file() -> PathBuf {
141    app_path(&config_home_from_env(), STYLE_FILE)
142}
143
144// ---------------------------------------------------------------------------
145// Config values
146// ---------------------------------------------------------------------------
147
148/// Which edge of the screen the panel docks to.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
150#[serde(rename_all = "lowercase")]
151pub enum PanelSide {
152    #[default]
153    Left,
154    Right,
155}
156
157impl PanelSide {
158    /// Both sides, left then right.
159    pub const ALL: [PanelSide; 2] = [PanelSide::Left, PanelSide::Right];
160
161    /// The other side ("right" for left, and vice versa).
162    pub fn opposite(&self) -> PanelSide {
163        match self {
164            PanelSide::Left => PanelSide::Right,
165            PanelSide::Right => PanelSide::Left,
166        }
167    }
168
169    /// The lowercase word used in user-facing labels ("left" / "right").
170    pub fn name(&self) -> &'static str {
171        match self {
172            PanelSide::Left => "left",
173            PanelSide::Right => "right",
174        }
175    }
176}
177
178/// The wlr-layer-shell layer the panel lives in. `Bottom` keeps it behind
179/// normal windows (the Ormachy/Hyprland "stays out of the way" mode).
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
181#[serde(rename_all = "lowercase")]
182pub enum PanelLayer {
183    Background,
184    #[default]
185    Bottom,
186    Top,
187    Overlay,
188}
189
190/// Smallest allowed panel width, in pixels.
191pub const PANEL_MIN_WIDTH: u32 = 160;
192/// Largest allowed panel width, in pixels.
193pub const PANEL_MAX_WIDTH: u32 = 2000;
194
195// Field-level serde defaults. These run only for a field omitted from a
196// *user's* partial table (the shipped file lists every field, so they are never
197// reached while [`builtin`] is being initialized). Each returns the shipped
198// value, so an omitted key still matches `default-config.toml`.
199fn default_panel_side() -> PanelSide {
200    builtin().panel.side
201}
202fn default_panel_layer() -> PanelLayer {
203    builtin().panel.layer
204}
205fn default_panel_width() -> u32 {
206    builtin().panel.width
207}
208fn default_dirs_first() -> bool {
209    builtin().tree.dirs_first
210}
211fn default_sort_key() -> String {
212    builtin().tree.sort_key.clone()
213}
214fn default_sort_ascending() -> bool {
215    builtin().tree.sort_ascending
216}
217fn default_font_size() -> u32 {
218    builtin().tree.font_size
219}
220fn default_icon_size() -> u32 {
221    builtin().tree.icon_size
222}
223fn default_confirm_drop_move() -> bool {
224    builtin().tree.confirm_drop_move
225}
226
227/// Dock/panel configuration. Field-level serde defaults keep a partially
228/// written `[panel]` table valid without consulting `Self::default()` (which
229/// parses the shipped file — see [`builtin`]).
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
231pub struct PanelConfig {
232    #[serde(default = "default_panel_side")]
233    pub side: PanelSide,
234    #[serde(default = "default_panel_layer")]
235    pub layer: PanelLayer,
236    /// Panel width in pixels.
237    #[serde(default = "default_panel_width")]
238    pub width: u32,
239    /// Pixel margin around the panel within the screen edge.
240    #[serde(default)]
241    pub margin: u32,
242}
243
244impl Default for PanelConfig {
245    fn default() -> Self {
246        builtin().panel
247    }
248}
249
250/// Sorting and visibility rules for the tree.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TreeConfig {
253    /// When true, directories are listed before files; otherwise everything is
254    /// interleaved and only alphabetically sorted. Names are always sorted
255    /// alphabetically (case-insensitively).
256    #[serde(default = "default_dirs_first")]
257    pub dirs_first: bool,
258    /// Primary sort key: `name`, `size`, `modified`, or `type`.
259    #[serde(default = "default_sort_key")]
260    pub sort_key: String,
261    /// Sort ascending (A→Z, smallest first) when true; descending otherwise.
262    #[serde(default = "default_sort_ascending")]
263    pub sort_ascending: bool,
264    /// Show dotfiles. When false, hidden entries are never read into the model.
265    #[serde(default)]
266    pub show_hidden: bool,
267    /// Base font size in px applied to the tree.
268    #[serde(default = "default_font_size")]
269    pub font_size: u32,
270    /// Pixel size of file/directory icons in the tree.
271    #[serde(default = "default_icon_size")]
272    pub icon_size: u32,
273    /// When true, dropping a dragged selection onto a directory asks for
274    /// confirmation before moving; when false (the default) the move happens
275    /// immediately. Copying (Ctrl+drag) never asks.
276    #[serde(default = "default_confirm_drop_move")]
277    pub confirm_drop_move: bool,
278}
279
280impl Default for TreeConfig {
281    fn default() -> Self {
282        builtin().tree.clone()
283    }
284}
285
286impl TreeConfig {
287    /// Translate the configured sort fields into a [`SortOptions`]. Unknown
288    /// `sort_key` values fall back to name.
289    pub fn sort_options(&self) -> crate::fs::SortOptions {
290        crate::fs::SortOptions {
291            dirs_first: self.dirs_first,
292            key: crate::fs::SortKey::parse(&self.sort_key).unwrap_or(crate::fs::SortKey::Name),
293            ascending: self.sort_ascending,
294        }
295    }
296}
297
298/// Where the panel opens when launched without an explicit path argument.
299///
300/// In TOML this is a single `root` value: `"home"` (the default), `"last"`, or
301/// a table naming a fixed directory — `{ path = "/some/dir" }`. A leading `~` in
302/// the path expands to `$HOME`.
303#[derive(Debug, Clone, PartialEq, Eq, Default)]
304pub enum StartupRoot {
305    /// Always open the user's home directory.
306    #[default]
307    Home,
308    /// Reopen the most recently used directory (persisted between launches).
309    Last,
310    /// Always open the named directory.
311    Path(String),
312}
313
314impl StartupRoot {
315    /// Resolve the directory to open, given the last-used directory from session
316    /// state. `None` means "fall back to home" (unknown/unusable path).
317    pub fn resolve(&self, last: Option<PathBuf>) -> Option<PathBuf> {
318        match self {
319            StartupRoot::Last => last,
320            StartupRoot::Home => None,
321            StartupRoot::Path(path) => expand_tilde(path),
322        }
323    }
324}
325
326impl Serialize for StartupRoot {
327    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
328        use serde::ser::SerializeMap;
329        match self {
330            StartupRoot::Last => serializer.serialize_str("last"),
331            StartupRoot::Home => serializer.serialize_str("home"),
332            StartupRoot::Path(path) => {
333                let mut map = serializer.serialize_map(Some(1))?;
334                map.serialize_entry("path", path)?;
335                map.end()
336            }
337        }
338    }
339}
340
341impl<'de> Deserialize<'de> for StartupRoot {
342    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
343        use serde::de::Error as _;
344
345        #[derive(Deserialize)]
346        #[serde(untagged)]
347        enum Repr {
348            Keyword(String),
349            Path { path: String },
350        }
351
352        match Repr::deserialize(deserializer)? {
353            Repr::Keyword(word) => match word.to_ascii_lowercase().as_str() {
354                "last" => Ok(StartupRoot::Last),
355                "home" => Ok(StartupRoot::Home),
356                other => Err(D::Error::custom(format!(
357                    "unknown startup root {other:?} (expected \"last\", \"home\", or {{ path = \"...\" }})"
358                ))),
359            },
360            Repr::Path { path } => Ok(StartupRoot::Path(path)),
361        }
362    }
363}
364
365/// Expand a leading `~` in `path` to `$HOME`. An empty string stays empty.
366pub fn expand_tilde(path: &str) -> Option<PathBuf> {
367    if path.is_empty() {
368        return None;
369    }
370    match path.strip_prefix('~') {
371        Some(rest) => std::env::var_os("HOME").map(|home| {
372            let mut p = PathBuf::from(home);
373            p.push(rest.strip_prefix('/').unwrap_or(rest));
374            p
375        }),
376        // A relative configured path is resolved against the working directory
377        // (matching how CLI arguments are normalized), so the pane and its path
378        // entry never show a bare relative spelling.
379        None => {
380            let path = PathBuf::from(path);
381            Some(std::path::absolute(&path).unwrap_or(path))
382        }
383    }
384}
385
386/// Which rows a context-menu rule applies to. Checked in order of rule
387/// precedence: [`Dir`](ContextMatch::Dir) first, then extension, then
388/// no-extension, then [`Regex`](ContextMatch::Regex) / [`Fallback`](ContextMatch::Fallback)
389/// for anything else.
390#[derive(Debug, Clone)]
391pub enum ContextMatch {
392    /// A directory.
393    Dir,
394    /// A file with no extension (the name has no `.` after the last path
395    /// separator).
396    NoExt,
397    /// Every path that was not claimed by a more specific rule.
398    Fallback,
399    /// A multi-row selection (more than one row selected). Not a property of a
400    /// single path, so [`Self::matches`] always returns false for it; it only
401    /// participates in [`ContextMenu::actions_for_selection`].
402    Multi,
403    /// A file whose *name* ends with the extension, case-insensitively:
404    /// `Ext("py")` matches both `x.py` and `x.tar.gz` (multi-part suffixes are
405    /// matched from the end, so more specific rules should come first).
406    Ext(String),
407    /// The full row path matched against a regex. Applies to files and
408    /// directories alike; case-sensitive unless the pattern opts out with
409    /// `(?i)`. Matched against the whole path, so name-only patterns should
410    /// anchor with `.*\.NAME$`.
411    Regex(Regex),
412}
413
414impl ContextMatch {
415    /// The canonical configuration spelling.
416    pub fn as_str(&self) -> String {
417        match self {
418            ContextMatch::Dir => "dir".to_owned(),
419            ContextMatch::NoExt => "noext".to_owned(),
420            ContextMatch::Fallback => "fallback".to_owned(),
421            ContextMatch::Multi => "multi".to_owned(),
422            ContextMatch::Ext(ext) => format!("ext:{ext}"),
423            ContextMatch::Regex(re) => format!("regex:{}", re.as_str()),
424        }
425    }
426
427    /// Parse a config spelling. `"*"` is an alias for `"fallback"`.
428    pub fn parse(value: &str) -> Result<Self, String> {
429        let value = value.trim();
430        match value {
431            "dir" | "directory" => Ok(ContextMatch::Dir),
432            "noext" => Ok(ContextMatch::NoExt),
433            "fallback" | "*" | "all" => Ok(ContextMatch::Fallback),
434            "multi" | "multi-select" | "selection" => Ok(ContextMatch::Multi),
435            _ => {
436                if let Some(ext) = value.strip_prefix("ext:") {
437                    return Ok(ContextMatch::Ext(ext.to_owned()));
438                }
439                if let Some(pattern) = value.strip_prefix("regex:") {
440                    return Regex::new(pattern)
441                        .map(ContextMatch::Regex)
442                        .map_err(|err| {
443                            format!(
444                                "invalid regex {pattern:?} in context menu matcher {value:?}: {err}"
445                            )
446                        });
447                }
448                Err(format!("unknown context menu matcher {value:?}"))
449            }
450        }
451    }
452
453    /// Whether `path` is claimed by this matcher. Extension and no-extention
454    /// matchers only ever claim files.
455    pub fn matches(&self, path: &Path) -> bool {
456        match self {
457            ContextMatch::Dir => path.is_dir(),
458            ContextMatch::NoExt => {
459                let Some(name) = path.file_name() else {
460                    return false;
461                };
462                let name = name.to_string_lossy();
463                !path.is_dir() && !name.ends_with('.') && {
464                    // No dot past the last path separators that isn't the
465                    // leading dot of a hidden file name.
466                    let after_sep = name.rsplit(['/', '\\']).next().unwrap_or("");
467                    !after_sep[1..].contains('.')
468                }
469            }
470            ContextMatch::Fallback => true,
471            // Selection-level, never a property of one path.
472            ContextMatch::Multi => false,
473            ContextMatch::Ext(ext) => {
474                if path.is_dir() || ext.is_empty() {
475                    return false;
476                }
477                let Some(name) = path.file_name() else {
478                    return false;
479                };
480                let name = name.to_string_lossy().to_lowercase();
481                name.trim_start_matches('.').ends_with(&format!(".{}", ext.trim().to_lowercase()))
482            }
483            ContextMatch::Regex(re) => re.is_match(&path.to_string_lossy()),
484        }
485    }
486}
487
488impl PartialEq for ContextMatch {
489    fn eq(&self, other: &Self) -> bool {
490        match (self, other) {
491            (ContextMatch::Dir, ContextMatch::Dir) => true,
492            (ContextMatch::NoExt, ContextMatch::NoExt) => true,
493            (ContextMatch::Fallback, ContextMatch::Fallback) => true,
494            (ContextMatch::Multi, ContextMatch::Multi) => true,
495            (ContextMatch::Ext(a), ContextMatch::Ext(b)) => a == b,
496            (ContextMatch::Regex(a), ContextMatch::Regex(b)) => a.as_str() == b.as_str(),
497            _ => false,
498        }
499    }
500}
501
502impl Eq for ContextMatch {}
503
504impl Serialize for ContextMatch {
505    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
506        serializer.serialize_str(&self.as_str())
507    }
508}
509
510impl<'de> Deserialize<'de> for ContextMatch {
511    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
512        let raw = String::deserialize(deserializer)?;
513        ContextMatch::parse(&raw).map_err(serde::de::Error::custom)
514    }
515}
516
517/// A built-in context-menu action: everything the panel can do to a row that
518/// is not a custom command. A [`Separator`](BuiltinAction::Separator) is only a
519/// menu divider; it has no action.
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub enum BuiltinAction {
522    /// Open: expand/collapse a directory, or open a file in its default app.
523    Open,
524    /// Open the directory as a new split pane *in the current dock*.
525    OpenSplit,
526    /// Open the directory as a new pane in the *same* panel (like a split view).
527    InNewPanel,
528    /// Open the directory in the *opposite* panel (the dock on the other side
529    /// of the screen). The menu label names that side: "In right panel".
530    InOppositePanel,
531    /// Pick an application for the row and open it with that app.
532    OpenWith,
533    /// Open a file with its default application (a sibling of
534    /// [`Self::Open`]; the label shows the default app's name).
535    OpenWithDefault,
536    /// Show or hide an inline thumbnail preview: below an image row, or below
537    /// every image inside a directory. Purely visual and non-persistent.
538    ViewThumbnail,
539    /// Create a new file in the row's target directory.
540    NewFile,
541    /// Create a new folder in the row's target directory.
542    NewFolder,
543    /// Inline-rename the row.
544    Rename,
545    /// Duplicate the row.
546    Duplicate,
547    /// Copy the row's absolute path.
548    CopyPath,
549    /// Copy the row's path relative to the tree root.
550    CopyRelativePath,
551    /// Cut the selection (moved on the next paste).
552    Cut,
553    /// Copy the selection (duplicated on the next paste).
554    Copy,
555    /// Paste the clipboard into the row's directory (the row itself for a
556    /// directory, its parent for a file).
557    Paste,
558    /// Create a symlink to the row next to it.
559    CreateLink,
560    /// Show the row's properties.
561    Properties,
562    /// Move the row to the trash.
563    Trash,
564    /// Permanently delete the row.
565    DeletePermanently,
566    /// Toggle visibility of dotfiles in the current tree.
567    ToggleHidden,
568    /// Sort entries by name.
569    SortByName,
570    /// Sort entries by size.
571    SortBySize,
572    /// Sort entries by modification time.
573    SortByModified,
574    /// Sort entries by type/extension.
575    SortByType,
576    /// Flip ascending/descending order.
577    ToggleSortAscending,
578    /// Pane-level: pick a folder to browse (the hamburger menu).
579    OpenFolder,
580    /// Pane-level: open the filter bar.
581    Filter,
582    /// Pane-level: split the pane.
583    SplitView,
584    /// Pane-level: open the current directory's parent (the enclosing folder).
585    Up,
586    /// Pane-level: go back to the previously shown directory in this pane.
587    Back,
588    /// Pane-level: go forward again after going back.
589    Forward,
590    /// Pane-level: hide this pane's whole side.
591    Collapse,
592    /// Pane-level: close this pane.
593    ClosePane,
594    /// A menu divider; never does anything.
595    Separator,
596}
597
598impl BuiltinAction {
599    /// The configuration string identifying this action (also its menu label,
600    /// unless the menu uses a dynamic label).
601    pub fn as_str(&self) -> &'static str {
602        match self {
603            BuiltinAction::Open => "Open",
604            BuiltinAction::OpenSplit => "Open in Split View",
605            BuiltinAction::InNewPanel => "In new panel",
606            BuiltinAction::InOppositePanel => "In opposite panel",
607            BuiltinAction::OpenWith => "Open With...",
608            BuiltinAction::OpenWithDefault => "Open With Default",
609            BuiltinAction::ViewThumbnail => "View Thumbnail",
610            BuiltinAction::NewFile => "New File",
611            BuiltinAction::NewFolder => "New Folder",
612            BuiltinAction::Rename => "Rename",
613            BuiltinAction::Duplicate => "Duplicate",
614            BuiltinAction::CopyPath => "Copy Path",
615            BuiltinAction::CopyRelativePath => "Copy Relative Path",
616            BuiltinAction::Cut => "Cut",
617            BuiltinAction::Copy => "Copy",
618            BuiltinAction::Paste => "Paste",
619            BuiltinAction::CreateLink => "Create Link",
620            BuiltinAction::Properties => "Properties",
621            BuiltinAction::Trash => "Move to Trash",
622            BuiltinAction::DeletePermanently => "Delete Permanently",
623            BuiltinAction::ToggleHidden => "Toggle Hidden Files",
624            BuiltinAction::SortByName => "Sort by Name",
625            BuiltinAction::SortBySize => "Sort by Size",
626            BuiltinAction::SortByModified => "Sort by Modified",
627            BuiltinAction::SortByType => "Sort by Type",
628            BuiltinAction::ToggleSortAscending => "Reverse Sort Order",
629            BuiltinAction::OpenFolder => "Open Folder...",
630            BuiltinAction::Filter => "Filter...",
631            BuiltinAction::SplitView => "Split View",
632            BuiltinAction::Up => "Up One Level",
633            BuiltinAction::Back => "Back",
634            BuiltinAction::Forward => "Forward",
635            BuiltinAction::Collapse => "Collapse",
636            BuiltinAction::ClosePane => "Close Pane",
637            BuiltinAction::Separator => "---",
638        }
639    }
640
641    /// The shortcut that a bare string item implies, when the user does not
642    /// spell one out. `None` for actions without a default key.
643    pub fn default_shortcut(&self) -> Option<&'static str> {
644        match self {
645            BuiltinAction::Cut => Some("Ctrl+x"),
646            BuiltinAction::Copy => Some("Ctrl+c"),
647            BuiltinAction::Paste => Some("Ctrl+v"),
648            BuiltinAction::Rename => Some("F2"),
649            BuiltinAction::Duplicate => Some("Ctrl+d"),
650            BuiltinAction::NewFile => Some("Ctrl+n"),
651            BuiltinAction::NewFolder => Some("Ctrl+Shift+n"),
652            BuiltinAction::CreateLink => Some("Ctrl+Shift+m"),
653            BuiltinAction::Trash => Some("Delete"),
654            BuiltinAction::DeletePermanently => Some("Shift+Delete"),
655            BuiltinAction::ViewThumbnail => Some("Ctrl+t"),
656            BuiltinAction::ToggleHidden => Some("Ctrl+h"),
657            BuiltinAction::SortByName => Some("Ctrl+1"),
658            BuiltinAction::SortBySize => Some("Ctrl+2"),
659            BuiltinAction::SortByModified => Some("Ctrl+3"),
660            BuiltinAction::SortByType => Some("Ctrl+4"),
661            BuiltinAction::ToggleSortAscending => Some("Ctrl+Shift+r"),
662            BuiltinAction::SplitView => Some("Ctrl+s"),
663            BuiltinAction::Up => Some("Alt+Up"),
664            BuiltinAction::Back => Some("Alt+Left"),
665            BuiltinAction::Forward => Some("Alt+Right"),
666            BuiltinAction::ClosePane => Some("Ctrl+w"),
667            _ => None,
668        }
669    }
670
671    /// Whether this action operates on a single row only, and so should be
672    /// hidden from a context menu opened over a multi-row selection. Actions
673    /// that fan out over the selection (trash, delete, copy, cut, properties,
674    /// copy path) are not listed here.
675    pub fn is_single_row_only(&self) -> bool {
676        matches!(
677            self,
678            BuiltinAction::Open
679                | BuiltinAction::OpenSplit
680                | BuiltinAction::InNewPanel
681                | BuiltinAction::InOppositePanel
682                | BuiltinAction::OpenWith
683                | BuiltinAction::OpenWithDefault
684                | BuiltinAction::ViewThumbnail
685                | BuiltinAction::NewFile
686                | BuiltinAction::NewFolder
687                | BuiltinAction::Rename
688                | BuiltinAction::Duplicate
689                | BuiltinAction::CreateLink
690        )
691    }
692
693    /// Whether this action only makes sense for directories (the menu drops
694    /// it from file rows).
695    pub fn is_directory_only(&self) -> bool {
696        matches!(self, BuiltinAction::OpenSplit | BuiltinAction::InNewPanel | BuiltinAction::InOppositePanel)
697    }
698
699    /// Whether this is a pane-level action (open a folder, filter, split,
700    /// collapse, close) that the app, not a tree, must perform. Pane actions
701    /// belong in the hamburger menu; a shortcut bound to one is routed upward.
702    pub fn is_pane_action(&self) -> bool {
703        matches!(
704            self,
705            BuiltinAction::OpenFolder
706                | BuiltinAction::Filter
707                | BuiltinAction::SplitView
708                | BuiltinAction::Up
709                | BuiltinAction::Back
710                | BuiltinAction::Forward
711                | BuiltinAction::Collapse
712                | BuiltinAction::ClosePane
713        )
714    }
715
716    /// Parse a configuration string, case-insensitively. `"---"` and
717    /// `"separator"` both spell a menu divider.
718    ///
719    /// The typographic ellipsis (`…`, U+2026) is accepted as a synonym for
720    /// three ASCII dots (`...`) in labels, so configs written before the switch
721    /// to plain ASCII still load.
722    pub fn parse(name: &str) -> Option<Self> {
723        let name = name.trim();
724        let name = if name.contains('\u{2026}') {
725            name.replace('\u{2026}', "...")
726        } else {
727            name.to_owned()
728        };
729        let name = name.as_str();
730        [
731            Self::Open,
732            Self::OpenSplit,
733            Self::InNewPanel,
734            Self::InOppositePanel,
735            Self::OpenWith,
736            Self::OpenWithDefault,
737            Self::ViewThumbnail,
738            Self::NewFile,
739            Self::NewFolder,
740            Self::Rename,
741            Self::Duplicate,
742            Self::CopyPath,
743            Self::CopyRelativePath,
744            Self::Cut,
745            Self::Copy,
746            Self::Paste,
747            Self::CreateLink,
748            Self::Properties,
749            Self::Trash,
750            Self::DeletePermanently,
751            Self::ToggleHidden,
752            Self::SortByName,
753            Self::SortBySize,
754            Self::SortByModified,
755            Self::SortByType,
756            Self::ToggleSortAscending,
757            Self::OpenFolder,
758            Self::Filter,
759            Self::SplitView,
760            Self::Up,
761            Self::Back,
762            Self::Forward,
763            Self::Collapse,
764            Self::ClosePane,
765            Self::Separator,
766        ]
767        .into_iter()
768        .find(|a| {
769            a.as_str().eq_ignore_ascii_case(name)
770                || (matches!(a, Self::Separator) && name.eq_ignore_ascii_case("separator"))
771        })
772    }
773}
774
775impl Serialize for BuiltinAction {
776    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
777        serializer.serialize_str(self.as_str())
778    }
779}
780
781impl<'de> Deserialize<'de> for BuiltinAction {
782    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
783        let raw = String::deserialize(deserializer)?;
784        BuiltinAction::parse(&raw).ok_or_else(|| {
785            serde::de::Error::custom(format!("unknown context menu builtin action {raw:?}"))
786        })
787    }
788}
789
790/// A custom command run with the row's path. The `{path}` marker is replaced
791/// with the row's full path; `{dir}` with the directory itself for a directory
792/// row or its parent for a file. With no marker, the path is appended as the
793/// final argument.
794#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
795pub struct CommandAction {
796    pub command: String,
797    /// Optional menu label; defaults to the command text.
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub label: Option<String>,
800    /// Optional accelerator (GTK syntax: `"Ctrl+Shift+m"`, `"F2"`, `"Delete"`).
801    /// While the tree is focused, the key fires this command against the row
802    /// under the keyboard cursor.
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub shortcut: Option<String>,
805    /// Hide this item from the rendered menu while keeping its shortcut live —
806    /// a way to bind a key without cluttering the menu.
807    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
808    pub hidden: bool,
809}
810
811/// A builtin menu item written out in table form, so a label override and/or a
812/// shortcut can be attached to it: `{ action = "Cut", shortcut = "Ctrl+x" }`.
813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
814pub struct BuiltinEntry {
815    pub action: BuiltinAction,
816    /// Optional menu label override; defaults to the action's name.
817    #[serde(default, skip_serializing_if = "Option::is_none")]
818    pub label: Option<String>,
819    /// Optional accelerator override; defaults to the action's default
820    /// shortcut (see [`BuiltinAction::default_shortcut`]).
821    #[serde(default, skip_serializing_if = "Option::is_none")]
822    pub shortcut: Option<String>,
823    /// Hide this item from the rendered menu while keeping its shortcut live.
824    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
825    pub hidden: bool,
826}
827
828/// A nested submenu: a labelled row that opens a child menu of its own items.
829/// The items may be anything a top-level menu item can be, including further
830/// submenus.
831#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
832pub struct Submenu {
833    /// The row's label.
834    pub label: String,
835    /// The child menu's items, in order.
836    pub items: Vec<ContextAction>,
837    /// Hide this submenu from the rendered menu while keeping the shortcuts of
838    /// its children live.
839    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
840    pub hidden: bool,
841}
842
843impl Submenu {
844    /// Wrap `items` in a submenu with the given `label`.
845    pub fn new(label: impl Into<String>, items: Vec<ContextAction>) -> Self {
846        Self { label: label.into(), items, hidden: false }
847    }
848}
849
850/// One entry in a context-menu rule:
851///   * a builtin action, written as a plain string (`"Cut"`), which gets its
852///     action's default shortcut;
853///   * a builtin in table form (`{ action = "Cut", shortcut = "Ctrl+x" }`);
854///   * a custom command (`{ command = "...", label = "...", shortcut = "..." }`);
855///   * a submenu (`{ label = "More", items = [ ... ] }`).
856#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
857#[serde(untagged)]
858pub enum ContextAction {
859    Builtin(BuiltinAction),
860    Entry(BuiltinEntry),
861    Command(CommandAction),
862    Submenu(Submenu),
863}
864
865/// The action bound by a shortcut: a builtin, or a custom command that is run
866/// against the current row.
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
868#[serde(untagged)]
869pub enum ShortcutTarget {
870    Builtin(BuiltinAction),
871    Command(CommandAction),
872}
873
874impl ShortcutTarget {
875    /// Resolve a menu item to the target a shortcut would invoke. Submenus have
876    /// no action of their own, so they yield `None`.
877    pub fn from_action(action: &ContextAction) -> Option<Self> {
878        Some(match action {
879            ContextAction::Builtin(a) => ShortcutTarget::Builtin(*a),
880            ContextAction::Entry(e) => ShortcutTarget::Builtin(e.action),
881            ContextAction::Command(cmd) => ShortcutTarget::Command(cmd.clone()),
882            ContextAction::Submenu(_) => return None,
883        })
884    }
885}
886
887impl ContextAction {
888    /// The menu item's label (the *base* label; a few actions use a dynamic
889    /// label that depends on the row, resolved in the UI layer).
890    pub fn label(&self) -> String {
891        match self {
892            ContextAction::Builtin(action) => action.as_str().to_owned(),
893            ContextAction::Entry(entry) => {
894                entry.label.clone().unwrap_or_else(|| entry.action.as_str().to_owned())
895            }
896            ContextAction::Command(cmd) => cmd.label.clone().unwrap_or_else(|| cmd.command.clone()),
897            ContextAction::Submenu(sub) => sub.label.clone(),
898        }
899    }
900
901    /// Whether this item opens a submenu rather than dispatching an action.
902    pub fn is_submenu(&self) -> bool {
903        matches!(self, ContextAction::Submenu(_))
904    }
905
906    /// Whether this item is hidden from the rendered menu. A hidden item keeps
907    /// its shortcut live; only its row is skipped. A plain-string builtin
908    /// cannot be hidden (write it as a table with `hidden = true` instead).
909    pub fn is_hidden(&self) -> bool {
910        match self {
911            ContextAction::Builtin(_) => false,
912            ContextAction::Entry(entry) => entry.hidden,
913            ContextAction::Command(cmd) => cmd.hidden,
914            ContextAction::Submenu(sub) => sub.hidden,
915        }
916    }
917
918    /// The child items when this is a submenu.
919    pub fn submenu_items(&self) -> Option<&[ContextAction]> {
920        match self {
921            ContextAction::Submenu(sub) => Some(&sub.items),
922            _ => None,
923        }
924    }
925
926    /// The shortcut attached to this item, if any: an explicit override when
927    /// written in table form, otherwise the builtin's default shortcut.
928    pub fn shortcut(&self) -> Option<String> {
929        match self {
930            ContextAction::Builtin(action) => action
931                .default_shortcut()
932                .map(str::to_owned),
933            ContextAction::Entry(entry) => entry
934                .shortcut
935                .clone()
936                .or_else(|| entry.action.default_shortcut().map(str::to_owned)),
937            ContextAction::Command(cmd) => cmd.shortcut.clone(),
938            ContextAction::Submenu(_) => None,
939        }
940    }
941}
942
943/// A context-menu rule: rows claimed by any `matches` entry get exactly
944/// `items`. Rules are evaluated top-down; the first match wins.
945///
946/// A rule may instead be an **include**: `include = "rules.d"` (no `matches`)
947/// pulls in the `[[context_menu.rules]]` of every `*.toml` file in that
948/// directory, spliced in at the include's position. This lets specific rules
949/// live in their own files yet still be checked *before* a later catch-all
950/// `fallback` rule. See [`expand_includes`].
951#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
952pub struct ContextRule {
953    #[serde(default)]
954    pub matches: Vec<ContextMatch>,
955    #[serde(default)]
956    pub items: Vec<ContextAction>,
957    /// Directory (relative to the config file, or absolute) whose `*.toml`
958    /// files supply rules to splice in here. Mutually exclusive with `matches`.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub include: Option<PathBuf>,
961}
962
963impl ContextRule {
964    /// A plain matching rule.
965    pub fn rule(matches: Vec<ContextMatch>, items: Vec<ContextAction>) -> Self {
966        ContextRule { matches, items, include: None }
967    }
968
969    /// An include placeholder.
970    pub fn include(path: PathBuf) -> Self {
971        ContextRule { matches: Vec::new(), items: Vec::new(), include: Some(path) }
972    }
973}
974
975/// Configurable per-row context menus.
976///
977/// With no configuration the built-in rules produce the classic menu; the
978/// built-in defaults are listed below so users can copy and adapt them.
979#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
980pub struct ContextMenu {
981    pub rules: Vec<ContextRule>,
982}
983
984#[cfg(test)]
985macro_rules! builtin_items {
986    ($($name:ident),* $(,)?) => {
987        vec![
988            $(ContextAction::Builtin(BuiltinAction::$name),)*
989        ]
990    };
991}
992
993impl ContextMenu {
994    /// The menu for a row no rule claims. Sourced from the first `dir`/`fallback`
995    /// rule of the shipped default (so `default-config.toml` also decides the
996    /// fallback); an empty menu when neither is present.
997    pub fn fallback_actions(path: &Path) -> Vec<ContextAction> {
998        let wanted = if path.is_dir() { ContextMatch::Dir } else { ContextMatch::Fallback };
999        builtin()
1000            .context_menu
1001            .rules
1002            .iter()
1003            .find(|rule| rule.matches.contains(&wanted))
1004            .map(|rule| rule.items.clone())
1005            .unwrap_or_default()
1006    }
1007
1008    /// The menu actions for `path`, in display order.
1009    ///
1010    /// Resolution is top-down over [`Self::rules`]; the first rule with at
1011    /// least one claimed matcher provides the whole menu. Rows no rule claims
1012    /// get [`Self::fallback_actions`].
1013    pub fn actions_for(&self, path: &Path) -> Vec<ContextAction> {
1014        for rule in &self.rules {
1015            if rule.matches.iter().any(|m| m.matches(path)) {
1016                return rule.items.clone();
1017            }
1018        }
1019        Self::fallback_actions(path)
1020    }
1021
1022    /// The menu actions for a context menu opened over the given selection.
1023    ///
1024    /// When more than one row is selected the first rule carrying the
1025    /// [`Multi`](ContextMatch::Multi) matcher provides the whole menu (so a
1026    /// `multi` rule takes precedence over the per-path rules). For a single
1027    /// selection this is exactly [`Self::actions_for`].
1028    pub fn actions_for_selection(&self, path: &Path, selection_len: usize) -> Vec<ContextAction> {
1029        if selection_len > 1 {
1030            for rule in &self.rules {
1031                if rule.matches.contains(&ContextMatch::Multi) {
1032                    return rule.items.clone();
1033                }
1034            }
1035        }
1036        self.actions_for(path)
1037    }
1038
1039    /// Every configured shortcut, in rule order, deduplicated (first wins).
1040    ///
1041    /// These drive the tree's keyboard handling: while the tree is focused, a
1042    /// pressed key fires the bound action against the row under the cursor.
1043    /// Shortcuts are collected from *all* rules (not just the one that claims
1044    /// the current row), so a shortcut stays live wherever its action is
1045    /// reachable in some menu.
1046    pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1047        let mut seen = std::collections::HashSet::new();
1048        let mut out = Vec::new();
1049        for rule in &self.rules {
1050            collect_shortcuts(&rule.items, &mut seen, &mut out);
1051        }
1052        out
1053    }
1054}
1055
1056/// Recursively collect shortcuts from `actions`, descending into submenus.
1057/// `seen` deduplicates by accelerator so the first binding wins.
1058fn collect_shortcuts(
1059    actions: &[ContextAction],
1060    seen: &mut std::collections::HashSet<String>,
1061    out: &mut Vec<(String, ShortcutTarget)>,
1062) {
1063    for action in actions {
1064        if let Some(items) = action.submenu_items() {
1065            collect_shortcuts(items, seen, out);
1066            continue;
1067        }
1068        let Some(shortcut) = action.shortcut() else {
1069            continue;
1070        };
1071        if !seen.insert(shortcut.clone()) {
1072            continue;
1073        }
1074        if let Some(target) = ShortcutTarget::from_action(action) {
1075            out.push((shortcut, target));
1076        }
1077    }
1078}
1079
1080impl Default for ContextMenu {
1081    fn default() -> Self {
1082        builtin().context_menu.clone()
1083    }
1084}
1085
1086/// The configurable hamburger ("pane") menu that opens from the toolbar.
1087///
1088/// Unlike the per-row [`ContextMenu`] there are no match rules — the pane menu
1089/// is one flat list of items that apply to the whole pane (open a folder, toggle
1090/// hidden files, change the sort order, close the pane, ...). Items reuse the same
1091/// [`ContextAction`] vocabulary as the context menu, so builtins, table entries
1092/// with a shortcut, and custom commands all work here too.
1093#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1094pub struct PaneMenu {
1095    pub items: Vec<ContextAction>,
1096}
1097
1098impl PaneMenu {
1099    /// The label for each item, in display order (used by tests and the UI).
1100    pub fn labels(&self) -> Vec<String> {
1101        self.items.iter().map(ContextAction::label).collect()
1102    }
1103
1104    /// Every shortcut attached to a pane-menu item, in order, deduplicated
1105    /// (first wins). Merged with the context-menu shortcuts so pane actions
1106    /// (`Ctrl+h`, `Ctrl+1`...) work while the tree is focused.
1107    pub fn shortcuts(&self) -> Vec<(String, ShortcutTarget)> {
1108        let mut seen = std::collections::HashSet::new();
1109        let mut out = Vec::new();
1110        collect_shortcuts(&self.items, &mut seen, &mut out);
1111        out
1112    }
1113}
1114
1115impl Default for PaneMenu {
1116    fn default() -> Self {
1117        builtin().pane_menu.clone()
1118    }
1119}
1120
1121/// Top-level configuration.
1122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1123#[serde(default)]
1124pub struct Config {
1125    pub panel: PanelConfig,
1126    pub tree: TreeConfig,
1127    pub startup: StartupRoot,
1128    pub context_menu: ContextMenu,
1129    pub pane_menu: PaneMenu,
1130}
1131
1132impl Default for Config {
1133    /// The shipped defaults, parsed from `default-config.toml` (see [`builtin`]).
1134    /// `#[serde(default)]` above uses this to fill any section a user's file
1135    /// omits, so editing `default-config.toml` changes both.
1136    fn default() -> Self {
1137        builtin().clone()
1138    }
1139}
1140
1141impl Config {
1142    /// Load the config file for the current environment.
1143    ///
1144    /// On first launch — when neither the config file nor its `tree-space`
1145    /// directory exists — the default config is written out to disk first, so
1146    /// the user always has a file to edit afterwards.
1147    ///
1148    /// Unparsable files fall back to defaults with an error report (a broken
1149    /// dotfile cannot take down the panel, and is never clobbered).
1150    pub fn load() -> LoadResult {
1151        Self::load_and_ensure(&config_file())
1152    }
1153
1154    /// [`Self::load`] against an explicit path (tests).
1155    fn load_and_ensure(path: &Path) -> LoadResult {
1156        let result = Self::load_from_path(path);
1157        if result.problem.is_none()
1158            && !path.exists()
1159            && let Err(problem) = Self::ensure_default_file(path)
1160        {
1161            return LoadResult { config: result.config, problem: Some(problem) };
1162        }
1163        result
1164    }
1165
1166    /// Write the default config to `<path>`, creating parent directories. Used
1167    /// on first launch so a config file always exists. Best-effort: failures
1168    /// are reported but never prevent startup.
1169    ///
1170    /// Skips the attempt entirely when neither the config directory nor the
1171    /// file exists *and* the parent directory is not writable: a read-only
1172    /// `$XDG_CONFIG_HOME` then produces no error on every launch (the panel
1173    /// simply uses defaults).
1174    fn ensure_default_file(path: &Path) -> Result<(), LoadProblem> {
1175        let Some(parent) = path.parent() else {
1176            return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1177        };
1178        if !parent.exists()
1179            && let Err(err) = std::fs::create_dir_all(parent)
1180        {
1181            return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1182        }
1183        // A read-only directory is a normal, expected setup (e.g. a Nix or
1184        // immutable-home install); treat it as "no config to write" rather
1185        // than an error.
1186        if let Ok(metadata) = std::fs::metadata(parent)
1187            && metadata.permissions().readonly()
1188        {
1189            return Ok(());
1190        }
1191        std::fs::write(path, DEFAULT_CONFIG)
1192            .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1193    }
1194
1195    /// Parse a config from a file, returning any problem encountered.
1196    pub fn load_from_path(path: &Path) -> LoadResult {
1197        let raw = match std::fs::read_to_string(path) {
1198            Ok(raw) => raw,
1199            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1200                return LoadResult {
1201                    config: Config::default(),
1202                    problem: None,
1203                };
1204            }
1205            Err(err) => {
1206                return LoadResult {
1207                    config: Config::default(),
1208                    problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1209                };
1210            }
1211        };
1212        Self::parse(&raw, path)
1213    }
1214
1215    /// Validate and normalize a parsed config.
1216    fn normalize(mut self) -> Self {
1217        self.panel.width = self
1218            .panel
1219            .width
1220            .clamp(PANEL_MIN_WIDTH, PANEL_MAX_WIDTH);
1221        self.panel.margin = self.panel.margin.clamp(0, 200);
1222        self.tree.font_size = self.tree.font_size.clamp(6, 40);
1223        self.tree.icon_size = self.tree.icon_size.clamp(12, 128);
1224        self
1225    }
1226}
1227
1228/// Why a config could not be loaded cleanly. Absence of a problem (/ defaults
1229/// used because the file doesn't exist) is not reported.
1230#[derive(Debug, Clone)]
1231pub enum LoadProblem {
1232    Io(PathBuf, std::io::ErrorKind),
1233    Parse(PathBuf, toml::de::Error),
1234}
1235
1236/// The outcome of loading a config: always a usable config plus an optional
1237/// problem description for the status bar.
1238#[derive(Debug, Clone)]
1239pub struct LoadResult {
1240    pub config: Config,
1241    pub problem: Option<LoadProblem>,
1242}
1243
1244/// The outcome of loading the user stylesheet.
1245#[derive(Debug, Clone)]
1246pub struct Stylesheet {
1247    /// The CSS to apply: the user's file when present and readable, otherwise
1248    /// the shipped default.
1249    pub css: String,
1250    /// Where the CSS came from, for a status message or diagnostics.
1251    pub source: StyleSource,
1252    /// A read problem, when the file existed but could not be read. The `css`
1253    /// then falls back to the shipped default.
1254    pub problem: Option<LoadProblem>,
1255}
1256
1257/// Which stylesheet [`load_stylesheet`] actually returned.
1258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1259pub enum StyleSource {
1260    /// The user's `main.css`.
1261    User,
1262    /// The compiled-in default (no user file, or it could not be read).
1263    Builtin,
1264}
1265
1266/// Load the user stylesheet for the current environment.
1267///
1268/// Prefers `$XDG_CONFIG_HOME/tree-space/main.css`; when that is missing the
1269/// shipped default is used. On first launch the default is written out first
1270/// (see [`ensure_default_stylesheet`]) so a file always exists to edit.
1271pub fn load_stylesheet() -> Stylesheet {
1272    let path = style_file();
1273    // Materialize the default on first launch, mirroring the config file, so
1274    // there is always something to edit.
1275    let _ = ensure_default_stylesheet(&path);
1276    load_stylesheet_from_path(&path)
1277}
1278
1279/// Read the stylesheet at `path`, falling back to the built-in default when it
1280/// is missing or unreadable.
1281fn load_stylesheet_from_path(path: &Path) -> Stylesheet {
1282    match std::fs::read_to_string(path) {
1283        Ok(css) => Stylesheet {
1284            css,
1285            source: StyleSource::User,
1286            problem: None,
1287        },
1288        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Stylesheet {
1289            css: DEFAULT_STYLESHEET.to_owned(),
1290            source: StyleSource::Builtin,
1291            problem: None,
1292        },
1293        Err(err) => Stylesheet {
1294            css: DEFAULT_STYLESHEET.to_owned(),
1295            source: StyleSource::Builtin,
1296            problem: Some(LoadProblem::Io(path.to_path_buf(), err.kind())),
1297        },
1298    }
1299}
1300
1301/// Write the shipped stylesheet to `path` when it does not exist yet, creating
1302/// parent directories. Best-effort, like [`Config::ensure_default_file`]:
1303/// a read-only config directory simply means "no file to write", not an error.
1304fn ensure_default_stylesheet(path: &Path) -> Result<(), LoadProblem> {
1305    if path.exists() {
1306        return Ok(());
1307    }
1308    let Some(parent) = path.parent() else {
1309        return Err(LoadProblem::Io(path.to_path_buf(), std::io::ErrorKind::NotFound));
1310    };
1311    if !parent.exists()
1312        && let Err(err) = std::fs::create_dir_all(parent)
1313    {
1314        return Err(LoadProblem::Io(parent.to_path_buf(), err.kind()));
1315    }
1316    if let Ok(metadata) = std::fs::metadata(parent)
1317        && metadata.permissions().readonly()
1318    {
1319        return Ok(());
1320    }
1321    std::fs::write(path, DEFAULT_STYLESHEET)
1322        .map_err(|err| LoadProblem::Io(path.to_path_buf(), err.kind()))
1323}
1324
1325impl Config {
1326    /// Parse TOML text, reporting parse problems without aborting.
1327    fn parse(raw: &str, path: &Path) -> LoadResult {
1328        match toml::from_str::<Config>(raw) {
1329            Ok(mut config) => {
1330                // Resolve `include = "..."` rules relative to the config file.
1331                let base = path.parent().unwrap_or_else(|| Path::new("."));
1332                let mut problems = Vec::new();
1333                config.context_menu.rules =
1334                    expand_includes(&config.context_menu.rules, base, 0, &mut problems);
1335                let config = config.normalize();
1336                LoadResult {
1337                    config,
1338                    // Only the first problem is surfaced, matching the single
1339                    // status-line slot; the config is still fully usable.
1340                    problem: problems.into_iter().next(),
1341                }
1342            }
1343            Err(err) => LoadResult {
1344                config: Config::default(),
1345                problem: Some(LoadProblem::Parse(path.to_path_buf(), err)),
1346            },
1347        }
1348    }
1349}
1350
1351/// Maximum include nesting, so a cycle (`a.toml` inside the directory it
1352/// includes) cannot recurse forever.
1353const MAX_INCLUDE_DEPTH: usize = 8;
1354
1355/// The shape of a drop-in file pulled in by an include rule: the same
1356/// `[[context_menu.rules]]` table as the main config, nothing else.
1357#[derive(Deserialize)]
1358struct IncludedRules {
1359    #[serde(default)]
1360    context_menu: ContextMenu,
1361}
1362
1363/// Replace every include rule in `rules` with the rules of the `*.toml` files
1364/// in its directory, spliced in at the include's position.
1365///
1366/// The directory is resolved relative to `base` (the containing config file's
1367/// directory). Files are read in filename order (so `10-a.toml` sorts before
1368/// `20-b.toml`), and each file's `[[context_menu.rules]]` are expanded
1369/// recursively. A missing directory contributes nothing; an unreadable or
1370/// unparsable file is added to `problems` and skipped, so one bad drop-in never
1371/// breaks the rest of the menu.
1372fn expand_includes(
1373    rules: &[ContextRule],
1374    base: &Path,
1375    depth: usize,
1376    problems: &mut Vec<LoadProblem>,
1377) -> Vec<ContextRule> {
1378    let mut out = Vec::new();
1379    for rule in rules {
1380        let Some(include) = &rule.include else {
1381            out.push(rule.clone());
1382            continue;
1383        };
1384        if depth >= MAX_INCLUDE_DEPTH {
1385            continue;
1386        }
1387        let dir = if include.is_absolute() {
1388            include.clone()
1389        } else {
1390            base.join(include)
1391        };
1392        for file in toml_files_in(&dir, problems) {
1393            // Nested includes resolve relative to *this* file's directory.
1394            let nested_base = file.parent().unwrap_or(base);
1395            match std::fs::read_to_string(&file) {
1396                Ok(text) => match toml::from_str::<IncludedRules>(&text) {
1397                    Ok(included) => {
1398                        out.extend(expand_includes(
1399                            &included.context_menu.rules,
1400                            nested_base,
1401                            depth + 1,
1402                            problems,
1403                        ));
1404                    }
1405                    Err(err) => problems.push(LoadProblem::Parse(file.clone(), err)),
1406                },
1407                Err(err) => problems.push(LoadProblem::Io(file.clone(), err.kind())),
1408            }
1409        }
1410    }
1411    out
1412}
1413
1414/// The `*.toml` files directly inside `dir`, sorted by filename. A missing
1415/// directory (or one that cannot be read) yields an empty list.
1416fn toml_files_in(dir: &Path, problems: &mut Vec<LoadProblem>) -> Vec<PathBuf> {
1417    let entries = match std::fs::read_dir(dir) {
1418        Ok(entries) => entries,
1419        // A missing include directory is normal: contribute nothing.
1420        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
1421        Err(err) => {
1422            problems.push(LoadProblem::Io(dir.to_path_buf(), err.kind()));
1423            return Vec::new();
1424        }
1425    };
1426    let mut files: Vec<PathBuf> = entries
1427        .filter_map(Result::ok)
1428        .map(|entry| entry.path())
1429        .filter(|path| {
1430            path.is_file()
1431                && path
1432                    .extension()
1433                    .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
1434        })
1435        .collect();
1436    files.sort();
1437    files
1438}
1439
1440// ---------------------------------------------------------------------------
1441// Session state
1442// ---------------------------------------------------------------------------
1443
1444/// Small runtime state persisted between launches.
1445#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1446pub struct SessionState {
1447    /// The most recently opened root directory, if any.
1448    pub last_root: Option<PathBuf>,
1449    /// Left dock width from the last interactive resize, if any. Overrides the
1450    /// configured `[panel] width` on the next launch.
1451    pub left_width: Option<u32>,
1452    /// Right dock width from the last interactive resize, if any.
1453    pub right_width: Option<u32>,
1454}
1455
1456impl SessionState {
1457    /// Load state from the environment's state file. Missing or broken files
1458    /// yield an empty default.
1459    pub fn load() -> Self {
1460        Self::load_from_path(&state_file())
1461    }
1462
1463    /// Load state from an explicit path.
1464    pub fn load_from_path(path: &Path) -> Self {
1465        match std::fs::read_to_string(path) {
1466            Ok(raw) => toml::from_str(&raw).unwrap_or_default(),
1467            Err(_) => Self::default(),
1468        }
1469    }
1470
1471    /// Persist state to the environment's state file, creating directories.
1472    pub fn save(&self) -> std::io::Result<()> {
1473        Self::save_to_path(&state_file(), self)
1474    }
1475
1476    /// Persist state to an explicit path, creating parent directories.
1477    ///
1478    /// Atomic: the serialized bytes are written to a sibling temp file first and
1479    /// then `rename`d over the target, so a crash mid-write can never leave a
1480    /// truncated `state.toml` behind (the old file stays intact until the swap).
1481    pub fn save_to_path(path: &Path, state: &Self) -> std::io::Result<()> {
1482        if let Some(parent) = path.parent() {
1483            std::fs::create_dir_all(parent)?;
1484        }
1485        let body = toml::to_string(state).map_err(|e| {
1486            std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())
1487        })?;
1488        let tmp = path.with_extension("toml.tmp");
1489        std::fs::write(&tmp, body)?;
1490        std::fs::rename(&tmp, path)
1491    }
1492}
1493
1494/// Build a command line for a custom context-menu action on `path`.
1495///
1496/// The template is tokenized on whitespace.
1497/// Literal `{path}` is replaced with the row's full path and `{dir}` with the
1498/// directory row itself (or, for a file row, its parent). When the template has
1499/// neither marker, the row's path is appended as the trailing argument.
1500pub fn action_command(template: &str, path: &Path) -> Vec<String> {
1501    let path_s = path.to_string_lossy();
1502    let dir = if path.is_dir() {
1503        path.to_string_lossy()
1504    } else {
1505        path.parent().unwrap_or(path).to_string_lossy()
1506    };
1507    let has_path = template.contains("{path}");
1508    let has_dir = template.contains("{dir}");
1509    let mut tokens: Vec<String> = template
1510        .split_whitespace()
1511        .map(|t| t.replace("{path}", &path_s).replace("{dir}", &dir))
1512        .filter(|t| !t.is_empty())
1513        .collect();
1514    if !has_path && !has_dir {
1515        tokens.push(path_s.into_owned());
1516    }
1517    tokens
1518}
1519
1520#[cfg(test)]
1521mod tests {
1522    use super::*;
1523
1524    fn parse(raw: &str) -> Config {
1525        Config::parse(raw, Path::new("test.toml")).config
1526    }
1527
1528    #[test]
1529    fn defaults_are_sane() {
1530        // Values themselves live in `default-config.toml` (the single source of
1531        // truth); this only checks they are structurally usable, so it does not
1532        // become a second copy of the defaults.
1533        let config = Config::default();
1534        assert!((PANEL_MIN_WIDTH..=PANEL_MAX_WIDTH).contains(&config.panel.width));
1535        assert!(config.tree.icon_size > 0);
1536        assert!(config.tree.font_size > 0);
1537        assert!(!config.context_menu.rules.is_empty());
1538        assert!(!config.pane_menu.items.is_empty());
1539    }
1540
1541    #[test]
1542    fn shipping_default_file_parses_and_is_current() {
1543        // `default-config.toml` is the single source of truth; `Config::default`
1544        // parses it. This guards that the embedded copy still parses cleanly and
1545        // matches the file on disk (so an un-rebuilt stale include is caught).
1546        assert_eq!(parse(DEFAULT_CONFIG), Config::default());
1547
1548        let written = std::fs::read_to_string(concat!(
1549            env!("CARGO_MANIFEST_DIR"),
1550            "/default-config.toml"
1551        ))
1552        .unwrap();
1553        assert_eq!(DEFAULT_CONFIG, written);
1554    }
1555
1556    #[test]
1557    fn empty_document_uses_defaults() {
1558        let config = parse("");
1559        assert_eq!(config, Config::default());
1560    }
1561
1562    #[test]
1563    fn full_document_round_trips() {
1564        let raw = r#"
1565[panel]
1566side = "right"
1567layer = "top"
1568width = 420
1569margin = 8
1570
1571[tree]
1572dirs_first = false
1573show_hidden = true
1574font_size = 14
1575icon_size = 20
1576"#;
1577        let config = parse(raw);
1578        assert_eq!(config.panel.side, PanelSide::Right);
1579        assert_eq!(config.panel.layer, PanelLayer::Top);
1580        assert_eq!(config.panel.width, 420);
1581        assert!(!config.tree.dirs_first);
1582        assert!(config.tree.show_hidden);
1583
1584        // Round-trip through TOML serialization.
1585        let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1586        assert_eq!(config, reparsed);
1587    }
1588
1589    #[test]
1590    fn partial_document_is_merged_with_defaults() {
1591        let config = parse("[panel]\nside = \"right\"\n");
1592        assert_eq!(config.panel.side, PanelSide::Right);
1593        assert_eq!(config.panel.width, Config::default().panel.width);
1594        assert_eq!(config.tree, Config::default().tree);
1595    }
1596
1597    #[test]
1598    fn defaults_come_from_the_shipped_file() {
1599        // Every section's default (not just the whole config) must be the value
1600        // parsed from `default-config.toml`, so editing that file is sufficient.
1601        let shipped = parse(DEFAULT_CONFIG);
1602        assert_eq!(Config::default(), shipped);
1603        assert_eq!(PanelConfig::default(), shipped.panel);
1604        assert_eq!(TreeConfig::default(), shipped.tree);
1605        assert_eq!(ContextMenu::default(), shipped.context_menu);
1606        assert_eq!(PaneMenu::default(), shipped.pane_menu);
1607        // A partial *section* falls back to the shipped value of the missing key.
1608        let partial = parse("[tree]\nfont_size = 21\n");
1609        assert_eq!(partial.tree.font_size, 21);
1610        assert_eq!(partial.tree.icon_size, shipped.tree.icon_size);
1611        assert_eq!(partial.tree.sort_key, shipped.tree.sort_key);
1612        assert_eq!(partial.tree.confirm_drop_move, shipped.tree.confirm_drop_move);
1613        // Drop-to-move is immediate by default; confirmation is opt-in.
1614        assert!(!shipped.tree.confirm_drop_move);
1615    }
1616
1617    #[test]
1618    fn out_of_range_values_are_clamped() {
1619        let config = parse(
1620            "[panel]\nwidth = 100000\nmargin = 9000\n\n[tree]\nfont_size = 2\nicon_size = 2\n",
1621        );
1622        assert_eq!(config.panel.width, 2000);
1623        assert_eq!(config.panel.margin, 200);
1624        assert_eq!(config.tree.font_size, 6);
1625        assert_eq!(config.tree.icon_size, 12);
1626    }
1627
1628    #[test]
1629    fn invalid_toml_falls_back_to_defaults_with_problem() {
1630        let result = Config::parse("panel = ", Path::new("broken.toml"));
1631        assert_eq!(result.config, Config::default());
1632        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
1633    }
1634
1635    #[test]
1636    fn missing_file_yields_defaults_without_problem() {
1637        let result = Config::load_from_path(Path::new("/definitely/not/here/config.toml"));
1638        assert_eq!(result.config, Config::default());
1639        assert!(result.problem.is_none());
1640    }
1641
1642    #[test]
1643    fn first_launch_materializes_default_config_file() {
1644        let dir = tempfile::tempdir().unwrap();
1645        let path = dir.path().join("tree-space/config.toml");
1646        assert!(!path.exists());
1647
1648        let result = Config::load_and_ensure(&path);
1649        assert_eq!(result.config, Config::default());
1650        assert!(result.problem.is_none());
1651
1652        // The directory and file were created with the shipped template.
1653        let written = std::fs::read_to_string(&path).unwrap();
1654        assert_eq!(written, DEFAULT_CONFIG);
1655
1656        // A second load reads it back cleanly.
1657        let reloaded = Config::load_and_ensure(&path);
1658        assert_eq!(reloaded.config, Config::default());
1659        assert!(reloaded.problem.is_none());
1660    }
1661
1662    #[test]
1663    fn existing_file_is_never_overwritten() {
1664        let dir = tempfile::tempdir().unwrap();
1665        let path = dir.path().join("tree-space/config.toml");
1666        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1667        std::fs::write(&path, "[panel]\nwidth = 500\n").unwrap();
1668
1669        let result = Config::load_and_ensure(&path);
1670        assert_eq!(result.config.panel.width, 500);
1671        assert!(result.problem.is_none());
1672        assert_eq!(std::fs::read_to_string(&path).unwrap(), "[panel]\nwidth = 500\n");
1673    }
1674
1675    #[test]
1676    fn xdg_config_resolution_follows_spec() {
1677        // Explicit override wins.
1678        assert_eq!(
1679            xdg_config_home(Some("/opt/cfg"), Some("/home/u")),
1680            PathBuf::from("/opt/cfg")
1681        );
1682        // Falls back below HOME.
1683        assert_eq!(
1684            xdg_config_home(None, Some("/home/u")),
1685            PathBuf::from("/home/u/.config")
1686        );
1687        assert_eq!(
1688            xdg_state_home(None, Some("/home/u")),
1689            PathBuf::from("/home/u/.local/state")
1690        );
1691        // Empty XDG values are ignored per the spec.
1692        assert_eq!(xdg_config_home(Some(""), Some("/h")), PathBuf::from("/h/.config"));
1693    }
1694
1695    #[test]
1696    fn app_path_joins_app_dir() {
1697        assert_eq!(
1698            app_path(&PathBuf::from("/cfg"), STATE_FILE),
1699            PathBuf::from("/cfg/tree-space/state.toml")
1700        );
1701    }
1702
1703    #[test]
1704    fn session_state_round_trips_through_disk() {
1705        let dir = tempfile::tempdir().unwrap();
1706        let path = dir.path().join("state.toml");
1707        let state = SessionState {
1708            last_root: Some(PathBuf::from("/home/user/projects/foo")),
1709            left_width: Some(420),
1710            right_width: None,
1711        };
1712        SessionState::save_to_path(&path, &state).unwrap();
1713        assert_eq!(SessionState::load_from_path(&path), state);
1714    }
1715
1716    #[test]
1717    fn missing_or_broken_state_defaults() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let broken = dir.path().join("broken.toml");
1720        std::fs::write(&broken, "not [ valid").unwrap();
1721        assert_eq!(SessionState::load_from_path(&broken), SessionState::default());
1722        assert_eq!(
1723            SessionState::load_from_path(&dir.path().join("missing.toml")),
1724            SessionState::default()
1725        );
1726    }
1727
1728    /// Flatten every label in a menu, descending into submenus, so tests can
1729    /// assert against items regardless of nesting.
1730    fn all_labels(actions: &[ContextAction]) -> Vec<String> {
1731        let mut out = Vec::new();
1732        for action in actions {
1733            match action.submenu_items() {
1734                Some(items) => out.extend(all_labels(items)),
1735                None => out.push(action.label()),
1736            }
1737        }
1738        out
1739    }
1740
1741    #[test]
1742    fn default_context_menu_reproduces_classic_items() {
1743        let menu = ContextMenu::default();
1744        let dir = tempfile::tempdir().unwrap();
1745        let file = dir.path().join("notes.txt");
1746        std::fs::write(&file, "").unwrap();
1747        std::fs::write(dir.path().join("photo.jpg"), "").unwrap();
1748
1749        let dir_labels = all_labels(&menu.actions_for(dir.path()));
1750        // Classic items still present (now reachable through the "Open"/"New"
1751        // submenus)...
1752        for classic in [
1753            "In same panel",
1754            "In split view",
1755            "Folder",
1756            "File",
1757            "Copy Path",
1758            "Move to Trash",
1759            "Delete Permanently",
1760        ] {
1761            assert!(dir_labels.contains(&classic.to_owned()), "missing {classic}: {dir_labels:?}");
1762        }
1763        // ...plus the file-manager parity additions.
1764        for parity in ["In opposite panel", "With...", "Cut", "Copy", "Paste", "Create Link", "Rename", "Properties"] {
1765            assert!(
1766                dir_labels.contains(&parity.to_owned()),
1767                "missing {parity}: {dir_labels:?}"
1768            );
1769        }
1770
1771        let file_labels = all_labels(&menu.actions_for(&file));
1772        // Directory-only items are dropped for files.
1773        for dropped in ["In split view", "In opposite panel"] {
1774            assert!(!file_labels.contains(&dropped.to_owned()), "{dropped} present: {file_labels:?}");
1775        }
1776        // The file "Open" submenu offers both the default app and "With...".
1777        assert!(file_labels.contains(&"With Default".to_owned()));
1778        assert!(file_labels.contains(&"With...".to_owned()));
1779        assert!(file_labels.len() > 15);
1780
1781        // Shortcuts are spelled out on the default actions (submenu items too).
1782        let menus = menu.actions_for(dir.path());
1783        let cut = menus
1784            .iter()
1785            .flat_map(flatten_actions)
1786            .find(|a| a.label() == "Cut")
1787            .expect("Cut present");
1788        assert_eq!(cut.shortcut().as_deref(), Some("Ctrl+x"));
1789        let trash = menus
1790            .iter()
1791            .flat_map(flatten_actions)
1792            .find(|a| a.label() == "Move to Trash")
1793            .expect("Trash present");
1794        assert_eq!(trash.shortcut().as_deref(), Some("Delete"));
1795    }
1796
1797    /// Borrow every leaf action in a menu, descending into submenus.
1798    fn flatten_actions(action: &ContextAction) -> Vec<&ContextAction> {
1799        match action.submenu_items() {
1800            Some(items) => items.iter().flat_map(flatten_actions).collect(),
1801            None => vec![action],
1802        }
1803    }
1804
1805    #[test]
1806    fn default_shortcuts_follow_conventions() {
1807        assert_eq!(BuiltinAction::Cut.default_shortcut(), Some("Ctrl+x"));
1808        assert_eq!(BuiltinAction::Copy.default_shortcut(), Some("Ctrl+c"));
1809        assert_eq!(BuiltinAction::Rename.default_shortcut(), Some("F2"));
1810        assert_eq!(BuiltinAction::CreateLink.default_shortcut(), Some("Ctrl+Shift+m"));
1811        assert_eq!(BuiltinAction::Trash.default_shortcut(), Some("Delete"));
1812        assert_eq!(BuiltinAction::DeletePermanently.default_shortcut(), Some("Shift+Delete"));
1813        assert_eq!(BuiltinAction::ViewThumbnail.default_shortcut(), Some("Ctrl+t"));
1814        assert_eq!(BuiltinAction::Open.default_shortcut(), None);
1815        assert_eq!(BuiltinAction::Separator.default_shortcut(), None);
1816    }
1817
1818    #[test]
1819    fn shortcuts_are_collected_top_down_and_deduplicated() {
1820        let menu = ContextMenu {
1821            rules: vec![
1822                ContextRule::rule(
1823                    vec![ContextMatch::Dir],
1824                    vec![
1825                        ContextAction::Builtin(BuiltinAction::Open),
1826                        ContextAction::Entry(BuiltinEntry {
1827                            action: BuiltinAction::Rename,
1828                            label: None,
1829                            shortcut: Some("F2".into()),
1830                            hidden: false,
1831                        }),
1832                        ContextAction::Command(CommandAction {
1833                            command: "code {path}".into(),
1834                            label: None,
1835                            shortcut: Some("Ctrl+Shift+e".into()),
1836                            hidden: false,
1837                        }),
1838                    ],
1839                ),
1840                ContextRule::rule(
1841                    vec![ContextMatch::Fallback],
1842                    vec![ContextAction::Builtin(BuiltinAction::Trash)],
1843                ),
1844            ],
1845        };
1846        let shortcuts = menu.shortcuts();
1847        // Open has no shortcut; separators would contribute none either.
1848        assert_eq!(
1849            shortcuts,
1850            vec![
1851                ("F2".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Rename)),
1852                ("Ctrl+Shift+e".to_owned(), ShortcutTarget::Command(CommandAction {
1853                    command: "code {path}".into(),
1854                    label: None,
1855                    shortcut: Some("Ctrl+Shift+e".into()),
1856                    hidden: false,
1857                })),
1858                ("Delete".to_owned(), ShortcutTarget::Builtin(BuiltinAction::Trash)),
1859            ]
1860        );
1861    }
1862
1863    #[test]
1864    fn table_form_spells_out_shortcuts_and_labels() {
1865        let raw = r#"
1866[[context_menu.rules]]
1867matches = ["fallback"]
1868items = [
1869    { action = "Cut", shortcut = "Ctrl+Alt+x", label = "Cut here" },
1870    { command = "sh {path}", shortcut = "Ctrl+Alt+s" },
1871    "---",
1872]
1873"#;
1874        let config = parse(raw);
1875        let actions = &config.context_menu.rules[0].items;
1876        assert_eq!(actions.len(), 3);
1877
1878        match &actions[0] {
1879            ContextAction::Entry(entry) => {
1880                assert_eq!(entry.action, BuiltinAction::Cut);
1881                assert_eq!(entry.shortcut.as_deref(), Some("Ctrl+Alt+x"));
1882                assert_eq!(entry.label.as_deref(), Some("Cut here"));
1883                assert_eq!(actions[0].label(), "Cut here");
1884                assert_eq!(actions[0].shortcut().as_deref(), Some("Ctrl+Alt+x"));
1885            }
1886            other => panic!("expected Entry, got {other:?}"),
1887        }
1888        match &actions[1] {
1889            ContextAction::Command(cmd) => {
1890                assert_eq!(cmd.shortcut.as_deref(), Some("Ctrl+Alt+s"));
1891            }
1892            other => panic!("expected Command, got {other:?}"),
1893        }
1894        match &actions[2] {
1895            ContextAction::Builtin(BuiltinAction::Separator) => {}
1896            other => panic!("expected Separator, got {other:?}"),
1897        }
1898
1899        let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
1900        assert_eq!(config, reparsed);
1901    }
1902
1903    #[test]
1904    fn pane_actions_are_classified() {
1905        for action in [
1906            BuiltinAction::OpenFolder,
1907            BuiltinAction::Filter,
1908            BuiltinAction::SplitView,
1909            BuiltinAction::Collapse,
1910            BuiltinAction::ClosePane,
1911        ] {
1912            assert!(action.is_pane_action(), "{action:?} should be a pane action");
1913        }
1914        for action in [
1915            BuiltinAction::Open,
1916            BuiltinAction::OpenSplit,
1917            BuiltinAction::ToggleHidden,
1918            BuiltinAction::SortByName,
1919            BuiltinAction::Separator,
1920        ] {
1921            assert!(!action.is_pane_action(), "{action:?} is not a pane action");
1922        }
1923    }
1924
1925    #[test]
1926    fn separator_matches_both_spellings() {
1927        assert_eq!(BuiltinAction::parse("---"), Some(BuiltinAction::Separator));
1928        assert_eq!(BuiltinAction::parse("separator"), Some(BuiltinAction::Separator));
1929        assert_eq!(BuiltinAction::parse("SEPARATOR"), Some(BuiltinAction::Separator));
1930        assert_eq!(BuiltinAction::Separator.as_str(), "---");
1931    }
1932
1933    #[test]
1934    fn ellipsis_labels_use_ascii_and_accept_the_typographic_form() {
1935        // The canonical spellings are plain ASCII, so users can type them.
1936        assert_eq!(BuiltinAction::OpenWith.as_str(), "Open With...");
1937        assert_eq!(BuiltinAction::OpenFolder.as_str(), "Open Folder...");
1938        assert_eq!(BuiltinAction::Filter.as_str(), "Filter...");
1939        // The typographic ellipsis is still accepted as a synonym.
1940        assert_eq!(BuiltinAction::parse("Open With…"), Some(BuiltinAction::OpenWith));
1941        assert_eq!(BuiltinAction::parse("Open Folder…"), Some(BuiltinAction::OpenFolder));
1942        assert_eq!(BuiltinAction::parse("Filter…"), Some(BuiltinAction::Filter));
1943        // And the ASCII form parses too.
1944        assert_eq!(BuiltinAction::parse("open folder..."), Some(BuiltinAction::OpenFolder));
1945    }
1946
1947    #[test]
1948    fn pane_menu_defaults_include_view_and_pane_actions() {
1949        let menu = PaneMenu::default();
1950        let labels = menu.labels();
1951        assert!(labels.contains(&"Open Folder...".to_owned()));
1952        assert!(labels.contains(&"Split View".to_owned()));
1953        assert!(labels.contains(&"Close Pane".to_owned()));
1954        assert!(labels.contains(&"Toggle Hidden Files".to_owned()));
1955        assert!(labels.contains(&"Sort by Size".to_owned()));
1956        // The view actions are NOT in the row context menus.
1957        let dir_menu = ContextMenu::default();
1958        let dir_labels: Vec<String> = dir_menu
1959            .rules
1960            .iter()
1961            .flat_map(|rule| rule.items.iter())
1962            .map(ContextAction::label)
1963            .collect();
1964        assert!(!dir_labels.contains(&"Toggle Hidden Files".to_owned()));
1965    }
1966
1967    #[test]
1968    fn pane_menu_shortcuts_are_collected() {
1969        let menu = PaneMenu::default();
1970        let shortcuts = menu.shortcuts();
1971        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+h"));
1972        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+1"));
1973        // Separators and shortcut-less pane actions contribute nothing.
1974        assert!(shortcuts.iter().all(|(k, _)| !k.is_empty()));
1975    }
1976
1977    #[test]
1978    fn pane_menu_parses_custom_commands() {
1979        let toml = r#"
1980            items = [
1981                "Split View",
1982                "---",
1983                { command = "echo hi", label = "Say hi" },
1984            ]
1985        "#;
1986        let menu: PaneMenu = toml::from_str(toml).unwrap();
1987        assert_eq!(menu.labels(), vec!["Split View", "---", "Say hi"]);
1988    }
1989
1990    #[test]
1991    fn navigation_builtins_round_trip_and_are_pane_actions() {
1992        for action in [BuiltinAction::Up, BuiltinAction::Back, BuiltinAction::Forward] {
1993            let s = action.as_str();
1994            assert_eq!(BuiltinAction::parse(s), Some(action), "{s} should parse");
1995            assert!(action.is_pane_action(), "{s} should be a pane action");
1996            assert!(action.default_shortcut().is_some(), "{s} needs a default key");
1997        }
1998        assert_eq!(BuiltinAction::Up.default_shortcut(), Some("Alt+Up"));
1999        assert_eq!(BuiltinAction::Back.default_shortcut(), Some("Alt+Left"));
2000        assert_eq!(BuiltinAction::Forward.default_shortcut(), Some("Alt+Right"));
2001    }
2002
2003    #[test]
2004    fn hidden_items_parse_and_keep_their_shortcut() {
2005        let toml = r#"
2006            items = [
2007                { action = "Up One Level", hidden = true },
2008                { command = "echo hi", label = "Say hi", shortcut = "Ctrl+g", hidden = true },
2009                { label = "More", hidden = true, items = [ "Copy Path" ] },
2010                "Open",
2011            ]
2012        "#;
2013        let menu: PaneMenu = toml::from_str(toml).unwrap();
2014        assert!(menu.items[0].is_hidden());
2015        assert!(menu.items[1].is_hidden());
2016        assert!(menu.items[2].is_hidden());
2017        assert!(!menu.items[3].is_hidden());
2018        // Hidden items are still parsed with their labels and shortcuts.
2019        assert_eq!(menu.items[0].label(), "Up One Level");
2020        assert_eq!(menu.items[1].shortcut().as_deref(), Some("Ctrl+g"));
2021        // And hidden items contribute their shortcuts exactly like visible ones.
2022        let shortcuts = menu.shortcuts();
2023        assert!(shortcuts.iter().any(|(k, _)| k == "Alt+Up"));
2024        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+g"));
2025    }
2026
2027    #[test]
2028    fn hidden_submenu_children_keep_their_shortcuts() {
2029        let toml = r#"
2030            items = [
2031                { label = "More", hidden = true, items = [
2032                    { action = "Rename", shortcut = "Ctrl+Shift+r" },
2033                ] },
2034            ]
2035        "#;
2036        let menu: PaneMenu = toml::from_str(toml).unwrap();
2037        assert!(menu.items[0].is_hidden());
2038        assert!(menu.shortcuts().iter().any(|(k, _)| k == "Ctrl+Shift+r"));
2039    }
2040
2041    #[test]
2042    fn hidden_defaults_to_false_and_is_not_serialized() {
2043        #[derive(serde::Deserialize)]
2044        struct One {
2045            item: ContextAction,
2046        }
2047        let visible: One = toml::from_str(r#"item = { action = "Cut", shortcut = "Ctrl+x" }"#).unwrap();
2048        assert!(!visible.item.is_hidden());
2049        // A plain-string builtin can never be hidden.
2050        let plain: One = toml::from_str(r#"item = "Cut""#).unwrap();
2051        assert!(!plain.item.is_hidden());
2052        // Round-trips omit the flag when it is false.
2053        #[derive(serde::Serialize)]
2054        struct OneOut {
2055            item: ContextAction,
2056        }
2057        let encoded = toml::to_string(&OneOut { item: visible.item }).unwrap();
2058        assert!(!encoded.contains("hidden"), "{encoded}");
2059    }
2060
2061    #[test]
2062    fn menu_items_parse_submenus_recursively() {
2063        let toml = r#"
2064            items = [
2065                "Open",
2066                { label = "More", items = [
2067                    { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2068                    { label = "Deeper", items = [ { command = "echo hi", label = "Hi" } ] },
2069                ] },
2070            ]
2071        "#;
2072        let menu: PaneMenu = toml::from_str(toml).unwrap();
2073        assert_eq!(menu.labels(), vec!["Open", "More"]);
2074        // A submenu reports its children and no shortcut of its own.
2075        let sub = &menu.items[1];
2076        assert!(sub.is_submenu());
2077        assert!(sub.shortcut().is_none());
2078        let children = sub.submenu_items().unwrap();
2079        assert_eq!(children.len(), 2);
2080        assert_eq!(children[0].label(), "Copy Path");
2081        assert!(children[1].is_submenu());
2082        // Shortcuts inside submenus (at any depth) stay live.
2083        let shortcuts = menu.shortcuts();
2084        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2085    }
2086
2087    #[test]
2088    fn context_menu_collects_nested_shortcuts() {
2089        let raw = r#"
2090            [[context_menu.rules]]
2091            matches = ["dir"]
2092            items = [
2093                "Open",
2094                { label = "More", items = [
2095                    { action = "Copy Path", shortcut = "Ctrl+Shift+c" },
2096                ] },
2097            ]
2098            [[context_menu.rules]]
2099            matches = ["fallback"]
2100            items = ["Open"]
2101        "#;
2102        let config = parse(raw);
2103        let shortcuts = config.context_menu.shortcuts();
2104        assert!(shortcuts.iter().any(|(k, _)| k == "Ctrl+Shift+c"));
2105    }
2106
2107    #[test]
2108    fn startup_last_uses_session_root() {
2109        let root = StartupRoot::Last;
2110        let last = Some(PathBuf::from("/tmp/last"));
2111        assert_eq!(root.resolve(last.clone()), last);
2112        assert_eq!(root.resolve(None), None);
2113    }
2114
2115    #[test]
2116    fn startup_home_ignores_session_root() {
2117        let root = StartupRoot::Home;
2118        assert_eq!(root.resolve(Some(PathBuf::from("/tmp/last"))), None);
2119    }
2120
2121    #[test]
2122    fn startup_path_uses_configured_directory() {
2123        let root = StartupRoot::Path("/tmp/fixed".to_owned());
2124        assert_eq!(
2125            root.resolve(Some(PathBuf::from("/tmp/last"))),
2126            Some(PathBuf::from("/tmp/fixed"))
2127        );
2128        // An empty path resolves to nothing, so callers fall back to home.
2129        assert_eq!(StartupRoot::Path(String::new()).resolve(None), None);
2130    }
2131
2132    #[test]
2133    fn startup_tilde_expands_to_home() {
2134        // SAFETY: single-threaded test setup; HOME is read-only here.
2135        unsafe { std::env::set_var("HOME", "/home/tester") };
2136        let root = StartupRoot::Path("~/notes".to_owned());
2137        assert_eq!(root.resolve(None), Some(PathBuf::from("/home/tester/notes")));
2138        assert_eq!(expand_tilde("~"), Some(PathBuf::from("/home/tester")));
2139        assert_eq!(expand_tilde("/abs/path"), Some(PathBuf::from("/abs/path")));
2140        assert_eq!(expand_tilde(""), None);
2141        // A relative configured path is made absolute (against the cwd), so it
2142        // never surfaces as a bare relative spelling in the UI.
2143        let rel = expand_tilde("some/relative/dir").unwrap();
2144        assert!(rel.is_absolute(), "{rel:?} should be absolute");
2145        assert!(rel.ends_with("some/relative/dir"), "{rel:?}");
2146    }
2147
2148    #[test]
2149    fn startup_parses_from_toml() {
2150        assert_eq!(parse("startup = \"home\"\n").startup, StartupRoot::Home);
2151        assert_eq!(parse("startup = \"last\"\n").startup, StartupRoot::Last);
2152        assert_eq!(
2153            parse("startup = { path = \"/srv\" }\n").startup,
2154            StartupRoot::Path("/srv".to_owned())
2155        );
2156        // Default is "home".
2157        assert_eq!(Config::default().startup, StartupRoot::Home);
2158        // An unknown keyword is rejected (whole config falls back to defaults).
2159        assert!(
2160            Config::parse("startup = \"bogus\"\n", Path::new("test.toml"))
2161                .problem
2162                .is_some()
2163        );
2164        // Round-trips through serialization.
2165        let cfg = Config { startup: StartupRoot::Path("/srv".to_owned()), ..Config::default() };
2166        let text = toml::to_string(&cfg).unwrap();
2167        assert_eq!(toml::from_str::<Config>(&text).unwrap().startup, cfg.startup);
2168    }
2169
2170    #[test]
2171    fn context_matches_classify_rows() {
2172        let dir = tempfile::tempdir().unwrap();
2173        let file = dir.path().join("report.md");
2174        std::fs::write(&file, "").unwrap();
2175        let noext = dir.path().join("Makefile");
2176        std::fs::write(&noext, "").unwrap();
2177        let tarball = dir.path().join("app.tar.gz");
2178        std::fs::write(&tarball, "").unwrap();
2179
2180        assert!(ContextMatch::parse("dir").unwrap().matches(dir.path()));
2181        assert!(!ContextMatch::parse("dir").unwrap().matches(&file));
2182        assert!(ContextMatch::parse("noext").unwrap().matches(&noext));
2183        assert!(!ContextMatch::parse("noext").unwrap().matches(&file));
2184        assert!(ContextMatch::parse("ext:md").unwrap().matches(&file));
2185        assert!(ContextMatch::parse("ext:MD").unwrap().matches(&file));
2186        assert!(ContextMatch::parse("ext:gz").unwrap().matches(&tarball));
2187        assert!(!ContextMatch::parse("ext:md").unwrap().matches(dir.path()));
2188        assert_eq!(ContextMatch::parse("directory"), Ok(ContextMatch::Dir));
2189        assert_eq!(ContextMatch::parse("*"), Ok(ContextMatch::Fallback));
2190        assert!(ContextMatch::parse("bogus").is_err());
2191    }
2192
2193    #[test]
2194    fn regex_matcher_matches_the_full_path() {
2195        let dir = tempfile::tempdir().unwrap();
2196        let md = dir.path().join("guide.md");
2197        std::fs::write(&md, "").unwrap();
2198
2199        // Anchored at the end of the full path.
2200        assert!(ContextMatch::parse(r"regex:\.md$").unwrap().matches(&md));
2201        assert!(!ContextMatch::parse(r"regex:\.rs$").unwrap().matches(&md));
2202
2203        // Matching the directory path itself (regexes claim directories too).
2204        let exact = format!("regex:^{}$", regex::escape(dir.path().to_str().unwrap()));
2205        assert!(ContextMatch::parse(&exact).unwrap().matches(dir.path()));
2206
2207        // Spelling round-trips through serialization.
2208        let parsed = ContextMatch::parse(r"regex:\.rs$").unwrap();
2209        assert_eq!(parsed.as_str(), r"regex:\.rs$");
2210        let reparsed = ContextMatch::parse(&parsed.as_str()).unwrap();
2211        assert_eq!(parsed, reparsed);
2212    }
2213
2214    #[test]
2215    fn invalid_regex_is_a_parse_error() {
2216        let err = ContextMatch::parse("regex:(").unwrap_err();
2217        assert!(err.contains("invalid regex"), "got: {err}");
2218    }
2219
2220    #[test]
2221    fn stylesheet_prefers_the_user_file_and_materializes_a_default() {
2222        let dir = tempfile::tempdir().unwrap();
2223        let path = dir.path().join("tree-space/main.css");
2224
2225        // No file yet: the built-in default is returned (and no problem).
2226        let first = load_stylesheet_from_path(&path);
2227        assert_eq!(first.source, StyleSource::Builtin);
2228        assert!(first.problem.is_none());
2229        assert_eq!(first.css, DEFAULT_STYLESHEET);
2230
2231        // Materializing writes the shipped default to the path.
2232        ensure_default_stylesheet(&path).unwrap();
2233        assert_eq!(std::fs::read_to_string(&path).unwrap(), DEFAULT_STYLESHEET);
2234
2235        // A user edit is then picked up verbatim.
2236        std::fs::write(&path, ".tree-row { color: red; }").unwrap();
2237        let user = load_stylesheet_from_path(&path);
2238        assert_eq!(user.source, StyleSource::User);
2239        assert!(user.problem.is_none());
2240        assert_eq!(user.css, ".tree-row { color: red; }");
2241    }
2242
2243    #[test]
2244    fn materialize_never_overwrites_an_existing_stylesheet() {
2245        let dir = tempfile::tempdir().unwrap();
2246        let path = dir.path().join("tree-space/main.css");
2247        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2248        std::fs::write(&path, "/* mine */").unwrap();
2249
2250        ensure_default_stylesheet(&path).unwrap();
2251        assert_eq!(std::fs::read_to_string(&path).unwrap(), "/* mine */");
2252    }
2253
2254    #[test]
2255    fn unreadable_stylesheet_falls_back_to_builtin_with_a_problem() {
2256        let dir = tempfile::tempdir().unwrap();
2257        // A directory where a file is expected: `read_to_string` fails with a
2258        // non-NotFound error, so we fall back and report it.
2259        let path = dir.path().join("main.css");
2260        std::fs::create_dir(&path).unwrap();
2261
2262        let stylesheet = load_stylesheet_from_path(&path);
2263        assert_eq!(stylesheet.source, StyleSource::Builtin);
2264        assert_eq!(stylesheet.css, DEFAULT_STYLESHEET);
2265        assert!(matches!(stylesheet.problem, Some(LoadProblem::Io(..))));
2266    }
2267
2268    #[test]
2269    fn include_rule_splices_files_in_place_and_name_sorted() {        let dir = tempfile::tempdir().unwrap();
2270        let rules_dir = dir.path().join("rules.d");
2271        std::fs::create_dir_all(&rules_dir).unwrap();
2272        // Two drop-ins, deliberately named so name-sort decides their order.
2273        std::fs::write(
2274            rules_dir.join("20-second.toml"),
2275            "[[context_menu.rules]]\nmatches = [\"ext:b\"]\nitems = [\"Properties\"]\n",
2276        )
2277        .unwrap();
2278        std::fs::write(
2279            rules_dir.join("10-first.toml"),
2280            "[[context_menu.rules]]\nmatches = [\"ext:a\"]\nitems = [\"Properties\", \"Copy Path\"]\n",
2281        )
2282        .unwrap();
2283        // A non-TOML file in the directory is ignored.
2284        std::fs::write(rules_dir.join("README.md"), "not config").unwrap();
2285
2286        let main = dir.path().join("config.toml");
2287        std::fs::write(
2288            &main,
2289            r#"
2290[[context_menu.rules]]
2291matches = ["dir"]
2292items = ["Open"]
2293
2294[[context_menu.rules]]
2295include = "rules.d"
2296
2297[[context_menu.rules]]
2298matches = ["fallback"]
2299items = ["Move to Trash"]
2300"#,
2301        )
2302        .unwrap();
2303
2304        let config = Config::load_from_path(&main);
2305        assert!(config.problem.is_none(), "{:?}", config.problem);
2306        let rules = &config.config.context_menu.rules;
2307        // dir, 10-first, 20-second, fallback — included rules land in place.
2308        assert_eq!(rules.len(), 4);
2309        assert_eq!(rules[0].matches, vec![ContextMatch::Dir]);
2310        assert_eq!(rules[1].matches, vec![ContextMatch::Ext("a".to_owned())]);
2311        assert_eq!(rules[2].matches, vec![ContextMatch::Ext("b".to_owned())]);
2312        assert_eq!(rules[3].matches, vec![ContextMatch::Fallback]);
2313        // The include placeholder itself is gone.
2314        assert!(rules.iter().all(|r| r.include.is_none()));
2315    }
2316
2317    #[test]
2318    fn include_of_a_missing_directory_contributes_nothing() {
2319        let dir = tempfile::tempdir().unwrap();
2320        let main = dir.path().join("config.toml");
2321        std::fs::write(
2322            &main,
2323            "[[context_menu.rules]]\ninclude = \"nope.d\"\n\n[[context_menu.rules]]\nmatches = [\"fallback\"]\nitems = [\"Move to Trash\"]\n",
2324        )
2325        .unwrap();
2326        let result = Config::load_from_path(&main);
2327        // A missing include directory is not an error.
2328        assert!(result.problem.is_none(), "{:?}", result.problem);
2329        assert_eq!(result.config.context_menu.rules.len(), 1);
2330        assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Fallback]);
2331    }
2332
2333    #[test]
2334    fn include_of_an_invalid_file_is_reported_but_others_still_load() {
2335        let dir = tempfile::tempdir().unwrap();
2336        let rules_dir = dir.path().join("rules.d");
2337        std::fs::create_dir_all(&rules_dir).unwrap();
2338        std::fs::write(rules_dir.join("10-good.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2339        std::fs::write(rules_dir.join("20-bad.toml"), "this is not = valid toml [").unwrap();
2340
2341        let main = dir.path().join("config.toml");
2342        std::fs::write(
2343            &main,
2344            "[[context_menu.rules]]\ninclude = \"rules.d\"\n",
2345        )
2346        .unwrap();
2347
2348        let result = Config::load_from_path(&main);
2349        // The bad file is reported...
2350        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))), "{:?}", result.problem);
2351        // ...but the good file's rule still made it in.
2352        assert_eq!(result.config.context_menu.rules.len(), 1);
2353        assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2354    }
2355
2356    #[test]
2357    fn included_rules_are_expanded_recursively() {
2358        let dir = tempfile::tempdir().unwrap();
2359        let outer = dir.path().join("outer.d");
2360        let inner = outer.join("inner.d");
2361        std::fs::create_dir_all(&inner).unwrap();
2362        std::fs::write(inner.join("nested.toml"), "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Open\"]\n").unwrap();
2363        std::fs::write(
2364            outer.join("mid.toml"),
2365            "[[context_menu.rules]]\ninclude = \"inner.d\"\n",
2366        )
2367        .unwrap();
2368
2369        let main = dir.path().join("config.toml");
2370        std::fs::write(&main, "[[context_menu.rules]]\ninclude = \"outer.d\"\n").unwrap();
2371
2372        let result = Config::load_from_path(&main);
2373        assert!(result.problem.is_none(), "{:?}", result.problem);
2374        assert_eq!(result.config.context_menu.rules.len(), 1);
2375        assert_eq!(result.config.context_menu.rules[0].matches, vec![ContextMatch::Dir]);
2376    }
2377
2378    #[test]
2379    fn first_matching_rule_wins() {
2380        let menu = ContextMenu {
2381            rules: vec![
2382                ContextRule::rule(
2383                    vec![ContextMatch::Ext("md".to_owned())],
2384                    builtin_items![CopyPath],
2385                ),
2386                ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2387            ],
2388        };
2389        let dir = tempfile::tempdir().unwrap();
2390        let md = dir.path().join("a.md");
2391        std::fs::write(&md, "").unwrap();
2392        let txt = dir.path().join("a.txt");
2393        std::fs::write(&txt, "").unwrap();
2394
2395        assert_eq!(menu.actions_for(&md), builtin_items![CopyPath]);
2396        // txt matches the second (fallback) rule, so it gets that rule's menu.
2397        assert_eq!(menu.actions_for(&txt), builtin_items![Open]);
2398    }
2399
2400    #[test]
2401    fn multi_matcher_parses_and_never_matches_a_single_path() {
2402        assert_eq!(ContextMatch::parse("multi").unwrap(), ContextMatch::Multi);
2403        assert_eq!(ContextMatch::parse("multi-select").unwrap(), ContextMatch::Multi);
2404        assert_eq!(ContextMatch::Multi.as_str(), "multi");
2405        // A selection-level matcher is never true for one path.
2406        let dir = tempfile::tempdir().unwrap();
2407        assert!(!ContextMatch::Multi.matches(dir.path()));
2408    }
2409
2410    #[test]
2411    fn multi_rule_wins_for_a_multi_selection_only() {
2412        let menu = ContextMenu {
2413            rules: vec![
2414                ContextRule::rule(vec![ContextMatch::Multi], builtin_items![Trash, CopyPath]),
2415                ContextRule::rule(vec![ContextMatch::Dir], builtin_items![Open]),
2416                ContextRule::rule(vec![ContextMatch::Fallback], builtin_items![Open]),
2417            ],
2418        };
2419        let dir = tempfile::tempdir().unwrap();
2420
2421        // One selected path: the per-path rule applies.
2422        assert_eq!(menu.actions_for_selection(dir.path(), 1), builtin_items![Open]);
2423        // Two or more: the multi rule applies instead.
2424        assert_eq!(
2425            menu.actions_for_selection(dir.path(), 2),
2426            builtin_items![Trash, CopyPath]
2427        );
2428        assert_eq!(
2429            menu.actions_for_selection(dir.path(), 7),
2430            builtin_items![Trash, CopyPath]
2431        );
2432    }
2433
2434    #[test]
2435    fn shipped_multi_rule_offers_selection_actions() {
2436        let menu = ContextMenu::default();
2437        let dir = tempfile::tempdir().unwrap();
2438        let file = dir.path().join("a.txt");
2439        std::fs::write(&file, "").unwrap();
2440
2441        // One row: the multi rule does not intervene.
2442        let single = menu.actions_for_selection(&file, 1);
2443        assert_eq!(single, menu.actions_for(&file));
2444
2445        // Two or more rows: the shipped `multi` rule supplies the menu, with the
2446        // fan-out actions present and single-row actions absent.
2447        let labels = all_labels(&menu.actions_for_selection(&file, 3));
2448        assert!(labels.contains(&"Move to Trash".to_owned()), "{labels:?}");
2449        assert!(labels.contains(&"Copy Path".to_owned()), "{labels:?}");
2450        assert!(labels.contains(&"Properties".to_owned()), "{labels:?}");
2451        assert!(!labels.contains(&"Rename".to_owned()), "{labels:?}");
2452        assert!(!labels.contains(&"Duplicate".to_owned()), "{labels:?}");
2453        assert!(!labels.contains(&"With...".to_owned()), "{labels:?}");
2454    }
2455
2456    #[test]
2457    fn single_row_only_actions_are_classified() {
2458        assert!(BuiltinAction::Rename.is_single_row_only());
2459        assert!(BuiltinAction::Open.is_single_row_only());
2460        assert!(BuiltinAction::Duplicate.is_single_row_only());
2461        assert!(!BuiltinAction::Trash.is_single_row_only());
2462        assert!(!BuiltinAction::DeletePermanently.is_single_row_only());
2463        assert!(!BuiltinAction::Copy.is_single_row_only());
2464        assert!(!BuiltinAction::Cut.is_single_row_only());
2465        assert!(!BuiltinAction::Properties.is_single_row_only());
2466        assert!(!BuiltinAction::CopyPath.is_single_row_only());
2467    }
2468
2469    #[test]
2470    fn empty_rule_list_falls_back_to_classic_menu() {
2471        let menu = ContextMenu { rules: Vec::new() };
2472        let dir = tempfile::tempdir().unwrap();
2473        assert_eq!(menu.actions_for(dir.path()), ContextMenu::fallback_actions(dir.path()));
2474    }
2475
2476    #[test]
2477    fn custom_context_menu_round_trips() {
2478        let raw = r#"
2479[[context_menu.rules]]
2480matches = ["ext:rs", "ext:toml"]
2481items = ["Open", { command = "cargo fmt", label = "Fmt" }]
2482
2483[[context_menu.rules]]
2484matches = ["dir"]
2485items = [{ command = "foot -D {dir}", label = "Open in Terminal" }, "New Folder"]
2486
2487[[context_menu.rules]]
2488matches = ["noext"]
2489items = [{ command = "sh {path}" }]
2490
2491[[context_menu.rules]]
2492matches = ['regex:.*\.lock$']
2493items = ["Open", { command = "rm {path}", label = "Discard lock" }]
2494"#;
2495        let config = parse(raw);
2496        assert_eq!(config.context_menu.rules.len(), 4);
2497
2498        let first = &config.context_menu.rules[0];
2499        assert_eq!(
2500            first.matches,
2501            vec![ContextMatch::Ext("rs".to_owned()), ContextMatch::Ext("toml".to_owned())]
2502        );
2503        assert!(matches!(first.items[0], ContextAction::Builtin(BuiltinAction::Open)));
2504        assert!(matches!(
2505            &first.items[1],
2506            ContextAction::Command(cmd) if cmd.command == "cargo fmt" && cmd.label.as_deref() == Some("Fmt")
2507        ));
2508
2509        let last = &config.context_menu.rules[3];
2510        assert_eq!(
2511            last.matches,
2512            vec![ContextMatch::parse(r"regex:.*\.lock$").unwrap()]
2513        );
2514
2515        let reparsed = toml::from_str::<Config>(&toml::to_string(&config).unwrap()).unwrap();
2516        assert_eq!(config, reparsed);
2517    }
2518
2519    #[test]
2520    fn unknown_builtin_action_is_a_parse_problem() {
2521        let result = Config::parse(
2522            "[[context_menu.rules]]\nmatches = [\"dir\"]\nitems = [\"Frobnicate\"]\n",
2523            Path::new("bad-menu.toml"),
2524        );
2525        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2526        assert_eq!(result.config, Config::default());
2527    }
2528
2529    #[test]
2530    fn unknown_context_matcher_is_a_parse_problem() {
2531        let result = Config::parse(
2532            "[[context_menu.rules]]\nmatches = [\"gibberish\"]\nitems = [\"Open\"]\n",
2533            Path::new("bad-matcher.toml"),
2534        );
2535        assert!(matches!(result.problem, Some(LoadProblem::Parse(..))));
2536        assert_eq!(result.config, Config::default());
2537    }
2538
2539    #[test]
2540    fn action_command_substitutes_markers() {
2541        let dir = tempfile::tempdir().unwrap();
2542        let dir_s = dir.path().to_string_lossy().into_owned();
2543        assert_eq!(
2544            action_command("code {path}", dir.path()),
2545            vec!["code".to_owned(), dir_s.clone()]
2546        );
2547        assert_eq!(
2548            action_command("code {dir}", &dir.path().join("x.rs")),
2549            vec!["code".to_owned(), dir_s]
2550        );
2551    }
2552
2553    #[test]
2554    fn action_command_appends_path_without_marker() {
2555        assert_eq!(
2556            action_command("sh", Path::new("/tmp/s.sh")),
2557            vec!["sh".to_owned(), "/tmp/s.sh".to_owned()]
2558        );
2559    }
2560}