use crate::watcher::{EventSource, SystemEvent}; use anyhow::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. /// /// For Linux-to-Linux migrations, `source_distro_family` (e.g. "debian", "fedora") is /// tried first: `debian-to-arch.toml` before the generic `linux-to-arch.toml` fallback. /// Tries each candidate at three path depths: dev-relative, src-tauri-relative, system. pub fn load( source_os: &str, source_distro_family: Option<&str>, distro_family: &str, ) -> Result { let mut candidates: Vec = Vec::new(); // Helper: push paths for a given filename into candidates. let mut push_candidates = |filename: &str| { // 1. Relative to binary: covers both bundled installs (patterns/ next to binary) // and dev builds (target/debug/ → ../../patterns/ = src-tauri/patterns/). if let Ok(exe) = std::env::current_exe() { if let Some(exe_dir) = exe.parent() { candidates.push(exe_dir.join("patterns").join(filename).display().to_string()); candidates.push(exe_dir.join("../../patterns").join(filename).display().to_string()); } } // 2. CWD-relative fallbacks (for manual / script invocations). candidates.push(format!("patterns/{filename}")); candidates.push(format!("src-tauri/patterns/{filename}")); // 3. System install path. candidates.push(format!("/usr/share/robin/patterns/{filename}")); }; if let Some(src_distro) = source_distro_family { push_candidates(&format!("{src_distro}-to-{distro_family}.toml")); } let generic = format!("{source_os}-to-{distro_family}.toml"); push_candidates(&generic); for path in &candidates { let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(_) => continue, }; let pf: PatternFile = match toml::from_str(&content) { Ok(p) => p, Err(e) => { log::warn!("patterns: failed to parse {path}: {e}"); continue; } }; 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 for {source_os}-to-{distro_family}.toml") } impl PatternFile { /// Merge patterns and log_paths from `other` into this file. /// Used to layer a dual-boot supplement on top of the primary pattern file. pub fn extend(&mut self, other: PatternFile) { self.patterns.extend(other.patterns); for (k, v) in other.log_paths { self.log_paths.entry(k).or_insert(v); } } } /// Load a supplementary pattern file by name (e.g. "windows" loads `dualboot-windows.toml`). /// Supplement files cover coexistence-specific issues and are merged into the primary file. pub fn load_supplement(name: &str) -> Result { let filename = format!("dualboot-{name}.toml"); let candidates = [ format!("patterns/{filename}"), format!("src-tauri/patterns/{filename}"), format!("/usr/share/robin/patterns/{filename}"), ]; for path in &candidates { let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(_) => continue, }; match toml::from_str::(&content) { Ok(pf) => return Ok(pf), Err(e) => { log::warn!("patterns: failed to parse supplement {path}: {e}"); continue; } } } anyhow::bail!("supplement 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")); } }