1use std::collections::HashMap;
23use std::fs;
24use std::path::Path;
25
26use serde::{Deserialize, Serialize};
27
28use crate::{Error, Result};
29
30pub const DEFAULT_PRIORITY: i32 = 500;
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct FormatHint {
36 pub pattern: String,
38 pub format: String,
40 #[serde(default = "default_priority")]
42 pub priority: i32,
43}
44
45fn default_priority() -> i32 {
46 DEFAULT_PRIORITY
47}
48
49impl FormatHint {
50 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 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#[derive(Debug, Clone, Default)]
71pub struct FormatHints {
72 hints: Vec<FormatHint>,
74 default_format: String,
76}
77
78impl FormatHints {
79 pub fn new() -> Self {
81 Self {
82 hints: Vec::new(),
83 default_format: "auto".to_string(),
84 }
85 }
86
87 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 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 if let Some(format_hints) = value.get("format-hints").and_then(|v| v.as_table()) {
107 for (key, val) in format_hints {
108 if let Ok(priority) = key.parse::<i32>() {
110 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 if let Some(s) = val.as_str() {
120 default_format = s.to_string();
121 }
122 } else {
123 let hint = parse_hint_value(key, val, None)?;
125 hints.push(hint);
126 }
127 }
128 }
129
130 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 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 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 pub fn save(&self, path: &Path) -> Result<()> {
164 let contents = self.to_toml();
165 fs::write(path, contents)?;
166 Ok(())
167 }
168
169 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 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 let mut priorities: Vec<_> = by_priority.keys().copied().collect();
183 priorities.sort_by(|a, b| b.cmp(a));
184
185 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 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 pub fn hints(&self) -> &[FormatHint] {
220 &self.hints
221 }
222
223 pub fn default_format(&self) -> &str {
225 &self.default_format
226 }
227
228 pub fn set_default_format(&mut self, format: impl Into<String>) {
230 self.default_format = format.into();
231 }
232
233 pub fn add(&mut self, hint: FormatHint) {
235 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 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 pub fn get(&self, pattern: &str) -> Option<&FormatHint> {
252 self.hints.iter().find(|h| h.pattern == pattern)
253 }
254
255 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
267fn 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
294pub 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 if !parts[0].is_empty() && !text.starts_with(parts[0]) {
305 return false;
306 }
307 let mut pos = parts[0].len();
308
309 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 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
329pub 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 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 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 assert_eq!(hints.detect("mycompany-build"), "gcc");
448
449 assert_eq!(hints.detect("eslint check"), "eslint");
451 assert_eq!(hints.detect("npm run lint"), "eslint");
452
453 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 assert_eq!(hints.detect("mycompany-build main.c"), "gcc");
470
471 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 assert_eq!(hints.hints()[0].pattern, "*build*");
487
488 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 let parsed = FormatHints::parse(&toml).unwrap();
505 assert_eq!(parsed.hints().len(), 3);
506
507 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}