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    merged.sort_by(|a, b| a.name.cmp(&b.name));
157
158    // Validate the merged patterns
159    for tool in &merged {
160        if tool.patterns.is_empty() {
161            anyhow::bail!("Tool '{}' has no patterns defined", tool.name);
162        }
163
164        if tool.metadata.confidence < 0.0 || tool.metadata.confidence > 1.0 {
165            anyhow::bail!(
166                "Tool '{}' has invalid confidence score: {}",
167                tool.name,
168                tool.metadata.confidence
169            );
170        }
171    }
172
173    Ok(merged)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_load_patterns_from_str() {
182        let toml = r#"
183[[tools]]
184name = "wrangler"
185patterns = ["npx wrangler", "bunx wrangler"]
186
187[tools.metadata]
188category = "cloudflare"
189description = "Cloudflare Workers CLI"
190confidence = 0.95
191
192[[tools]]
193name = "npm"
194patterns = ["npm "]
195
196[tools.metadata]
197category = "package-manager"
198description = "Node package manager"
199confidence = 0.9
200"#;
201
202        let patterns = load_patterns_from_str(toml).unwrap();
203        assert_eq!(patterns.len(), 2);
204
205        assert_eq!(patterns[0].name, "wrangler");
206        assert_eq!(patterns[0].patterns.len(), 2);
207        assert_eq!(patterns[0].metadata.category, "cloudflare");
208        assert_eq!(patterns[0].metadata.confidence, 0.95);
209
210        assert_eq!(patterns[1].name, "npm");
211        assert_eq!(patterns[1].patterns.len(), 1);
212        assert_eq!(patterns[1].metadata.category, "package-manager");
213    }
214
215    #[test]
216    fn test_default_confidence() {
217        let toml = r#"
218[[tools]]
219name = "test"
220patterns = ["test"]
221
222[tools.metadata]
223category = "test"
224"#;
225
226        let patterns = load_patterns_from_str(toml).unwrap();
227        assert_eq!(patterns[0].metadata.confidence, 0.9);
228    }
229
230    #[test]
231    fn test_empty_patterns_validation() {
232        let toml = r#"
233[[tools]]
234name = "empty"
235patterns = []
236
237[tools.metadata]
238category = "test"
239"#;
240
241        let result = load_patterns_from_str(toml);
242        assert!(result.is_err());
243        assert!(result.unwrap_err().to_string().contains("no patterns"));
244    }
245
246    #[test]
247    fn test_invalid_confidence_validation() {
248        let toml = r#"
249[[tools]]
250name = "invalid"
251patterns = ["test"]
252
253[tools.metadata]
254category = "test"
255confidence = 1.5
256"#;
257
258        let result = load_patterns_from_str(toml);
259        assert!(result.is_err());
260        assert!(
261            result
262                .unwrap_err()
263                .to_string()
264                .contains("invalid confidence")
265        );
266    }
267
268    #[test]
269    fn test_load_built_in_patterns() {
270        // This will test the actual patterns.toml file once created
271        let result = load_patterns();
272        assert!(
273            result.is_ok(),
274            "Failed to load built-in patterns: {:?}",
275            result.err()
276        );
277
278        let patterns = result.unwrap();
279        assert!(
280            !patterns.is_empty(),
281            "Built-in patterns should not be empty"
282        );
283
284        // Verify some expected tools exist
285        let tool_names: Vec<&str> = patterns.iter().map(|p| p.name.as_str()).collect();
286        assert!(
287            tool_names.contains(&"wrangler"),
288            "Expected wrangler pattern"
289        );
290        assert!(tool_names.contains(&"npm"), "Expected npm pattern");
291    }
292
293    #[test]
294    fn test_merge_patterns_unique() {
295        let builtin = vec![
296            ToolPattern {
297                name: "npm".to_string(),
298                patterns: vec!["npm ".to_string()],
299                metadata: ToolMetadata {
300                    category: "package-manager".to_string(),
301                    description: Some("Node package manager".to_string()),
302                    confidence: 0.9,
303                },
304            },
305            ToolPattern {
306                name: "cargo".to_string(),
307                patterns: vec!["cargo ".to_string()],
308                metadata: ToolMetadata {
309                    category: "rust-toolchain".to_string(),
310                    description: Some("Rust package manager".to_string()),
311                    confidence: 0.95,
312                },
313            },
314        ];
315
316        let user = vec![ToolPattern {
317            name: "custom".to_string(),
318            patterns: vec!["custom ".to_string()],
319            metadata: ToolMetadata {
320                category: "custom".to_string(),
321                description: Some("Custom tool".to_string()),
322                confidence: 0.8,
323            },
324        }];
325
326        let merged = merge_patterns(builtin, user).unwrap();
327        assert_eq!(merged.len(), 3);
328
329        let tool_names: Vec<&str> = merged.iter().map(|p| p.name.as_str()).collect();
330        assert!(tool_names.contains(&"npm"));
331        assert!(tool_names.contains(&"cargo"));
332        assert!(tool_names.contains(&"custom"));
333    }
334
335    #[test]
336    fn test_merge_patterns_override() {
337        let builtin = vec![ToolPattern {
338            name: "npm".to_string(),
339            patterns: vec!["npm ".to_string()],
340            metadata: ToolMetadata {
341                category: "package-manager".to_string(),
342                description: Some("Node package manager".to_string()),
343                confidence: 0.9,
344            },
345        }];
346
347        let user = vec![ToolPattern {
348            name: "npm".to_string(),
349            patterns: vec!["npm install".to_string(), "npm run".to_string()],
350            metadata: ToolMetadata {
351                category: "package-manager".to_string(),
352                description: Some("Custom npm config".to_string()),
353                confidence: 0.95,
354            },
355        }];
356
357        let merged = merge_patterns(builtin, user).unwrap();
358        assert_eq!(merged.len(), 1);
359
360        let npm = merged.iter().find(|p| p.name == "npm").unwrap();
361        assert_eq!(npm.patterns.len(), 2);
362        assert_eq!(
363            npm.metadata.description.as_deref(),
364            Some("Custom npm config")
365        );
366        assert_eq!(npm.metadata.confidence, 0.95);
367    }
368
369    #[test]
370    fn test_merge_patterns_validation_fails() {
371        let builtin = vec![];
372
373        let user = vec![ToolPattern {
374            name: "invalid".to_string(),
375            patterns: vec![],
376            metadata: ToolMetadata {
377                category: "test".to_string(),
378                description: None,
379                confidence: 0.9,
380            },
381        }];
382
383        let result = merge_patterns(builtin, user);
384        assert!(result.is_err());
385        assert!(result.unwrap_err().to_string().contains("no patterns"));
386    }
387
388    #[test]
389    fn test_load_user_patterns_no_file() {
390        // This should succeed and return empty vec when no user config exists
391        let result = load_user_patterns();
392        assert!(result.is_ok());
393    }
394
395    #[test]
396    fn test_load_all_patterns() {
397        // Should at minimum load built-in patterns even without user config
398        let result = load_all_patterns();
399        assert!(result.is_ok());
400
401        let patterns = result.unwrap();
402        assert!(
403            !patterns.is_empty(),
404            "Should have at least built-in patterns"
405        );
406    }
407}