Skip to main content

magic_bird/
format_hints.rs

1//! Format hints configuration for command-to-format detection.
2//!
3//! Supports multiple configuration styles:
4//!
5//! ```toml
6//! [format-hints]
7//! # Simple form - default priority (500)
8//! "*lint*" = "eslint"
9//! "cargo*" = "cargo"
10//!
11//! # Structured form - explicit priority inline
12//! "*pytest*" = { format = "pytest", priority = 100 }
13//!
14//! # Priority sections - all entries inherit the section's priority
15//! [format-hints.1000]
16//! "mycompany-*" = "gcc"
17//!
18//! [format-hints.100]
19//! "legacy-*" = "text"
20//! ```
21
22use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25
26use serde::{Deserialize, Serialize};
27
28use crate::{Error, Result};
29
30/// Default priority for simple pattern = "format" entries.
31pub const DEFAULT_PRIORITY: i32 = 500;
32
33/// A single format hint rule.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct FormatHint {
36    /// Glob pattern to match against command/executable.
37    pub pattern: String,
38    /// Format name (e.g., "gcc", "pytest", "cargo_build").
39    pub format: String,
40    /// Priority (higher wins). Default is 500.
41    #[serde(default = "default_priority")]
42    pub priority: i32,
43}
44
45fn default_priority() -> i32 {
46    DEFAULT_PRIORITY
47}
48
49impl FormatHint {
50    /// Create a new format hint with default priority.
51    pub fn new(pattern: impl Into<String>, format: impl Into<String>) -> Self {
52        Self {
53            pattern: pattern.into(),
54            format: format.into(),
55            priority: DEFAULT_PRIORITY,
56        }
57    }
58
59    /// Create a new format hint with explicit priority.
60    pub fn with_priority(pattern: impl Into<String>, format: impl Into<String>, priority: i32) -> Self {
61        Self {
62            pattern: pattern.into(),
63            format: format.into(),
64            priority,
65        }
66    }
67}
68
69/// Format hints configuration.
70#[derive(Debug, Clone, Default)]
71pub struct FormatHints {
72    /// All hints sorted by priority (highest first).
73    hints: Vec<FormatHint>,
74    /// Default format when no hints match.
75    default_format: String,
76}
77
78impl FormatHints {
79    /// Create an empty FormatHints with default "auto" fallback.
80    pub fn new() -> Self {
81        Self {
82            hints: Vec::new(),
83            default_format: "auto".to_string(),
84        }
85    }
86
87    /// Load format hints from a TOML file.
88    pub fn load(path: &Path) -> Result<Self> {
89        if !path.exists() {
90            return Ok(Self::new());
91        }
92
93        let contents = fs::read_to_string(path)?;
94        Self::parse(&contents)
95    }
96
97    /// Parse format hints from TOML string.
98    pub fn parse(toml_str: &str) -> Result<Self> {
99        let value: toml::Value = toml::from_str(toml_str)
100            .map_err(|e| Error::Config(format!("Failed to parse format-hints: {}", e)))?;
101
102        let mut hints = Vec::new();
103        let mut default_format = "auto".to_string();
104
105        // Get the format-hints table
106        if let Some(format_hints) = value.get("format-hints").and_then(|v| v.as_table()) {
107            for (key, val) in format_hints {
108                // Check if it's a priority subsection (numeric key)
109                if let Ok(priority) = key.parse::<i32>() {
110                    // Priority section: [format-hints.1000]
111                    if let Some(section) = val.as_table() {
112                        for (pattern, format_val) in section {
113                            let hint = parse_hint_value(pattern, format_val, Some(priority))?;
114                            hints.push(hint);
115                        }
116                    }
117                } else if key == "default" {
118                    // Default format setting
119                    if let Some(s) = val.as_str() {
120                        default_format = s.to_string();
121                    }
122                } else {
123                    // Regular entry in [format-hints] section
124                    let hint = parse_hint_value(key, val, None)?;
125                    hints.push(hint);
126                }
127            }
128        }
129
130        // Also check for legacy [[rules]] format for backwards compatibility
131        if let Some(rules) = value.get("rules").and_then(|v| v.as_array()) {
132            for rule in rules {
133                if let (Some(pattern), Some(format)) = (
134                    rule.get("pattern").and_then(|v| v.as_str()),
135                    rule.get("format").and_then(|v| v.as_str()),
136                ) {
137                    let priority = rule
138                        .get("priority")
139                        .and_then(|v| v.as_integer())
140                        .map(|p| p as i32)
141                        .unwrap_or(DEFAULT_PRIORITY);
142                    hints.push(FormatHint::with_priority(pattern, format, priority));
143                }
144            }
145        }
146
147        // Check for legacy [default] section
148        if let Some(default) = value.get("default").and_then(|v| v.as_table()) {
149            if let Some(format) = default.get("format").and_then(|v| v.as_str()) {
150                default_format = format.to_string();
151            }
152        }
153
154        // Sort by priority (highest first), then by pattern for stable ordering
155        hints.sort_by(|a, b| {
156            b.priority.cmp(&a.priority).then_with(|| a.pattern.cmp(&b.pattern))
157        });
158
159        Ok(Self { hints, default_format })
160    }
161
162    /// Save format hints to a TOML file.
163    pub fn save(&self, path: &Path) -> Result<()> {
164        let contents = self.to_toml();
165        fs::write(path, contents)?;
166        Ok(())
167    }
168
169    /// Convert to TOML string.
170    pub fn to_toml(&self) -> String {
171        let mut output = String::new();
172        output.push_str("# Format hints for command-to-format detection\n");
173        output.push_str("# Higher priority values take precedence\n\n");
174
175        // Group by priority
176        let mut by_priority: HashMap<i32, Vec<&FormatHint>> = HashMap::new();
177        for hint in &self.hints {
178            by_priority.entry(hint.priority).or_default().push(hint);
179        }
180
181        // Get sorted priorities (highest first)
182        let mut priorities: Vec<_> = by_priority.keys().copied().collect();
183        priorities.sort_by(|a, b| b.cmp(a));
184
185        // Output default priority (500) entries in [format-hints] section
186        if let Some(default_hints) = by_priority.get(&DEFAULT_PRIORITY) {
187            output.push_str("[format-hints]\n");
188            for hint in default_hints {
189                output.push_str(&format!("\"{}\" = \"{}\"\n", hint.pattern, hint.format));
190            }
191            if self.default_format != "auto" {
192                output.push_str(&format!("default = \"{}\"\n", self.default_format));
193            }
194            output.push('\n');
195        } else if self.default_format != "auto" {
196            output.push_str("[format-hints]\n");
197            output.push_str(&format!("default = \"{}\"\n", self.default_format));
198            output.push('\n');
199        }
200
201        // Output other priority sections
202        for priority in priorities {
203            if priority == DEFAULT_PRIORITY {
204                continue;
205            }
206            if let Some(hints) = by_priority.get(&priority) {
207                output.push_str(&format!("[format-hints.{}]\n", priority));
208                for hint in hints {
209                    output.push_str(&format!("\"{}\" = \"{}\"\n", hint.pattern, hint.format));
210                }
211                output.push('\n');
212            }
213        }
214
215        output
216    }
217
218    /// Get all hints (sorted by priority, highest first).
219    pub fn hints(&self) -> &[FormatHint] {
220        &self.hints
221    }
222
223    /// Get the default format.
224    pub fn default_format(&self) -> &str {
225        &self.default_format
226    }
227
228    /// Set the default format.
229    pub fn set_default_format(&mut self, format: impl Into<String>) {
230        self.default_format = format.into();
231    }
232
233    /// Add a hint (maintains sorted order).
234    pub fn add(&mut self, hint: FormatHint) {
235        // Remove any existing hint with the same pattern
236        self.hints.retain(|h| h.pattern != hint.pattern);
237        self.hints.push(hint);
238        self.hints.sort_by(|a, b| {
239            b.priority.cmp(&a.priority).then_with(|| a.pattern.cmp(&b.pattern))
240        });
241    }
242
243    /// Remove a hint by pattern. Returns true if removed.
244    pub fn remove(&mut self, pattern: &str) -> bool {
245        let len_before = self.hints.len();
246        self.hints.retain(|h| h.pattern != pattern);
247        self.hints.len() < len_before
248    }
249
250    /// Find a hint by pattern.
251    pub fn get(&self, pattern: &str) -> Option<&FormatHint> {
252        self.hints.iter().find(|h| h.pattern == pattern)
253    }
254
255    /// Detect format for a command string.
256    /// Returns the format from the highest-priority matching hint, or default.
257    pub fn detect(&self, cmd: &str) -> &str {
258        for hint in &self.hints {
259            if pattern_matches(&hint.pattern, cmd) {
260                return &hint.format;
261            }
262        }
263        &self.default_format
264    }
265}
266
267/// Parse a hint value (string or structured).
268fn parse_hint_value(pattern: &str, val: &toml::Value, section_priority: Option<i32>) -> Result<FormatHint> {
269    match val {
270        toml::Value::String(format) => {
271            let priority = section_priority.unwrap_or(DEFAULT_PRIORITY);
272            Ok(FormatHint::with_priority(pattern, format, priority))
273        }
274        toml::Value::Table(table) => {
275            let format = table
276                .get("format")
277                .and_then(|v| v.as_str())
278                .ok_or_else(|| Error::Config(format!("Missing 'format' field for pattern '{}'", pattern)))?;
279            let priority = table
280                .get("priority")
281                .and_then(|v| v.as_integer())
282                .map(|p| p as i32)
283                .or(section_priority)
284                .unwrap_or(DEFAULT_PRIORITY);
285            Ok(FormatHint::with_priority(pattern, format, priority))
286        }
287        _ => Err(Error::Config(format!(
288            "Invalid value for pattern '{}': expected string or table",
289            pattern
290        ))),
291    }
292}
293
294/// Simple glob pattern matching.
295/// `*` matches any characters (including none).
296pub fn pattern_matches(pattern: &str, text: &str) -> bool {
297    let parts: Vec<&str> = pattern.split('*').collect();
298
299    if parts.len() == 1 {
300        return pattern == text;
301    }
302
303    // First part must match at start (if not empty)
304    if !parts[0].is_empty() && !text.starts_with(parts[0]) {
305        return false;
306    }
307    let mut pos = parts[0].len();
308
309    // Middle parts must appear in order
310    for part in &parts[1..parts.len() - 1] {
311        if part.is_empty() {
312            continue;
313        }
314        match text[pos..].find(part) {
315            Some(found) => pos += found + part.len(),
316            None => return false,
317        }
318    }
319
320    // Last part must match at end (if not empty)
321    let last = parts[parts.len() - 1];
322    if !last.is_empty() && !text[pos..].ends_with(last) {
323        return false;
324    }
325
326    true
327}
328
329/// Convert a glob pattern to SQL LIKE pattern.
330pub fn glob_to_like(pattern: &str) -> String {
331    let mut result = String::with_capacity(pattern.len() + 10);
332    for c in pattern.chars() {
333        match c {
334            '*' => result.push('%'),
335            '?' => result.push('_'),
336            '%' => result.push_str("\\%"),
337            '_' => result.push_str("\\_"),
338            '\\' => result.push_str("\\\\"),
339            _ => result.push(c),
340        }
341    }
342    result
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_parse_simple_format() {
351        let toml = r#"
352[format-hints]
353"*lint*" = "eslint"
354"cargo*" = "cargo"
355"#;
356        let hints = FormatHints::parse(toml).unwrap();
357        assert_eq!(hints.hints().len(), 2);
358
359        let eslint = hints.get("*lint*").unwrap();
360        assert_eq!(eslint.format, "eslint");
361        assert_eq!(eslint.priority, DEFAULT_PRIORITY);
362    }
363
364    #[test]
365    fn test_parse_structured_format() {
366        let toml = r#"
367[format-hints]
368"*pytest*" = { format = "pytest", priority = 100 }
369"#;
370        let hints = FormatHints::parse(toml).unwrap();
371        let pytest = hints.get("*pytest*").unwrap();
372        assert_eq!(pytest.format, "pytest");
373        assert_eq!(pytest.priority, 100);
374    }
375
376    #[test]
377    fn test_parse_priority_sections() {
378        let toml = r#"
379[format-hints]
380"*lint*" = "eslint"
381
382[format-hints.1000]
383"mycompany-*" = "gcc"
384
385[format-hints.100]
386"legacy-*" = "text"
387"#;
388        let hints = FormatHints::parse(toml).unwrap();
389        assert_eq!(hints.hints().len(), 3);
390
391        // Check priorities
392        let mycompany = hints.get("mycompany-*").unwrap();
393        assert_eq!(mycompany.priority, 1000);
394
395        let legacy = hints.get("legacy-*").unwrap();
396        assert_eq!(legacy.priority, 100);
397
398        let lint = hints.get("*lint*").unwrap();
399        assert_eq!(lint.priority, DEFAULT_PRIORITY);
400
401        // Should be sorted by priority (highest first)
402        assert_eq!(hints.hints()[0].pattern, "mycompany-*");
403        assert_eq!(hints.hints()[1].pattern, "*lint*");
404        assert_eq!(hints.hints()[2].pattern, "legacy-*");
405    }
406
407    #[test]
408    fn test_parse_legacy_rules() {
409        let toml = r#"
410[[rules]]
411pattern = "*gcc*"
412format = "gcc"
413
414[[rules]]
415pattern = "*make*"
416format = "make"
417priority = 100
418
419[default]
420format = "text"
421"#;
422        let hints = FormatHints::parse(toml).unwrap();
423        assert_eq!(hints.hints().len(), 2);
424        assert_eq!(hints.default_format(), "text");
425
426        let gcc = hints.get("*gcc*").unwrap();
427        assert_eq!(gcc.format, "gcc");
428        assert_eq!(gcc.priority, DEFAULT_PRIORITY);
429
430        let make = hints.get("*make*").unwrap();
431        assert_eq!(make.format, "make");
432        assert_eq!(make.priority, 100);
433    }
434
435    #[test]
436    fn test_detect() {
437        let toml = r#"
438[format-hints]
439"*lint*" = "eslint"
440
441[format-hints.1000]
442"mycompany-*" = "gcc"
443"#;
444        let hints = FormatHints::parse(toml).unwrap();
445
446        // High priority match
447        assert_eq!(hints.detect("mycompany-build"), "gcc");
448
449        // Default priority match
450        assert_eq!(hints.detect("eslint check"), "eslint");
451        assert_eq!(hints.detect("npm run lint"), "eslint");
452
453        // No match -> default
454        assert_eq!(hints.detect("cargo test"), "auto");
455    }
456
457    #[test]
458    fn test_priority_ordering() {
459        let toml = r#"
460[format-hints]
461"*build*" = "generic"
462
463[format-hints.1000]
464"mycompany-build*" = "gcc"
465"#;
466        let hints = FormatHints::parse(toml).unwrap();
467
468        // High priority should win even though both match
469        assert_eq!(hints.detect("mycompany-build main.c"), "gcc");
470
471        // Only generic matches
472        assert_eq!(hints.detect("npm run build"), "generic");
473    }
474
475    #[test]
476    fn test_add_remove() {
477        let mut hints = FormatHints::new();
478
479        hints.add(FormatHint::new("*test*", "pytest"));
480        assert_eq!(hints.hints().len(), 1);
481
482        hints.add(FormatHint::with_priority("*build*", "gcc", 1000));
483        assert_eq!(hints.hints().len(), 2);
484
485        // High priority should be first
486        assert_eq!(hints.hints()[0].pattern, "*build*");
487
488        // Remove
489        assert!(hints.remove("*test*"));
490        assert_eq!(hints.hints().len(), 1);
491        assert!(!hints.remove("*nonexistent*"));
492    }
493
494    #[test]
495    fn test_to_toml() {
496        let mut hints = FormatHints::new();
497        hints.add(FormatHint::new("*lint*", "eslint"));
498        hints.add(FormatHint::with_priority("mycompany-*", "gcc", 1000));
499        hints.add(FormatHint::with_priority("legacy-*", "text", 100));
500
501        let toml = hints.to_toml();
502
503        // Parse it back
504        let parsed = FormatHints::parse(&toml).unwrap();
505        assert_eq!(parsed.hints().len(), 3);
506
507        // Check they're equivalent
508        assert_eq!(parsed.get("*lint*").unwrap().format, "eslint");
509        assert_eq!(parsed.get("mycompany-*").unwrap().priority, 1000);
510        assert_eq!(parsed.get("legacy-*").unwrap().priority, 100);
511    }
512
513    #[test]
514    fn test_pattern_matches() {
515        assert!(pattern_matches("*gcc*", "gcc -o foo foo.c"));
516        assert!(pattern_matches("*gcc*", "/usr/bin/gcc main.c"));
517        assert!(pattern_matches("cargo *", "cargo build"));
518        assert!(pattern_matches("cargo *", "cargo test --release"));
519        assert!(!pattern_matches("cargo *", "rustc main.rs"));
520        assert!(pattern_matches("*", "anything"));
521        assert!(pattern_matches("exact", "exact"));
522        assert!(!pattern_matches("exact", "not exact"));
523    }
524}