1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Shell {
10 Bash,
11 Zsh,
12 Pwsh,
13 Clink,
14 Nu,
15}
16
17#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
24#[serde(untagged)]
25pub enum PerShellString {
26 All(String),
28 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 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, };
55 specific.or(default.as_deref())
56 }
57 }
58 }
59
60 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#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
81#[serde(untagged)]
82pub enum PerShellCmds {
83 All(Vec<String>),
85 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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
164pub struct PrecacheConfig {
165 #[serde(default)]
170 pub path_only: bool,
171}
172
173#[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#[derive(Debug, Clone, PartialEq)]
187pub enum ExpandResult {
188 Expanded { text: String, cursor_offset: Option<usize> },
192 PassThrough(String),
193}
194
195pub const CURSOR_PLACEHOLDER: &str = "{}";
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use super::Shell;
202
203 #[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")); 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); 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 #[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}