Skip to main content

runex_core/
expand.rs

1use crate::model::{Config, ExpandResult};
2
3/// A single skipped rule — part of the `which_abbr` trace.
4#[derive(Debug, Clone, PartialEq)]
5pub enum SkipReason {
6    /// key == expand (self-loop guard).
7    SelfLoop,
8    /// One or more `when_command_exists` commands were absent.
9    ConditionFailed {
10        found_commands: Vec<String>,
11        missing_commands: Vec<String>,
12    },
13}
14
15/// Result of a `which` lookup — mirrors `expand()` scan order exactly.
16///
17/// `skipped` contains every rule that matched the key but was bypassed,
18/// in the same order `expand()` would skip them. This ensures `which_abbr`
19/// and `expand` agree on the final outcome even with duplicate-key rules.
20#[derive(Debug, Clone, PartialEq)]
21pub enum WhichResult {
22    /// Token matched a rule and all conditions passed.
23    Expanded {
24        key: String,
25        expansion: String,
26        rule_index: usize,
27        /// Commands that were checked via `when_command_exists` and passed.
28        satisfied_conditions: Vec<String>,
29        /// Earlier rules with the same key that were skipped before this one.
30        skipped: Vec<(usize, SkipReason)>,
31    },
32    /// Every matching rule was skipped; here is why each one was bypassed.
33    AllSkipped {
34        token: String,
35        skipped: Vec<(usize, SkipReason)>,
36    },
37    /// No rule had this key at all.
38    NoMatch { token: String },
39}
40
41/// Expand a token using the config.
42///
43/// `command_exists` is injected for testability (DI).
44pub fn expand<F>(config: &Config, token: &str, command_exists: F) -> ExpandResult
45where
46    F: Fn(&str) -> bool,
47{
48    for abbr in &config.abbr {
49        if abbr.key != token {
50            continue;
51        }
52        // Infinite-loop guard: key == expand means no-op.
53        if abbr.key == abbr.expand {
54            continue;
55        }
56        // Check when_command_exists condition.
57        if let Some(cmds) = &abbr.when_command_exists {
58            if !cmds.iter().all(|c| command_exists(c)) {
59                continue;
60            }
61        }
62        return ExpandResult::Expanded(abbr.expand.clone());
63    }
64    ExpandResult::PassThrough(token.to_string())
65}
66
67/// Look up a token and return why it expands (or doesn't).
68///
69/// Scans rules in the same order as `expand()`, collecting skip reasons for
70/// every bypassed rule before returning the first one that passes. This means
71/// `which_abbr` and `expand` always agree on the final outcome, even when
72/// multiple rules share the same key.
73pub fn which_abbr<F>(config: &Config, token: &str, command_exists: F) -> WhichResult
74where
75    F: Fn(&str) -> bool,
76{
77    let mut skipped: Vec<(usize, SkipReason)> = Vec::new();
78    let mut any_key_matched = false;
79
80    for (i, abbr) in config.abbr.iter().enumerate() {
81        if abbr.key != token {
82            continue;
83        }
84        any_key_matched = true;
85
86        if abbr.key == abbr.expand {
87            skipped.push((i, SkipReason::SelfLoop));
88            continue;
89        }
90        if let Some(cmds) = &abbr.when_command_exists {
91            let (found, missing): (Vec<String>, Vec<String>) =
92                cmds.iter().cloned().partition(|c| command_exists(c));
93            if !missing.is_empty() {
94                skipped.push((
95                    i,
96                    SkipReason::ConditionFailed {
97                        found_commands: found,
98                        missing_commands: missing,
99                    },
100                ));
101                continue;
102            }
103        }
104        return WhichResult::Expanded {
105            key: abbr.key.clone(),
106            expansion: abbr.expand.clone(),
107            rule_index: i,
108            satisfied_conditions: abbr
109                .when_command_exists
110                .as_deref()
111                .unwrap_or(&[])
112                .to_vec(),
113            skipped,
114        };
115    }
116
117    if any_key_matched {
118        WhichResult::AllSkipped {
119            token: token.to_string(),
120            skipped,
121        }
122    } else {
123        WhichResult::NoMatch {
124            token: token.to_string(),
125        }
126    }
127}
128
129/// List all abbreviations as (key, expand) pairs.
130pub fn list(config: &Config) -> Vec<(&str, &str)> {
131    config
132        .abbr
133        .iter()
134        .map(|a| (a.key.as_str(), a.expand.as_str()))
135        .collect()
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::model::{Abbr, Config};
142
143    fn cfg(abbrs: Vec<Abbr>) -> Config {
144        Config {
145            version: 1,
146            keybind: crate::model::KeybindConfig::default(),
147            abbr: abbrs,
148        }
149    }
150
151    fn abbr(key: &str, expand: &str) -> Abbr {
152        Abbr {
153            key: key.into(),
154            expand: expand.into(),
155            when_command_exists: None,
156        }
157    }
158
159    fn abbr_when(key: &str, exp: &str, cmds: Vec<&str>) -> Abbr {
160        Abbr {
161            key: key.into(),
162            expand: exp.into(),
163            when_command_exists: Some(cmds.into_iter().map(String::from).collect()),
164        }
165    }
166
167    #[test]
168    fn match_expands() {
169        let c = cfg(vec![abbr("gcm", "git commit -m")]);
170        assert_eq!(
171            expand(&c, "gcm", |_| true),
172            ExpandResult::Expanded("git commit -m".into())
173        );
174    }
175
176    #[test]
177    fn no_match_passes_through() {
178        let c = cfg(vec![abbr("gcm", "git commit -m")]);
179        assert_eq!(
180            expand(&c, "xyz", |_| true),
181            ExpandResult::PassThrough("xyz".into())
182        );
183    }
184
185    #[test]
186    fn selects_correct_abbr() {
187        let c = cfg(vec![
188            abbr("gcm", "git commit -m"),
189            abbr("gp", "git push"),
190        ]);
191        assert_eq!(
192            expand(&c, "gp", |_| true),
193            ExpandResult::Expanded("git push".into())
194        );
195    }
196
197    #[test]
198    fn key_eq_expand_passes_through() {
199        let c = cfg(vec![abbr("ls", "ls")]);
200        assert_eq!(
201            expand(&c, "ls", |_| true),
202            ExpandResult::PassThrough("ls".into())
203        );
204    }
205
206    #[test]
207    fn when_command_exists_present() {
208        let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
209        assert_eq!(
210            expand(&c, "ls", |_| true),
211            ExpandResult::Expanded("lsd".into())
212        );
213    }
214
215    #[test]
216    fn when_command_exists_absent() {
217        let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
218        assert_eq!(
219            expand(&c, "ls", |_| false),
220            ExpandResult::PassThrough("ls".into())
221        );
222    }
223
224    #[test]
225    fn duplicate_key_self_loop_then_real_expands() {
226        // expand() must skip the self-loop and expand using the second rule
227        let c = cfg(vec![abbr("ls", "ls"), abbr("ls", "lsd")]);
228        assert_eq!(expand(&c, "ls", |_| true), ExpandResult::Expanded("lsd".into()));
229    }
230
231    #[test]
232    fn duplicate_key_failed_condition_then_real_expands() {
233        // expand() skips the first (condition fails) and uses the second (no condition)
234        let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"]), abbr("ls", "ls2")]);
235        assert_eq!(expand(&c, "ls", |_| false), ExpandResult::Expanded("ls2".into()));
236    }
237
238    #[test]
239    fn which_abbr_duplicate_self_loop_then_expanded() {
240        let c = cfg(vec![abbr("ls", "ls"), abbr("ls", "lsd")]);
241        let result = which_abbr(&c, "ls", |_| true);
242        match result {
243            WhichResult::Expanded { expansion, skipped, .. } => {
244                assert_eq!(expansion, "lsd");
245                assert_eq!(skipped.len(), 1);
246                assert_eq!(skipped[0].0, 0); // rule index 0 was skipped
247                assert!(matches!(skipped[0].1, SkipReason::SelfLoop));
248            }
249            other => panic!("expected Expanded, got {other:?}"),
250        }
251    }
252
253    #[test]
254    fn which_abbr_all_skipped_returns_all_skipped() {
255        let c = cfg(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
256        let result = which_abbr(&c, "ls", |_| false);
257        match result {
258            WhichResult::AllSkipped { skipped, .. } => {
259                assert_eq!(skipped.len(), 1);
260                assert!(matches!(
261                    &skipped[0].1,
262                    SkipReason::ConditionFailed { missing_commands, .. }
263                    if missing_commands == &["lsd"]
264                ));
265            }
266            other => panic!("expected AllSkipped, got {other:?}"),
267        }
268    }
269
270    #[test]
271    fn which_abbr_no_match() {
272        let c = cfg(vec![abbr("gcm", "git commit -m")]);
273        assert!(matches!(which_abbr(&c, "xyz", |_| true), WhichResult::NoMatch { .. }));
274    }
275
276    #[test]
277    fn list_returns_all_pairs() {
278        let c = cfg(vec![
279            abbr("gcm", "git commit -m"),
280            abbr("gp", "git push"),
281        ]);
282        let pairs = list(&c);
283        assert_eq!(pairs, vec![("gcm", "git commit -m"), ("gp", "git push")]);
284    }
285}