Skip to main content

tui_dispatch_shared/
lib.rs

1//! Helpers for parsing action variant names.
2//!
3//! These utilities are shared by derive-time and runtime category inference so
4//! action filtering stays consistent across crates.
5
6/// Action verbs used by category inference.
7///
8/// Category inference treats these as boundaries between a domain prefix and
9/// the action verb (for example `SearchStart` -> `search`).
10pub const ACTION_VERBS: &[&str] = &[
11    "Start", "End", "Open", "Close", "Submit", "Confirm", "Cancel", "Next", "Prev", "Up", "Down",
12    "Left", "Right", "Enter", "Exit", "Escape", "Add", "Remove", "Clear", "Update", "Set", "Get",
13    "Load", "Save", "Delete", "Create", "Fetch", "Change", "Resize", "Error", "Show", "Hide",
14    "Enable", "Disable", "Toggle", "Focus", "Blur", "Select", "Move", "Copy", "Cycle", "Reset",
15    "Scroll",
16];
17
18/// Split PascalCase text into parts.
19///
20/// Handles acronym boundaries: `APIFetchStart` -> `["API", "Fetch", "Start"]`.
21pub fn split_pascal_case(value: &str) -> Vec<String> {
22    let chars: Vec<char> = value.chars().collect();
23    if chars.is_empty() {
24        return Vec::new();
25    }
26
27    let mut parts = Vec::new();
28    let mut start = 0usize;
29    for idx in 1..chars.len() {
30        let prev = chars[idx - 1];
31        let curr = chars[idx];
32        let next = chars.get(idx + 1).copied();
33
34        let lower_to_upper = prev.is_ascii_lowercase() && curr.is_ascii_uppercase();
35        let acronym_to_word = prev.is_ascii_uppercase()
36            && curr.is_ascii_uppercase()
37            && next.is_some_and(|ch| ch.is_ascii_lowercase());
38
39        if lower_to_upper || acronym_to_word {
40            parts.push(chars[start..idx].iter().collect());
41            start = idx;
42        }
43    }
44    parts.push(chars[start..].iter().collect());
45    parts
46}
47
48/// Convert PascalCase text to snake_case.
49pub fn pascal_to_snake_case(value: &str) -> String {
50    split_pascal_case(value)
51        .into_iter()
52        .map(|part| part.to_ascii_lowercase())
53        .collect::<Vec<_>>()
54        .join("_")
55}
56
57/// Infer action category from an action variant name.
58///
59/// Returns:
60/// - `Some("async_result")` for `Did*`
61/// - `Some(<domain>)` for `DomainVerb*` patterns
62/// - `None` when no category can be inferred
63pub fn infer_action_category(action_name: &str) -> Option<String> {
64    let parts = split_pascal_case(action_name);
65    if parts.is_empty() {
66        return None;
67    }
68
69    if parts[0] == "Did" {
70        return Some("async_result".to_string());
71    }
72
73    if parts.len() < 2 {
74        return None;
75    }
76
77    // VerbNoun actions (e.g. OpenFile) intentionally do not infer categories.
78    if ACTION_VERBS.contains(&parts[0].as_str()) {
79        return None;
80    }
81
82    let mut prefix_end = parts.len();
83    let mut found_verb = false;
84    for (idx, part) in parts.iter().enumerate().skip(1) {
85        if part == "Did" || ACTION_VERBS.contains(&part.as_str()) {
86            prefix_end = idx;
87            found_verb = true;
88            break;
89        }
90    }
91
92    if !found_verb || prefix_end == 0 {
93        return None;
94    }
95
96    Some(
97        parts[..prefix_end]
98            .iter()
99            .map(|part| part.to_ascii_lowercase())
100            .collect::<Vec<_>>()
101            .join("_"),
102    )
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_split_pascal_case_handles_acronyms() {
111        assert_eq!(
112            split_pascal_case("APIFetchStart"),
113            vec!["API".to_string(), "Fetch".to_string(), "Start".to_string()]
114        );
115        assert_eq!(
116            split_pascal_case("SearchHTTPResult"),
117            vec![
118                "Search".to_string(),
119                "HTTP".to_string(),
120                "Result".to_string()
121            ]
122        );
123    }
124
125    #[test]
126    fn test_pascal_to_snake_case_handles_acronyms() {
127        assert_eq!(pascal_to_snake_case("APIFetch"), "api_fetch");
128        assert_eq!(pascal_to_snake_case("HTTPResult"), "http_result");
129    }
130
131    #[test]
132    fn test_infer_action_category() {
133        assert_eq!(
134            infer_action_category("SearchStart"),
135            Some("search".to_string())
136        );
137        assert_eq!(
138            infer_action_category("SearchQuerySubmit"),
139            Some("search_query".to_string())
140        );
141        assert_eq!(
142            infer_action_category("WeatherDidLoad"),
143            Some("weather".to_string())
144        );
145        assert_eq!(
146            infer_action_category("DidLoad"),
147            Some("async_result".to_string())
148        );
149        assert_eq!(infer_action_category("Tick"), None);
150        assert_eq!(infer_action_category("OpenConnectionForm"), None);
151        assert_eq!(
152            infer_action_category("APIFetchStart"),
153            Some("api".to_string())
154        );
155    }
156}