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!(result
261            .unwrap_err()
262            .to_string()
263            .contains("invalid confidence"));
264    }
265
266    #[test]
267    fn test_load_built_in_patterns() {
268        // This will test the actual patterns.toml file once created
269        let result = load_patterns();
270        assert!(
271            result.is_ok(),
272            "Failed to load built-in patterns: {:?}",
273            result.err()
274        );
275
276        let patterns = result.unwrap();
277        assert!(
278            !patterns.is_empty(),
279            "Built-in patterns should not be empty"
280        );
281
282        // Verify some expected tools exist
283        let tool_names: Vec<&str> = patterns.iter().map(|p| p.name.as_str()).collect();
284        assert!(
285            tool_names.contains(&"wrangler"),
286            "Expected wrangler pattern"
287        );
288        assert!(tool_names.contains(&"npm"), "Expected npm pattern");
289    }
290
291    #[test]
292    fn test_merge_patterns_unique() {
293        let builtin = vec![
294            ToolPattern {
295                name: "npm".to_string(),
296                patterns: vec!["npm ".to_string()],
297                metadata: ToolMetadata {
298                    category: "package-manager".to_string(),
299                    description: Some("Node package manager".to_string()),
300                    confidence: 0.9,
301                },
302            },
303            ToolPattern {
304                name: "cargo".to_string(),
305                patterns: vec!["cargo ".to_string()],
306                metadata: ToolMetadata {
307                    category: "rust-toolchain".to_string(),
308                    description: Some("Rust package manager".to_string()),
309                    confidence: 0.95,
310                },
311            },
312        ];
313
314        let user = vec![ToolPattern {
315            name: "custom".to_string(),
316            patterns: vec!["custom ".to_string()],
317            metadata: ToolMetadata {
318                category: "custom".to_string(),
319                description: Some("Custom tool".to_string()),
320                confidence: 0.8,
321            },
322        }];
323
324        let merged = merge_patterns(builtin, user).unwrap();
325        assert_eq!(merged.len(), 3);
326
327        let tool_names: Vec<&str> = merged.iter().map(|p| p.name.as_str()).collect();
328        assert!(tool_names.contains(&"npm"));
329        assert!(tool_names.contains(&"cargo"));
330        assert!(tool_names.contains(&"custom"));
331    }
332
333    #[test]
334    fn test_merge_patterns_override() {
335        let builtin = vec![ToolPattern {
336            name: "npm".to_string(),
337            patterns: vec!["npm ".to_string()],
338            metadata: ToolMetadata {
339                category: "package-manager".to_string(),
340                description: Some("Node package manager".to_string()),
341                confidence: 0.9,
342            },
343        }];
344
345        let user = vec![ToolPattern {
346            name: "npm".to_string(),
347            patterns: vec!["npm install".to_string(), "npm run".to_string()],
348            metadata: ToolMetadata {
349                category: "package-manager".to_string(),
350                description: Some("Custom npm config".to_string()),
351                confidence: 0.95,
352            },
353        }];
354
355        let merged = merge_patterns(builtin, user).unwrap();
356        assert_eq!(merged.len(), 1);
357
358        let npm = merged.iter().find(|p| p.name == "npm").unwrap();
359        assert_eq!(npm.patterns.len(), 2);
360        assert_eq!(
361            npm.metadata.description.as_deref(),
362            Some("Custom npm config")
363        );
364        assert_eq!(npm.metadata.confidence, 0.95);
365    }
366
367    #[test]
368    fn test_merge_patterns_validation_fails() {
369        let builtin = vec![];
370
371        let user = vec![ToolPattern {
372            name: "invalid".to_string(),
373            patterns: vec![],
374            metadata: ToolMetadata {
375                category: "test".to_string(),
376                description: None,
377                confidence: 0.9,
378            },
379        }];
380
381        let result = merge_patterns(builtin, user);
382        assert!(result.is_err());
383        assert!(result.unwrap_err().to_string().contains("no patterns"));
384    }
385
386    #[test]
387    fn test_load_user_patterns_no_file() {
388        // This should succeed and return empty vec when no user config exists
389        let result = load_user_patterns();
390        assert!(result.is_ok());
391    }
392
393    #[test]
394    fn test_load_all_patterns() {
395        // Should at minimum load built-in patterns even without user config
396        let result = load_all_patterns();
397        assert!(result.is_ok());
398
399        let patterns = result.unwrap();
400        assert!(
401            !patterns.is_empty(),
402            "Should have at least built-in patterns"
403        );
404    }
405}