fix(m1): validate patterns at load, add Serialize to EventSource, expand tests
- load() now rejects patterns with empty match_text or empty sources list
- EventSource derives Serialize/Deserialize with serde tag for emit() readiness
- AppLog variant changed to struct form (AppLog { app }) for tagged enum compat
- classify() takes &SystemEvent directly (top-level use import, not per-fn)
- #[must_use] on classify()
- 5 new tests: any-source wildcard (journald+kmsg), applog mismatch, empty-field validation
This commit is contained in:
parent
1e733a062b
commit
6acf085c0f
2 changed files with 141 additions and 34 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
use crate::watcher::{EventSource, SystemEvent};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
@ -36,16 +37,34 @@ pub struct MatchedEvent {
|
||||||
pub timestamp: u64,
|
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<PatternFile> {
|
pub fn load(source_os: &str, distro_family: &str) -> Result<PatternFile> {
|
||||||
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)
|
let content = std::fs::read_to_string(&resource_path)
|
||||||
.with_context(|| format!("pattern file not found: {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<MatchedEvent> {
|
#[must_use]
|
||||||
use crate::watcher::EventSource;
|
pub fn classify(event: &SystemEvent, pf: &PatternFile) -> Option<MatchedEvent> {
|
||||||
|
|
||||||
for pattern in &pf.patterns {
|
for pattern in &pf.patterns {
|
||||||
if !event.raw_line.contains(&pattern.match_text) {
|
if !event.raw_line.contains(&pattern.match_text) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -58,7 +77,7 @@ pub fn classify(event: &crate::watcher::SystemEvent, pf: &PatternFile) -> Option
|
||||||
match &event.source {
|
match &event.source {
|
||||||
EventSource::Journald => s == "journald",
|
EventSource::Journald => s == "journald",
|
||||||
EventSource::Kmsg => s == "kmsg",
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::watcher::{EventSource, SystemEvent};
|
||||||
|
|
||||||
const FIXTURE: &str = r#"
|
const FIXTURE: &str = r#"
|
||||||
[meta]
|
[meta]
|
||||||
|
|
@ -102,13 +122,29 @@ match_text = "wine: cannot find"
|
||||||
severity = "warn"
|
severity = "warn"
|
||||||
title = "Proton runtime issue"
|
title = "Proton runtime issue"
|
||||||
body = "Steam Proton couldn't find a required file."
|
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]
|
#[test]
|
||||||
fn loads_pattern_file_from_toml() {
|
fn loads_pattern_file_from_toml() {
|
||||||
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
assert_eq!(pf.meta.source_os, "macos");
|
assert_eq!(pf.meta.source_os, "macos");
|
||||||
assert_eq!(pf.patterns.len(), 2);
|
assert_eq!(pf.patterns.len(), 3);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pf.log_paths.get("steam").unwrap(),
|
pf.log_paths.get("steam").unwrap(),
|
||||||
"~/.local/share/Steam/logs/content_log.txt"
|
"~/.local/share/Steam/logs/content_log.txt"
|
||||||
|
|
@ -117,13 +153,8 @@ body = "Steam Proton couldn't find a required file."
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_matches_journald_event() {
|
fn classify_matches_journald_event() {
|
||||||
use crate::watcher::{EventSource, SystemEvent};
|
|
||||||
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
let event = SystemEvent {
|
let event = make_event(EventSource::Journald, ":: error: failed to build (foo-git)");
|
||||||
source: EventSource::Journald,
|
|
||||||
raw_line: ":: error: failed to build (foo-git)".into(),
|
|
||||||
timestamp: 0,
|
|
||||||
};
|
|
||||||
let matched = classify(&event, &pf).unwrap();
|
let matched = classify(&event, &pf).unwrap();
|
||||||
assert_eq!(matched.pattern_id, "aur-build-failure");
|
assert_eq!(matched.pattern_id, "aur-build-failure");
|
||||||
assert_eq!(matched.title, "AUR package build failed");
|
assert_eq!(matched.title, "AUR package build failed");
|
||||||
|
|
@ -131,38 +162,113 @@ body = "Steam Proton couldn't find a required file."
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_no_match_returns_none() {
|
fn classify_no_match_returns_none() {
|
||||||
use crate::watcher::{EventSource, SystemEvent};
|
|
||||||
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
let event = SystemEvent {
|
let event = make_event(
|
||||||
source: EventSource::Journald,
|
EventSource::Journald,
|
||||||
raw_line: "systemd: Started NetworkManager.service".into(),
|
"systemd: Started NetworkManager.service",
|
||||||
timestamp: 0,
|
);
|
||||||
};
|
|
||||||
assert!(classify(&event, &pf).is_none());
|
assert!(classify(&event, &pf).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_applog_matches_correct_source() {
|
fn classify_applog_matches_correct_source() {
|
||||||
use crate::watcher::{EventSource, SystemEvent};
|
|
||||||
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
let event = SystemEvent {
|
let event = make_event(
|
||||||
source: EventSource::AppLog("steam".into()),
|
EventSource::AppLog {
|
||||||
raw_line: "wine: cannot find /run/media/user/game.exe".into(),
|
app: "steam".into(),
|
||||||
timestamp: 0,
|
},
|
||||||
};
|
"wine: cannot find /run/media/user/game.exe",
|
||||||
|
);
|
||||||
let matched = classify(&event, &pf).unwrap();
|
let matched = classify(&event, &pf).unwrap();
|
||||||
assert_eq!(matched.pattern_id, "proton-runtime");
|
assert_eq!(matched.pattern_id, "proton-runtime");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_does_not_match_wrong_source() {
|
fn classify_does_not_match_wrong_source() {
|
||||||
use crate::watcher::{EventSource, SystemEvent};
|
|
||||||
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
let pf: PatternFile = toml::from_str(FIXTURE).unwrap();
|
||||||
let event = SystemEvent {
|
// proton pattern is applog:steam — journald event must not match
|
||||||
source: EventSource::Journald,
|
let event = make_event(EventSource::Journald, "wine: cannot find something");
|
||||||
raw_line: "wine: cannot find something".into(),
|
|
||||||
timestamp: 0,
|
|
||||||
};
|
|
||||||
assert!(classify(&event, &pf).is_none());
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,15 @@ pub enum EventSeverity {
|
||||||
Crit,
|
Crit,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum EventSource {
|
pub enum EventSource {
|
||||||
Journald,
|
Journald,
|
||||||
Kmsg,
|
Kmsg,
|
||||||
AppLog(String),
|
AppLog { app: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct SystemEvent {
|
pub struct SystemEvent {
|
||||||
pub source: EventSource,
|
pub source: EventSource,
|
||||||
pub raw_line: String,
|
pub raw_line: String,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue