snapshot of https://github.com/ActivityWatch/aw-server-rust.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / ubuntu-latest (push) Failing after 5s
Build / Android (push) Failing after 2s
Build / Code coverage (push) Failing after 2s
Lint / format (push) Failing after 3s
Lint / clippy (push) Failing after 3s
Build / macOS-latest (push) Has been cancelled
Build / windows-latest (push) Has been cancelled
Some checks failed
Build / ubuntu-latest (push) Failing after 5s
Build / Android (push) Failing after 2s
Build / Code coverage (push) Failing after 2s
Lint / format (push) Failing after 3s
Lint / clippy (push) Failing after 3s
Build / macOS-latest (push) Has been cancelled
Build / windows-latest (push) Has been cancelled
This commit is contained in:
80
aw-transform/src/chunk.rs
Normal file
80
aw-transform/src/chunk.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use aw_models::Event;
|
||||
|
||||
/// Chunks together events with the same key
|
||||
///
|
||||
/// NOTE: In most cases you should use merge_events_by_keys instead, this
|
||||
/// transform is mostly just for backwards compatibility with older versions
|
||||
/// of aw-webui
|
||||
/// NOTE: Does not support sub-chunking which aw-server-python supports
|
||||
/// Without sub-chunking it is pretty much the same as merge_events_by_key
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// key: a
|
||||
/// input:
|
||||
/// { duration: 1.0, data: { "a": 1, "b": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 1, "b": 2 } }
|
||||
/// { duration: 1.0, data: { "a": 2, "b": 1 } }
|
||||
/// output:
|
||||
/// { duration: 2.0, data: { "a": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 2 } }
|
||||
/// ```
|
||||
pub fn chunk_events_by_key(events: Vec<Event>, key: &str) -> Vec<Event> {
|
||||
let mut chunked_events: Vec<Event> = Vec::new();
|
||||
for event in events {
|
||||
if chunked_events.is_empty() && event.data.get(key).is_some() {
|
||||
// TODO: Add sub-chunks
|
||||
chunked_events.push(event);
|
||||
} else {
|
||||
let val = match event.data.get(key) {
|
||||
None => continue,
|
||||
Some(v) => v,
|
||||
};
|
||||
let mut last_event = chunked_events.pop().unwrap();
|
||||
let last_val = last_event.data.get(key).unwrap().clone();
|
||||
if &last_val == val {
|
||||
// TODO: Add sub-chunks
|
||||
last_event.duration += event.duration;
|
||||
}
|
||||
chunked_events.push(last_event);
|
||||
if &last_val != val {
|
||||
// TODO: Add sub-chunks
|
||||
chunked_events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
chunked_events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::chunk_events_by_key;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_events_by_key() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let mut e2 = e1.clone();
|
||||
e2.data = json_map! {"test2": json!(1)};
|
||||
let e3 = e1.clone();
|
||||
let mut e4 = e1.clone();
|
||||
e4.data = json_map! {"test": json!(2)};
|
||||
|
||||
let res = chunk_events_by_key(vec![e1, e2, e3, e4], "test");
|
||||
assert_eq!(res.len(), 2);
|
||||
assert_eq!(res[0].duration, Duration::seconds(2));
|
||||
assert_eq!(res[1].duration, Duration::seconds(1));
|
||||
}
|
||||
}
|
||||
642
aw-transform/src/classify.rs
Normal file
642
aw-transform/src/classify.rs
Normal file
@@ -0,0 +1,642 @@
|
||||
/// Transforms for classifying (tagging and categorizing) events.
|
||||
///
|
||||
/// Based on code in aw_research: https://github.com/ActivityWatch/aw-research/blob/master/aw_research/classify.py
|
||||
use aw_models::Event;
|
||||
use fancy_regex::Regex;
|
||||
use lru::LruCache;
|
||||
use std::collections::HashMap;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
const REGEX_CACHE_CAPACITY: usize = 512;
|
||||
|
||||
static REGEX_CACHE: OnceLock<Mutex<LruCache<String, Arc<Regex>>>> = OnceLock::new();
|
||||
|
||||
pub enum Rule {
|
||||
None,
|
||||
Regex(RegexRule),
|
||||
}
|
||||
|
||||
impl RuleTrait for Rule {
|
||||
fn matches(&self, event: &Event) -> bool {
|
||||
match self {
|
||||
Rule::None => false,
|
||||
Rule::Regex(rule) => rule.matches(event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait RuleTrait {
|
||||
fn matches(&self, event: &Event) -> bool;
|
||||
}
|
||||
|
||||
pub struct RegexRule {
|
||||
regex: Arc<Regex>,
|
||||
select_keys: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl RegexRule {
|
||||
pub fn new(
|
||||
regex_str: &str,
|
||||
ignore_case: bool,
|
||||
select_keys: Option<Vec<String>>,
|
||||
) -> Result<RegexRule, fancy_regex::Error> {
|
||||
// Validate that select_keys is not an empty list, which would silently never match.
|
||||
if let Some(ref keys) = select_keys {
|
||||
if keys.is_empty() {
|
||||
return Err(fancy_regex::Error::ParseError(
|
||||
0,
|
||||
fancy_regex::ParseError::GeneralParseError(
|
||||
"select_keys must not be empty".to_string(),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// can't use `RegexBuilder::case_insensitive` because it's not supported by fancy_regex,
|
||||
// so we need to prefix with `(?i)` to make it case insensitive.
|
||||
let full_regex_str = if ignore_case {
|
||||
format!("(?i){regex_str}")
|
||||
} else {
|
||||
regex_str.to_string()
|
||||
};
|
||||
|
||||
let cache = REGEX_CACHE.get_or_init(|| {
|
||||
Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(REGEX_CACHE_CAPACITY).unwrap(),
|
||||
))
|
||||
});
|
||||
let mut cache = cache.lock().unwrap();
|
||||
|
||||
let regex = if let Some(re) = cache.get(&full_regex_str) {
|
||||
re.clone()
|
||||
} else {
|
||||
let re = Arc::new(Regex::new(&full_regex_str)?);
|
||||
cache.put(full_regex_str.clone(), re.clone());
|
||||
re
|
||||
};
|
||||
|
||||
Ok(RegexRule { regex, select_keys })
|
||||
}
|
||||
|
||||
fn value_matches(&self, value: &serde_json::Value) -> bool {
|
||||
match value.as_str() {
|
||||
Some(value) => self.regex.is_match(value).unwrap_or(false),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This struct defines the rules for classification.
|
||||
/// For now it just needs to contain the regex to match with, but in the future it might contain a
|
||||
/// glob-pattern, or other options for classifying.
|
||||
/// It's puropse is to make the API easy to extend in the future without having to break backwards
|
||||
/// compatibility (or have to maintain "old" query2 functions).
|
||||
impl RuleTrait for RegexRule {
|
||||
fn matches(&self, event: &Event) -> bool {
|
||||
match &self.select_keys {
|
||||
Some(select_keys) => select_keys
|
||||
.iter()
|
||||
.filter_map(|key| event.data.get(key))
|
||||
.any(|val| self.value_matches(val)),
|
||||
None => event.data.values().any(|val| self.value_matches(val)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Regex> for Rule {
|
||||
fn from(re: Regex) -> Self {
|
||||
Rule::Regex(RegexRule {
|
||||
regex: Arc::new(re),
|
||||
select_keys: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A category matching rule passed to [`categorize`].
|
||||
///
|
||||
/// `priority` is an optional integer ranking score. When set, it is used
|
||||
/// instead of the depth-derived default to pick among matching rules (higher
|
||||
/// wins). When `None`, ranking falls back to `depth * 10` so existing configs
|
||||
/// keep their current ordering, while explicit values can slot between levels
|
||||
/// (depth 1 → 10, depth 2 → 20).
|
||||
pub struct CategoryRule {
|
||||
pub category: Vec<String>,
|
||||
pub rule: Rule,
|
||||
pub priority: Option<i64>,
|
||||
}
|
||||
|
||||
impl CategoryRule {
|
||||
pub fn new(category: Vec<String>, rule: Rule) -> Self {
|
||||
Self {
|
||||
category,
|
||||
rule,
|
||||
priority: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_priority(mut self, priority: i64) -> Self {
|
||||
self.priority = Some(priority);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(Vec<String>, Rule)> for CategoryRule {
|
||||
fn from((category, rule): (Vec<String>, Rule)) -> Self {
|
||||
Self::new(category, rule)
|
||||
}
|
||||
}
|
||||
|
||||
/// Categorizes a list of events
|
||||
///
|
||||
/// An event can only have one category, although the category may have a hierarchy,
|
||||
/// for instance: "Work -> ActivityWatch -> aw-server-rust"
|
||||
/// If multiple categories match, the highest-ranking one is chosen.
|
||||
/// Ranking is the optional integer `priority` on the rule when present,
|
||||
/// otherwise `depth * 10` ("the deepest one will be chosen", with room to
|
||||
/// slot values between levels). Equal ranks keep the later match, matching
|
||||
/// the previous depth-only `>=` comparison.
|
||||
///
|
||||
/// Performance: builds an in-memory cache keyed on the event's data JSON so that
|
||||
/// events with identical data (same app/title — very common in practice) are only
|
||||
/// matched against the rule set once. On a month's data with 50k+ events but only
|
||||
/// a few hundred distinct app/title pairs this reduces regex work by >99%.
|
||||
pub fn categorize(mut events: Vec<Event>, rules: &[CategoryRule]) -> Vec<Event> {
|
||||
// Cache: serialized event data → assigned category
|
||||
let mut category_cache: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut classified_events = Vec::with_capacity(events.len());
|
||||
for mut event in events.drain(..) {
|
||||
// Key on the full event data. serde_json::Map preserves insertion order, so
|
||||
// events with the same fields in the same order produce the same key — which
|
||||
// is the normal case for heartbeat-based watchers.
|
||||
let cache_key = serde_json::to_string(&event.data).unwrap_or_default();
|
||||
let category = category_cache
|
||||
.entry(cache_key)
|
||||
.or_insert_with(|| _pick_category(&event, rules))
|
||||
.clone();
|
||||
event
|
||||
.data
|
||||
.insert("$category".into(), serde_json::json!(category));
|
||||
classified_events.push(event);
|
||||
}
|
||||
classified_events
|
||||
}
|
||||
|
||||
fn _pick_category(event: &Event, rules: &[CategoryRule]) -> Vec<String> {
|
||||
let mut category: Vec<String> = vec!["Uncategorized".into()];
|
||||
// Uncategorized loses to any non-empty match, including a match with a very
|
||||
// low explicit priority. i64::MIN is only used as this sentinel.
|
||||
// Empty category paths are skipped so they cannot replace the fallback
|
||||
// (old depth comparison: len 0 does not beat Uncategorized's len 1).
|
||||
let mut rank = i64::MIN;
|
||||
for class in rules {
|
||||
if class.category.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if class.rule.matches(event) {
|
||||
let item_rank = _effective_rank(&class.category, class.priority);
|
||||
if item_rank >= rank {
|
||||
category = class.category.clone();
|
||||
rank = item_rank;
|
||||
}
|
||||
}
|
||||
}
|
||||
category
|
||||
}
|
||||
|
||||
/// Tags a list of events
|
||||
///
|
||||
/// An event can have many tags (as opposed to only one category) which will be put into the `$tags` key of
|
||||
/// the event data object.
|
||||
pub fn tag(mut events: Vec<Event>, rules: &[(String, Rule)]) -> Vec<Event> {
|
||||
let mut events_tagged = Vec::new();
|
||||
for event in events.drain(..) {
|
||||
events_tagged.push(tag_one(event, rules));
|
||||
}
|
||||
events_tagged
|
||||
}
|
||||
|
||||
fn tag_one(mut event: Event, rules: &[(String, Rule)]) -> Event {
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
for (cls, rule) in rules {
|
||||
if rule.matches(&event) {
|
||||
tags.push(cls.clone());
|
||||
}
|
||||
}
|
||||
tags.sort_unstable();
|
||||
tags.dedup();
|
||||
event.data.insert("$tags".into(), serde_json::json!(tags));
|
||||
event
|
||||
}
|
||||
|
||||
fn _effective_rank(category: &[String], priority: Option<i64>) -> i64 {
|
||||
// Integer-only. Default is depth * 10 so explicit priorities can slot
|
||||
// between nesting levels (depth 1 → 10, depth 2 → 20). Relative order of
|
||||
// unprioritized rules is unchanged.
|
||||
// https://github.com/ActivityWatch/aw-server-rust/pull/663#issuecomment-5481349757
|
||||
priority.unwrap_or((category.len() as i64) * 10)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rule() {
|
||||
let mut e_match = Event::default();
|
||||
e_match
|
||||
.data
|
||||
.insert("test".into(), serde_json::json!("just a test"));
|
||||
|
||||
let mut e_no_match = Event::default();
|
||||
e_no_match
|
||||
.data
|
||||
.insert("nonono".into(), serde_json::json!("no match!"));
|
||||
|
||||
let rule_from_regex = Rule::from(Regex::new("test").unwrap());
|
||||
let rule_from_new = Rule::Regex(RegexRule::new("test", false, None).unwrap());
|
||||
let rule_none = Rule::None;
|
||||
assert!(rule_from_regex.matches(&e_match));
|
||||
assert!(rule_from_new.matches(&e_match));
|
||||
assert!(!rule_from_regex.matches(&e_no_match));
|
||||
assert!(!rule_from_new.matches(&e_no_match));
|
||||
|
||||
assert!(!rule_none.matches(&e_match));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rule_lookahead() {
|
||||
// Originally requested by a user here, to match aw-server-python: https://canary.discord.com/channels/755040852727955476/755334543891759194/994291987878522961
|
||||
let mut e_match = Event::default();
|
||||
e_match
|
||||
.data
|
||||
.insert("test".into(), serde_json::json!("testing lookahead"));
|
||||
|
||||
let rule_from_regex = Rule::from(Regex::new("testing (?!lookahead)").unwrap());
|
||||
assert!(!rule_from_regex.matches(&e_match));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rule_select_keys() {
|
||||
let mut event = Event::default();
|
||||
event
|
||||
.data
|
||||
.insert("app".into(), serde_json::json!("terminal"));
|
||||
event
|
||||
.data
|
||||
.insert("title".into(), serde_json::json!("just a test"));
|
||||
event.data.insert("pid".into(), serde_json::json!(123));
|
||||
|
||||
let title_only =
|
||||
Rule::Regex(RegexRule::new("test", false, Some(vec!["title".into()])).unwrap());
|
||||
let app_only = Rule::Regex(RegexRule::new("test", false, Some(vec!["app".into()])).unwrap());
|
||||
let missing_key =
|
||||
Rule::Regex(RegexRule::new("test", false, Some(vec!["missing".into()])).unwrap());
|
||||
let non_string_key =
|
||||
Rule::Regex(RegexRule::new("123", false, Some(vec!["pid".into()])).unwrap());
|
||||
|
||||
assert!(title_only.matches(&event));
|
||||
assert!(!app_only.matches(&event));
|
||||
assert!(!missing_key.matches(&event));
|
||||
assert!(!non_string_key.matches(&event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rule_select_keys_empty_list() {
|
||||
// An empty select_keys list should return an error rather than
|
||||
// silently producing a rule that never matches anything.
|
||||
let result = RegexRule::new("test", false, Some(vec![]));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
#[test]
|
||||
fn test_categorize() {
|
||||
let mut e = Event::default();
|
||||
e.data
|
||||
.insert("test".into(), serde_json::json!("just a test"));
|
||||
|
||||
let mut events = vec![e];
|
||||
let rules: Vec<CategoryRule> = vec![
|
||||
CategoryRule::new(
|
||||
vec!["Test".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
),
|
||||
CategoryRule::new(
|
||||
vec!["Test".into(), "Subtest".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
),
|
||||
CategoryRule::new(
|
||||
vec!["Other".into()],
|
||||
Rule::from(Regex::new(r"nonmatching").unwrap()),
|
||||
),
|
||||
];
|
||||
events = categorize(events, &rules);
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events.first().unwrap().data.get("$category").unwrap(),
|
||||
&serde_json::json!(vec!["Test", "Subtest"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_uncategorized() {
|
||||
// Checks that the category correctly becomes uncategorized when no category matches
|
||||
let mut e = Event::default();
|
||||
e.data
|
||||
.insert("test".into(), serde_json::json!("just a test"));
|
||||
|
||||
let mut events = vec![e];
|
||||
let rules: Vec<CategoryRule> = vec![CategoryRule::new(
|
||||
vec!["Non-matching".into(), "test".into()],
|
||||
Rule::from(Regex::new(r"not going to match").unwrap()),
|
||||
)];
|
||||
events = categorize(events, &rules);
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events.first().unwrap().data.get("$category").unwrap(),
|
||||
&serde_json::json!(vec!["Uncategorized"])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn event_with_data(value: &str) -> Event {
|
||||
let mut e = Event::default();
|
||||
e.data.insert("test".into(), serde_json::json!(value));
|
||||
e
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn category_of(events: &[Event]) -> &serde_json::Value {
|
||||
events.first().unwrap().data.get("$category").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_depth_wins_without_priority() {
|
||||
// Reported scenario from ActivityWatch/aw-server-rust#597: a deeper nested
|
||||
// match still beats a shallower match when neither rule sets priority.
|
||||
// Category A (depth 1) and Category B → B1 (depth 2) both match.
|
||||
let events = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[
|
||||
CategoryRule::new(vec!["A".into()], Rule::from(Regex::new(r"test").unwrap())),
|
||||
CategoryRule::new(
|
||||
vec!["B".into(), "B1".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(category_of(&events), &serde_json::json!(vec!["B", "B1"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_explicit_priority_overrides_depth() {
|
||||
// The same #597 tree, but A is given a higher priority than B1's default
|
||||
// (depth 2 → 20). Organizational nesting no longer forces B1 to win.
|
||||
let events = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[
|
||||
CategoryRule::new(vec!["A".into()], Rule::from(Regex::new(r"test").unwrap()))
|
||||
.with_priority(25),
|
||||
CategoryRule::new(
|
||||
vec!["B".into(), "B1".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(category_of(&events), &serde_json::json!(vec!["A"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_inter_level_priority() {
|
||||
// depth * 10 leaves integers between levels: 15 beats a depth-1 default
|
||||
// (10) but loses to a depth-2 default (20).
|
||||
let between = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[
|
||||
CategoryRule::new(vec!["A".into()], Rule::from(Regex::new(r"test").unwrap())),
|
||||
CategoryRule::new(vec!["A2".into()], Rule::from(Regex::new(r"test").unwrap()))
|
||||
.with_priority(15),
|
||||
],
|
||||
);
|
||||
assert_eq!(category_of(&between), &serde_json::json!(vec!["A2"]));
|
||||
|
||||
let still_loses_to_deeper = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[
|
||||
CategoryRule::new(vec!["A2".into()], Rule::from(Regex::new(r"test").unwrap()))
|
||||
.with_priority(15),
|
||||
CategoryRule::new(
|
||||
vec!["B".into(), "B1".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
category_of(&still_loses_to_deeper),
|
||||
&serde_json::json!(vec!["B", "B1"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_lower_priority_loses_to_default_depth() {
|
||||
// A deep rule can also be demoted below a shallow rule's default
|
||||
// (depth * 10) by setting an explicit lower priority on the deep rule.
|
||||
let events = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[
|
||||
CategoryRule::new(vec!["A".into()], Rule::from(Regex::new(r"test").unwrap())),
|
||||
CategoryRule::new(
|
||||
vec!["B".into(), "B1".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
)
|
||||
.with_priority(0),
|
||||
],
|
||||
);
|
||||
assert_eq!(category_of(&events), &serde_json::json!(vec!["A"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_equal_priority_keeps_later_match() {
|
||||
// Preserve the historical `>=` later-wins rule when ranks tie.
|
||||
let events = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[
|
||||
CategoryRule::new(
|
||||
vec!["First".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
)
|
||||
.with_priority(5),
|
||||
CategoryRule::new(
|
||||
vec!["Second".into()],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
)
|
||||
.with_priority(5),
|
||||
],
|
||||
);
|
||||
assert_eq!(category_of(&events), &serde_json::json!(vec!["Second"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_negative_priority_still_beats_uncategorized() {
|
||||
let events = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[
|
||||
CategoryRule::new(vec!["Low".into()], Rule::from(Regex::new(r"test").unwrap()))
|
||||
.with_priority(-100),
|
||||
],
|
||||
);
|
||||
assert_eq!(category_of(&events), &serde_json::json!(vec!["Low"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_empty_category_keeps_uncategorized() {
|
||||
// Empty path used to lose to Uncategorized (depth 0 < 1). The MIN-rank
|
||||
// sentinel must not let it replace the fallback.
|
||||
let events = categorize(
|
||||
vec![event_with_data("just a test")],
|
||||
&[CategoryRule::new(
|
||||
vec![],
|
||||
Rule::from(Regex::new(r"test").unwrap()),
|
||||
)],
|
||||
);
|
||||
assert_eq!(
|
||||
category_of(&events),
|
||||
&serde_json::json!(vec!["Uncategorized"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_categorize_cache_correctness() {
|
||||
// Verifies that the deduplication cache produces the same result as
|
||||
// per-event categorization when many events share the same data.
|
||||
let mut base = Event::default();
|
||||
base.data.insert("app".into(), serde_json::json!("firefox"));
|
||||
base.data
|
||||
.insert("title".into(), serde_json::json!("GitHub"));
|
||||
|
||||
let mut other = Event::default();
|
||||
other
|
||||
.data
|
||||
.insert("app".into(), serde_json::json!("terminal"));
|
||||
other.data.insert("title".into(), serde_json::json!("bash"));
|
||||
|
||||
// 50 events with same data, then 1 different event, then 50 more same
|
||||
let mut events: Vec<Event> = std::iter::repeat(base.clone())
|
||||
.take(50)
|
||||
.chain(std::iter::once(other.clone()))
|
||||
.chain(std::iter::repeat(base.clone()).take(50))
|
||||
.collect();
|
||||
|
||||
let rules: Vec<CategoryRule> = vec![
|
||||
CategoryRule::new(
|
||||
vec!["Browser".into()],
|
||||
Rule::Regex(RegexRule::new("firefox", true, Some(vec!["app".into()])).unwrap()),
|
||||
),
|
||||
CategoryRule::new(
|
||||
vec!["Terminal".into()],
|
||||
Rule::Regex(RegexRule::new("terminal", true, Some(vec!["app".into()])).unwrap()),
|
||||
),
|
||||
];
|
||||
|
||||
events = categorize(events, &rules);
|
||||
|
||||
assert_eq!(events.len(), 101);
|
||||
// All firefox events → Browser
|
||||
for e in events.iter().take(50) {
|
||||
assert_eq!(
|
||||
e.data.get("$category").unwrap(),
|
||||
&serde_json::json!(vec!["Browser"])
|
||||
);
|
||||
}
|
||||
// The single terminal event → Terminal
|
||||
assert_eq!(
|
||||
events[50].data.get("$category").unwrap(),
|
||||
&serde_json::json!(vec!["Terminal"])
|
||||
);
|
||||
// Remaining firefox events → Browser (cache hit path)
|
||||
for e in events.iter().skip(51) {
|
||||
assert_eq!(
|
||||
e.data.get("$category").unwrap(),
|
||||
&serde_json::json!(vec!["Browser"])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag() {
|
||||
let mut e = Event::default();
|
||||
e.data
|
||||
.insert("test".into(), serde_json::json!("just a test"));
|
||||
|
||||
let mut events = vec![e];
|
||||
let rules: Vec<(String, Rule)> = vec![
|
||||
("test".into(), Rule::from(Regex::new(r"test").unwrap())),
|
||||
("test-2".into(), Rule::from(Regex::new(r"test").unwrap())),
|
||||
(
|
||||
"nomatch".into(),
|
||||
Rule::from(Regex::new(r"nomatch").unwrap()),
|
||||
),
|
||||
];
|
||||
events = tag(events, &rules);
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let event = events.first().unwrap();
|
||||
let tags = event.data.get("$tags").unwrap();
|
||||
assert_eq!(tags, &serde_json::json!(vec!["test", "test-2"]));
|
||||
}
|
||||
|
||||
/// Verify that syntactically invalid regex patterns are rejected by RegexRule::new()
|
||||
/// rather than panicking or silently succeeding.
|
||||
///
|
||||
/// Note: fancy_regex supports possessive quantifiers (e.g. `++`, `**`) which standard
|
||||
/// Python `re` does not. The original ActivityWatch#1340 bug (`Notepad++` → 500 error)
|
||||
/// only affected the Python aw-server; in aw-server-rust `Notepad++` is a valid
|
||||
/// possessive quantifier and is accepted. Users wanting a literal `+` must escape it:
|
||||
/// `Notepad\+\+`.
|
||||
#[test]
|
||||
fn test_invalid_regex_patterns_are_rejected() {
|
||||
let invalid_patterns = [
|
||||
"***", // no target for first `*` quantifier
|
||||
"???", // no target for first `?` quantifier
|
||||
"[unclosed", // unclosed character class
|
||||
"(", // unclosed group
|
||||
"(?P<name", // malformed named capturing group
|
||||
"\\", // lone backslash (incomplete escape sequence)
|
||||
"(?i", // incomplete flag group (no closing parenthesis)
|
||||
];
|
||||
for pattern in &invalid_patterns {
|
||||
let result = RegexRule::new(pattern, false, None);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Expected pattern {:?} to be rejected as invalid regex, but it was accepted",
|
||||
pattern
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that valid patterns — including possessive quantifiers and lookaheads — are
|
||||
/// accepted. These cover the correct workaround for ActivityWatch#1340 and other
|
||||
/// patterns users commonly write.
|
||||
#[test]
|
||||
fn test_valid_regex_patterns_are_accepted() {
|
||||
let valid_patterns = [
|
||||
r"Notepad\+\+", // literal `+` match — correct workaround for #1340
|
||||
"Notepad++", // possessive quantifier (valid in fancy_regex, unlike Python re)
|
||||
r"test.*value",
|
||||
r"(?i)case.insensitive",
|
||||
r"^start",
|
||||
r"end$",
|
||||
r"\d+",
|
||||
r"[a-z]+",
|
||||
r"(group1|group2)",
|
||||
r"look(?=ahead)",
|
||||
r"look(?!ahead)",
|
||||
];
|
||||
for pattern in &valid_patterns {
|
||||
let result = RegexRule::new(pattern, false, None);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Expected pattern {:?} to be accepted as valid regex, but it was rejected: {:?}",
|
||||
pattern,
|
||||
result.err()
|
||||
);
|
||||
}
|
||||
}
|
||||
166
aw-transform/src/filter_keyvals.rs
Normal file
166
aw-transform/src/filter_keyvals.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use fancy_regex::Regex;
|
||||
use serde_json::value::Value;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
/// Drops events not matching the specified key and value(s)
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// key: a
|
||||
/// vals: [1,2]
|
||||
/// input: [a:1][a:2][a:3][b:4]
|
||||
/// output: [a:1][a:2]
|
||||
/// ```
|
||||
pub fn filter_keyvals(mut events: Vec<Event>, key: &str, vals: &[Value]) -> Vec<Event> {
|
||||
let mut filtered_events = Vec::new();
|
||||
for event in events.drain(..) {
|
||||
if let Some(v) = event.data.get(key) {
|
||||
for val in vals {
|
||||
if val == v {
|
||||
filtered_events.push(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered_events
|
||||
}
|
||||
|
||||
/// Drops events not matching the regex on the value for a specified key
|
||||
/// Will only match if the value is a string
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// key: a
|
||||
/// regex: "[A-Z]+"
|
||||
/// input: [a:"HELLO"][a:"hello"][a:3][b:"HELLO"]
|
||||
/// output: [a:"HELLO"]
|
||||
/// ```
|
||||
pub fn filter_keyvals_regex(mut events: Vec<Event>, key: &str, regex: &Regex) -> Vec<Event> {
|
||||
let mut filtered_events = Vec::new();
|
||||
|
||||
for event in events.drain(..) {
|
||||
if let Some(value) = event.data.get(key).and_then(|v| v.as_str()) {
|
||||
match regex.is_match(value) {
|
||||
Ok(true) => filtered_events.push(event),
|
||||
Ok(false) => (),
|
||||
Err(err) => warn!("Failed to run regex: {}", err),
|
||||
};
|
||||
}
|
||||
}
|
||||
filtered_events
|
||||
}
|
||||
|
||||
/// Drops events matching the specified key and value(s). Opposite of filter_keyvals.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// key: a
|
||||
/// vals: [1,2]
|
||||
/// input: [a:1][a:2][a:3][b:4]
|
||||
/// output: [a:3][b:4]
|
||||
/// ```
|
||||
pub fn exclude_keyvals(mut events: Vec<Event>, key: &str, vals: &[Value]) -> Vec<Event> {
|
||||
let mut filtered_events = Vec::new();
|
||||
'events: for event in events.drain(..) {
|
||||
if let Some(v) = event.data.get(key) {
|
||||
for val in vals {
|
||||
if val == v {
|
||||
continue 'events;
|
||||
}
|
||||
}
|
||||
}
|
||||
filtered_events.push(event);
|
||||
}
|
||||
filtered_events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Duration};
|
||||
use fancy_regex::RegexBuilder;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::{exclude_keyvals, filter_keyvals, filter_keyvals_regex};
|
||||
|
||||
#[test]
|
||||
fn test_filter_keyvals() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let mut e2 = e1.clone();
|
||||
e2.data = json_map! {"test": json!(1), "test2": json!(1)};
|
||||
let mut e3 = e1.clone();
|
||||
e3.data = json_map! {"test2": json!(2)};
|
||||
let res = filter_keyvals(vec![e1.clone(), e2.clone(), e3], "test", &[json!(1)]);
|
||||
assert_eq!(vec![e1, e2], res);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_keyvals_regex() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"key1": json!("value1")},
|
||||
};
|
||||
let mut e2 = e1.clone();
|
||||
e2.data = json_map! {"key1": json!("value2")};
|
||||
let mut e3 = e1.clone();
|
||||
e3.data = json_map! {"key2": json!("value3")};
|
||||
|
||||
let events = vec![e1.clone(), e2.clone(), e3.clone()];
|
||||
|
||||
let regex_value = RegexBuilder::new("value").build().unwrap();
|
||||
let regex_value1 = RegexBuilder::new("value1").build().unwrap();
|
||||
|
||||
let res = filter_keyvals_regex(events.clone(), "key1", ®ex_value);
|
||||
assert_eq!(vec![e1.clone(), e2], res);
|
||||
let res = filter_keyvals_regex(events.clone(), "key1", ®ex_value1);
|
||||
assert_eq!(vec![e1], res);
|
||||
let res = filter_keyvals_regex(events.clone(), "key2", ®ex_value);
|
||||
assert_eq!(vec![e3], res);
|
||||
let res = filter_keyvals_regex(events.clone(), "key2", ®ex_value1);
|
||||
assert_eq!(0, res.len());
|
||||
let res = filter_keyvals_regex(events, "key3", ®ex_value);
|
||||
assert_eq!(0, res.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_keyvals_regex_non_string_data_value() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"key1": json!(100)},
|
||||
};
|
||||
let events = vec![e1.clone()];
|
||||
let regex_value = RegexBuilder::new("value").build().unwrap();
|
||||
let res = filter_keyvals_regex(events.clone(), "key1", ®ex_value);
|
||||
assert!(res.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exclude_keyvals() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let mut e2 = e1.clone();
|
||||
e2.data = json_map! {"test": json!(1), "test2": json!(2)};
|
||||
let mut e3 = e1.clone();
|
||||
e3.data = json_map! {"test": json!(2)};
|
||||
let res = exclude_keyvals(vec![e1.clone(), e2.clone(), e3], "test", &[json!(2)]);
|
||||
assert_eq!(vec![e1, e2], res);
|
||||
}
|
||||
}
|
||||
158
aw-transform/src/filter_period.rs
Normal file
158
aw-transform/src/filter_period.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
use aw_models::Event;
|
||||
use chrono::Duration;
|
||||
|
||||
use crate::sort_by_timestamp;
|
||||
|
||||
/// Removes events not intersecting with the provided filter_events
|
||||
///
|
||||
/// Usually used to filter buckets unaware if the user is making any activity with an bucket which
|
||||
/// is aware if the user is at the computer or not.
|
||||
/// For example the events from aw-watcher-window should be called with filter_period_intersect
|
||||
/// with the "not-afk" events from aw-watcher-afk to give events with durations of only when the
|
||||
/// user is at the computer.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// events: [a ][b ]
|
||||
/// filter_events: [ ] [ ]
|
||||
/// output: [a ] [a ][b ]
|
||||
/// ```
|
||||
pub fn filter_period_intersect(events: Vec<Event>, filter_events: Vec<Event>) -> Vec<Event> {
|
||||
if events.is_empty() || filter_events.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut filtered_events = Vec::new();
|
||||
let events = sort_by_timestamp(events);
|
||||
let filter_events = sort_by_timestamp(filter_events);
|
||||
|
||||
let mut events_iter = events.into_iter();
|
||||
let mut filter_events_iter = filter_events.into_iter();
|
||||
let mut cur_event = events_iter.next().unwrap();
|
||||
let mut cur_filter_event = filter_events_iter.next().unwrap();
|
||||
|
||||
loop {
|
||||
let event_endtime = cur_event.calculate_endtime();
|
||||
let filter_endtime = cur_filter_event.calculate_endtime();
|
||||
if cur_event.duration == Duration::seconds(0) || event_endtime <= cur_filter_event.timestamp
|
||||
{
|
||||
match events_iter.next() {
|
||||
Some(e) => {
|
||||
cur_event = e;
|
||||
continue;
|
||||
}
|
||||
None => return filtered_events,
|
||||
}
|
||||
}
|
||||
if cur_event.timestamp >= cur_filter_event.calculate_endtime() {
|
||||
match filter_events_iter.next() {
|
||||
Some(e) => {
|
||||
cur_filter_event = e;
|
||||
continue;
|
||||
}
|
||||
None => return filtered_events,
|
||||
}
|
||||
}
|
||||
|
||||
let mut e = cur_event.clone();
|
||||
e.timestamp = std::cmp::max(e.timestamp, cur_filter_event.timestamp);
|
||||
let endtime = std::cmp::min(event_endtime, filter_endtime);
|
||||
e.duration = endtime - e.timestamp;
|
||||
|
||||
// trim current event
|
||||
let old_timestamp = cur_event.timestamp;
|
||||
cur_event.timestamp = e.timestamp + e.duration;
|
||||
cur_event.duration = old_timestamp + cur_event.duration - cur_event.timestamp;
|
||||
|
||||
filtered_events.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::filter_period_intersect;
|
||||
|
||||
#[test]
|
||||
fn test_filter_period_intersect() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let mut e2 = e1.clone();
|
||||
e2.timestamp = DateTime::from_str("2000-01-01T00:00:02Z").unwrap();
|
||||
let mut e3 = e1.clone();
|
||||
e3.timestamp = DateTime::from_str("2000-01-01T00:00:03Z").unwrap();
|
||||
let mut e4 = e1.clone();
|
||||
e4.timestamp = DateTime::from_str("2000-01-01T00:00:04Z").unwrap();
|
||||
let mut e5 = e1.clone();
|
||||
e5.timestamp = DateTime::from_str("2000-01-01T00:00:05Z").unwrap();
|
||||
|
||||
let filter_event = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:02.5Z").unwrap(),
|
||||
duration: Duration::seconds(2),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
let filtered_events =
|
||||
filter_period_intersect(vec![e1, e2, e3, e4, e5], vec![filter_event.clone()]);
|
||||
assert_eq!(filtered_events.len(), 3);
|
||||
assert_eq!(filtered_events[0].duration, Duration::milliseconds(500));
|
||||
assert_eq!(filtered_events[1].duration, Duration::milliseconds(1000));
|
||||
assert_eq!(filtered_events[2].duration, Duration::milliseconds(500));
|
||||
|
||||
let dt: DateTime<Utc> = DateTime::from_str("2000-01-01T00:00:02.500Z").unwrap();
|
||||
assert_eq!(filtered_events[0].timestamp, dt);
|
||||
let dt: DateTime<Utc> = DateTime::from_str("2000-01-01T00:00:03.000Z").unwrap();
|
||||
assert_eq!(filtered_events[1].timestamp, dt);
|
||||
let dt: DateTime<Utc> = DateTime::from_str("2000-01-01T00:00:04.000Z").unwrap();
|
||||
assert_eq!(filtered_events[2].timestamp, dt);
|
||||
|
||||
let timestamp_01s = DateTime::from_str("2000-01-01T00:00:01Z").unwrap();
|
||||
let e = Event {
|
||||
id: None,
|
||||
timestamp: timestamp_01s,
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let mut f2 = filter_event.clone();
|
||||
f2.timestamp = DateTime::from_str("2000-01-01T00:00:00Z").unwrap();
|
||||
f2.duration = Duration::milliseconds(1500);
|
||||
let res = filter_period_intersect(vec![e.clone()], vec![f2]);
|
||||
assert_eq!(res[0].timestamp, timestamp_01s);
|
||||
assert_eq!(res[0].duration, Duration::milliseconds(500));
|
||||
|
||||
let timestamp_01_5s = DateTime::from_str("2000-01-01T00:00:01.5Z").unwrap();
|
||||
let mut f3 = filter_event.clone();
|
||||
f3.timestamp = timestamp_01_5s;
|
||||
f3.duration = Duration::milliseconds(1000);
|
||||
let res = filter_period_intersect(vec![e.clone()], vec![f3]);
|
||||
assert_eq!(res[0].timestamp, timestamp_01_5s);
|
||||
assert_eq!(res[0].duration, Duration::milliseconds(500));
|
||||
|
||||
let mut f4 = filter_event.clone();
|
||||
f4.timestamp = DateTime::from_str("2000-01-01T00:00:01.5Z").unwrap();
|
||||
f4.duration = Duration::milliseconds(100);
|
||||
let res = filter_period_intersect(vec![e.clone()], vec![f4]);
|
||||
assert_eq!(res[0].timestamp, timestamp_01_5s);
|
||||
assert_eq!(res[0].duration, Duration::milliseconds(100));
|
||||
|
||||
let mut f5 = filter_event.clone();
|
||||
f5.timestamp = DateTime::from_str("2000-01-01T00:00:00Z").unwrap();
|
||||
f5.duration = Duration::seconds(10);
|
||||
let res = filter_period_intersect(vec![e.clone()], vec![f5]);
|
||||
assert_eq!(res[0].timestamp, timestamp_01s);
|
||||
assert_eq!(res[0].duration, Duration::milliseconds(1000));
|
||||
}
|
||||
}
|
||||
78
aw-transform/src/find_bucket.rs
Normal file
78
aw-transform/src/find_bucket.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use aw_models::Bucket;
|
||||
|
||||
/// Finds the first bucket which starts with the specified string, and optionally matches a
|
||||
/// hostname.
|
||||
pub fn find_bucket<'a>(
|
||||
bucket_filter: &str,
|
||||
hostname_filter: &Option<String>,
|
||||
buckets: impl IntoIterator<Item = &'a Bucket>,
|
||||
) -> Option<String> {
|
||||
let mut prefix_match = None;
|
||||
for bucket in buckets {
|
||||
if bucket.id.starts_with(bucket_filter) {
|
||||
let hostname_matches = match hostname_filter {
|
||||
Some(hostname) => hostname == &bucket.hostname,
|
||||
None => true,
|
||||
};
|
||||
|
||||
if hostname_matches {
|
||||
if bucket.id == bucket_filter {
|
||||
// Exact match, return immediately
|
||||
return Some(bucket.id.to_string());
|
||||
}
|
||||
if prefix_match.is_none() {
|
||||
// Save the first prefix match in case we don't find an exact match
|
||||
prefix_match = Some(bucket.id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
prefix_match
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::find_bucket;
|
||||
use aw_models::Bucket;
|
||||
use aw_models::BucketMetadata;
|
||||
|
||||
#[test]
|
||||
fn test_find_bucket() {
|
||||
let expected_bucketname = "aw-datastore-test_test-host".to_string();
|
||||
let expected_hostname = "testhost".to_string();
|
||||
let b1 = Bucket {
|
||||
bid: None,
|
||||
id: "no match".to_string(),
|
||||
_type: "type".to_string(),
|
||||
hostname: expected_hostname,
|
||||
client: "testclient".to_string(),
|
||||
created: None,
|
||||
data: json_map! {},
|
||||
metadata: BucketMetadata::default(),
|
||||
events: None,
|
||||
last_updated: None,
|
||||
};
|
||||
let mut b2 = b1.clone();
|
||||
b2.id = expected_bucketname.clone();
|
||||
let b3 = b1.clone();
|
||||
|
||||
let buckets = vec![b1.clone(), b2, b3.clone()];
|
||||
|
||||
// Check that it correctly finds bucket
|
||||
let res = find_bucket("aw-datastore-test", &Some("testhost".to_string()), &buckets);
|
||||
assert_eq!(res, Some(expected_bucketname));
|
||||
|
||||
// Check that it doesn't find a bucket for an unavailable hostname
|
||||
let res = find_bucket(
|
||||
"aw-datastore-test",
|
||||
&Some("unavailablehost".to_string()),
|
||||
&buckets,
|
||||
);
|
||||
assert_eq!(res, None);
|
||||
|
||||
// Check that it doesn't find a bucket for any hostname
|
||||
let buckets = vec![b1, b3];
|
||||
let res = find_bucket("aw-datastore-test", &None, &buckets);
|
||||
assert_eq!(res, None);
|
||||
}
|
||||
}
|
||||
389
aw-transform/src/flood.rs
Normal file
389
aw-transform/src/flood.rs
Normal file
@@ -0,0 +1,389 @@
|
||||
use aw_models::Event;
|
||||
|
||||
use crate::sort_by_timestamp;
|
||||
|
||||
/// Floods event to the nearest neighbouring event if within the specified pulsetime.
|
||||
///
|
||||
/// Also merges events if they have the same data and are within the pulsetime.
|
||||
///
|
||||
/// "Flooding" actually performs two similar, but slightly different tasks, which we can describe as:
|
||||
/// - Flooding: removes small gaps between events, by extending nearby events to fill the space.
|
||||
/// - Merging: merging adjacent or overlapping events that have the same data.
|
||||
/// - This is sometimes done in conjunction with flooding, if events with nearby events have the same data.
|
||||
///
|
||||
/// Python implementation:
|
||||
/// https://github.com/ActivityWatch/aw-core/blob/master/aw_transform/flood.py
|
||||
/// Python re-implementation using generators (different spec):
|
||||
/// https://github.com/ErikBjare/copilot-testing/blob/master/playground/flooding.py
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// Example with forward-fill:
|
||||
///
|
||||
/// ```ignore
|
||||
/// pulsetime: 1 second (one space)
|
||||
/// input: [a] [b] [c]
|
||||
/// output: [a ][b ][c]
|
||||
/// ```
|
||||
///
|
||||
/// Example with forward-fill and event merging:
|
||||
///
|
||||
/// ```ignore
|
||||
/// pulsetime: 1 second (one space)
|
||||
/// input: [a] [a] [b ][b]
|
||||
/// output: [a ] [b ]
|
||||
/// ```
|
||||
pub fn flood(events: Vec<Event>, pulsetime: chrono::Duration) -> Vec<Event> {
|
||||
let mut new_events = Vec::new();
|
||||
let mut events_sorted = sort_by_timestamp(events);
|
||||
let mut e1_iter = events_sorted.drain(..).peekable();
|
||||
|
||||
let mut gap_prev: Option<chrono::Duration> = None;
|
||||
let mut retry_e: Option<Event> = None;
|
||||
|
||||
// If negative gaps are smaller than this, prune them to become zero
|
||||
let negative_gap_trim_thres = chrono::Duration::milliseconds(100);
|
||||
|
||||
let mut warned_negative_gap_safe = false;
|
||||
let mut warned_negative_gap_unsafe = false;
|
||||
|
||||
while let Some(mut e1) = match retry_e {
|
||||
Some(e) => {
|
||||
retry_e = None;
|
||||
Some(e)
|
||||
}
|
||||
None => e1_iter.next(),
|
||||
} {
|
||||
if let Some(gap) = gap_prev {
|
||||
e1.timestamp -= gap / 2;
|
||||
e1.duration += gap / 2;
|
||||
gap_prev = None;
|
||||
}
|
||||
let e2 = match e1_iter.peek() {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
new_events.push(e1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure that ordering is preserved, at least in tests
|
||||
debug_assert!(e1.timestamp <= e2.timestamp);
|
||||
|
||||
let gap = e2.timestamp - e1.calculate_endtime();
|
||||
|
||||
// We split the program flow into 2 parts: positive and negative gaps
|
||||
// First we check negative gaps (if events overlap)
|
||||
if gap < chrono::Duration::seconds(0) {
|
||||
// Python implementation:
|
||||
//
|
||||
// if gap < timedelta(0) and e1.data == e2.data:
|
||||
// start = min(e1.timestamp, e2.timestamp)
|
||||
// end = max(e1.timestamp + e1.duration, e2.timestamp + e2.duration)
|
||||
// e1.timestamp, e1.duration = start, (end - start)
|
||||
// e2.timestamp, e2.duration = end, timedelta(0)
|
||||
// if not warned_about_negative_gap_safe:
|
||||
// logger.warning(
|
||||
// "Gap was of negative duration but could be safely merged ({}s). This message will only show once per batch.".format(
|
||||
// gap.total_seconds()
|
||||
// )
|
||||
// )
|
||||
// warned_about_negative_gap_safe = True
|
||||
|
||||
// If data is same, we can safely merge them.
|
||||
if e1.data == e2.data {
|
||||
// Gap was negative and could be safely merged
|
||||
if !warned_negative_gap_safe {
|
||||
warn!("Gap was of negative duration ({}s), but could be safely merged. This error will only show once per batch.", gap);
|
||||
warned_negative_gap_safe = true;
|
||||
}
|
||||
let start = std::cmp::min(e1.timestamp, e2.timestamp);
|
||||
let end = std::cmp::max(e1.calculate_endtime(), e2.calculate_endtime()); // e2 isn't guaranteed to end last
|
||||
e1.timestamp = start;
|
||||
e1.duration = end - start;
|
||||
// Drop next event since it is merged/flooded into e1
|
||||
e1_iter.next();
|
||||
// Retry this event again to give it a change to merge e1
|
||||
// with 'e3'
|
||||
retry_e = Some(e1);
|
||||
continue;
|
||||
// If data differs and gap is negative beyond the trim thres, throw a warning
|
||||
} else if gap < -negative_gap_trim_thres && !warned_negative_gap_unsafe {
|
||||
// Events with negative gap but differing data cannot be merged safely
|
||||
warn!("Gap was of negative duration and could NOT be safely merged ({}s). This warning will only show once per batch.", gap);
|
||||
warned_negative_gap_unsafe = true;
|
||||
}
|
||||
|
||||
// By now, we've ensured gap is non-negative, now we want to know if the gap is smaller
|
||||
// than pulsetime, in which case we should fill it.
|
||||
} else if gap < pulsetime {
|
||||
// Python implementation:
|
||||
//
|
||||
// elif gap < -negative_gap_trim_thres and not warned_about_negative_gap_unsafe:
|
||||
// # Events with negative gap but differing data cannot be merged safely
|
||||
// logger.warning(
|
||||
// "Gap was of negative duration and could NOT be safely merged ({}s). This warning will only show once per batch.".format(
|
||||
// gap.total_seconds()
|
||||
// )
|
||||
// )
|
||||
// warned_about_negative_gap_unsafe = True
|
||||
|
||||
// Ensure that gap is actually non-negative here, at least in tests
|
||||
debug_assert!(gap >= chrono::Duration::seconds(0));
|
||||
|
||||
// If data is the same, we should merge them.
|
||||
if e1.data == e2.data {
|
||||
// Choose the longest event and set the endtime to it
|
||||
let start = std::cmp::min(e1.timestamp, e2.timestamp); // isn't e1 guaranteed to start?
|
||||
let end = std::cmp::max(e1.calculate_endtime(), e2.calculate_endtime()); // e2 isn't guaranteed to end last
|
||||
e1.timestamp = start;
|
||||
e1.duration = end - start;
|
||||
// Drop next event since it is merged/flooded into e1
|
||||
e1_iter.next();
|
||||
// Retry this event again to give it a change to merge e1
|
||||
// with 'e3'
|
||||
retry_e = Some(e1);
|
||||
// Since we are retrying on this event we don't want to push it
|
||||
// to the new_events vec
|
||||
continue;
|
||||
} else {
|
||||
// Extend e1 to middle of the gap.
|
||||
e1.duration += gap / 2;
|
||||
|
||||
// Make sure next event (e2) is gets extended before it's processed
|
||||
gap_prev = Some(gap);
|
||||
}
|
||||
}
|
||||
// else: nothing to do, events not near each other
|
||||
|
||||
new_events.push(e1);
|
||||
}
|
||||
new_events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::flood;
|
||||
|
||||
#[test]
|
||||
fn test_flood_merge() {
|
||||
// Test merging of events with the same data
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:03Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let e_expected = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(4),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let res = flood(vec![e1, e2], Duration::seconds(5));
|
||||
assert_eq!(1, res.len());
|
||||
assert_eq!(&res[0], &e_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flood_meet_in_middle() {
|
||||
// Test flood gap between two different events which should meet in the middle
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:03Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(2)},
|
||||
};
|
||||
let e1_expected = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(2),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let e2_expected = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:02Z").unwrap(),
|
||||
duration: Duration::seconds(2),
|
||||
data: json_map! {"test": json!(2)},
|
||||
};
|
||||
let res = flood(vec![e1, e2], Duration::seconds(5));
|
||||
assert_eq!(2, res.len());
|
||||
assert_eq!(&res[0], &e1_expected);
|
||||
assert_eq!(&res[1], &e2_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flood_partial_overlap() {
|
||||
// Tests flooding an identical event contained within another event
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(10),
|
||||
data: json_map! {"type": "a"},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:05Z").unwrap(),
|
||||
duration: Duration::seconds(10),
|
||||
data: json_map! {"type": "a"},
|
||||
};
|
||||
let e1_expected = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(15),
|
||||
data: json_map! {"type": "a"},
|
||||
};
|
||||
let res = flood(vec![e1, e2], Duration::seconds(5));
|
||||
assert_eq!(1, res.len());
|
||||
assert_eq!(&res[0], &e1_expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flood_containing() {
|
||||
// Tests flooding an identical event contained within another event
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(10),
|
||||
data: json_map! {"type": "a"},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(5),
|
||||
data: json_map! {"type": "a"},
|
||||
};
|
||||
let res = flood(vec![e1.clone(), e2], Duration::seconds(5));
|
||||
assert_eq!(1, res.len());
|
||||
assert_eq!(&res[0], &e1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flood_containing_diff() {
|
||||
// Tests flooding an event with different data contained within another event
|
||||
// Events should pass unmodified.
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(10),
|
||||
data: json_map! {"type": "a"},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(5),
|
||||
data: json_map! {"type": "b"},
|
||||
};
|
||||
let res = flood(vec![e1.clone(), e2.clone()], Duration::seconds(5));
|
||||
assert_eq!(2, res.len());
|
||||
assert_eq!(&res[0], &e1);
|
||||
assert_eq!(&res[1], &e2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flood_same_timestamp() {
|
||||
// e1, stay same
|
||||
// e2, base merge (longest duration, this should be the duration selected)
|
||||
// e3, merge with e2
|
||||
// e4, stay same
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"status": "afk"},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(5),
|
||||
data: json_map! {"status": "not-afk"},
|
||||
};
|
||||
let e3 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"status": "not-afk"},
|
||||
};
|
||||
let e4 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:06Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"status": "afk"},
|
||||
};
|
||||
let res = flood(
|
||||
vec![e1.clone(), e2.clone(), e3, e4.clone()],
|
||||
Duration::seconds(5),
|
||||
);
|
||||
assert_eq!(3, res.len());
|
||||
assert_eq!(&res[0], &e1);
|
||||
assert_eq!(&res[1], &e2);
|
||||
assert_eq!(&res[2], &e4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flood_same_timestamp_duplicates() {
|
||||
// e1, stay same
|
||||
// e2, base merge
|
||||
// e3, merge with e2
|
||||
// e4, merge with e2 (longest duration, this should be the duration selected)
|
||||
// e5, stay same
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"status": "afk"},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(5),
|
||||
data: json_map! {"status": "not-afk"},
|
||||
};
|
||||
let e3 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"status": "not-afk"},
|
||||
};
|
||||
let e4 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(10),
|
||||
data: json_map! {"status": "not-afk"},
|
||||
};
|
||||
let e5 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:11Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"status": "afk"},
|
||||
};
|
||||
let res = flood(
|
||||
vec![e1.clone(), e2, e3, e4.clone(), e5.clone()],
|
||||
Duration::seconds(5),
|
||||
);
|
||||
assert_eq!(3, res.len());
|
||||
assert_eq!(&res[0], &e1);
|
||||
assert_eq!(&res[1], &e4);
|
||||
assert_eq!(&res[2], &e5);
|
||||
}
|
||||
}
|
||||
183
aw-transform/src/heartbeat.rs
Normal file
183
aw-transform/src/heartbeat.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
use aw_models::Event;
|
||||
|
||||
/// Returns a merged event if two events have the same data and are within the pulsetime
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// pulsetime: 1 second (one space)
|
||||
/// input: [a] [a] [a][b]
|
||||
/// output: [a ] [a][b]
|
||||
/// ```
|
||||
pub fn heartbeat(last_event: &Event, heartbeat: &Event, pulsetime: f64) -> Option<Event> {
|
||||
// Verify that data is the same
|
||||
if heartbeat.data != last_event.data {
|
||||
debug!("Can't merge, data is different");
|
||||
return None;
|
||||
}
|
||||
|
||||
let last_event_endtime = last_event.calculate_endtime();
|
||||
let heartbeat_endtime = heartbeat.calculate_endtime();
|
||||
|
||||
// Verify that timestamps intersect (including pulsetime)
|
||||
let pulsetime_ns: i64 = (pulsetime * 1_000_000_000.0).round() as i64;
|
||||
let last_endtime_allowed = last_event_endtime + chrono::Duration::nanoseconds(pulsetime_ns);
|
||||
if last_event.timestamp > heartbeat.timestamp {
|
||||
debug!("Can't merge, last event timestamp is after heartbeat timestamp");
|
||||
return None;
|
||||
}
|
||||
if heartbeat.timestamp > last_endtime_allowed {
|
||||
debug!("Can't merge, heartbeat timestamp is after last event endtime");
|
||||
return None;
|
||||
}
|
||||
|
||||
let starttime = if heartbeat.timestamp < last_event.timestamp {
|
||||
&heartbeat.timestamp
|
||||
} else {
|
||||
&last_event.timestamp
|
||||
};
|
||||
|
||||
let endtime = if last_event_endtime > heartbeat_endtime {
|
||||
&last_event_endtime
|
||||
} else {
|
||||
&heartbeat_endtime
|
||||
};
|
||||
|
||||
let duration = endtime.signed_duration_since(*starttime);
|
||||
if duration.num_nanoseconds().unwrap() < 0 {
|
||||
debug!("Merging heartbeats would result in a negative duration, refusing to merge!");
|
||||
return None;
|
||||
}
|
||||
|
||||
// Success, return successful heartbeat last_event
|
||||
Some(Event {
|
||||
id: None,
|
||||
timestamp: *starttime,
|
||||
duration,
|
||||
data: last_event.data.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::heartbeat;
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_pulsetime() {
|
||||
let now = Utc::now();
|
||||
let event1 = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let heartbeat1 = Event {
|
||||
id: None,
|
||||
timestamp: now + Duration::seconds(2),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
// Merge result
|
||||
let res_merge = heartbeat(&event1, &heartbeat1, 2.0).unwrap();
|
||||
assert_eq!(res_merge.timestamp, now);
|
||||
assert_eq!(res_merge.duration, Duration::seconds(3));
|
||||
assert_eq!(res_merge.data, event1.data);
|
||||
|
||||
// No merge result
|
||||
let res_no_merge = heartbeat(&event1, &heartbeat1, 0.0);
|
||||
assert!(res_no_merge.is_none());
|
||||
|
||||
// TODO: needs more tests!
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_long_pulse_merge() {
|
||||
let now = Utc::now();
|
||||
let event = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let long_pulse_event = Event {
|
||||
id: None,
|
||||
// note that no duration is sent, which is how aw-client works
|
||||
duration: Duration::seconds(0),
|
||||
timestamp: now + Duration::seconds(120),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
// Merge result
|
||||
let res_merge = heartbeat(&event, &long_pulse_event, 120.0).unwrap();
|
||||
assert_eq!(res_merge.timestamp, now);
|
||||
assert_eq!(res_merge.data, event.data);
|
||||
assert_eq!(res_merge.duration, Duration::seconds(120));
|
||||
|
||||
// No merge result when pulsetime is less than the timestamp delta between heartbeats
|
||||
let res_no_merge = heartbeat(&event, &long_pulse_event, 60.0);
|
||||
assert!(res_no_merge.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_data() {
|
||||
let now = Utc::now();
|
||||
let event = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::seconds(0),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let heartbeat_same_data = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
// Data is same, should merge
|
||||
let res_merge = heartbeat(&event, &heartbeat_same_data, 1.0);
|
||||
assert!(res_merge.is_some());
|
||||
|
||||
let heartbeat_different_data = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(2)},
|
||||
};
|
||||
// Data is different, should not merge
|
||||
let res_merge = heartbeat(&event, &heartbeat_different_data, 1.0);
|
||||
assert!(res_merge.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_same_timestamp() {
|
||||
let now = Utc::now();
|
||||
let event = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::seconds(0),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let heartbeat_same_data = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
// Should merge
|
||||
let res_merge = heartbeat(&event, &heartbeat_same_data, 1.0).unwrap();
|
||||
assert_eq!(Duration::seconds(1), res_merge.duration);
|
||||
|
||||
// Order shouldn't matter, should merge anyway
|
||||
let res_merge = heartbeat(&heartbeat_same_data, &event, 1.0).unwrap();
|
||||
assert_eq!(Duration::seconds(1), res_merge.duration);
|
||||
}
|
||||
}
|
||||
52
aw-transform/src/lib.rs
Normal file
52
aw-transform/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
// TODO: Move this to some more suitable place
|
||||
#[macro_export]
|
||||
macro_rules! json_map {
|
||||
{ $( $key:literal : $value:expr),* } => {{
|
||||
use serde_json::Value;
|
||||
use serde_json::map::Map;
|
||||
#[allow(unused_mut)]
|
||||
let mut map : Map<String, Value> = Map::new();
|
||||
$(
|
||||
map.insert( $key.to_string(), json!($value) );
|
||||
)*
|
||||
map
|
||||
}};
|
||||
}
|
||||
|
||||
pub mod classify;
|
||||
|
||||
mod heartbeat;
|
||||
pub use heartbeat::heartbeat;
|
||||
|
||||
mod find_bucket;
|
||||
pub use find_bucket::find_bucket;
|
||||
|
||||
mod flood;
|
||||
pub use flood::flood;
|
||||
|
||||
mod merge;
|
||||
pub use merge::merge_events_by_keys;
|
||||
|
||||
mod chunk;
|
||||
pub use chunk::chunk_events_by_key;
|
||||
|
||||
mod sort;
|
||||
pub use sort::{sort_by_duration, sort_by_timestamp};
|
||||
|
||||
mod filter_keyvals;
|
||||
pub use filter_keyvals::{exclude_keyvals, filter_keyvals, filter_keyvals_regex};
|
||||
|
||||
mod filter_period;
|
||||
pub use filter_period::filter_period_intersect;
|
||||
|
||||
mod split_url;
|
||||
pub use split_url::split_url_event;
|
||||
|
||||
mod period_union;
|
||||
pub use period_union::period_union;
|
||||
|
||||
mod union_no_overlap;
|
||||
pub use union_no_overlap::union_no_overlap;
|
||||
141
aw-transform/src/merge.rs
Normal file
141
aw-transform/src/merge.rs
Normal file
@@ -0,0 +1,141 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
/// Merge events with the same values at the specified keys
|
||||
///
|
||||
/// Doesn't care about if events are neighbouring or not, this transform merges
|
||||
/// all events with the same key.
|
||||
/// The timestamp will be the timestamp of the first event with a specific key value
|
||||
///
|
||||
/// # Example 1
|
||||
/// A simple example only using one key
|
||||
///
|
||||
/// ```ignore
|
||||
/// keys: ["a"]
|
||||
/// input:
|
||||
/// { duration: 1.0, data: { "a": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 2 } }
|
||||
/// { duration: 1.0, data: { "b": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 1 } }
|
||||
/// output:
|
||||
/// { duration: 3.0, data: { "a": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 2 } }
|
||||
/// { duration: 1.0, data: { "b": 1 } }
|
||||
/// ```
|
||||
///
|
||||
/// # Example 2
|
||||
/// A more complex example only using two keys
|
||||
/// ```ignore
|
||||
/// keys: ["a", "b"]
|
||||
/// input:
|
||||
/// { duration: 1.0, data: { "a": 1, "b": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 2, "b": 2 } }
|
||||
/// { duration: 1.0, data: { "a": 1, "b": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 1, "b": 2 } }
|
||||
/// output:
|
||||
/// { duration: 2.0, data: { "a": 1, "b": 1 } }
|
||||
/// { duration: 1.0, data: { "a": 2, "b": 2 } }
|
||||
/// { duration: 1.0, data: { "a": 1, "b": 2 } }
|
||||
/// ```
|
||||
#[allow(clippy::map_entry)]
|
||||
pub fn merge_events_by_keys(events: Vec<Event>, keys: Vec<String>) -> Vec<Event> {
|
||||
if keys.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let mut merged_events_map: HashMap<String, Event> = HashMap::new();
|
||||
'event: for event in events {
|
||||
let mut key_values = Vec::new();
|
||||
for key in &keys {
|
||||
match event.data.get(key) {
|
||||
Some(v) => key_values.push(v.to_string()),
|
||||
None => continue 'event,
|
||||
}
|
||||
}
|
||||
let summed_key = key_values.join(".");
|
||||
if merged_events_map.contains_key(&summed_key) {
|
||||
let merged_event = merged_events_map.get_mut(&summed_key).unwrap();
|
||||
merged_event.duration += event.duration;
|
||||
} else {
|
||||
let mut data = HashMap::new();
|
||||
for key in &keys {
|
||||
data.insert(key.clone(), event.data.get(key).unwrap());
|
||||
}
|
||||
let merged_event = Event {
|
||||
id: None,
|
||||
timestamp: event.timestamp,
|
||||
duration: event.duration,
|
||||
data: event.data.clone(),
|
||||
};
|
||||
merged_events_map.insert(summed_key, merged_event);
|
||||
}
|
||||
}
|
||||
let mut merged_events_list = Vec::new();
|
||||
for (_key, event) in merged_events_map.drain() {
|
||||
merged_events_list.push(event);
|
||||
}
|
||||
merged_events_list
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use crate::sort_by_timestamp;
|
||||
|
||||
use super::merge_events_by_keys;
|
||||
|
||||
#[test]
|
||||
fn test_merge_events_by_key() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(3),
|
||||
data: json_map! {"test2": json!(3)},
|
||||
};
|
||||
let e3 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:02Z").unwrap(),
|
||||
duration: Duration::seconds(7),
|
||||
data: json_map! {"test": json!(6)},
|
||||
};
|
||||
let e4 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:03Z").unwrap(),
|
||||
duration: Duration::seconds(9),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let in_events = vec![e1, e2, e3, e4];
|
||||
let res1 = merge_events_by_keys(in_events, vec!["test".to_string()]);
|
||||
// Needed, otherwise the order is undeterministic
|
||||
let res2 = sort_by_timestamp(res1);
|
||||
let expected = vec![
|
||||
Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(10),
|
||||
data: json_map! {"test": json!(1)},
|
||||
},
|
||||
Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:02Z").unwrap(),
|
||||
duration: Duration::seconds(7),
|
||||
data: json_map! {"test": json!(6)},
|
||||
},
|
||||
];
|
||||
assert_eq!(&res2, &expected);
|
||||
}
|
||||
}
|
||||
139
aw-transform/src/period_union.rs
Normal file
139
aw-transform/src/period_union.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
use super::sort::sort_by_timestamp;
|
||||
use aw_models::Event;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Takes a list of two events and returns a new list of events covering the union
|
||||
/// of the timeperiods contained in the eventlists with no overlapping events.
|
||||
///
|
||||
/// aw-core implementation: https://github.com/ActivityWatch/aw-core/blob/b11fbe08a0405dec01380493f7b3261163cc6878/aw_transform/filter_period_intersect.py#L92
|
||||
///
|
||||
/// WARNING: This function strips all data from events as it cannot keep it consistent.
|
||||
///
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// events1 | ------- --------- |
|
||||
/// events2 | ------ --- -- ---- |
|
||||
/// result | ----------- -- --------- |
|
||||
/// ```
|
||||
pub fn period_union(events1: &[Event], events2: &[Event]) -> Vec<Event> {
|
||||
let mut sorted_events: VecDeque<Event> = VecDeque::new();
|
||||
sorted_events.extend(sort_by_timestamp([events1, events2].concat()));
|
||||
|
||||
let mut events_union = Vec::new();
|
||||
|
||||
if !sorted_events.is_empty() {
|
||||
events_union.push(sorted_events.pop_front().unwrap())
|
||||
}
|
||||
|
||||
for e in sorted_events {
|
||||
let last_event = events_union.last().unwrap();
|
||||
|
||||
let e_p = e.interval();
|
||||
let le_p = last_event.interval();
|
||||
|
||||
match e_p.union(&le_p) {
|
||||
Some(new_period) => {
|
||||
// If no gap and could be unioned, modify last event
|
||||
let mut e_mod = events_union.pop().unwrap();
|
||||
e_mod.duration = new_period.duration();
|
||||
events_union.push(e_mod);
|
||||
}
|
||||
None => {
|
||||
// If gap and could not be unioned, push event
|
||||
events_union.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events_union
|
||||
.drain(..)
|
||||
.map(|mut e| {
|
||||
e.data = json_map! {};
|
||||
e
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::period_union;
|
||||
|
||||
#[test]
|
||||
fn test_period_union_empty() {
|
||||
let e_result = period_union(&[], &[]);
|
||||
assert_eq!(e_result.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_period_union() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
let mut e2 = e1.clone();
|
||||
e2.timestamp = DateTime::from_str("2000-01-01T00:00:02Z").unwrap();
|
||||
|
||||
let e_result = period_union(&[e1], &[e2]);
|
||||
assert_eq!(e_result.len(), 1);
|
||||
|
||||
let dt: DateTime<Utc> = DateTime::from_str("2000-01-01T00:00:01.000Z").unwrap();
|
||||
assert_eq!(e_result[0].timestamp, dt);
|
||||
assert_eq!(e_result[0].duration, Duration::milliseconds(2000));
|
||||
}
|
||||
|
||||
/// Make sure nothing gets done when nothing to union (gaps present)
|
||||
#[test]
|
||||
fn test_period_union_nop() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
let mut e2 = e1.clone();
|
||||
e2.timestamp = DateTime::from_str("2000-01-01T00:00:03Z").unwrap();
|
||||
|
||||
let e_result = period_union(&[e1], &[e2]);
|
||||
assert_eq!(e_result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_period_union_2nd_empty() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
let e_result = period_union(&[e1], &[]);
|
||||
assert_eq!(e_result.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_period_union_1st_empty() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
|
||||
let e_result = period_union(&[], &[e1]);
|
||||
assert_eq!(e_result.len(), 1);
|
||||
}
|
||||
}
|
||||
62
aw-transform/src/sort.rs
Normal file
62
aw-transform/src/sort.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use aw_models::Event;
|
||||
|
||||
/// Sort a list of events by timestamp
|
||||
pub fn sort_by_timestamp(mut events: Vec<Event>) -> Vec<Event> {
|
||||
events.sort_by_key(|e1| e1.timestamp);
|
||||
events
|
||||
}
|
||||
|
||||
/// Sort a list of events by duration with the highest duration first
|
||||
pub fn sort_by_duration(mut events: Vec<Event>) -> Vec<Event> {
|
||||
events.sort_by_key(|e| std::cmp::Reverse(e.duration));
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::{sort_by_duration, sort_by_timestamp};
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_timestamp() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:03Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let res = sort_by_timestamp(vec![e2.clone(), e1.clone()]);
|
||||
assert_eq!(res, vec![e1, e2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_duration() {
|
||||
let e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:00Z").unwrap(),
|
||||
duration: Duration::seconds(2),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let e2 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:03Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"test": json!(1)},
|
||||
};
|
||||
let res = sort_by_duration(vec![e2.clone(), e1.clone()]);
|
||||
assert_eq!(res, vec![e1, e2]);
|
||||
}
|
||||
}
|
||||
126
aw-transform/src/split_url.rs
Normal file
126
aw-transform/src/split_url.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use aw_models::Event;
|
||||
use serde_json::value::Value;
|
||||
|
||||
/// Adds $protocol, $domain, $path and $params keys for events with an "url" key
|
||||
///
|
||||
/// But it only adds the generated field if it exists, for example if a url does not have a path
|
||||
/// the path value will not be set at all.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// input: {
|
||||
/// "data": {
|
||||
/// "url": "http://google.com/test"
|
||||
/// }
|
||||
/// }
|
||||
/// output: {
|
||||
/// "data": {
|
||||
/// "$domain": "google.com",
|
||||
/// "$path": "/test",
|
||||
/// "$protocol": "http"
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn split_url_event(event: &mut Event) {
|
||||
use url::Url;
|
||||
|
||||
let uri_str = match event.data.get("url") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
_ => return,
|
||||
};
|
||||
let uri = match Url::parse(&uri_str) {
|
||||
Ok(uri) => uri,
|
||||
Err(_) => return,
|
||||
};
|
||||
// Protocol
|
||||
let protocol = uri.scheme().to_string();
|
||||
event
|
||||
.data
|
||||
.insert("$protocol".to_string(), Value::String(protocol));
|
||||
// Domain
|
||||
// For URLs without a host (e.g. file://, about:), fall back to the scheme
|
||||
// so they don't all cluster as an empty string in "Top Browser Domains".
|
||||
let domain = match uri.host_str() {
|
||||
Some(domain) => domain.trim_start_matches("www.").to_string(),
|
||||
None => uri.scheme().to_string(),
|
||||
};
|
||||
event
|
||||
.data
|
||||
.insert("$domain".to_string(), Value::String(domain));
|
||||
|
||||
// Path
|
||||
let path = uri.path().to_string();
|
||||
event.data.insert("$path".to_string(), Value::String(path));
|
||||
|
||||
// Params
|
||||
let params = match uri.query() {
|
||||
Some(query) => query.to_string(),
|
||||
None => "".to_string(),
|
||||
};
|
||||
event
|
||||
.data
|
||||
.insert("$params".to_string(), Value::String(params));
|
||||
|
||||
// TODO: aw-server-python also has options and identifier
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use serde_json::json;
|
||||
|
||||
use aw_models::Event;
|
||||
|
||||
use super::split_url_event;
|
||||
|
||||
#[test]
|
||||
fn test_split_url_events() {
|
||||
let mut e1 = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"url": "http://www.google.com/path?query=1"},
|
||||
};
|
||||
split_url_event(&mut e1);
|
||||
assert_eq!(
|
||||
e1.data,
|
||||
json_map! {
|
||||
"url": json!("http://www.google.com/path?query=1"),
|
||||
"$protocol": json!("http"),
|
||||
"$domain": json!("google.com"),
|
||||
"$path": json!("/path"),
|
||||
"$params": json!("query=1")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_url_file_scheme() {
|
||||
let mut e = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"url": "file:///home/user/document.pdf"},
|
||||
};
|
||||
split_url_event(&mut e);
|
||||
assert_eq!(e.data.get("$protocol"), Some(&json!("file")));
|
||||
assert_eq!(e.data.get("$domain"), Some(&json!("file")));
|
||||
assert_eq!(e.data.get("$path"), Some(&json!("/home/user/document.pdf")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_url_about_scheme() {
|
||||
let mut e = Event {
|
||||
id: None,
|
||||
timestamp: DateTime::from_str("2000-01-01T00:00:01Z").unwrap(),
|
||||
duration: Duration::seconds(1),
|
||||
data: json_map! {"url": "about:blank"},
|
||||
};
|
||||
split_url_event(&mut e);
|
||||
assert_eq!(e.data.get("$protocol"), Some(&json!("about")));
|
||||
assert_eq!(e.data.get("$domain"), Some(&json!("about")));
|
||||
}
|
||||
}
|
||||
297
aw-transform/src/union_no_overlap.rs
Normal file
297
aw-transform/src/union_no_overlap.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
use aw_models::{Event, TimeInterval};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Merges two eventlists and removes overlap, the first eventlist will have precedence
|
||||
///
|
||||
/// aw-core implementation: https://github.com/ActivityWatch/aw-core/blob/master/aw_transform/union_no_overlap.py
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// events1 | xxx xx xxx |
|
||||
/// events1 | ---- ------ -- |
|
||||
/// result | xxx-- xx ----xxx -- |
|
||||
/// ```
|
||||
pub fn union_no_overlap(events1: Vec<Event>, events2: Vec<Event>) -> Vec<Event> {
|
||||
let mut events_union: Vec<Event> = Vec::new();
|
||||
let mut events1 = events1.into_iter().peekable();
|
||||
let mut events2 = events2.into_iter();
|
||||
// Keep a split remainder here instead of inserting it ahead of the entire
|
||||
// unprocessed suffix. Each step consumes an input or emits a fragment.
|
||||
let mut pending = events2.next();
|
||||
while let (Some(e1), Some(e2)) = (events1.peek(), pending.as_ref()) {
|
||||
let e1_p: TimeInterval = e1.into();
|
||||
let e2_p: TimeInterval = e2.into();
|
||||
|
||||
if e1_p.intersects(&e2_p) {
|
||||
if e1.timestamp <= e2.timestamp {
|
||||
let end = e1.timestamp + e1.duration;
|
||||
let e2_end = e2.timestamp + e2.duration;
|
||||
if end > e2_end {
|
||||
// This foreground event may cover more background events.
|
||||
// Keep it pending until all of those have been removed.
|
||||
pending = events2.next();
|
||||
continue;
|
||||
}
|
||||
if e2.timestamp < end && end < e2_end {
|
||||
let remainder = pending.as_mut().unwrap();
|
||||
remainder.timestamp = end;
|
||||
remainder.duration = e2_end - end;
|
||||
remainder.id = None;
|
||||
} else {
|
||||
pending = events2.next();
|
||||
}
|
||||
events_union.push(events1.next().unwrap());
|
||||
} else {
|
||||
let (prefix, remainder) = split_event(pending.take().unwrap(), e1.timestamp);
|
||||
events_union.push(prefix);
|
||||
pending = remainder.or_else(|| events2.next());
|
||||
}
|
||||
} else if e1.timestamp <= e2.timestamp {
|
||||
events_union.push(events1.next().unwrap());
|
||||
} else {
|
||||
events_union.push(pending.take().unwrap());
|
||||
pending = events2.next();
|
||||
}
|
||||
}
|
||||
|
||||
// Now we just need to add any remaining events
|
||||
events_union.extend(events1);
|
||||
events_union.extend(pending);
|
||||
events_union.extend(events2);
|
||||
|
||||
events_union
|
||||
}
|
||||
|
||||
fn split_event(mut e: Event, timestamp: DateTime<Utc>) -> (Event, Option<Event>) {
|
||||
if e.timestamp < timestamp && timestamp < e.timestamp + e.duration {
|
||||
let e1 = Event::new(e.timestamp, timestamp - e.timestamp, e.data.clone());
|
||||
e.duration -= timestamp - e.timestamp;
|
||||
e.timestamp = timestamp;
|
||||
e.id = None;
|
||||
(e1, Some(e))
|
||||
} else {
|
||||
(e, None)
|
||||
}
|
||||
}
|
||||
|
||||
// Some tests
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
#[test]
|
||||
fn union_matches_discrete_coverage_for_all_small_inputs() {
|
||||
let now = Utc::now();
|
||||
let events = |mask: u32, source: &str| {
|
||||
let mut result = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < 6 {
|
||||
if mask & (1 << start) == 0 {
|
||||
start += 1;
|
||||
continue;
|
||||
}
|
||||
let mut end = start + 1;
|
||||
while end < 6 && mask & (1 << end) != 0 {
|
||||
end += 1;
|
||||
}
|
||||
let mut data = serde_json::Map::new();
|
||||
data.insert("source".into(), serde_json::json!(source));
|
||||
result.push(Event::new(
|
||||
now + Duration::seconds(start),
|
||||
Duration::seconds(end - start),
|
||||
data,
|
||||
));
|
||||
start = end;
|
||||
}
|
||||
result
|
||||
};
|
||||
for a in 0..64 {
|
||||
for b in 0..64 {
|
||||
let result = union_no_overlap(events(a, "a"), events(b, "b"));
|
||||
for slot in 0..6 {
|
||||
let time = now + Duration::seconds(slot);
|
||||
let covering: Vec<_> = result
|
||||
.iter()
|
||||
.filter(|e| e.timestamp <= time && time < e.timestamp + e.duration)
|
||||
.collect();
|
||||
let expected = if a & (1 << slot) != 0 {
|
||||
Some("a")
|
||||
} else if b & (1 << slot) != 0 {
|
||||
Some("b")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
assert_eq!(
|
||||
covering.len(),
|
||||
usize::from(expected.is_some()),
|
||||
"a={a}, b={b}, slot={slot}"
|
||||
);
|
||||
if let Some(source) = expected {
|
||||
assert_eq!(covering[0].data["source"], source);
|
||||
}
|
||||
}
|
||||
assert!(result
|
||||
.windows(2)
|
||||
.all(|pair| pair[0].timestamp + pair[0].duration <= pair[1].timestamp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_splits_preserve_precedence_payloads_and_ids() {
|
||||
let now = Utc::now();
|
||||
let mut background = Event::new(now, Duration::seconds(12), serde_json::Map::new());
|
||||
background.id = Some(99);
|
||||
background
|
||||
.data
|
||||
.insert("source".into(), serde_json::json!("background"));
|
||||
let foreground: Vec<_> = [2, 5, 8]
|
||||
.into_iter()
|
||||
.map(|start| {
|
||||
let mut e = Event::new(
|
||||
now + Duration::seconds(start),
|
||||
Duration::seconds(1),
|
||||
serde_json::Map::new(),
|
||||
);
|
||||
e.id = Some(start);
|
||||
e.data
|
||||
.insert("source".into(), serde_json::json!("foreground"));
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
let result = union_no_overlap(foreground, vec![background]);
|
||||
let expected = [
|
||||
(0, 2, None),
|
||||
(2, 1, Some(2)),
|
||||
(3, 2, None),
|
||||
(5, 1, Some(5)),
|
||||
(6, 2, None),
|
||||
(8, 1, Some(8)),
|
||||
(9, 3, None),
|
||||
];
|
||||
assert_eq!(result.len(), expected.len());
|
||||
for (event, (start, duration, id)) in result.iter().zip(expected) {
|
||||
assert_eq!(event.timestamp, now + Duration::seconds(start));
|
||||
assert_eq!(event.duration, Duration::seconds(duration));
|
||||
assert_eq!(event.id, id);
|
||||
assert_eq!(
|
||||
event.data["source"],
|
||||
if id.is_some() {
|
||||
"foreground"
|
||||
} else {
|
||||
"background"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_disjoint_and_zero_duration_events_keep_ids() {
|
||||
let now = Utc::now();
|
||||
let mut event = Event::new(now, Duration::zero(), serde_json::Map::new());
|
||||
event.id = Some(12);
|
||||
assert_eq!(
|
||||
union_no_overlap(vec![], vec![event.clone()])[0].id,
|
||||
Some(12)
|
||||
);
|
||||
assert_eq!(
|
||||
union_no_overlap(vec![event.clone()], vec![])[0].id,
|
||||
Some(12)
|
||||
);
|
||||
let mut later = event.clone();
|
||||
later.timestamp += Duration::seconds(1);
|
||||
later.id = Some(13);
|
||||
let result = union_no_overlap(vec![event], vec![later]);
|
||||
assert_eq!(
|
||||
result.iter().map(|e| e.id).collect::<Vec<_>>(),
|
||||
vec![Some(12), Some(13)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_event() {
|
||||
let now = Utc::now();
|
||||
let td1h = Duration::hours(1);
|
||||
let e = Event {
|
||||
id: None,
|
||||
timestamp: now,
|
||||
duration: Duration::hours(2),
|
||||
data: serde_json::Map::new(),
|
||||
};
|
||||
let (e1, e2_opt) = split_event(e.clone(), now + td1h);
|
||||
assert_eq!(e1.timestamp, now);
|
||||
assert_eq!(e1.duration, td1h);
|
||||
|
||||
let e2 = e2_opt.unwrap();
|
||||
assert_eq!(e2.timestamp, now + td1h);
|
||||
assert_eq!(e2.duration, td1h);
|
||||
|
||||
// Now a test which does not lead to a split
|
||||
let (e1, e2_opt) = split_event(e, now);
|
||||
assert_eq!(e1.timestamp, now);
|
||||
assert_eq!(e1.duration, Duration::hours(2));
|
||||
assert!(e2_opt.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_union_no_overlap() {
|
||||
// A test without any actual overlap
|
||||
let now = Utc::now();
|
||||
let td1h = Duration::hours(1);
|
||||
let e1 = Event::new(now, td1h, serde_json::Map::new());
|
||||
let e2 = Event::new(now + td1h, td1h, serde_json::Map::new());
|
||||
let events1 = vec![e1.clone()];
|
||||
let events2 = vec![e2.clone()];
|
||||
let events_union = union_no_overlap(events1, events2);
|
||||
|
||||
assert_eq!(events_union.len(), 2);
|
||||
assert_eq!(events_union[0].timestamp, now);
|
||||
assert_eq!(events_union[0].duration, td1h);
|
||||
assert_eq!(events_union[1].timestamp, now + td1h);
|
||||
assert_eq!(events_union[1].duration, td1h);
|
||||
|
||||
// Now do in reverse order
|
||||
let events1 = vec![e2];
|
||||
let events2 = vec![e1];
|
||||
let events_union = union_no_overlap(events1, events2);
|
||||
|
||||
// Resulting order should be the same, since there is no overlap.
|
||||
assert_eq!(events_union.len(), 2);
|
||||
assert_eq!(events_union[0].timestamp, now);
|
||||
assert_eq!(events_union[0].duration, td1h);
|
||||
assert_eq!(events_union[1].timestamp, now + td1h);
|
||||
assert_eq!(events_union[1].duration, td1h);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_union_no_overlap_with_overlap() {
|
||||
// A test where the events overlap
|
||||
let now = Utc::now();
|
||||
let td1h = Duration::hours(1);
|
||||
let e1 = Event::new(now, td1h, serde_json::Map::new());
|
||||
let e2 = Event::new(now, Duration::hours(2), serde_json::Map::new());
|
||||
let events1 = vec![e1];
|
||||
let events2 = vec![e2];
|
||||
let events_union = union_no_overlap(events1, events2);
|
||||
|
||||
assert_eq!(events_union.len(), 2);
|
||||
assert_eq!(events_union[0].timestamp, now);
|
||||
assert_eq!(events_union[0].duration, td1h);
|
||||
assert_eq!(events_union[1].timestamp, now + td1h);
|
||||
assert_eq!(events_union[1].duration, td1h);
|
||||
|
||||
// Now test the case where e2 starts before e1
|
||||
let e1 = Event::new(now + td1h, td1h, serde_json::Map::new());
|
||||
let e2 = Event::new(now, Duration::hours(2), serde_json::Map::new());
|
||||
let events1 = vec![e1];
|
||||
let events2 = vec![e2];
|
||||
let events_union = union_no_overlap(events1, events2);
|
||||
|
||||
assert_eq!(events_union.len(), 2);
|
||||
assert_eq!(events_union[0].timestamp, now);
|
||||
assert_eq!(events_union[0].duration, td1h);
|
||||
assert_eq!(events_union[1].timestamp, now + td1h);
|
||||
assert_eq!(events_union[1].duration, td1h);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user