Skip to main content

terraphim_session_analyzer/patterns/
loader.rs

1//! Pattern loader from TOML configuration
2//!
3//! This module handles loading tool patterns from TOML files.
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9/// Tool pattern configuration
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ToolPattern {
12    /// Unique name of the tool
13    pub name: String,
14
15    /// List of patterns to match (e.g., "npx wrangler", "bunx wrangler")
16    pub patterns: Vec<String>,
17
18    /// Metadata about the tool
19    pub metadata: ToolMetadata,
20}
21
22/// Metadata associated with a tool
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ToolMetadata {
25    /// Category of the tool (e.g., "cloudflare", "package-manager")
26    pub category: String,
27
28    /// Human-readable description
29    pub description: Option<String>,
30
31    /// Confidence score (0.0 - 1.0) for pattern matches
32    #[serde(default = "default_confidence")]
33    pub confidence: f32,
34}
35
36fn default_confidence() -> f32 {
37    0.9
38}
39
40/// Container for TOML file structure
41#[derive(Debug, Deserialize)]
42struct ToolPatternsConfig {
43    tools: Vec<ToolPattern>,
44}
45
46/// Load patterns from built-in TOML configuration
47///
48/// # Errors
49///
50/// Returns an error if the built-in patterns cannot be parsed
51pub fn load_patterns() -> Result<Vec<ToolPattern>> {
52    let toml_content = include_str!("../patterns.toml");
53    load_patterns_from_str(toml_content)
54}
55
56/// Load patterns from a custom TOML file
57///
58/// # Errors
59///
60/// Returns an error if the file cannot be read or parsed
61pub fn load_patterns_from_file<P: AsRef<Path>>(path: P) -> Result<Vec<ToolPattern>> {
62    let content = std::fs::read_to_string(path.as_ref())
63        .with_context(|| format!("Failed to read patterns from {}", path.as_ref().display()))?;
64
65    load_patterns_from_str(&content)
66}
67
68/// Load patterns from a TOML string
69///
70/// # Errors
71///
72/// Returns an error if the TOML cannot be parsed
73pub fn load_patterns_from_str(toml_str: &str) -> Result<Vec<ToolPattern>> {
74    let config: ToolPatternsConfig =
75        toml::from_str(toml_str).context("Failed to parse tool patterns TOML")?;
76
77    // Validate patterns
78    for tool in &config.tools {
79        if tool.patterns.is_empty() {
80            anyhow::bail!("Tool '{}' has no patterns defined", tool.name);
81        }
82
83        if tool.metadata.confidence < 0.0 || tool.metadata.confidence > 1.0 {
84            anyhow::bail!(
85                "Tool '{}' has invalid confidence score: {}",
86                tool.name,
87                tool.metadata.confidence
88            );
89        }
90    }
91
92    Ok(config.tools)
93}
94
95/// Load user-defined patterns from config file
96///
97/// # Errors
98///
99/// Returns an error if the config file exists but cannot be read or parsed
100pub fn load_user_patterns() -> Result<Vec<ToolPattern>> {
101    let home = home::home_dir().context("No home directory")?;
102    let config_path = home
103        .join(".config")
104        .join("claude-log-analyzer")
105        .join("tools.toml");
106
107    if !config_path.exists() {
108        return Ok(Vec::new());
109    }
110
111    load_patterns_from_file(config_path)
112}
113
114/// Load and merge built-in + user patterns
115///
116/// User patterns with the same name as built-in patterns will override them.
117///
118/// # Errors
119///
120/// Returns an error if patterns cannot be loaded or merged
121pub fn load_all_patterns() -> Result<Vec<ToolPattern>> {
122    let builtin = load_patterns()?;
123    let user = load_user_patterns()?;
124
125    merge_patterns(builtin, user)
126}
127
128/// Merge built-in and user patterns
129///
130/// User patterns override built-in patterns with the same name.
131/// All unique patterns are preserved.
132///
133/// # Errors
134///
135/// Returns an error if pattern validation fails
136fn merge_patterns(builtin: Vec<ToolPattern>, user: Vec<ToolPattern>) -> Result<Vec<ToolPattern>> {
137    use std::collections::HashMap;
138
139    // Create a map of tool name -> pattern
140    let mut pattern_map: HashMap<String, ToolPattern> = HashMap::new();
141
142    // Add built-in patterns first
143    for pattern in builtin {
144        pattern_map.insert(pattern.name.clone(), pattern);
145    }
146
147    // User patterns override built-in with same name
148    for pattern in user {
149        pattern_map.insert(pattern.name.clone(), pattern);
150    }
151
152    // Convert back to vector and validate
153    let mut merged: Vec<ToolPattern> = pattern_map.into_values().collect();
154
155    // Sort by name for consistent ordering
156    #[allow(clippy::unnecessary_sort_by)]
157    merged.sort_by(|a, b| a.name.cmp(&b.name));
158
159    // Validate the merged patterns
160    for tool in &merged {
161        if tool.patterns.is_empty() {
162            anyhow::bail!("Tool '{}' has no patterns defined", tool.name);
163        }
164
165        if tool.metadata.confidence < 0.0 || tool.metadata.confidence > 1.0 {
166            anyhow::bail!(
167                "Tool '{}' has invalid confidence score: {}",
168                tool.name,
169                tool.metadata.confidence
170            );
171        }
172    }
173
174    Ok(merged)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_load_patterns_from_str() {
183        let toml = r#"
184[[tools]]
185name = "wrangler"
186patterns = ["npx wrangler", "bunx wrangler"]
187
188[tools.metadata]
189category = "cloudflare"
190description = "Cloudflare Workers CLI"
191confidence = 0.95
192
193[[tools]]
194name = "npm"
195patterns = ["npm "]
196
197[tools.metadata]
198category = "package-manager"
199description = "Node package manager"
200confidence = 0.9
201"#;
202
203        let patterns = load_patterns_from_str(toml).unwrap();
204        assert_eq!(patterns.len(), 2);
205
206        assert_eq!(patterns[0].name, "wrangler");
207        assert_eq!(patterns[0].patterns.len(), 2);
208        assert_eq!(patterns[0].metadata.category, "cloudflare");
209        assert_eq!(patterns[0].metadata.confidence, 0.95);
210
211        assert_eq!(patterns[1].name, "npm");
212        assert_eq!(patterns[1].patterns.len(), 1);
213        assert_eq!(patterns[1].metadata.category, "package-manager");
214    }
215
216    #[test]
217    fn test_default_confidence() {
218        let toml = r#"
219[[tools]]
220name = "test"
221patterns = ["test"]
222
223[tools.metadata]
224category = "test"
225"#;
226
227        let patterns = load_patterns_from_str(toml).unwrap();
228        assert_eq!(patterns[0].metadata.confidence, 0.9);
229    }
230
231    #[test]
232    fn test_empty_patterns_validation() {
233        let toml = r#"
234[[tools]]
235name = "empty"
236patterns = []
237
238[tools.metadata]
239category = "test"
240"#;
241
242        let result = load_patterns_from_str(toml);
243        assert!(result.is_err());
244        assert!(result.unwrap_err().to_string().contains("no patterns"));
245    }
246
247    #[test]
248    fn test_invalid_confidence_validation() {
249        let toml = r#"
250[[tools]]
251name = "invalid"
252patterns = ["test"]
253
254[tools.metadata]
255category = "test"
256confidence = 1.5
257"#;
258
259        let result = load_patterns_from_str(toml);
260        assert!(result.is_err());
261        assert!(
262            result
263                .unwrap_err()
264                .to_string()
265                .contains("invalid confidence")
266        );
267    }
268
269    #[test]
270    fn test_load_built_in_patterns() {
271        // This will test the actual patterns.toml file once created
272        let result = load_patterns();
273        assert!(
274            result.is_ok(),
275            "Failed to load built-in patterns: {:?}",
276            result.err()
277        );
278
279        let patterns = result.unwrap();
280        assert!(
281            !patterns.is_empty(),
282            "Built-in patterns should not be empty"
283        );
284
285        // Verify some expected tools exist
286        let tool_names: Vec<&str> = patterns.iter().map(|p| p.name.as_str()).collect();
287        assert!(
288            tool_names.contains(&"wrangler"),
289            "Expected wrangler pattern"
290        );
291        assert!(tool_names.contains(&"npm"), "Expected npm pattern");
292    }
293
294    #[test]
295    fn test_merge_patterns_unique() {
296        let builtin = vec![
297            ToolPattern {
298                name: "npm".to_string(),
299                patterns: vec!["npm ".to_string()],
300                metadata: ToolMetadata {
301                    category: "package-manager".to_string(),
302                    description: Some("Node package manager".to_string()),
303                    confidence: 0.9,
304                },
305            },
306            ToolPattern {
307                name: "cargo".to_string(),
308                patterns: vec!["cargo ".to_string()],
309                metadata: ToolMetadata {
310                    category: "rust-toolchain".to_string(),
311                    description: Some("Rust package manager".to_string()),
312                    confidence: 0.95,
313                },
314            },
315        ];
316
317        let user = vec![ToolPattern {
318            name: "custom".to_string(),
319            patterns: vec!["custom ".to_string()],
320            metadata: ToolMetadata {
321                category: "custom".to_string(),
322                description: Some("Custom tool".to_string()),
323                confidence: 0.8,
324            },
325        }];
326
327        let merged = merge_patterns(builtin, user).unwrap();
328        assert_eq!(merged.len(), 3);
329
330        let tool_names: Vec<&str> = merged.iter().map(|p| p.name.as_str()).collect();
331        assert!(tool_names.contains(&"npm"));
332        assert!(tool_names.contains(&"cargo"));
333        assert!(tool_names.contains(&"custom"));
334    }
335
336    #[test]
337    fn test_merge_patterns_override() {
338        let builtin = vec![ToolPattern {
339            name: "npm".to_string(),
340            patterns: vec!["npm ".to_string()],
341            metadata: ToolMetadata {
342                category: "package-manager".to_string(),
343                description: Some("Node package manager".to_string()),
344                confidence: 0.9,
345            },
346        }];
347
348        let user = vec![ToolPattern {
349            name: "npm".to_string(),
350            patterns: vec!["npm install".to_string(), "npm run".to_string()],
351            metadata: ToolMetadata {
352                category: "package-manager".to_string(),
353                description: Some("Custom npm config".to_string()),
354                confidence: 0.95,
355            },
356        }];
357
358        let merged = merge_patterns(builtin, user).unwrap();
359        assert_eq!(merged.len(), 1);
360
361        let npm = merged.iter().find(|p| p.name == "npm").unwrap();
362        assert_eq!(npm.patterns.len(), 2);
363        assert_eq!(
364            npm.metadata.description.as_deref(),
365            Some("Custom npm config")
366        );
367        assert_eq!(npm.metadata.confidence, 0.95);
368    }
369
370    #[test]
371    fn test_merge_patterns_validation_fails() {
372        let builtin = vec![];
373
374        let user = vec![ToolPattern {
375            name: "invalid".to_string(),
376            patterns: vec![],
377            metadata: ToolMetadata {
378                category: "test".to_string(),
379                description: None,
380                confidence: 0.9,
381            },
382        }];
383
384        let result = merge_patterns(builtin, user);
385        assert!(result.is_err());
386        assert!(result.unwrap_err().to_string().contains("no patterns"));
387    }
388
389    #[test]
390    fn test_load_user_patterns_no_file() {
391        // This should succeed and return empty vec when no user config exists
392        let result = load_user_patterns();
393        assert!(result.is_ok());
394    }
395
396    #[test]
397    fn test_load_all_patterns() {
398        // Should at minimum load built-in patterns even without user config
399        let result = load_all_patterns();
400        assert!(result.is_ok());
401
402        let patterns = result.unwrap();
403        assert!(
404            !patterns.is_empty(),
405            "Should have at least built-in patterns"
406        );
407    }
408}