robin/src-tauri/src/patterns.rs
pyr0ball 2706b2367d feat(m1): wire classifier loop — pattern file loads on startup, events dispatch to notify
- lib.rs: replaces stub setup with full wiring: loads PatternFile from
  config, extracts log_paths, spawns watcher, runs classifier loop in
  async task, dispatches MatchedEvents via notify::dispatch
- lib.rs: config Mutex lock uses unwrap_or_else(|e| e.into_inner()) to
  recover from poison instead of panicking
- patterns.rs: load() now tries three path candidates in order
  (dev-relative, src-tauri-relative, system) before returning bail!
  Validation loop (match_text, sources) retained inside candidate loop
2026-05-18 18:09:33 -07:00

282 lines
8.3 KiB
Rust

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<String>,
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<String, String>,
#[serde(default)]
pub patterns: Vec<Pattern>,
}
#[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<PatternFile> {
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<MatchedEvent> {
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"));
}
}