use crate::watcher::{EventSource, SystemEvent}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; #[derive(Debug, Clone, Deserialize)] pub struct PatternMeta { pub source_os: String, pub target_distro_family: String, } #[derive(Debug, Clone, Deserialize)] pub struct Pattern { pub id: String, pub sources: Vec, pub match_text: String, pub severity: String, pub title: String, pub body: String, } #[derive(Debug, Clone, Deserialize)] pub struct PatternFile { pub meta: PatternMeta, #[serde(default)] pub log_paths: HashMap, #[serde(default)] pub patterns: Vec, } #[derive(Debug, Clone, Serialize)] pub struct MatchedEvent { pub pattern_id: String, pub title: String, pub body: String, pub severity: String, pub timestamp: u64, } /// Load the pattern file for a source OS and distro family. /// /// Tries candidates in order: dev-relative path, src-tauri-relative path, /// system path. The Tauri resource directory is the authoritative location /// at runtime; passing a base path is handled in Task 10's caller via candidate list. pub fn load(source_os: &str, distro_family: &str) -> Result { let filename = format!("{source_os}-to-{distro_family}.toml"); let candidates = [ format!("patterns/{filename}"), format!("src-tauri/patterns/{filename}"), format!("/usr/share/robin/patterns/{filename}"), ]; for path in &candidates { if let Ok(content) = std::fs::read_to_string(path) { let pf: PatternFile = toml::from_str(&content).with_context(|| format!("failed to parse {path}"))?; for p in &pf.patterns { anyhow::ensure!( !p.match_text.is_empty(), "pattern '{}' has empty match_text in {path}", p.id ); anyhow::ensure!( !p.sources.is_empty(), "pattern '{}' has empty sources list in {path}", p.id ); } return Ok(pf); } } anyhow::bail!("pattern file not found: {filename}") } #[must_use] pub fn classify(event: &SystemEvent, pf: &PatternFile) -> Option { for pattern in &pf.patterns { if !event.raw_line.contains(&pattern.match_text) { continue; } let source_matches = pattern.sources.iter().any(|s| { if s == "any" { return true; } match &event.source { EventSource::Journald => s == "journald", EventSource::Kmsg => s == "kmsg", EventSource::AppLog { app } => s == &format!("applog:{app}"), } }); if source_matches { return Some(MatchedEvent { pattern_id: pattern.id.clone(), title: pattern.title.clone(), body: pattern.body.clone(), severity: pattern.severity.clone(), timestamp: event.timestamp, }); } } None } #[cfg(test)] mod tests { use super::*; use crate::watcher::{EventSource, SystemEvent}; const FIXTURE: &str = r#" [meta] source_os = "macos" target_distro_family = "arch" [log_paths] steam = "~/.local/share/Steam/logs/content_log.txt" [[patterns]] id = "aur-build-failure" sources = ["journald"] match_text = "error: failed to build" severity = "warn" title = "AUR package build failed" body = "A package failed to compile from source." [[patterns]] id = "proton-runtime" sources = ["applog:steam"] match_text = "wine: cannot find" severity = "warn" title = "Proton runtime issue" body = "Steam Proton couldn't find a required file." [[patterns]] id = "any-source-pattern" sources = ["any"] match_text = "kernel panic" severity = "crit" title = "Kernel panic" body = "The kernel encountered a fatal error." "#; fn make_event(source: EventSource, raw_line: &str) -> SystemEvent { SystemEvent { source, raw_line: raw_line.into(), timestamp: 0, } } #[test] fn loads_pattern_file_from_toml() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); assert_eq!(pf.meta.source_os, "macos"); assert_eq!(pf.patterns.len(), 3); assert_eq!( pf.log_paths.get("steam").unwrap(), "~/.local/share/Steam/logs/content_log.txt" ); } #[test] fn classify_matches_journald_event() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); let event = make_event(EventSource::Journald, ":: error: failed to build (foo-git)"); let matched = classify(&event, &pf).unwrap(); assert_eq!(matched.pattern_id, "aur-build-failure"); assert_eq!(matched.title, "AUR package build failed"); } #[test] fn classify_no_match_returns_none() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); let event = make_event( EventSource::Journald, "systemd: Started NetworkManager.service", ); assert!(classify(&event, &pf).is_none()); } #[test] fn classify_applog_matches_correct_source() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); let event = make_event( EventSource::AppLog { app: "steam".into(), }, "wine: cannot find /run/media/user/game.exe", ); let matched = classify(&event, &pf).unwrap(); assert_eq!(matched.pattern_id, "proton-runtime"); } #[test] fn classify_does_not_match_wrong_source() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); // proton pattern is applog:steam — journald event must not match let event = make_event(EventSource::Journald, "wine: cannot find something"); assert!(classify(&event, &pf).is_none()); } #[test] fn classify_applog_does_not_match_different_app() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); let event = make_event( EventSource::AppLog { app: "retroarch".into(), }, "wine: cannot find libvulkan.so", ); assert!(classify(&event, &pf).is_none()); } #[test] fn classify_any_source_matches_kmsg() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); let event = make_event(EventSource::Kmsg, "kernel panic - not syncing: VFS"); let matched = classify(&event, &pf).unwrap(); assert_eq!(matched.pattern_id, "any-source-pattern"); } #[test] fn classify_any_source_matches_journald() { let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); let event = make_event(EventSource::Journald, "kernel panic - not syncing: VFS"); let matched = classify(&event, &pf).unwrap(); assert_eq!(matched.pattern_id, "any-source-pattern"); } #[test] fn load_rejects_empty_match_text() { let bad = r#" [meta] source_os = "macos" target_distro_family = "arch" [[patterns]] id = "bad" sources = ["journald"] match_text = "" severity = "warn" title = "Bad" body = "Bad pattern." "#; let pf: PatternFile = toml::from_str(bad).unwrap(); // Validate manually since load() reads from filesystem; test the invariant directly let result: anyhow::Result<()> = (|| { for p in &pf.patterns { anyhow::ensure!(!p.match_text.is_empty(), "empty match_text in '{}'", p.id); } Ok(()) })(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("empty match_text")); } #[test] fn load_rejects_empty_sources() { let bad = r#" [meta] source_os = "macos" target_distro_family = "arch" [[patterns]] id = "bad" sources = [] match_text = "something" severity = "warn" title = "Bad" body = "Bad pattern." "#; let pf: PatternFile = toml::from_str(bad).unwrap(); let result: anyhow::Result<()> = (|| { for p in &pf.patterns { anyhow::ensure!(!p.sources.is_empty(), "empty sources in '{}'", p.id); } Ok(()) })(); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("empty sources")); } }