terraphim_session_analyzer/patterns/
loader.rs1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ToolPattern {
12 pub name: String,
14
15 pub patterns: Vec<String>,
17
18 pub metadata: ToolMetadata,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ToolMetadata {
25 pub category: String,
27
28 pub description: Option<String>,
30
31 #[serde(default = "default_confidence")]
33 pub confidence: f32,
34}
35
36fn default_confidence() -> f32 {
37 0.9
38}
39
40#[derive(Debug, Deserialize)]
42struct ToolPatternsConfig {
43 tools: Vec<ToolPattern>,
44}
45
46pub fn load_patterns() -> Result<Vec<ToolPattern>> {
52 let toml_content = include_str!("../patterns.toml");
53 load_patterns_from_str(toml_content)
54}
55
56pub 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
68pub 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 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
95pub 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
114pub 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
128fn merge_patterns(builtin: Vec<ToolPattern>, user: Vec<ToolPattern>) -> Result<Vec<ToolPattern>> {
137 use std::collections::HashMap;
138
139 let mut pattern_map: HashMap<String, ToolPattern> = HashMap::new();
141
142 for pattern in builtin {
144 pattern_map.insert(pattern.name.clone(), pattern);
145 }
146
147 for pattern in user {
149 pattern_map.insert(pattern.name.clone(), pattern);
150 }
151
152 let mut merged: Vec<ToolPattern> = pattern_map.into_values().collect();
154
155 #[allow(clippy::unnecessary_sort_by)]
157 merged.sort_by(|a, b| a.name.cmp(&b.name));
158
159 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 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 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 let result = load_user_patterns();
393 assert!(result.is_ok());
394 }
395
396 #[test]
397 fn test_load_all_patterns() {
398 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}