use std::collections::{BTreeMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Context, Result, bail}; use chrono::{DateTime, SecondsFormat, TimeDelta, Utc}; use clap::Parser; use detmir_aw_client::ActivityWatchClient; use reqwest::blocking::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; const DEFAULT_AW_URL: &str = "http://127.0.0.1:5600/api/0"; const DEFAULT_CLICKHOUSE_URL: &str = "http://127.0.0.1:8123"; const DEFAULT_CLICKHOUSE_DATABASE: &str = "aw_workforce"; const DEFAULT_STATE_PATH: &str = "/var/lib/aw-workforce-ingest/state.json"; const WINDOW_TABLE: &str = "aw_window_events"; const BROWSER_TABLE: &str = "aw_browser_events"; const WINDOW_COLUMNS: &[&str] = &[ "event_time", "host_name", "user_login", "process_name", "window_title", "duration_sec", "source_bucket", "source_event_id", ]; const BROWSER_COLUMNS: &[&str] = &[ "event_time", "host_name", "user_login", "browser_name", "url", "title", "duration_sec", "source_bucket", "source_event_id", ]; #[derive(Debug, Parser)] #[command( about = "Ingest ActivityWatch window/browser events into aw_workforce ClickHouse tables." )] pub struct Cli { #[arg(long, default_value = DEFAULT_AW_URL, env = "AW_WORKFORCE_AW_URL")] aw_url: String, #[arg( long, default_value = DEFAULT_CLICKHOUSE_URL, env = "AW_WORKFORCE_CLICKHOUSE_URL" )] clickhouse_url: String, #[arg( long, default_value = DEFAULT_CLICKHOUSE_DATABASE, env = "AW_WORKFORCE_CLICKHOUSE_DATABASE" )] clickhouse_database: String, #[arg(long, default_value = "", env = "AW_WORKFORCE_CLICKHOUSE_USER")] clickhouse_user: String, #[arg(long, default_value = "", env = "AW_WORKFORCE_CLICKHOUSE_PASSWORD")] clickhouse_password: String, #[arg(long, env = "AW_WORKFORCE_HOST")] host: String, #[arg(long = "window-bucket")] window_buckets: Vec, #[arg(long = "browser-bucket")] browser_buckets: Vec, #[arg(long, default_value = DEFAULT_STATE_PATH, env = "AW_WORKFORCE_STATE_PATH")] state_path: PathBuf, #[arg(long, default_value_t = 300, env = "AW_WORKFORCE_OVERLAP_SECONDS")] overlap_seconds: i64, #[arg(long, env = "AW_WORKFORCE_NO_STATE")] no_state: bool, #[arg(long)] since: Option, #[arg(long)] until: Option, #[arg(long, default_value_t = 24)] hours: i64, #[arg(long, default_value_t = 10_000)] limit: usize, #[arg(long, default_value_t = 30)] timeout_seconds: u64, #[arg(long, default_value_t = 3, env = "AW_WORKFORCE_RETRY_ATTEMPTS")] retry_attempts: usize, #[arg(long, default_value_t = 1000, env = "AW_WORKFORCE_RETRY_BACKOFF_MS")] retry_backoff_ms: u64, #[arg(long, env = "AW_WORKFORCE_FAIL_ON_EMPTY")] fail_on_empty: bool, #[arg(long)] dry_run: bool, #[arg(long)] json: bool, } #[derive(Debug, Clone, Deserialize)] struct Bucket { #[serde(default)] hostname: String, } #[derive(Debug, Deserialize)] struct RawAwEvent { #[serde(default)] id: Option, timestamp: String, #[serde(default)] duration: f64, #[serde(default)] data: Value, } #[derive(Debug)] struct AwEvent { bucket_id: String, bucket_hostname: String, event_id: String, timestamp: String, duration: f64, data: Value, } #[derive(Debug, Clone, Serialize)] struct WindowRow { event_time: String, host_name: String, user_login: String, process_name: String, window_title: String, duration_sec: u32, source_bucket: String, source_event_id: String, } #[derive(Debug, Clone, Serialize)] struct BrowserRow { event_time: String, host_name: String, user_login: String, browser_name: String, url: String, title: String, duration_sec: u32, source_bucket: String, source_event_id: String, } trait SourceKey { fn source_bucket(&self) -> &str; fn source_event_id(&self) -> &str; } impl SourceKey for WindowRow { fn source_bucket(&self) -> &str { &self.source_bucket } fn source_event_id(&self) -> &str { &self.source_event_id } } impl SourceKey for BrowserRow { fn source_bucket(&self) -> &str { &self.source_bucket } fn source_event_id(&self) -> &str { &self.source_event_id } } #[derive(Debug, Serialize)] struct RunSummary { ok: bool, dry_run: bool, aw_url: String, clickhouse_url: String, clickhouse_database: String, host: String, state_path: String, state_enabled: bool, state_loaded_last_end: Option, state_saved_last_end: Option, overlap_seconds: i64, start: String, end: String, window_buckets: Vec, browser_buckets: Vec, missing_buckets: Vec, window_events_fetched: usize, browser_events_fetched: usize, window_rows_filtered_invalid: usize, window_rows_prepared: usize, browser_rows_prepared: usize, window_rows_inserted: usize, browser_rows_inserted: usize, window_rows_skipped_existing: usize, browser_rows_skipped_existing: usize, } #[derive(Debug, Default, Deserialize, Serialize)] struct State { #[serde(default)] last_end: Option, } #[derive(Debug)] struct ClickHouseClient { client: Client, base_url: String, database: String, username: String, password: String, } impl ClickHouseClient { fn new( base_url: &str, database: &str, username: &str, password: &str, timeout_seconds: u64, ) -> Result { let database = normalize_identifier(database, "ClickHouse database")?; let client = Client::builder() .timeout(Duration::from_secs(timeout_seconds)) .no_proxy() .build() .context("build ClickHouse HTTP client")?; Ok(Self { client, base_url: base_url.trim_end_matches('/').to_string(), database, username: username.to_string(), password: password.to_string(), }) } fn execute(&self, sql: &str) -> Result { if sql.trim().is_empty() { return Ok(String::new()); } let mut req = self .client .post(&self.base_url) .query(&[("database", self.database.as_str())]) .body(sql.to_string()); if !self.username.trim().is_empty() { req = req.basic_auth(self.username.clone(), Some(self.password.clone())); } let response = req.send().context("ClickHouse request")?; let status = response.status(); let body = response.text().unwrap_or_default(); if !status.is_success() { bail!("ClickHouse HTTP {status}: {}", body.trim()); } Ok(body) } fn query_json_each_row(&self, sql: &str) -> Result> { let body = self.execute(sql)?; body.lines() .filter(|line| !line.trim().is_empty()) .map(|line| serde_json::from_str(line).with_context(|| "parse ClickHouse JSONEachRow")) .collect() } fn insert_json_each_row( &self, table: &str, columns: &[&str], rows: &[T], ) -> Result { if rows.is_empty() { return Ok(0); } let table_name = self.table_name(table)?; let mut payload = String::new(); payload.push_str(&format!( "INSERT INTO {table_name} ({}) FORMAT JSONEachRow\n", columns.join(", ") )); for row in rows { payload.push_str(&serde_json::to_string(row)?); payload.push('\n'); } self.execute(&payload)?; Ok(rows.len()) } fn table_name(&self, table: &str) -> Result { let table = normalize_identifier(table, "ClickHouse table")?; Ok(format!("{}.{}", self.database, table)) } } #[derive(Debug, Deserialize)] struct ExistingId { source_event_id: String, } pub fn run_from_args() -> Result<()> { let cli = Cli::parse(); let json_output = cli.json || std::env::var("AW_WORKFORCE_JSON") .map(|value| matches!(value.as_str(), "1" | "true" | "yes")) .unwrap_or(false); let summary = run_with_retries(cli)?; if summary.dry_run || summary.ok { if json_output || summary.dry_run { println!("{}", serde_json::to_string_pretty(&summary)?); } else { println!( "aw-workforce-ingest ok dry_run={} window_inserted={} browser_inserted={} window_existing={} browser_existing={} window_filtered_invalid={} missing_buckets={}", summary.dry_run, summary.window_rows_inserted, summary.browser_rows_inserted, summary.window_rows_skipped_existing, summary.browser_rows_skipped_existing, summary.window_rows_filtered_invalid, summary.missing_buckets.len() ); } } Ok(()) } fn run_with_retries(cli: Cli) -> Result { let retry_attempts = cli.retry_attempts.max(1); let retry_backoff_ms = cli.retry_backoff_ms; let mut last_error = None; for attempt in 1..=retry_attempts { match run_once(&cli) { Ok(summary) => return Ok(summary), Err(err) => { last_error = Some(err); if attempt < retry_attempts { std::thread::sleep(Duration::from_millis( retry_backoff_ms.saturating_mul(attempt as u64), )); } } } } Err(last_error.expect("at least one retry attempt")) } fn run_once(cli: &Cli) -> Result { validate_cli(cli)?; let host = cli.host.trim().to_string(); let state_enabled = !cli.no_state; let state = if state_enabled { load_state(&cli.state_path)? } else { State::default() }; let state_loaded_last_end = state.last_end.clone(); let end = match &cli.until { Some(value) => parse_time(value)?, None => Utc::now(), }; let start = match &cli.since { Some(value) => parse_time(value)?, None if state_enabled => match &state.last_end { Some(last_end) => parse_time(last_end)? - TimeDelta::seconds(cli.overlap_seconds), None => end - TimeDelta::hours(cli.hours), }, None => end - TimeDelta::hours(cli.hours), }; if start >= end { bail!("--since must be earlier than --until"); } let window_bucket_ids = default_window_buckets(&host, &cli.window_buckets); let browser_bucket_ids = default_browser_buckets(&host, &cli.browser_buckets); let aw_client = ActivityWatchClient::new(&cli.aw_url, Duration::from_secs(cli.timeout_seconds))?; let buckets: BTreeMap = aw_client.get_json("/buckets")?; let (window_buckets, mut missing_buckets) = resolve_buckets(&buckets, &window_bucket_ids); let (browser_buckets, browser_missing) = resolve_buckets(&buckets, &browser_bucket_ids); missing_buckets.extend(browser_missing); if window_buckets.is_empty() && browser_buckets.is_empty() { bail!("none of the requested ActivityWatch buckets exist"); } let window_events = fetch_events(&aw_client, &window_buckets, start, end, cli.limit, "window")?; let browser_events = fetch_events( &aw_client, &browser_buckets, start, end, cli.limit, "browser", )?; let mut window_rows_filtered_invalid = 0usize; let window_rows = window_events .iter() .filter_map(|event| match map_window_row(event, &host) { Ok(row) if is_invalid_synthetic_window_event(event, &row) => { window_rows_filtered_invalid = window_rows_filtered_invalid.saturating_add(1); None } Ok(row) => Some(Ok(row)), Err(err) => Some(Err(err)), }) .collect::>>()?; let browser_rows = browser_events .iter() .map(|event| map_browser_row(event, &host)) .collect::>>()?; let mut window_rows_inserted = 0; let mut browser_rows_inserted = 0; let mut window_rows_skipped_existing = 0; let mut browser_rows_skipped_existing = 0; if !cli.dry_run { let clickhouse = ClickHouseClient::new( &cli.clickhouse_url, &cli.clickhouse_database, &cli.clickhouse_user, &cli.clickhouse_password, cli.timeout_seconds, )?; let (new_window_rows, skipped_window) = filter_existing_rows(&clickhouse, WINDOW_TABLE, &window_rows)?; let (new_browser_rows, skipped_browser) = filter_existing_rows(&clickhouse, BROWSER_TABLE, &browser_rows)?; window_rows_inserted = clickhouse.insert_json_each_row(WINDOW_TABLE, WINDOW_COLUMNS, &new_window_rows)?; browser_rows_inserted = clickhouse.insert_json_each_row(BROWSER_TABLE, BROWSER_COLUMNS, &new_browser_rows)?; window_rows_skipped_existing = skipped_window; browser_rows_skipped_existing = skipped_browser; } if cli.fail_on_empty && window_rows.is_empty() && browser_rows.is_empty() { bail!("no ActivityWatch events fetched for requested buckets and time range"); } let mut state_saved_last_end = None; if state_enabled && !cli.dry_run { let next_state = State { last_end: Some(format_aw_timestamp(end)), }; save_state(&cli.state_path, &next_state)?; state_saved_last_end = next_state.last_end; } Ok(RunSummary { ok: true, dry_run: cli.dry_run, aw_url: cli.aw_url.clone(), clickhouse_url: cli.clickhouse_url.clone(), clickhouse_database: cli.clickhouse_database.clone(), host, state_path: cli.state_path.display().to_string(), state_enabled, state_loaded_last_end, state_saved_last_end, overlap_seconds: cli.overlap_seconds, start: format_aw_timestamp(start), end: format_aw_timestamp(end), window_buckets: window_bucket_ids, browser_buckets: browser_bucket_ids, missing_buckets, window_events_fetched: window_events.len(), browser_events_fetched: browser_events.len(), window_rows_filtered_invalid, window_rows_prepared: window_rows.len(), browser_rows_prepared: browser_rows.len(), window_rows_inserted, browser_rows_inserted, window_rows_skipped_existing, browser_rows_skipped_existing, }) } fn validate_cli(cli: &Cli) -> Result<()> { if cli.host.trim().is_empty() { bail!("--host or AW_WORKFORCE_HOST is required"); } if cli.hours <= 0 { bail!("--hours must be positive"); } if cli.overlap_seconds < 0 { bail!("--overlap-seconds must be zero or positive"); } if cli.limit == 0 { bail!("--limit must be positive"); } if cli.timeout_seconds == 0 { bail!("--timeout-seconds must be positive"); } if cli.retry_attempts == 0 { bail!("--retry-attempts must be positive"); } Ok(()) } fn load_state(path: &Path) -> Result { if !path.exists() { return Ok(State::default()); } let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) } fn save_state(path: &Path, state: &State) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; let mut temp = tempfile::NamedTempFile::new_in(parent) .with_context(|| format!("create temp state in {}", parent.display()))?; serde_json::to_writer_pretty(&mut temp, state)?; std::io::Write::write_all(&mut temp, b"\n")?; temp.persist(path) .map_err(|err| anyhow::anyhow!("persist {}: {}", path.display(), err))?; return Ok(()); } fs::write(path, serde_json::to_string_pretty(state)? + "\n") .with_context(|| format!("write {}", path.display())) } fn default_window_buckets(host: &str, requested: &[String]) -> Vec { if requested.is_empty() { return vec![format!("aw-watcher-window_{host}")]; } requested .iter() .map(|value| value.trim()) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect() } fn default_browser_buckets(host: &str, requested: &[String]) -> Vec { if requested.is_empty() { return vec![ format!("aw-watcher-web-edge_{host}"), format!("aw-watcher-web-chrome_{host}"), ]; } requested .iter() .map(|value| value.trim()) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .collect() } fn resolve_buckets( buckets: &BTreeMap, requested: &[String], ) -> (Vec<(String, Bucket)>, Vec) { let mut selected = Vec::new(); let mut missing = Vec::new(); for bucket_id in requested { match buckets.get(bucket_id) { Some(bucket) => selected.push((bucket_id.clone(), bucket.clone())), None => missing.push(bucket_id.clone()), } } (selected, missing) } fn fetch_events( client: &ActivityWatchClient, buckets: &[(String, Bucket)], start: DateTime, end: DateTime, limit: usize, label: &str, ) -> Result> { let mut events = Vec::new(); for (bucket_id, bucket) in buckets { let path = build_events_path(bucket_id, start, end, limit); let raw_events: Vec = client .get_json(&path) .with_context(|| format!("fetch {label} events from {bucket_id}"))?; events.extend(raw_events.into_iter().map(|raw| { let data = normalized_data(raw.data); let event_id = event_id(bucket_id, &raw.id, &raw.timestamp, raw.duration, &data); AwEvent { bucket_id: bucket_id.clone(), bucket_hostname: bucket.hostname.clone(), event_id, timestamp: raw.timestamp, duration: raw.duration, data, } })); } Ok(events) } fn build_events_path( bucket_id: &str, start: DateTime, end: DateTime, limit: usize, ) -> String { format!( "/buckets/{}/events?start={}&end={}&limit={}", urlencoding::encode(bucket_id), urlencoding::encode(&format_aw_timestamp(start)), urlencoding::encode(&format_aw_timestamp(end)), limit ) } fn map_window_row(event: &AwEvent, fallback_host: &str) -> Result { Ok(WindowRow { event_time: format_clickhouse_datetime(parse_time(&event.timestamp)?), host_name: event_host(event, fallback_host), user_login: event_user(event), process_name: normalize_process_name( &first_string( &event.data, &["app", "process", "process_name", "foregroundProcess"], ) .unwrap_or_else(|| "unknown".to_string()), ), window_title: first_string(&event.data, &["title", "window_title", "foregroundTitle"]) .unwrap_or_default(), duration_sec: duration_to_u32(event.duration), source_bucket: event.bucket_id.clone(), source_event_id: event.event_id.clone(), }) } fn is_invalid_synthetic_window_event(_event: &AwEvent, row: &WindowRow) -> bool { row.user_login == "unknown" || (row.process_name == "unknown" && row.window_title.trim().is_empty()) } fn map_browser_row(event: &AwEvent, fallback_host: &str) -> Result { Ok(BrowserRow { event_time: format_clickhouse_datetime(parse_time(&event.timestamp)?), host_name: event_host(event, fallback_host), user_login: event_user(event), browser_name: normalize_process_name( &first_string(&event.data, &["browser", "app"]) .unwrap_or_else(|| "unknown".to_string()), ), url: first_string(&event.data, &["url", "uri"]).unwrap_or_default(), title: first_string(&event.data, &["title"]).unwrap_or_default(), duration_sec: duration_to_u32(event.duration), source_bucket: event.bucket_id.clone(), source_event_id: event.event_id.clone(), }) } fn event_host(event: &AwEvent, fallback_host: &str) -> String { first_string(&event.data, &["hostname", "host"]) .or_else(|| { let trimmed = event.bucket_hostname.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) }) .unwrap_or_else(|| fallback_host.to_string()) } fn event_user(event: &AwEvent) -> String { let raw = first_string( &event.data, &["username", "user", "userName", "login", "userId"], ); normalize_user_login(raw.as_deref()) } fn normalized_data(value: Value) -> Value { if value.is_object() { value } else { json!({ "raw": value }) } } fn first_string(data: &Value, keys: &[&str]) -> Option { let object = data.as_object()?; for key in keys { if let Some(value) = object.get(*key) { match value { Value::Null => {} Value::String(value) => { let trimmed = value.trim(); if !trimmed.is_empty() { return Some(trimmed.to_string()); } } other => { let rendered = other.to_string(); if !rendered.is_empty() { return Some(rendered); } } } } } None } fn normalize_user_login(value: Option<&str>) -> String { let Some(value) = value else { return "unknown".to_string(); }; let mut value = value.trim(); if value.is_empty() { return "unknown".to_string(); } if let Some((_domain, user)) = value.rsplit_once('\\') { value = user; } else if let Some((_domain, user)) = value.rsplit_once('/') { value = user; } let normalized = value.trim().to_ascii_lowercase(); if normalized.is_empty() || normalized.ends_with('$') { "unknown".to_string() } else { normalized } } fn normalize_process_name(value: &str) -> String { let normalized = value.trim().to_ascii_lowercase(); if normalized.is_empty() { "unknown".to_string() } else { normalized } } fn duration_to_u32(value: f64) -> u32 { if !value.is_finite() || value <= 0.0 { return 0; } value.round().min(u32::MAX as f64) as u32 } fn event_id( bucket_id: &str, raw_id: &Option, timestamp: &str, duration: f64, data: &Value, ) -> String { match raw_id { Some(Value::String(value)) if !value.trim().is_empty() => value.trim().to_string(), Some(Value::Number(value)) => value.to_string(), Some(Value::Bool(value)) => value.to_string(), _ => { let payload = serde_json::to_string(data).unwrap_or_else(|_| "{}".to_string()); format!("{bucket_id}|{timestamp}|{duration}|{payload}") } } } fn filter_existing_rows( clickhouse: &ClickHouseClient, table: &str, rows: &[T], ) -> Result<(Vec, usize)> { if rows.is_empty() { return Ok((Vec::new(), 0)); } let existing = existing_source_keys(clickhouse, table, rows)?; let mut new_rows = Vec::new(); let mut skipped = 0; for row in rows { let key = ( row.source_bucket().to_string(), row.source_event_id().to_string(), ); if existing.contains(&key) { skipped += 1; } else { new_rows.push(row.clone()); } } Ok((new_rows, skipped)) } fn existing_source_keys( clickhouse: &ClickHouseClient, table: &str, rows: &[T], ) -> Result> { let table_name = clickhouse.table_name(table)?; let mut by_bucket: BTreeMap> = BTreeMap::new(); for row in rows { by_bucket .entry(row.source_bucket().to_string()) .or_default() .insert(row.source_event_id().to_string()); } let mut existing = HashSet::new(); for (bucket, ids) in by_bucket { let ids: Vec = ids.into_iter().collect(); for chunk in ids.chunks(500) { let id_list = chunk .iter() .map(|id| clickhouse_string_literal(id)) .collect::>() .join(", "); let sql = format!( "SELECT source_event_id FROM {table_name} WHERE source_bucket = {} AND source_event_id IN ({id_list}) FORMAT JSONEachRow", clickhouse_string_literal(&bucket) ); let found: Vec = clickhouse.query_json_each_row(&sql)?; for item in found { existing.insert((bucket.clone(), item.source_event_id)); } } } Ok(existing) } fn parse_time(value: &str) -> Result> { Ok(DateTime::parse_from_rfc3339(value) .with_context(|| format!("parse timestamp {value}"))? .with_timezone(&Utc)) } fn format_aw_timestamp(value: DateTime) -> String { value.to_rfc3339_opts(SecondsFormat::Micros, true) } fn format_clickhouse_datetime(value: DateTime) -> String { value.format("%Y-%m-%d %H:%M:%S").to_string() } fn normalize_identifier(value: &str, label: &str) -> Result { let value = value.trim(); if value.is_empty() || !value .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') { bail!("{label} must contain only ASCII letters, digits and underscores"); } Ok(value.to_string()) } fn clickhouse_string_literal(value: &str) -> String { let mut out = String::from("'"); for ch in value.chars() { match ch { '\'' => out.push_str("\\'"), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), _ => out.push(ch), } } out.push('\''); out } #[cfg(test)] mod tests { use super::*; const TEST_HOST: &str = "HOST-EXAMPLE"; const TEST_USER: &str = "Alice"; const TEST_BUCKET: &str = "aw-watcher-window_HOST-EXAMPLE"; #[test] fn normalizes_domain_user_login() { assert_eq!(normalize_user_login(Some("HOST-EXAMPLE\\Alice")), "alice"); assert_eq!(normalize_user_login(Some("Bob")), "bob"); assert_eq!(normalize_user_login(Some("DOMAIN/Carol")), "carol"); assert_eq!(normalize_user_login(Some("DOMAIN\\Оператор")), "Оператор"); assert_eq!(normalize_user_login(Some("MACHINE$")), "unknown"); assert_eq!(normalize_user_login(None), "unknown"); } #[test] fn builds_bounded_aw_events_path() { let start = parse_time("2026-06-23T10:00:00Z").unwrap(); let end = parse_time("2026-06-23T11:00:00Z").unwrap(); let path = build_events_path(TEST_BUCKET, start, end, 500); assert!(path.starts_with("/buckets/aw-watcher-window_HOST-EXAMPLE/events?")); assert!(path.contains("limit=500")); assert!(path.contains("start=2026-06-23T10%3A00%3A00.000000Z")); assert!(path.contains("end=2026-06-23T11%3A00%3A00.000000Z")); } #[test] fn maps_window_event_to_clickhouse_row() { let raw = RawAwEvent { id: Some(Value::from(42)), timestamp: "2026-06-23T12:17:13.853Z".to_string(), duration: 1.6, data: json!({ "app": "1CV8C.EXE", "hostname": TEST_HOST, "title": "document", "username": TEST_USER }), }; let data = normalized_data(raw.data); let event = AwEvent { bucket_id: TEST_BUCKET.to_string(), bucket_hostname: TEST_HOST.to_string(), event_id: event_id(TEST_BUCKET, &raw.id, &raw.timestamp, raw.duration, &data), timestamp: raw.timestamp, duration: raw.duration, data, }; let row = map_window_row(&event, "fallback").unwrap(); assert_eq!(row.event_time, "2026-06-23 12:17:13"); assert_eq!(row.host_name, TEST_HOST); assert_eq!(row.user_login, "alice"); assert_eq!(row.process_name, "1cv8c.exe"); assert_eq!(row.duration_sec, 2); assert_eq!(row.source_event_id, "42"); } #[test] fn detects_invalid_synthetic_window_event() { let raw = RawAwEvent { id: Some(Value::from(43)), timestamp: "2026-06-23T12:18:13.853Z".to_string(), duration: 10.0, data: json!({ "app": "", "hostname": TEST_HOST, "processId": 0, "source": "aw-windows-telemetry-rust", "title": "", "username": TEST_USER }), }; let data = normalized_data(raw.data); let event = AwEvent { bucket_id: TEST_BUCKET.to_string(), bucket_hostname: TEST_HOST.to_string(), event_id: event_id(TEST_BUCKET, &raw.id, &raw.timestamp, raw.duration, &data), timestamp: raw.timestamp, duration: raw.duration, data, }; let row = map_window_row(&event, "fallback").unwrap(); assert!(is_invalid_synthetic_window_event(&event, &row)); } #[test] fn detects_blank_window_event_without_source_marker() { let raw = RawAwEvent { id: Some(Value::from(45)), timestamp: "2026-06-23T12:20:13.853Z".to_string(), duration: 10.0, data: json!({}), }; let data = normalized_data(raw.data); let event = AwEvent { bucket_id: TEST_BUCKET.to_string(), bucket_hostname: TEST_HOST.to_string(), event_id: event_id(TEST_BUCKET, &raw.id, &raw.timestamp, raw.duration, &data), timestamp: raw.timestamp, duration: raw.duration, data, }; let row = map_window_row(&event, "fallback").unwrap(); assert!(is_invalid_synthetic_window_event(&event, &row)); } #[test] fn detects_window_event_without_user_identity() { let raw = RawAwEvent { id: Some(Value::from(46)), timestamp: "2026-06-23T12:21:13.853Z".to_string(), duration: 10.0, data: json!({ "app": "1cv8c.exe", "title": "ТРАНСГАЗ" }), }; let data = normalized_data(raw.data); let event = AwEvent { bucket_id: TEST_BUCKET.to_string(), bucket_hostname: TEST_HOST.to_string(), event_id: event_id(TEST_BUCKET, &raw.id, &raw.timestamp, raw.duration, &data), timestamp: raw.timestamp, duration: raw.duration, data, }; let row = map_window_row(&event, "fallback").unwrap(); assert!(is_invalid_synthetic_window_event(&event, &row)); } #[test] fn keeps_real_window_event_from_rust_collector() { let raw = RawAwEvent { id: Some(Value::from(44)), timestamp: "2026-06-23T12:19:13.853Z".to_string(), duration: 0.0, data: json!({ "app": "EXPLORER.EXE", "hostname": TEST_HOST, "processId": 1000, "source": "aw-windows-telemetry-rust", "title": "Рабочий стол", "username": TEST_USER }), }; let data = normalized_data(raw.data); let event = AwEvent { bucket_id: TEST_BUCKET.to_string(), bucket_hostname: TEST_HOST.to_string(), event_id: event_id(TEST_BUCKET, &raw.id, &raw.timestamp, raw.duration, &data), timestamp: raw.timestamp, duration: raw.duration, data, }; let row = map_window_row(&event, "fallback").unwrap(); assert!(!is_invalid_synthetic_window_event(&event, &row)); } #[test] fn escapes_clickhouse_string_literals() { assert_eq!( clickhouse_string_literal("domain\\user'1\n"), "'domain\\\\user\\'1\\n'" ); } #[test] fn loads_and_saves_state_atomically() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("state.json"); let state = State { last_end: Some("2026-06-23T12:00:00.000000Z".to_string()), }; save_state(&path, &state).unwrap(); let loaded = load_state(&path).unwrap(); assert_eq!(loaded.last_end, state.last_end); } #[test] fn missing_state_is_empty() { let dir = tempfile::tempdir().unwrap(); let loaded = load_state(&dir.path().join("missing.json")).unwrap(); assert_eq!(loaded.last_end, None); } }