diff --git a/src-tauri/src/patterns.rs b/src-tauri/src/patterns.rs index 31032d1..13b27c5 100644 --- a/src-tauri/src/patterns.rs +++ b/src-tauri/src/patterns.rs @@ -1,3 +1,4 @@ +use crate::watcher::{EventSource, SystemEvent}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -36,16 +37,34 @@ pub struct MatchedEvent { pub timestamp: u64, } +/// Load the pattern file for a source OS and distro family. +/// +/// Path is relative to the working directory. In tests this is the crate root +/// (where `patterns/` lives as a sibling of `src/`). At runtime, Task 10 resolves +/// the path via `tauri::path::BaseDirectory::Resource` before calling this function. pub fn load(source_os: &str, distro_family: &str) -> Result { - let resource_path = format!("patterns/{}-to-{}.toml", source_os, distro_family); + let resource_path = format!("patterns/{source_os}-to-{distro_family}.toml"); let content = std::fs::read_to_string(&resource_path) .with_context(|| format!("pattern file not found: {resource_path}"))?; - toml::from_str(&content).with_context(|| format!("failed to parse {resource_path}")) + let pf: PatternFile = + toml::from_str(&content).with_context(|| format!("failed to parse {resource_path}"))?; + for p in &pf.patterns { + anyhow::ensure!( + !p.match_text.is_empty(), + "pattern '{}' has empty match_text in {resource_path}", + p.id + ); + anyhow::ensure!( + !p.sources.is_empty(), + "pattern '{}' has empty sources list in {resource_path}", + p.id + ); + } + Ok(pf) } -pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option { - use crate::watcher::EventSource; - +#[must_use] +pub fn classify(event: &SystemEvent, pf: &PatternFile) -> Option { for pattern in &pf.patterns { if !event.raw_line.contains(&pattern.match_text) { continue; @@ -58,7 +77,7 @@ pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option match &event.source { EventSource::Journald => s == "journald", EventSource::Kmsg => s == "kmsg", - EventSource::AppLog(app) => s == &format!("applog:{app}"), + EventSource::AppLog { app } => s == &format!("applog:{app}"), } }); @@ -78,6 +97,7 @@ pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option #[cfg(test)] mod tests { use super::*; + use crate::watcher::{EventSource, SystemEvent}; const FIXTURE: &str = r#" [meta] @@ -102,13 +122,29 @@ 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(), 2); + assert_eq!(pf.patterns.len(), 3); assert_eq!( pf.log_paths.get("steam").unwrap(), "~/.local/share/Steam/logs/content_log.txt" @@ -117,13 +153,8 @@ body = "Steam Proton couldn't find a required file." #[test] fn classify_matches_journald_event() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::Journald, - raw_line: ":: error: failed to build (foo-git)".into(), - timestamp: 0, - }; + 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"); @@ -131,38 +162,113 @@ body = "Steam Proton couldn't find a required file." #[test] fn classify_no_match_returns_none() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::Journald, - raw_line: "systemd: Started NetworkManager.service".into(), - timestamp: 0, - }; + let event = make_event( + EventSource::Journald, + "systemd: Started NetworkManager.service", + ); assert!(classify(&event, &pf).is_none()); } #[test] fn classify_applog_matches_correct_source() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::AppLog("steam".into()), - raw_line: "wine: cannot find /run/media/user/game.exe".into(), - timestamp: 0, - }; + 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() { - use crate::watcher::{EventSource, SystemEvent}; let pf: PatternFile = toml::from_str(FIXTURE).unwrap(); - let event = SystemEvent { - source: EventSource::Journald, - raw_line: "wine: cannot find something".into(), - timestamp: 0, - }; + // 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")); + } } diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs index 88d72e0..9512fde 100644 --- a/src-tauri/src/watcher.rs +++ b/src-tauri/src/watcher.rs @@ -12,14 +12,15 @@ pub enum EventSeverity { Crit, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum EventSource { Journald, Kmsg, - AppLog(String), + AppLog { app: String }, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct SystemEvent { pub source: EventSource, pub raw_line: String,