//! Multi-account configuration + the dispatch registry. //! //! A single `hive-matrix-daemon` can serve N matrix accounts (one //! matrix-sdk `Client` each, with its own session/store dir + sync //! loop). Accounts come from two places: the `HIVE_MATRIX_ACCOUNTS` env var //! (JSON, written by the nix harness module from //! `services.hyperhive.agent.matrixAccounts`), which declares the //! **hive-internal account** (named `main`) as an ordinary entry //! alongside any others; and the accounts the swarm secret store links to //! this agent ([`discover_linked`]), which an operator linked through the //! swarm UI. //! //! `main` is always present and is always the primary: it is moved to //! index 0 whichever position it was declared at, and if the env var //! declares no `main` at all (it is unset, or an agent's harness //! predates the nix module emitting it) one is synthesized from the //! per-agent single-account paths (`/matrix-token` + //! `/matrix-sdk-state`) and the daemon-wide `HIVE_MATRIX_URL`. //! //! So a single-account agent (no `HIVE_MATRIX_ACCOUNTS`) gets exactly //! `main` — zero config, same behaviour as before: omitting `account` on //! a tool call resolves to `main`. When **multiple** accounts are //! configured, omitting `account` is rejected with the list of names (see //! `Registry::resolve` / `pick_name`) — the agent can't tell more than one //! account exists, so it must choose explicitly. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Context as _; use matrix_sdk::Client; use serde::{Deserialize, Serialize}; use crate::{credential, paths}; /// One matrix account. `homeserver` is optional per account /// (defaults to the daemon-wide `HIVE_MATRIX_URL`) so accounts on the /// same homeserver need not repeat it. #[derive(Debug, Clone, Deserialize)] pub struct AccountCfg { /// Logical name the agent uses to address this account /// (`account` arg on the MCP tools). Unique within the daemon. pub name: String, /// The declared bearer-token file, read when the store has no token for /// this account. `None` for an account the store links, whose token only /// the store holds. pub token_file: Option, /// Per-account matrix-sdk sqlite store dir (crypto keys + cache). pub state_dir: PathBuf, /// Homeserver URL; falls back to [`paths::homeserver_url`] when absent. #[serde(default)] pub homeserver: Option, } impl AccountCfg { /// The effective homeserver URL — this account's own, else the /// daemon-wide `HIVE_MATRIX_URL` — or `None` when neither is set. /// /// `None` is a real answer, not a failure: the account is skipped. #[must_use] pub fn homeserver(&self) -> Option { self.homeserver.clone().or_else(paths::homeserver_url) } } /// Name of the hive-internal account: the primary, and what a tool call /// resolves to when it omits `account`. const HIVE_ACCOUNT: &str = "main"; /// Build the declared account list: everything in `HIVE_MATRIX_ACCOUNTS`, /// with the hive-internal `main` account hoisted to index 0 (= primary) /// or synthesized there when the declaration doesn't carry one. /// /// With no `HIVE_MATRIX_ACCOUNTS` set this returns just `main`, so a /// single-account agent is unchanged. /// /// # Errors /// /// Returns an error if `HIVE_MATRIX_ACCOUNTS` is set but is not valid /// JSON, or if two declared accounts share a name. pub fn configured() -> anyhow::Result> { let declared: Vec = match std::env::var_os("HIVE_MATRIX_ACCOUNTS") { Some(raw) => serde_json::from_str(&raw.to_string_lossy()) .map_err(|e| anyhow::anyhow!("parse HIVE_MATRIX_ACCOUNTS as JSON array: {e}"))?, None => Vec::new(), }; ensure_hive_account(declared) } /// Put the hive-internal `main` account at index 0 of `declared`, then /// reject duplicate names. /// /// `main` is synthesized from the per-agent single-account paths **only /// if `declared` doesn't already carry one** — the nix harness module /// emits it as an ordinary `matrixAccounts` entry, so on a current /// harness the declaration is authoritative and is used verbatim (just /// moved to the front, since the module serializes an attrset and `main` /// sorts wherever its key falls). A harness that predates that emits /// nothing for it, and the synthesized account keeps that agent working /// unchanged. Never both: `main` is declared xor synthesized, so there /// is no arrangement where it is duplicated or missing. /// /// Index 0 is load-bearing: the daemon's startup loop (`main.rs`) takes /// index 0 as the primary — the account a tool call acts as when it /// omits `account`, and the only one whose failure to restore is fatal. /// /// # Errors /// /// Returns an error if two declared accounts share a name. fn ensure_hive_account(mut declared: Vec) -> anyhow::Result> { match declared.iter().position(|a| a.name == HIVE_ACCOUNT) { // Declared: keep it verbatim, just make it the primary. `remove` + // `insert` rather than `swap`, so the other accounts keep their // declared order. Some(idx) => { let hive = declared.remove(idx); declared.insert(0, hive); } // Not declared: synthesize it from the legacy single-account paths // + the daemon-wide homeserver. None => declared.insert( 0, AccountCfg { name: HIVE_ACCOUNT.to_owned(), token_file: Some(paths::token_file()), state_dir: paths::matrix_state_dir(), homeserver: None, }, ), } let mut seen = HashSet::new(); for a in &declared { if !seen.insert(a.name.as_str()) { anyhow::bail!("duplicate matrix account name {:?}", a.name); } } Ok(declared) } /// The accounts the store links to this agent beyond the declared ones, and /// the linked names that could not be brought up. #[derive(Debug, Default)] pub struct Discovery { /// Accounts to bring up, in the order the store lists them. pub accounts: Vec, /// Listed names the store holds no credential for. pub missing: Vec, /// Linked accounts stored without a homeserver. Defaulting to the hive's /// would be wrong for an external account, so they are skipped. pub no_homeserver: Vec, } /// Ask the store which accounts it links to this agent, as [`AccountCfg`]s /// for every one `declared` does not already name. /// /// Empty when the harness names no agent or the container was given no store. /// /// # Errors /// The store refusing or failing a read; see [`credential::linked_accounts`]. pub async fn discover_linked(declared: &[AccountCfg]) -> anyhow::Result { let Some(agent) = credential::agent_name() else { return Ok(Discovery::default()); }; let Some(linked) = credential::linked_accounts(&agent).await? else { return Ok(Discovery::default()); }; let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect(); let mut out = linked_cfgs(&state_root(), &declared, linked.found); out.missing = linked.missing; Ok(out) } /// The pure half of [`discover_linked`], with the state dir injected so it is /// testable without a store. fn linked_cfgs( state_root: &Path, declared: &HashSet<&str>, linked: Vec, ) -> Discovery { let mut out = Discovery::default(); for l in linked { // A declared account is brought up from its declaration. if declared.contains(l.name.as_str()) { continue; } let Some(homeserver) = l.homeserver.filter(|h| !h.is_empty()) else { out.no_homeserver.push(l.name); continue; }; out.accounts.push(AccountCfg { state_dir: state_root.join(format!("matrix-sdk-state-{}", l.name)), name: l.name, token_file: None, homeserver: Some(homeserver), }); } out } /// This agent's state dir, the parent of the `main` token file. fn state_root() -> PathBuf { paths::token_file() .parent() .map(Path::to_path_buf) .unwrap_or_default() } /// Remove token files a hive delivered into this agent's state dir: each /// `matrix-account-.json` homeserver sidecar, and the /// `matrix-token-` beside it unless `declared` names ``. /// /// Nothing in this tree writes those files: an account linked through the /// swarm comes from the store. A sidecar is what marks a pair as delivered /// rather than an operator's: a declared `tokenFile` has none. Best-effort — /// a failure is logged and skipped. pub fn remove_delivered_files(declared: &[AccountCfg]) { let declared: HashSet<&str> = declared.iter().map(|a| a.name.as_str()).collect(); remove_delivered_files_in(&state_root(), &declared); } /// Body of [`remove_delivered_files`] with the state dir injected. fn remove_delivered_files_in(state_root: &Path, declared: &HashSet<&str>) { let Ok(rd) = std::fs::read_dir(state_root) else { return; }; for entry in rd.flatten() { let fname = entry.file_name(); let Some(name) = fname .to_str() .and_then(|f| f.strip_prefix("matrix-account-")) .and_then(|f| f.strip_suffix(".json")) .filter(|n| !n.is_empty()) else { continue; }; let mut doomed = vec![entry.path()]; if !declared.contains(name) { doomed.push(state_root.join(format!("matrix-token-{name}"))); } for path in doomed { match std::fs::remove_file(&path) { Ok(()) => { tracing::info!(path = %path.display(), "removed a hive-delivered matrix file"); } Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => tracing::warn!( path = %path.display(), error = %e, "could not remove a hive-delivered matrix file" ), } } } } /// Live status of one matrix account, as reported by [`Registry::list`] /// (the `list_accounts` daemon op). Only accounts that successfully /// restored a session appear, so `live` is always `true` today; the /// field is kept so a future "configured but down" entry can report /// `false` without a wire-shape change. #[derive(Debug, Serialize)] pub struct AccountStatus { /// Logical account name (the `account` arg on the MCP tools). pub name: String, /// Effective homeserver URL the restored client is talking to. pub homeserver: String, /// The account's own matrix user id (`@user:server`), when known. pub user_id: Option, /// Whether the account has a live, restored client. Always `true` /// for registry entries today (the registry only holds restored /// accounts); reserved for future configured-but-down reporting. pub live: bool, /// Whether this is the primary account (selected when a tool call /// omits `account`). pub is_primary: bool, } /// Account name → live `Client` map plus the primary-account name used /// when a request omits `account`. Built once at daemon startup from /// the accounts that successfully restored a session. pub struct Registry { primary: String, by_name: HashMap>, } /// Pick which account name a request resolves to, or an error. Pure (no /// `Client`) so the ambiguity rule is unit-testable. `names` is the sorted /// set of configured account names. /// /// - `Some(name)` → that name (membership is checked by the caller). /// - `None` with a single account → the primary (zero-config default). /// - `None` with multiple accounts → an error: the agent can't tell more /// than one account exists, so force an explicit `account` and list the /// choices. fn pick_name<'a>( account: Option<&'a str>, primary: &'a str, names: &[&str], ) -> Result<&'a str, String> { match account { Some(name) => Ok(name), None if names.len() <= 1 => Ok(primary), None => Err(format!( "multiple matrix accounts configured; pass `account` explicitly — available: [{}]", names.join(", ") )), } } impl Registry { /// Build an empty registry whose primary is `primary`. Clients are /// added with [`Registry::insert`] as each account restores. #[must_use] pub fn new(primary: String) -> Self { Self { primary, by_name: HashMap::new(), } } /// Register a restored client under `name`. pub fn insert(&mut self, name: String, client: Client) { self.by_name.insert(name, Arc::new(client)); } /// Whether any account restored successfully. #[must_use] pub fn is_empty(&self) -> bool { self.by_name.is_empty() } /// Snapshot every restored account: name, homeserver, user id, and /// primary flag. Registry membership == a session restored, so every /// entry is reported `live`. Sorted primary-first then by name for a /// stable order in the dashboard. Account-agnostic — the caller does /// not resolve a single client (see `MatrixMcp::resolve` in /// `crate::mcp`). #[must_use] pub fn list(&self) -> Vec { let mut out: Vec = self .by_name .iter() .map(|(name, client)| AccountStatus { name: name.clone(), homeserver: client.homeserver().to_string(), user_id: client.user_id().map(ToString::to_string), live: true, is_primary: *name == self.primary, }) .collect(); out.sort_by(|a, b| { b.is_primary .cmp(&a.is_primary) .then_with(|| a.name.cmp(&b.name)) }); out } /// Resolve a request's `account` to a client. `None` selects the /// primary account *only when it's unambiguous* — a single configured /// account. With multiple accounts, omitting `account` is an error /// (see `pick_name`): the agent can't tell more than one exists, so we /// force an explicit choice and list the names. Returns a /// human-readable error (listing known accounts) surfaced to the agent /// as a tool error. /// /// # Errors /// /// Errors when `account` is omitted but multiple accounts are /// configured, or when the named account (or the primary, if `None`) /// has no live client — e.g. an unknown name, or the primary failed to /// restore at startup. pub fn resolve(&self, account: Option<&str>) -> Result<&Arc, String> { let mut names: Vec<&str> = self.by_name.keys().map(String::as_str).collect(); names.sort_unstable(); let name = pick_name(account, &self.primary, &names)?; self.by_name.get(name).ok_or_else(|| { format!( "unknown matrix account {name:?}; available accounts: [{}]", names.join(", ") ) }) } /// Publish the live-account snapshot ([`Registry::list`]) to `path`. /// Thin wrapper over [`write_accounts_snapshot`] (split out so the /// atomic-write logic is unit-testable without a live `Client`). /// Called once at daemon startup after all restores. /// /// # Errors /// Propagates filesystem errors from the atomic write. pub fn write_snapshot(&self, path: &Path) -> anyhow::Result<()> { write_accounts_snapshot(path, &self.list()) } /// Heartbeat the snapshot: rewrite it unconditionally so the file /// mtime advances even when the account set is unchanged. The daemon /// calls this on a timer (see `main`) so the dashboard's `as_of` /// (derived from the file mtime) tracks real daemon liveness instead /// of freezing at the boot-time write — a stalled mtime then means /// the daemon is down, which the dashboard can render as stale/dimmed. /// /// # Errors /// Propagates filesystem errors from the atomic write. pub fn heartbeat_snapshot(&self, path: &Path) -> anyhow::Result<()> { heartbeat_accounts_snapshot(path, &self.list()) } } /// Atomically write `accounts` as pretty-printed JSON to `path` /// (`.tmp` + rename) so a dashboard reader never sees a partial /// file. Idempotent — skips the rewrite when the on-disk content already /// matches, keeping the mtime stable. Used for the boot-time publish. /// /// The daemon rebuilds this file fresh on every boot, and restarts itself /// when the store's linked accounts change, so the file lists exactly the /// accounts that restored at the last start. Real-time liveness is conveyed by the file mtime, /// which the daemon advances on a timer via /// [`heartbeat_accounts_snapshot`] — a stalled mtime means the daemon is /// down, so a reader can treat an old snapshot as stale. /// /// # Errors /// Returns an error if the parent dir can't be created or the /// write/rename fails. pub fn write_accounts_snapshot(path: &Path, accounts: &[AccountStatus]) -> anyhow::Result<()> { write_accounts_snapshot_inner(path, accounts, false) } /// Like [`write_accounts_snapshot`] but always rewrites (tmp + rename) /// even when the on-disk content is byte-identical, so the file mtime /// advances. The daemon calls this on a periodic heartbeat so the /// dashboard's `as_of` (the file mtime) reflects daemon liveness rather /// than freezing at the boot-time write. /// /// # Errors /// Returns an error if the parent dir can't be created or the /// write/rename fails. pub fn heartbeat_accounts_snapshot(path: &Path, accounts: &[AccountStatus]) -> anyhow::Result<()> { write_accounts_snapshot_inner(path, accounts, true) } /// Shared body for [`write_accounts_snapshot`] (idempotent) and /// [`heartbeat_accounts_snapshot`] (`force`). When `force` is false the /// rewrite is skipped if the on-disk content already matches, keeping the /// mtime stable; when true the tmp + rename always runs so the mtime /// advances. fn write_accounts_snapshot_inner( path: &Path, accounts: &[AccountStatus], force: bool, ) -> anyhow::Result<()> { let body = serde_json::to_string_pretty(accounts).expect("Vec is always serialisable"); if !force && std::fs::read_to_string(path).ok().as_deref() == Some(body.as_str()) { return Ok(()); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; } let tmp = path.with_extension("json.tmp"); std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, path).with_context(|| { format!( "rename {} -> {} (atomic publish)", tmp.display(), path.display() ) })?; Ok(()) } #[cfg(test)] mod tests { use std::collections::HashSet; use std::path::{Path, PathBuf}; use super::{ AccountCfg, AccountStatus, ensure_hive_account, heartbeat_accounts_snapshot, linked_cfgs, pick_name, remove_delivered_files_in, write_accounts_snapshot, }; use crate::credential::Linked; /// A declared account, as the nix module would serialize it. fn cfg(name: &str) -> AccountCfg { AccountCfg { name: name.to_owned(), token_file: Some(PathBuf::from(format!( "/agents/a/state/matrix-token-{name}" ))), state_dir: PathBuf::from(format!("/agents/a/state/matrix-sdk-state-{name}")), homeserver: Some(format!("https://{name}.example")), } } #[test] fn declared_main_is_used_verbatim_and_not_duplicated() { // A current harness declares `main` itself; it must be taken as-is // (its own paths, not the synthesized ones) and must not gain a // second, synthesized copy. let out = ensure_hive_account(vec![cfg("main")]).unwrap(); assert_eq!(out.len(), 1); assert_eq!(out[0].name, "main"); assert_eq!( out[0].token_file, Some(PathBuf::from("/agents/a/state/matrix-token-main")) ); assert_eq!(out[0].homeserver.as_deref(), Some("https://main.example")); } #[test] fn missing_main_is_synthesized_as_primary() { // A harness predating the nix module's `main` entry declares extras // only (or nothing at all): `main` is synthesized at index 0 from // the per-agent paths, so such an agent keeps working. let out = ensure_hive_account(Vec::new()).unwrap(); assert_eq!(out.len(), 1); assert_eq!(out[0].name, "main"); let out = ensure_hive_account(vec![cfg("public")]).unwrap(); let names: Vec<&str> = out.iter().map(|a| a.name.as_str()).collect(); assert_eq!(names, vec!["main", "public"]); // Synthesized, so it carries no per-account homeserver and falls // back to the daemon-wide `HIVE_MATRIX_URL`. assert!(out[0].homeserver.is_none()); } #[test] fn declared_main_alongside_extras_is_not_a_collision() { // The nix module serializes an attrset, so `main` arrives wherever // its key sorts. It is hoisted to index 0 (= primary), the extras // keep their declared order, and nothing trips the duplicate check. let out = ensure_hive_account(vec![cfg("ccc"), cfg("main"), cfg("public")]).unwrap(); let names: Vec<&str> = out.iter().map(|a| a.name.as_str()).collect(); assert_eq!(names, vec!["main", "ccc", "public"]); } #[test] fn two_accounts_of_the_same_name_are_rejected() { let err = ensure_hive_account(vec![cfg("public"), cfg("public")]).unwrap_err(); assert!(err.to_string().contains("duplicate matrix account name")); } #[test] fn linked_accounts_skip_declared_names_and_ones_with_no_homeserver() { let linked = vec![ Linked { name: "catgirl".to_owned(), homeserver: Some("https://declared.example".to_owned()), }, Linked { name: "bare".to_owned(), homeserver: None, }, Linked { name: "good".to_owned(), homeserver: Some("https://good.example".to_owned()), }, ]; let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect(); let out = linked_cfgs(Path::new("/agents/a/state"), &declared, linked); let names: Vec<&str> = out.accounts.iter().map(|a| a.name.as_str()).collect(); assert_eq!(names, ["good"]); assert_eq!(out.no_homeserver, ["bare"]); let good = &out.accounts[0]; // The store holds the token, so there is no file to fall back to; the // session dir keeps the name a hive-delivered account had, so its // crypto store carries over. assert_eq!(good.token_file, None); assert_eq!( good.state_dir, PathBuf::from("/agents/a/state/matrix-sdk-state-good") ); assert_eq!(good.homeserver.as_deref(), Some("https://good.example")); } #[test] fn delivered_files_go_and_operator_files_stay() { let dir = std::env::temp_dir().join(format!( "hh-acct-clean-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let sidecar = r#"{"homeserver":"https://x.example"}"#; // Delivered and not declared: both files go. std::fs::write(dir.join("matrix-token-linked"), "tok").unwrap(); std::fs::write(dir.join("matrix-account-linked.json"), sidecar).unwrap(); // Delivered and also declared: the sidecar goes, the declared token stays. std::fs::write(dir.join("matrix-token-catgirl"), "tok").unwrap(); std::fs::write(dir.join("matrix-account-catgirl.json"), sidecar).unwrap(); // An operator's by-hand token has no sidecar, and the hive account's own // token is not an extra: both stay. std::fs::write(dir.join("matrix-token-byhand"), "tok").unwrap(); std::fs::write(dir.join("matrix-token"), "tok").unwrap(); let declared: HashSet<&str> = ["main", "catgirl"].into_iter().collect(); remove_delivered_files_in(&dir, &declared); let mut left: Vec = std::fs::read_dir(&dir) .unwrap() .flatten() .map(|e| e.file_name().to_string_lossy().into_owned()) .collect(); left.sort(); assert_eq!( left, [ "matrix-token", "matrix-token-byhand", "matrix-token-catgirl" ] ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn pick_name_single_account_defaults_to_primary() { assert_eq!(pick_name(None, "main", &["main"]).unwrap(), "main"); } #[test] fn pick_name_multi_account_omitted_errors_with_menu() { let err = pick_name(None, "main", &["main", "public"]).unwrap_err(); assert!(err.contains("pass `account` explicitly")); assert!(err.contains("main")); assert!(err.contains("public")); } #[test] fn pick_name_explicit_passes_through_even_with_multiple() { assert_eq!( pick_name(Some("public"), "main", &["main", "public"]).unwrap(), "public" ); } fn sample() -> Vec { vec![ AccountStatus { name: "main".to_owned(), homeserver: "http://localhost:8008".to_owned(), user_id: Some("@agent:hive".to_owned()), live: true, is_primary: true, }, AccountStatus { name: "public".to_owned(), homeserver: "https://matrix.org".to_owned(), user_id: None, live: true, is_primary: false, }, ] } #[test] fn snapshot_writes_json_and_cleans_up_tmp() { let dir = std::env::temp_dir().join(format!( "hh-acct-snap-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("matrix-accounts.json"); write_accounts_snapshot(&path, &sample()).unwrap(); let written = std::fs::read_to_string(&path).unwrap(); // Round-trips to the same shape the dashboard consumes. let parsed: serde_json::Value = serde_json::from_str(&written).unwrap(); assert_eq!(parsed[0]["name"], "main"); assert_eq!(parsed[0]["live"], true); assert_eq!(parsed[0]["is_primary"], true); assert_eq!(parsed[1]["homeserver"], "https://matrix.org"); assert!(parsed[1]["user_id"].is_null()); // No leftover temp file after the atomic publish. assert!(!path.with_extension("json.tmp").exists()); std::fs::remove_dir_all(&dir).ok(); } #[test] fn snapshot_is_idempotent_when_unchanged() { let dir = std::env::temp_dir().join(format!( "hh-acct-idem-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("matrix-accounts.json"); write_accounts_snapshot(&path, &sample()).unwrap(); let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap(); // Second write with identical content must skip the rename (mtime // stays put), so inotify watchers don't see a spurious change. write_accounts_snapshot(&path, &sample()).unwrap(); let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap(); assert_eq!(mtime1, mtime2); std::fs::remove_dir_all(&dir).ok(); } #[test] fn heartbeat_advances_mtime_even_when_unchanged() { let dir = std::env::temp_dir().join(format!( "hh-acct-hb-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("matrix-accounts.json"); write_accounts_snapshot(&path, &sample()).unwrap(); let mtime1 = std::fs::metadata(&path).unwrap().modified().unwrap(); // The heartbeat must rewrite (tmp + rename) even when the content // is byte-identical, so the mtime advances and the dashboard reads // a fresh `as_of`. Small sleep so the new mtime is strictly later // than the first (tmpfs/ext4 have sub-second mtime resolution). std::thread::sleep(std::time::Duration::from_millis(20)); heartbeat_accounts_snapshot(&path, &sample()).unwrap(); let mtime2 = std::fs::metadata(&path).unwrap().modified().unwrap(); assert!(mtime2 > mtime1, "heartbeat should advance mtime"); // Content is still the same shape, and no leftover temp file. let written = std::fs::read_to_string(&path).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&written).unwrap(); assert_eq!(parsed[0]["name"], "main"); assert!(!path.with_extension("json.tmp").exists()); std::fs::remove_dir_all(&dir).ok(); } }