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

This commit is contained in:
upstream-mirror
2026-09-16 00:19:17 +05:00
commit da934b693c
120 changed files with 26659 additions and 0 deletions

80
aw-transform/src/chunk.rs Normal file
View 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));
}
}

View 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()
);
}
}

View 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", &regex_value);
assert_eq!(vec![e1.clone(), e2], res);
let res = filter_keyvals_regex(events.clone(), "key1", &regex_value1);
assert_eq!(vec![e1], res);
let res = filter_keyvals_regex(events.clone(), "key2", &regex_value);
assert_eq!(vec![e3], res);
let res = filter_keyvals_regex(events.clone(), "key2", &regex_value1);
assert_eq!(0, res.len());
let res = filter_keyvals_regex(events, "key3", &regex_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", &regex_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);
}
}

View 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));
}
}

View 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
View 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);
}
}

View 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
View 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
View 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);
}
}

View 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
View 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]);
}
}

View 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")));
}
}

View 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);
}
}