manage.sh: - run: foreground execution with auto DISPLAY + RUST_LOG - start/stop/restart/status: background daemon lifecycle - logs: tail Robin log file - build-debug: Rust-only build (no Node/npm needed) - desktop-install/remove: system application menu entry - autostart-enable/disable: XDG autostart entry - install: build-debug + desktop-install + autostart-enable in one step - uninstall: reverses install cleanly patterns.rs: - Add exe-relative candidates: binary-dir/patterns/ and binary-dir/../../patterns/ This means the binary works from any working directory without requiring the user to cd into ~/robin/ first
347 lines
11 KiB
Rust
347 lines
11 KiB
Rust
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<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.
|
|
///
|
|
/// 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<PatternFile> {
|
|
let mut candidates: Vec<String> = 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<PatternFile> {
|
|
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::<PatternFile>(&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<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"));
|
|
}
|
|
}
|