Skip to main content

runex_core/
model.rs

1use serde::{Deserialize, Serialize};
2
3/// Identifies which shell is running.
4///
5/// This lives in `model` (not `shell`) to avoid a circular dependency:
6/// `shell.rs` uses `model::Config`/`TriggerKey`, and `model.rs` needs `Shell`
7/// for the per-shell expand/condition helpers.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Shell {
10    Bash,
11    Zsh,
12    Pwsh,
13    Clink,
14    Nu,
15}
16
17/// Expansion string that can be uniform across all shells or per-shell.
18///
19/// ```toml
20/// expand = "lsd"                              # All — same for every shell
21/// expand = { default = "7z", pwsh = "7z.exe" } # ByShell — per-shell override
22/// ```
23#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
24#[serde(untagged)]
25pub enum PerShellString {
26    /// Same expansion for every shell.
27    All(String),
28    /// Per-shell overrides; `default` is the fallback.
29    ByShell {
30        default: Option<String>,
31        bash:    Option<String>,
32        zsh:     Option<String>,
33        pwsh:    Option<String>,
34        nu:      Option<String>,
35    },
36}
37
38impl PerShellString {
39    /// Return the expansion string for `shell`, or `None` if no entry applies.
40    ///
41    /// For `ByShell`, the shell-specific field takes priority over `default`.
42    /// `Shell::Clink` always uses `default` (no clink-specific field).
43    /// Returns `None` when neither the shell-specific field nor `default` is set.
44    pub fn for_shell(&self, shell: Shell) -> Option<&str> {
45        match self {
46            PerShellString::All(s) => Some(s.as_str()),
47            PerShellString::ByShell { default, bash, zsh, pwsh, nu } => {
48                let specific = match shell {
49                    Shell::Bash  => bash.as_deref(),
50                    Shell::Zsh   => zsh.as_deref(),
51                    Shell::Pwsh  => pwsh.as_deref(),
52                    Shell::Nu    => nu.as_deref(),
53                    Shell::Clink => None, // clink has no dedicated field
54                };
55                specific.or(default.as_deref())
56            }
57        }
58    }
59
60    /// Iterate over all non-None string values (used for validation).
61    pub fn all_values(&self) -> Vec<&str> {
62        match self {
63            PerShellString::All(s) => vec![s.as_str()],
64            PerShellString::ByShell { default, bash, zsh, pwsh, nu } => {
65                [default, bash, zsh, pwsh, nu]
66                    .iter()
67                    .filter_map(|v| v.as_deref())
68                    .collect()
69            }
70        }
71    }
72}
73
74/// `when_command_exists` list that can be uniform or per-shell.
75///
76/// ```toml
77/// when_command_exists = ["lsd"]
78/// when_command_exists = { default = ["7z"], pwsh = ["7z.exe"] }
79/// ```
80#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
81#[serde(untagged)]
82pub enum PerShellCmds {
83    /// Same command list for every shell.
84    All(Vec<String>),
85    /// Per-shell overrides; `default` is the fallback.
86    ByShell {
87        default: Option<Vec<String>>,
88        bash:    Option<Vec<String>>,
89        zsh:     Option<Vec<String>>,
90        pwsh:    Option<Vec<String>>,
91        nu:      Option<Vec<String>>,
92    },
93}
94
95impl PerShellCmds {
96    /// Return the command list for `shell`, or `None` if no entry applies.
97    pub fn for_shell(&self, shell: Shell) -> Option<&[String]> {
98        match self {
99            PerShellCmds::All(v) => Some(v.as_slice()),
100            PerShellCmds::ByShell { default, bash, zsh, pwsh, nu } => {
101                let specific: Option<&Vec<String>> = match shell {
102                    Shell::Bash  => bash.as_ref(),
103                    Shell::Zsh   => zsh.as_ref(),
104                    Shell::Pwsh  => pwsh.as_ref(),
105                    Shell::Nu    => nu.as_ref(),
106                    Shell::Clink => None,
107                };
108                specific.or(default.as_ref()).map(Vec::as_slice)
109            }
110        }
111    }
112
113    /// Iterate over all non-None vec values (used for validation).
114    pub fn all_values(&self) -> Vec<&[String]> {
115        match self {
116            PerShellCmds::All(v) => vec![v.as_slice()],
117            PerShellCmds::ByShell { default, bash, zsh, pwsh, nu } => {
118                [default, bash, zsh, pwsh, nu]
119                    .iter()
120                    .filter_map(|v| v.as_deref())
121                    .collect()
122            }
123        }
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
128#[serde(rename_all = "kebab-case")]
129pub enum TriggerKey {
130    #[default]
131    Space,
132    Tab,
133    AltSpace,
134    ShiftSpace,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
138pub struct PerShellKey {
139    pub default: Option<TriggerKey>,
140    pub bash: Option<TriggerKey>,
141    pub zsh: Option<TriggerKey>,
142    pub pwsh: Option<TriggerKey>,
143    pub nu: Option<TriggerKey>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
147pub struct KeybindConfig {
148    #[serde(default)]
149    pub trigger: PerShellKey,
150    #[serde(default)]
151    pub self_insert: PerShellKey,
152}
153
154/// A single abbreviation rule: rune → cast.
155#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
156pub struct Abbr {
157    pub key: String,
158    pub expand: PerShellString,
159    pub when_command_exists: Option<PerShellCmds>,
160}
161
162/// Precache configuration.
163#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
164pub struct PrecacheConfig {
165    /// When true, only check PATH binaries (via which). Faster but misses
166    /// shell builtins, aliases, functions, and cmdlets.
167    /// When false (default), use shell-native detection (Get-Command,
168    /// command -v, etc.) for full coverage.
169    #[serde(default)]
170    pub path_only: bool,
171}
172
173/// Top-level configuration.
174#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
175pub struct Config {
176    pub version: u32,
177    #[serde(default)]
178    pub keybind: KeybindConfig,
179    #[serde(default)]
180    pub precache: PrecacheConfig,
181    #[serde(default)]
182    pub abbr: Vec<Abbr>,
183}
184
185/// Result of an expand operation.
186#[derive(Debug, Clone, PartialEq)]
187pub enum ExpandResult {
188    /// Token was expanded. `cursor_offset` is the byte position within `text`
189    /// where the cursor should be placed (from the `{}` placeholder).
190    /// `None` means cursor goes to end of expansion (default).
191    Expanded { text: String, cursor_offset: Option<usize> },
192    PassThrough(String),
193}
194
195/// Cursor placeholder marker in expansion text.
196pub const CURSOR_PLACEHOLDER: &str = "{}";
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use super::Shell;
202
203    // ── PerShellString ──────────────────────────────────────────────────────
204
205    #[test]
206    fn per_shell_string_all_always_returns_value() {
207        let v = PerShellString::All("lsd".into());
208        assert_eq!(v.for_shell(Shell::Bash), Some("lsd"));
209        assert_eq!(v.for_shell(Shell::Pwsh), Some("lsd"));
210        assert_eq!(v.for_shell(Shell::Nu),   Some("lsd"));
211    }
212
213    #[test]
214    fn per_shell_string_for_shell_returns_shell_specific() {
215        let v = PerShellString::ByShell {
216            default: Some("7z".into()),
217            pwsh:    Some("7z.exe".into()),
218            bash: None, zsh: None, nu: None,
219        };
220        assert_eq!(v.for_shell(Shell::Pwsh), Some("7z.exe"));
221        assert_eq!(v.for_shell(Shell::Bash), Some("7z")); // default fallback
222        assert_eq!(v.for_shell(Shell::Nu),   Some("7z"));
223    }
224
225    #[test]
226    fn per_shell_string_none_when_no_entry() {
227        let v = PerShellString::ByShell {
228            default: None,
229            pwsh:    Some("7z.exe".into()),
230            bash: None, zsh: None, nu: None,
231        };
232        assert_eq!(v.for_shell(Shell::Bash), None); // no default, no bash
233        assert_eq!(v.for_shell(Shell::Pwsh), Some("7z.exe"));
234    }
235
236    #[test]
237    fn per_shell_string_clink_uses_default() {
238        let v = PerShellString::ByShell {
239            default: Some("cmd".into()),
240            bash: None, zsh: None, pwsh: None, nu: None,
241        };
242        assert_eq!(v.for_shell(Shell::Clink), Some("cmd"));
243    }
244
245    // ── PerShellCmds ────────────────────────────────────────────────────────
246
247    #[test]
248    fn per_shell_cmds_all_always_returns_value() {
249        let v = PerShellCmds::All(vec!["lsd".into()]);
250        assert_eq!(v.for_shell(Shell::Bash), Some(["lsd".to_string()].as_slice()));
251        assert_eq!(v.for_shell(Shell::Pwsh), Some(["lsd".to_string()].as_slice()));
252    }
253
254    #[test]
255    fn per_shell_cmds_for_shell_returns_shell_specific() {
256        let v = PerShellCmds::ByShell {
257            default: Some(vec!["7z".into()]),
258            pwsh:    Some(vec!["7z.exe".into()]),
259            bash: None, zsh: None, nu: None,
260        };
261        assert_eq!(v.for_shell(Shell::Pwsh), Some(["7z.exe".to_string()].as_slice()));
262        assert_eq!(v.for_shell(Shell::Bash), Some(["7z".to_string()].as_slice()));
263    }
264
265    #[test]
266    fn per_shell_cmds_none_when_no_entry() {
267        let v = PerShellCmds::ByShell {
268            default: None,
269            pwsh:    Some(vec!["7z.exe".into()]),
270            bash: None, zsh: None, nu: None,
271        };
272        assert_eq!(v.for_shell(Shell::Bash), None);
273        assert_eq!(v.for_shell(Shell::Pwsh), Some(["7z.exe".to_string()].as_slice()));
274    }
275
276    #[test]
277    fn abbr_fields() {
278        let a = Abbr {
279            key: "gcm".into(),
280            expand: PerShellString::All("git commit -m".into()),
281            when_command_exists: None,
282        };
283        assert_eq!(a.key, "gcm");
284        assert_eq!(a.expand, PerShellString::All("git commit -m".into()));
285        assert!(a.when_command_exists.is_none());
286    }
287
288    #[test]
289    fn abbr_with_when_command_exists() {
290        let a = Abbr {
291            key: "ls".into(),
292            expand: PerShellString::All("lsd".into()),
293            when_command_exists: Some(PerShellCmds::All(vec!["lsd".into()])),
294        };
295        match a.when_command_exists.unwrap() {
296            PerShellCmds::All(v) => assert_eq!(v, vec!["lsd".to_string()]),
297            _ => panic!("expected All"),
298        }
299    }
300
301    #[test]
302    fn config_fields() {
303        let c = Config {
304            version: 1,
305            keybind: KeybindConfig::default(),
306            precache: PrecacheConfig::default(),
307            abbr: vec![],
308        };
309        assert_eq!(c.version, 1);
310        assert_eq!(c.keybind, KeybindConfig::default());
311        assert!(c.abbr.is_empty());
312    }
313
314    #[test]
315    fn keybind_config_fields() {
316        let k = KeybindConfig {
317            trigger: PerShellKey {
318                default: Some(TriggerKey::Space),
319                bash: Some(TriggerKey::AltSpace),
320                zsh: Some(TriggerKey::Space),
321                pwsh: Some(TriggerKey::Tab),
322                nu: None,
323            },
324            self_insert: PerShellKey::default(),
325        };
326        assert_eq!(k.trigger.default, Some(TriggerKey::Space));
327        assert_eq!(k.trigger.bash, Some(TriggerKey::AltSpace));
328        assert_eq!(k.trigger.zsh, Some(TriggerKey::Space));
329        assert_eq!(k.trigger.pwsh, Some(TriggerKey::Tab));
330        assert_eq!(k.trigger.nu, None);
331        assert_eq!(k.self_insert, PerShellKey::default());
332    }
333
334    #[test]
335    fn parse_config_accepts_self_insert_shift_space() {
336        let toml = r#"
337version = 1
338[keybind.self_insert]
339pwsh = "shift-space"
340"#;
341        let config: Config = toml::from_str(toml).expect("should parse");
342        assert_eq!(
343            config.keybind.self_insert.pwsh,
344            Some(TriggerKey::ShiftSpace),
345            "self_insert.pwsh should deserialize to ShiftSpace"
346        );
347    }
348
349    #[test]
350    fn parse_config_keybind_entirely_absent() {
351        let toml = "version = 1\n";
352        let config: Config = toml::from_str(toml).expect("should parse");
353        assert_eq!(config.keybind.trigger, PerShellKey::default());
354        assert_eq!(config.keybind.self_insert, PerShellKey::default());
355    }
356
357    #[test]
358    fn expand_result_variants() {
359        let expanded = ExpandResult::Expanded { text: "git commit -m".into(), cursor_offset: None };
360        let pass = ExpandResult::PassThrough("unknown".into());
361        assert_eq!(expanded, ExpandResult::Expanded { text: "git commit -m".into(), cursor_offset: None });
362        assert_eq!(pass, ExpandResult::PassThrough("unknown".into()));
363    }
364}