449 lines
16 KiB
Rust
449 lines
16 KiB
Rust
//! Activity rules are matched in declaration order, followed by unreplaced
|
|
//! built-ins. Matching metadata is bounded and never stored in the result.
|
|
use crate::document;
|
|
use kdl::KdlNode;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Map, Value, json};
|
|
|
|
use crate::Error;
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct Settings {
|
|
pub max_visible: usize,
|
|
pub show_badge: bool,
|
|
pub max_total: usize,
|
|
pub owner_ceiling: u8,
|
|
pub start_delay_ms: u64,
|
|
pub result_flash_ms: u64,
|
|
pub sources: Sources,
|
|
pub pomodoro: ferese_activities::pomodoro::Settings,
|
|
pub rules: Vec<Rule>,
|
|
}
|
|
impl Default for Settings {
|
|
fn default() -> Self {
|
|
Self {
|
|
max_visible: 2,
|
|
show_badge: true,
|
|
max_total: 128,
|
|
owner_ceiling: 160,
|
|
start_delay_ms: 300,
|
|
result_flash_ms: 1_500,
|
|
sources: Sources::default(),
|
|
pomodoro: Default::default(),
|
|
rules: builtins(),
|
|
}
|
|
}
|
|
}
|
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct Sources {
|
|
pub shell_hook: bool,
|
|
pub process_watch: bool,
|
|
pub notification_bridge: bool,
|
|
}
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Rule {
|
|
pub name: String,
|
|
#[serde(rename = "match")]
|
|
pub pattern: String,
|
|
pub title: String,
|
|
pub icon: String,
|
|
pub priority: u8,
|
|
pub progress: String,
|
|
pub min_duration_ms: u64,
|
|
pub ignore_parents: Vec<String>,
|
|
pub dedupe_by: Option<String>,
|
|
pub flash_on_fail: bool,
|
|
pub result: ResultText,
|
|
}
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ResultText {
|
|
pub ok: String,
|
|
pub fail: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct Matcher {
|
|
rules: Vec<(Rule, Regex)>,
|
|
}
|
|
#[derive(Clone, Debug)]
|
|
pub struct Matched {
|
|
pub rule: Rule,
|
|
pub title: String,
|
|
pub group: Option<String>,
|
|
}
|
|
impl Matcher {
|
|
pub fn try_new(settings: &Settings) -> Result<Self, String> {
|
|
let mut rules = Vec::new();
|
|
for rule in &settings.rules {
|
|
let regex = regex::RegexBuilder::new(&rule.pattern)
|
|
.size_limit(256 * 1024)
|
|
.dfa_size_limit(256 * 1024)
|
|
.build()
|
|
.map_err(|error| error.to_string())?;
|
|
validate_rule(rule, ®ex)?;
|
|
rules.push((rule.clone(), regex));
|
|
}
|
|
Ok(Self { rules })
|
|
}
|
|
pub fn matches(&self, command: &str, cwd: &str, parents: &[String]) -> Option<Matched> {
|
|
if command.len() > 4_096 || cwd.len() > 1_024 || parents.len() > 16 {
|
|
return None;
|
|
}
|
|
for (rule, regex) in &self.rules {
|
|
if parents
|
|
.iter()
|
|
.any(|parent| rule.ignore_parents.contains(parent))
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(captures) = regex.captures(command) {
|
|
let mut title = rule.title.clone();
|
|
for index in 0..captures.len() {
|
|
title = title.replace(
|
|
&format!("{{{index}}}"),
|
|
captures.get(index).map_or("", |m| m.as_str()),
|
|
);
|
|
}
|
|
if title.len() > 256 || title.chars().any(char::is_control) {
|
|
return None;
|
|
}
|
|
return Some(Matched {
|
|
rule: rule.clone(),
|
|
title,
|
|
group: (rule.dedupe_by.as_deref() == Some("cwd")).then(|| {
|
|
use std::hash::{Hash, Hasher};
|
|
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
|
cwd.hash(&mut hash);
|
|
format!("{}:{:x}", rule.name, hash.finish())
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
}
|
|
fn validate_rule(rule: &Rule, regex: &Regex) -> Result<(), String> {
|
|
for value in [&rule.name, &rule.title, &rule.result.ok, &rule.result.fail] {
|
|
if value.len() > 256 || value.chars().any(char::is_control) {
|
|
return Err("rule text exceeds its budget".into());
|
|
}
|
|
}
|
|
if rule.pattern.len() > 2_048
|
|
|| rule.min_duration_ms > 60_000
|
|
|| !matches!(rule.progress.as_str(), "indeterminate" | "cargo-json")
|
|
|| rule
|
|
.dedupe_by
|
|
.as_deref()
|
|
.is_some_and(|value| value != "cwd")
|
|
{
|
|
return Err("invalid rule duration, progress adapter or dedupe policy".into());
|
|
}
|
|
if rule.ignore_parents.len() > 16 || rule.ignore_parents.iter().any(|name| name.len() > 128) {
|
|
return Err("too many or too long parent names".into());
|
|
}
|
|
if rule.icon.len() > 64
|
|
|| !rule
|
|
.icon
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
|
{
|
|
return Err("invalid symbolic icon".into());
|
|
}
|
|
let capture = Regex::new(r"\{([^}]+)\}").expect("static pattern");
|
|
for reference in capture.captures_iter(&rule.title) {
|
|
if reference[1]
|
|
.parse::<usize>()
|
|
.ok()
|
|
.is_none_or(|index| index >= regex.captures_len())
|
|
{
|
|
return Err("invalid title capture reference".into());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
impl Settings {
|
|
#[must_use]
|
|
pub fn limits(&self) -> ferese_activities::Limits {
|
|
ferese_activities::Limits {
|
|
max_visible: self.max_visible,
|
|
show_badge: self.show_badge,
|
|
max_total: self.max_total,
|
|
owner_ceiling: self.owner_ceiling,
|
|
start_delay_ms: self.start_delay_ms,
|
|
result_flash_ms: self.result_flash_ms,
|
|
}
|
|
}
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
self.pomodoro.validate().map_err(str::to_owned)?;
|
|
if !(1..=2).contains(&self.max_visible)
|
|
|| !(1..=128).contains(&self.max_total)
|
|
|| self.start_delay_ms > 60_000
|
|
|| self.result_flash_ms > 10_000
|
|
|| self.rules.len() > 64
|
|
{
|
|
return Err("activity settings exceed supported limits".into());
|
|
}
|
|
let mut names = std::collections::HashSet::new();
|
|
if self.rules.iter().any(|rule| !names.insert(&rule.name)) {
|
|
return Err("duplicate activity rule".into());
|
|
}
|
|
Matcher::try_new(self).map(|_| ())
|
|
}
|
|
}
|
|
|
|
fn diagnostic(node: &KdlNode, message: impl std::fmt::Display) -> Error {
|
|
Error::from(format!(
|
|
"activities at byte {}: {message}",
|
|
node.span().offset()
|
|
))
|
|
}
|
|
|
|
pub(crate) fn parse(node: &KdlNode) -> Result<Value, Error> {
|
|
if !node.entries().is_empty() {
|
|
return Err(diagnostic(node, "section takes no properties"));
|
|
}
|
|
let mut values = Map::new();
|
|
let mut rules = Vec::new();
|
|
let mut names = std::collections::HashSet::new();
|
|
for child in node.children().into_iter().flat_map(|doc| doc.nodes()) {
|
|
let key = child.name().value();
|
|
if key == "rule" {
|
|
let Some(name) = child.get(0).and_then(kdl::KdlValue::as_string) else {
|
|
return Err(diagnostic(child, "rule requires a name"));
|
|
};
|
|
if child.entries().len() != 1 || !names.insert(name.to_owned()) {
|
|
return Err(diagnostic(child, "duplicate rule or extra rule properties"));
|
|
}
|
|
let mut value = Map::new();
|
|
value.insert("name".into(), json!(name));
|
|
for property in child.children().into_iter().flat_map(|doc| doc.nodes()) {
|
|
let name = property.name().value();
|
|
if !matches!(
|
|
name,
|
|
"match"
|
|
| "title"
|
|
| "icon"
|
|
| "priority"
|
|
| "progress"
|
|
| "min-duration-ms"
|
|
| "ignore-parents"
|
|
| "dedupe-by"
|
|
| "flash-on-fail"
|
|
| "result"
|
|
) {
|
|
return Err(diagnostic(property, "unknown rule node"));
|
|
}
|
|
let key = name.replace('-', "_");
|
|
let parsed = if name == "ignore-parents" {
|
|
if property
|
|
.entries()
|
|
.iter()
|
|
.any(|entry| entry.name().is_some())
|
|
|| property.entries().is_empty()
|
|
{
|
|
return Err(diagnostic(property, "ignore-parents requires names"));
|
|
}
|
|
Value::Array(
|
|
property
|
|
.entries()
|
|
.iter()
|
|
.map(|entry| document::scalar(entry.value()))
|
|
.collect::<Result<_, _>>()?,
|
|
)
|
|
} else {
|
|
document::node_value(property, &key, "rule")
|
|
.map_err(|error| diagnostic(property, error))?
|
|
};
|
|
if value.insert(key, parsed).is_some() {
|
|
return Err(diagnostic(property, "duplicate rule setting"));
|
|
}
|
|
}
|
|
value.entry("icon").or_insert(json!("ferese"));
|
|
value.entry("priority").or_insert(json!(100));
|
|
value.entry("progress").or_insert(json!("indeterminate"));
|
|
value.entry("min_duration_ms").or_insert(json!(0));
|
|
value.entry("ignore_parents").or_insert(json!([]));
|
|
value.entry("dedupe_by").or_insert(Value::Null);
|
|
value.entry("flash_on_fail").or_insert(json!(true));
|
|
value
|
|
.entry("result")
|
|
.or_insert(json!({"ok":"Finished","fail":"Command failed"}));
|
|
let rule: Rule = serde_json::from_value(Value::Object(value))
|
|
.map_err(|error| diagnostic(child, error))?;
|
|
let settings = Settings {
|
|
rules: vec![rule.clone()],
|
|
..Default::default()
|
|
};
|
|
settings
|
|
.validate()
|
|
.map_err(|error| diagnostic(child, error))?;
|
|
rules.push(rule);
|
|
} else {
|
|
if !matches!(
|
|
key,
|
|
"max-visible"
|
|
| "show-badge"
|
|
| "max-total"
|
|
| "owner-ceiling"
|
|
| "start-delay-ms"
|
|
| "result-flash-ms"
|
|
| "sources"
|
|
| "pomodoro"
|
|
) {
|
|
return Err(diagnostic(child, "unknown activity node"));
|
|
}
|
|
let field = key.replace('-', "_");
|
|
let value = document::node_value(child, &field, "activities")
|
|
.map_err(|error| diagnostic(child, error))?;
|
|
if values.insert(field, value).is_some() {
|
|
return Err(diagnostic(child, "duplicate activity setting"));
|
|
}
|
|
}
|
|
}
|
|
rules.extend(
|
|
builtins()
|
|
.into_iter()
|
|
.filter(|rule| !names.contains(&rule.name)),
|
|
);
|
|
values.insert(
|
|
"rules".into(),
|
|
serde_json::to_value(rules)
|
|
.expect("typed configuration and IPC values must serialize to JSON"),
|
|
);
|
|
let settings: Settings =
|
|
serde_json::from_value(Value::Object(values)).map_err(|error| diagnostic(node, error))?;
|
|
settings
|
|
.validate()
|
|
.map_err(|error| diagnostic(node, error))?;
|
|
Ok(serde_json::to_value(settings)
|
|
.expect("typed configuration and IPC values must serialize to JSON"))
|
|
}
|
|
fn builtins() -> Vec<Rule> {
|
|
[
|
|
(
|
|
"cargo",
|
|
r"^cargo (build|check|test|run|clippy)\b",
|
|
"cargo {1}",
|
|
"Finished",
|
|
1_500,
|
|
),
|
|
(
|
|
"npm",
|
|
r"^(npm|pnpm) (install|build|test|run)\b",
|
|
"{1} {2}",
|
|
"Finished",
|
|
1_500,
|
|
),
|
|
("make", r"^make(?:\s|$)", "Make", "Built", 300),
|
|
("git-push", r"^git push(?:\s|$)", "Git push", "Pushed", 300),
|
|
(
|
|
"docker-build",
|
|
r"^docker build(?:\s|$)",
|
|
"Docker build",
|
|
"Image built",
|
|
1_500,
|
|
),
|
|
]
|
|
.into_iter()
|
|
.map(|(name, pattern, title, ok, min_duration_ms)| Rule {
|
|
name: name.into(),
|
|
pattern: pattern.into(),
|
|
title: title.into(),
|
|
icon: "hammer".into(),
|
|
priority: 100,
|
|
progress: "indeterminate".into(),
|
|
min_duration_ms,
|
|
ignore_parents: if name == "cargo" {
|
|
vec!["rust-analyzer".into()]
|
|
} else {
|
|
vec![]
|
|
},
|
|
dedupe_by: Some("cwd".into()),
|
|
flash_on_fail: true,
|
|
result: ResultText {
|
|
ok: ok.into(),
|
|
fail: "Command failed".into(),
|
|
},
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn pomodoro_settings_are_strict_and_validate_duration_ranges() {
|
|
let doc=crate::Document::parse("activities { pomodoro { work-minutes 40; short-break-minutes 7; long-break-minutes 20; intervals 3; auto-advance #true; notifications #false; }; }").unwrap();
|
|
let settings: Settings = serde_json::from_value(doc.value()["activities"].clone()).unwrap();
|
|
assert_eq!(settings.pomodoro.work_minutes, 40);
|
|
assert_eq!(settings.pomodoro.intervals, 3);
|
|
assert!(settings.pomodoro.auto_advance);
|
|
assert!(!settings.pomodoro.notifications);
|
|
for invalid in [
|
|
"work-minutes 0",
|
|
"short-break-minutes 61",
|
|
"long-break-minutes 121",
|
|
"intervals 13",
|
|
"work-minuts 20",
|
|
] {
|
|
assert!(
|
|
crate::Document::parse(&format!("activities {{ pomodoro {{ {invalid}; }}; }}"))
|
|
.is_err(),
|
|
"{invalid}"
|
|
);
|
|
}
|
|
}
|
|
#[test]
|
|
fn settings_are_opt_in_and_matching_discards_nonmatches() {
|
|
let settings = Settings::default();
|
|
assert!(!settings.sources.shell_hook);
|
|
assert!(!settings.sources.process_watch);
|
|
let matcher = Matcher::try_new(&settings).unwrap();
|
|
assert!(matcher.matches("secret command", "/tmp", &[]).is_none());
|
|
let matched = matcher.matches("cargo test", "/tmp", &[]).unwrap();
|
|
assert_eq!(matched.title, "cargo test");
|
|
assert!(
|
|
matcher
|
|
.matches("cargo test", "/tmp", &["rust-analyzer".into()])
|
|
.is_none()
|
|
);
|
|
}
|
|
#[test]
|
|
fn shipped_kdl_matches_the_builtin_catalog() {
|
|
let document = crate::Document::parse(include_str!("../activities.kdl")).unwrap();
|
|
assert_eq!(
|
|
document.value()["activities"]["rules"],
|
|
serde_json::to_value(Settings::default().rules).unwrap()
|
|
);
|
|
}
|
|
#[test]
|
|
fn complete_rule_replacement_and_diagnostics() {
|
|
let source = "activities { rule cargo { match \"^cargo\"; title Custom; }; }";
|
|
let doc = crate::Document::parse(source).unwrap();
|
|
let settings: Settings = serde_json::from_value(doc.value()["activities"].clone()).unwrap();
|
|
assert_eq!(settings.rules[0].title, "Custom");
|
|
assert_eq!(settings.rules[0].min_duration_ms, 0);
|
|
assert_eq!(settings.rules.len(), 5);
|
|
for invalid in [
|
|
"activities { unknown 1; }",
|
|
"activities { max-visible 3; }",
|
|
"activities { show-badge 1; }",
|
|
"activities { rule x { match \"[\"; title X; }; }",
|
|
"activities { rule x { match \"(a)\"; title \"{2}\"; }; }",
|
|
"activities { sources { typo #true; }; }",
|
|
] {
|
|
assert!(
|
|
crate::Document::parse(invalid)
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("byte")
|
|
);
|
|
}
|
|
}
|
|
}
|