diff --git a/Cargo.lock b/Cargo.lock index 2df8a8b6..5fb1fbb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,6 +257,7 @@ dependencies = [ "fancy-regex", "log", "plex", + "proptest", "serde", "serde_json", ] @@ -391,9 +392,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -2132,6 +2133,31 @@ dependencies = [ "yansi", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quinn" version = "0.11.9" @@ -2261,6 +2287,15 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rayon" version = "1.10.0" @@ -2647,6 +2682,18 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.20" @@ -3386,6 +3433,12 @@ dependencies = [ "serde", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "uncased" version = "0.9.10" @@ -3483,6 +3536,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/aw-client-rust/tests/test.rs b/aw-client-rust/tests/test.rs index d66d6566..64dd913d 100644 --- a/aw-client-rust/tests/test.rs +++ b/aw-client-rust/tests/test.rs @@ -121,6 +121,7 @@ mod test { }; let aw_config = aw_server::config::AWConfig { port, + testing: true, auth: aw_server::config::AWAuthConfig { api_key: api_key.map(str::to_owned), }, diff --git a/aw-models/src/info.rs b/aw-models/src/info.rs index c1763559..64f09713 100644 --- a/aw-models/src/info.rs +++ b/aw-models/src/info.rs @@ -6,5 +6,14 @@ pub struct Info { pub hostname: String, pub version: String, pub testing: bool, + /// Name of the running instance profile ("default" unless `--profile` was + /// given). Lets clients tell concurrent instances apart. Defaults on + /// deserialization so a new client can still talk to an older server. + #[serde(default = "default_profile")] + pub profile: String, pub device_id: String, } + +fn default_profile() -> String { + "default".to_string() +} diff --git a/aw-server/src/android/mod.rs b/aw-server/src/android/mod.rs index b36d6c6c..fd5aced8 100644 --- a/aw-server/src/android/mod.rs +++ b/aw-server/src/android/mod.rs @@ -54,7 +54,7 @@ pub mod android { match DATASTORE { Some(ref ds) => ds.clone(), None => { - let db_dir = dirs::db_path(false) + let db_dir = dirs::db_path("default") .expect("Failed to get db path") .to_str() .unwrap() @@ -125,7 +125,7 @@ pub mod android { }; info!("Using server_state:: device_id: {}", server_state.device_id); - let mut server_config = crate::config::create_config(false, None); + let mut server_config = crate::config::create_config("default", None); server_config.port = 5600; endpoints::build_rocket(server_state, server_config) diff --git a/aw-server/src/config.rs b/aw-server/src/config.rs index e0aafc2a..e32d82e1 100644 --- a/aw-server/src/config.rs +++ b/aw-server/src/config.rs @@ -1,6 +1,7 @@ use std::fs::{self, File}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::OnceLock; use rocket::config::Config; use rocket::data::{Limits, ToByteUnit}; @@ -9,15 +10,46 @@ use serde::{Deserialize, Serialize}; use crate::dirs; -// Far from an optimal way to solve it, but works and is simple -static mut TESTING: bool = true; -pub fn set_testing(testing: bool) { - unsafe { - TESTING = testing; +static PROFILE: OnceLock = OnceLock::new(); + +/// Return the current profile name (default: "default"). +pub fn get_profile() -> &'static str { + PROFILE.get().map(|s| s.as_str()).unwrap_or("default") +} + +/// Set the profile. Idempotent for the same value; panics if called with +/// a conflicting value (would silently redirect to a different datastore). +pub fn set_profile(profile: String) { + // Use the atomic OnceLock::set result to avoid a TOCTOU race: two callers + // with different values could both see get()==None before either sets it, + // causing the loser to silently proceed under the wrong profile. + match PROFILE.set(profile.clone()) { + Ok(()) => {} // we set it atomically, done + Err(_) => { + // Already set — verify no conflict (duplicate or concurrent same-value call is fine) + let existing = PROFILE + .get() + .expect("set returned Err but get returned None"); + if existing != &profile { + panic!( + "set_profile called with conflicting value: existing={existing:?}, new={profile:?}" + ); + } + // same value — idempotent, no action needed + } } } + +/// True when running in the legacy "testing" profile or in debug builds. pub fn is_testing() -> bool { - unsafe { TESTING } + get_profile() == "testing" +} + +/// Backwards-compat shim: callers that still use set_testing(bool) convert +/// true → profile "testing" and false → profile "default". +pub fn set_testing(testing: bool) { + let profile = if testing { "testing" } else { "default" }; + set_profile(profile.to_string()); } /// Authentication configuration, serialised as `[auth]` in config.toml. @@ -119,24 +151,29 @@ fn default_custom_static() -> std::collections::HashMap { std::collections::HashMap::new() } -fn get_config_path(testing: bool, config_override: Option<&Path>) -> PathBuf { +/// Config filename for a profile. +/// +/// Isolated profile roots (including new-style `activitywatch-testing/`) use +/// bare `config.toml` — the directory already isolates. Suffixed +/// `config-testing.toml` remains only in the legacy shared-root layout so +/// existing testing config is not orphaned. +pub fn config_filename(profile: &str) -> String { + crate::dirs::config_filename(profile) +} + +fn get_config_path(profile: &str, config_override: Option<&Path>) -> PathBuf { if let Some(config_path) = config_override { return config_path.to_path_buf(); } let mut config_path = dirs::get_config_dir().unwrap(); - if !testing { - config_path.push("config.toml") - } else { - config_path.push("config-testing.toml") - } - + config_path.push(config_filename(profile)); config_path } -pub fn create_config(testing: bool, config_override: Option<&Path>) -> AWConfig { - set_testing(testing); - let config_path = get_config_path(testing, config_override); +pub fn create_config(profile: &str, config_override: Option<&Path>) -> AWConfig { + set_profile(profile.to_string()); + let config_path = get_config_path(profile, config_override); if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).expect("Unable to create config dir"); } @@ -203,15 +240,25 @@ mod tests { } } + #[test] + fn config_filename_isolated_named_profiles_are_bare() { + // default and named isolated roots are always config.toml. Suffixed + // config-testing.toml is filesystem-dependent (legacy shared root) and + // covered in dirs.rs against fake roots. + assert_eq!(super::config_filename("default"), "config.toml"); + assert_eq!(super::config_filename("research"), "config.toml"); + assert_eq!(super::config_filename("my-profile"), "config.toml"); + } + #[test] fn create_config_uses_override_path() { - // create_config mutates the TESTING global, so these tests must not overlap. + // create_config sets the PROFILE OnceLock, so these tests must not overlap. let _lock = TEST_LOCK.lock().unwrap(); let paths = TestConfigPath::new("override"); fs::create_dir_all(paths.config_path.parent().unwrap()).unwrap(); fs::write(&paths.config_path, "address = \"0.0.0.0\"\nport = 5611\n").unwrap(); - let config = create_config(false, Some(paths.config_path.as_path())); + let config = create_config("default", Some(paths.config_path.as_path())); assert_eq!(config.address, "0.0.0.0"); assert_eq!(config.port, 5611); @@ -222,7 +269,7 @@ mod tests { let _lock = TEST_LOCK.lock().unwrap(); let paths = TestConfigPath::new("missing"); - let config = create_config(false, Some(paths.config_path.as_path())); + let config = create_config("default", Some(paths.config_path.as_path())); assert!(paths.config_path.is_file()); assert_eq!(config.address, "127.0.0.1"); diff --git a/aw-server/src/dirs.rs b/aw-server/src/dirs.rs index 86bef315..5881437c 100644 --- a/aw-server/src/dirs.rs +++ b/aw-server/src/dirs.rs @@ -1,7 +1,5 @@ -use std::path::PathBuf; - -#[cfg(not(target_os = "android"))] use std::fs; +use std::path::{Path, PathBuf}; #[cfg(target_os = "android")] use std::sync::Mutex; @@ -13,11 +11,185 @@ lazy_static! { )); } +const DEFAULT_APPNAME: &str = "activitywatch"; +const TESTING_PROFILE: &str = "testing"; +const TESTING_APPNAME: &str = "activitywatch-testing"; + +/// Filenames that mark a machine as still using the pre-profile shared-root +/// testing layout (ActivityWatch/activitywatch#1399). Keep this list specific: +/// a false positive would pin a fresh install to the legacy layout forever. +/// Identical to the python list in aw-core so both sides agree on disk state. +const LEGACY_TESTING_FILENAME_MARKERS: &[&str] = &[ + "peewee-sqlite-testing", + "sqlite-testing", + "settings-testing", + "config-testing", + "-testing.db", + "-testing.toml", + "-testing.json", + "_testing_", +]; + +/// Platform "appname" root for the current profile. +/// +/// Named profiles use a sibling root (`activitywatch-research`, …). `default` +/// keeps the bare `activitywatch` root. `testing` follows the new-root-plus +/// legacy-fallback rule from ActivityWatch/activitywatch#1399 — see +/// [`using_legacy_testing_root`]. +#[cfg(not(target_os = "android"))] +pub fn appname() -> String { + appname_for(crate::config::get_profile()) +} + +/// Platform appname for a given profile, observing on-disk state for `testing`. +#[cfg(not(target_os = "android"))] +pub fn appname_for(profile: &str) -> String { + match platform_roots() { + Some((data, config, cache)) => appname_for_in(profile, &data, &config, &cache), + None => appname_for_in(profile, Path::new(""), Path::new(""), Path::new("")), + } +} + +/// Pure appname resolution against explicit XDG-style parent dirs (testable). +pub fn appname_for_in(profile: &str, data: &Path, config: &Path, cache: &Path) -> String { + if profile.is_empty() || profile == "default" { + return DEFAULT_APPNAME.to_string(); + } + if profile == TESTING_PROFILE && using_legacy_testing_root_in(profile, data, config, cache) { + return DEFAULT_APPNAME.to_string(); + } + format!("{DEFAULT_APPNAME}-{profile}") +} + +fn platform_roots() -> Option<(PathBuf, PathBuf, PathBuf)> { + Some((dirs::data_dir()?, dirs::config_dir()?, dirs::cache_dir()?)) +} + +fn is_legacy_testing_filename(name: &str) -> bool { + let lower = name.to_lowercase(); + LEGACY_TESTING_FILENAME_MARKERS + .iter() + .any(|marker| lower.contains(marker)) +} + +fn dir_has_legacy_testing_file(dir: &Path) -> bool { + let Ok(entries) = fs::read_dir(dir) else { + return false; + }; + entries.flatten().any(|entry| { + entry.file_type().map(|t| t.is_file()).unwrap_or(false) + && is_legacy_testing_filename(&entry.file_name().to_string_lossy()) + }) +} + +/// Walk `activitywatch/` plus one extra level (`activitywatch/aw-server-rust/`). +fn legacy_testing_artifacts_in_app_root(app_root: &Path) -> bool { + if !app_root.is_dir() { + return false; + } + if dir_has_legacy_testing_file(app_root) { + return true; + } + let Ok(entries) = fs::read_dir(app_root) else { + return false; + }; + entries.flatten().any(|entry| { + let path = entry.path(); + path.is_dir() && dir_has_legacy_testing_file(&path) + }) +} + +fn new_testing_root_exists_in(data: &Path, config: &Path, cache: &Path) -> bool { + [data, config, cache] + .iter() + .any(|root| root.join(TESTING_APPNAME).is_dir()) +} + +fn legacy_testing_artifacts_exist_in(data: &Path, config: &Path, cache: &Path) -> bool { + [data, config, cache] + .iter() + .any(|root| legacy_testing_artifacts_in_app_root(&root.join(DEFAULT_APPNAME))) +} + +/// Testing-root resolution against explicit parent dirs (testable). +/// +/// Rule (ActivityWatch/activitywatch#1399), identical on python and rust: +/// +/// 1. If `activitywatch-testing/` already exists: use it (new layout). +/// 2. Else if legacy testing artifacts exist in the bare `activitywatch/` +/// root: stay in legacy mode (old paths, old filenames). +/// 3. Else (fresh setup): create and use `activitywatch-testing/`. +pub fn using_legacy_testing_root_in( + profile: &str, + data: &Path, + config: &Path, + cache: &Path, +) -> bool { + if profile != TESTING_PROFILE { + return false; + } + if new_testing_root_exists_in(data, config, cache) { + return false; + } + legacy_testing_artifacts_exist_in(data, config, cache) +} + +/// Whether `profile=testing` should stay on the shared `activitywatch` root. +pub fn using_legacy_testing_root(profile: &str) -> bool { + match platform_roots() { + Some((data, config, cache)) => { + using_legacy_testing_root_in(profile, &data, &config, &cache) + } + None => false, + } +} + +/// `"-testing"` only when testing data still shares the default root. +/// +/// Isolated profile roots (including new-style `activitywatch-testing/`) use +/// bare filenames: the directory already isolates. Suffixed names remain only +/// in legacy mode so existing `sqlite-testing.db` files keep working. +pub fn legacy_testing_suffix(profile: &str) -> &'static str { + if using_legacy_testing_root(profile) { + "-testing" + } else { + "" + } +} + +fn db_filename_for_legacy(legacy: bool) -> &'static str { + if legacy { + "sqlite-testing.db" + } else { + "sqlite.db" + } +} + +fn config_filename_for_legacy(legacy: bool) -> &'static str { + if legacy { + "config-testing.toml" + } else { + "config.toml" + } +} + +/// Database filename for a profile. Isolated roots use `sqlite.db`; legacy +/// testing keeps `sqlite-testing.db`. +pub fn db_filename(profile: &str) -> String { + db_filename_for_legacy(using_legacy_testing_root(profile)).to_string() +} + +/// Config filename for a profile. Isolated roots use `config.toml`; legacy +/// testing keeps `config-testing.toml`. +pub fn config_filename(profile: &str) -> String { + config_filename_for_legacy(using_legacy_testing_root(profile)).to_string() +} + #[cfg(not(target_os = "android"))] pub fn get_config_dir() -> Result { let dir = dirs::config_dir() .ok_or(())? - .join("activitywatch") + .join(appname()) .join("aw-server-rust"); fs::create_dir_all(&dir).expect("Unable to create config dir"); Ok(dir) @@ -32,7 +204,7 @@ pub fn get_config_dir() -> Result { pub fn get_data_dir() -> Result { let dir = dirs::data_dir() .ok_or(())? - .join("activitywatch") + .join(appname()) .join("aw-server-rust"); fs::create_dir_all(&dir).expect("Unable to create data dir"); Ok(dir) @@ -47,7 +219,7 @@ pub fn get_data_dir() -> Result { pub fn get_cache_dir() -> Result { let dir = dirs::cache_dir() .ok_or(())? - .join("activitywatch") + .join(appname()) .join("aw-server-rust"); fs::create_dir_all(&dir).expect("Unable to create cache dir"); Ok(dir) @@ -73,10 +245,7 @@ pub fn get_log_dir(module: &str) -> Result { /// - Windows: {LOCALAPPDATA}\activitywatch\Logs\ #[cfg(target_os = "linux")] fn get_user_log_dir() -> Result { - Ok(dirs::cache_dir() - .ok_or(())? - .join("activitywatch") - .join("log")) + Ok(dirs::cache_dir().ok_or(())?.join(appname()).join("log")) } #[cfg(target_os = "macos")] @@ -85,14 +254,14 @@ fn get_user_log_dir() -> Result { .ok_or(())? .join("Library") .join("Logs") - .join("activitywatch")) + .join(appname())) } #[cfg(target_os = "windows")] fn get_user_log_dir() -> Result { Ok(dirs::data_local_dir() .ok_or(())? - .join("activitywatch") + .join(appname()) .join("Logs")) } @@ -101,13 +270,57 @@ pub fn get_log_dir(module: &str) -> Result { panic!("not implemented on Android"); } -pub fn db_path(testing: bool) -> Result { - let mut db_path = get_data_dir()?; - if testing { - db_path.push("sqlite-testing.db"); - } else { - db_path.push("sqlite.db"); +/// Validate a profile name: lowercase alphanumerics plus `-` and `_`, max 32 +/// chars, must start with a letter or digit. Returns Err(message) on failure. +pub fn validate_profile(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("profile name must not be empty".into()); + } + if name.len() > 32 { + return Err(format!( + "profile name too long ({} chars, max 32)", + name.len() + )); } + let first = name.chars().next().unwrap(); + if !first.is_ascii_alphanumeric() { + return Err(format!( + "profile name must start with a letter or digit, got '{first}'" + )); + } + for c in name.chars() { + if !c.is_ascii_alphanumeric() && c != '-' && c != '_' { + return Err(format!("invalid character '{c}' in profile name")); + } + } + if name != name.to_lowercase() { + return Err("profile name must be lowercase".into()); + } + Ok(()) +} + +/// Data dir for an explicit profile (does not depend on the process-global +/// `OnceLock`). Creates the directory. Used by `db_path` so a caller asking +/// for `research` cannot land in the default root just because `set_profile` +/// has not run yet. +#[cfg(not(target_os = "android"))] +fn get_data_dir_for(profile: &str) -> Result { + let dir = dirs::data_dir() + .ok_or(())? + .join(appname_for(profile)) + .join("aw-server-rust"); + fs::create_dir_all(&dir).expect("Unable to create data dir"); + Ok(dir) +} + +#[cfg(target_os = "android")] +fn get_data_dir_for(_profile: &str) -> Result { + get_data_dir() +} + +pub fn db_path(profile: &str) -> Result { + let mut db_path = get_data_dir_for(profile)?; + db_path.push(db_filename(profile)); Ok(db_path) } @@ -117,6 +330,213 @@ pub fn set_android_data_dir(path: &str) { *android_data_dir = PathBuf::from(path); } +#[cfg(test)] +fn fake_roots() -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir() + .join("aw-testing-root-fallback") + .join(uuid::Uuid::new_v4().to_string()); + let data = root.join("data"); + let config = root.join("config"); + let cache = root.join("cache"); + fs::create_dir_all(&data).unwrap(); + fs::create_dir_all(&config).unwrap(); + fs::create_dir_all(&cache).unwrap(); + (root, data, config, cache) +} + +#[cfg(test)] +fn plant_legacy_testing_db(data: &Path) -> PathBuf { + let aw_server = data.join("activitywatch").join("aw-server-rust"); + fs::create_dir_all(&aw_server).unwrap(); + let marker = aw_server.join("sqlite-testing.db"); + fs::write(&marker, b"").unwrap(); + marker +} + +#[cfg(not(target_os = "android"))] +#[test] +fn test_appname_root_isolation() { + let (_root, data, config, cache) = fake_roots(); + assert_eq!( + appname_for_in("default", &data, &config, &cache), + "activitywatch" + ); + // Fresh setup: testing uses the isolated sibling root. + assert_eq!( + appname_for_in("testing", &data, &config, &cache), + "activitywatch-testing" + ); + assert_eq!( + appname_for_in("research", &data, &config, &cache), + "activitywatch-research" + ); + assert_eq!( + appname_for_in("my-profile", &data, &config, &cache), + "activitywatch-my-profile" + ); + let _ = fs::remove_dir_all(_root); +} + +#[test] +fn test_testing_root_fresh_setup_uses_new_root() { + let (_root, data, config, cache) = fake_roots(); + assert!(!using_legacy_testing_root_in( + "testing", &data, &config, &cache + )); + assert_eq!( + appname_for_in("testing", &data, &config, &cache), + "activitywatch-testing" + ); + let _ = fs::remove_dir_all(_root); +} + +#[test] +fn test_testing_root_legacy_artifacts_keep_shared_root() { + let (_root, data, config, cache) = fake_roots(); + plant_legacy_testing_db(&data); + assert!(using_legacy_testing_root_in( + "testing", &data, &config, &cache + )); + assert_eq!( + appname_for_in("testing", &data, &config, &cache), + "activitywatch" + ); + assert!(!data.join("activitywatch-testing").exists()); + let _ = fs::remove_dir_all(_root); +} + +#[test] +fn test_testing_root_new_root_wins_over_legacy_artifacts() { + let (_root, data, config, cache) = fake_roots(); + plant_legacy_testing_db(&data); + fs::create_dir_all(data.join("activitywatch-testing")).unwrap(); + assert!(!using_legacy_testing_root_in( + "testing", &data, &config, &cache + )); + assert_eq!( + appname_for_in("testing", &data, &config, &cache), + "activitywatch-testing" + ); + let _ = fs::remove_dir_all(_root); +} + +#[test] +fn test_config_testing_toml_is_a_legacy_marker() { + let (_root, data, config, cache) = fake_roots(); + let cfg = config.join("activitywatch"); + fs::create_dir_all(&cfg).unwrap(); + fs::write(cfg.join("config-testing.toml"), b"").unwrap(); + assert!(using_legacy_testing_root_in( + "testing", &data, &config, &cache + )); + assert_eq!( + appname_for_in("testing", &data, &config, &cache), + "activitywatch" + ); + let _ = fs::remove_dir_all(_root); +} + +#[test] +fn test_named_profile_never_uses_legacy_root() { + let (_root, data, config, cache) = fake_roots(); + plant_legacy_testing_db(&data); + assert!(!using_legacy_testing_root_in( + "research", &data, &config, &cache + )); + assert_eq!( + appname_for_in("research", &data, &config, &cache), + "activitywatch-research" + ); + let _ = fs::remove_dir_all(_root); +} + +#[cfg(test)] +fn db_filename_in(profile: &str, data: &Path, config: &Path, cache: &Path) -> &'static str { + db_filename_for_legacy(using_legacy_testing_root_in(profile, data, config, cache)) +} + +#[cfg(test)] +fn config_filename_in(profile: &str, data: &Path, config: &Path, cache: &Path) -> &'static str { + config_filename_for_legacy(using_legacy_testing_root_in(profile, data, config, cache)) +} + +#[test] +fn test_filenames_bare_except_legacy_testing() { + assert_eq!(db_filename_for_legacy(false), "sqlite.db"); + assert_eq!(db_filename_for_legacy(true), "sqlite-testing.db"); + assert_eq!(config_filename_for_legacy(false), "config.toml"); + assert_eq!(config_filename_for_legacy(true), "config-testing.toml"); +} + +#[test] +fn test_filenames_follow_disk_state_rule() { + let (_root, data, config, cache) = fake_roots(); + assert_eq!( + db_filename_in("testing", &data, &config, &cache), + "sqlite.db" + ); + assert_eq!( + config_filename_in("testing", &data, &config, &cache), + "config.toml" + ); + assert_eq!( + db_filename_in("research", &data, &config, &cache), + "sqlite.db" + ); + + plant_legacy_testing_db(&data); + assert_eq!( + db_filename_in("testing", &data, &config, &cache), + "sqlite-testing.db" + ); + assert_eq!( + config_filename_in("testing", &data, &config, &cache), + "config-testing.toml" + ); + // Named profiles stay bare even when legacy testing artifacts exist. + assert_eq!( + db_filename_in("research", &data, &config, &cache), + "sqlite.db" + ); + assert_eq!( + config_filename_in("research", &data, &config, &cache), + "config.toml" + ); + + fs::create_dir_all(data.join("activitywatch-testing")).unwrap(); + assert_eq!( + db_filename_in("testing", &data, &config, &cache), + "sqlite.db" + ); + assert_eq!( + config_filename_in("testing", &data, &config, &cache), + "config.toml" + ); + let _ = fs::remove_dir_all(_root); +} + +#[test] +fn test_validate_profile() { + assert!(validate_profile("default").is_ok()); + assert!(validate_profile("testing").is_ok()); + assert!(validate_profile("research").is_ok()); + assert!(validate_profile("my-profile").is_ok()); + assert!(validate_profile("profile_1").is_ok()); + + assert!(validate_profile("").is_err()); + assert!( + validate_profile("Research").is_err(), + "uppercase should be rejected" + ); + assert!(validate_profile("-bad").is_err(), "must start with alnum"); + assert!(validate_profile("bad name").is_err(), "spaces not allowed"); + assert!( + validate_profile("a/b").is_err(), + "path separator not allowed" + ); + assert!(validate_profile(&"a".repeat(33)).is_err(), "too long"); +} + #[test] fn test_get_dirs() { #[cfg(target_os = "android")] @@ -124,8 +544,9 @@ fn test_get_dirs() { get_cache_dir().unwrap(); get_log_dir("aw-server-rust").unwrap(); - db_path(true).unwrap(); - db_path(false).unwrap(); + // Do not call db_path("testing"): on a fresh CI machine that would create + // ~/.local/share/activitywatch-testing and pin later tests to the new root. + db_path("default").unwrap(); } #[test] diff --git a/aw-server/src/endpoints/mod.rs b/aw-server/src/endpoints/mod.rs index eff321cf..54b56a36 100644 --- a/aw-server/src/endpoints/mod.rs +++ b/aw-server/src/endpoints/mod.rs @@ -116,6 +116,7 @@ fn server_info(config: &State, state: &State) -> Json Result<(), fern::InitError> { +pub fn setup_logger(module: &str, profile: &str, verbose: bool) -> Result<(), fern::InitError> { + let testing = profile == "testing"; let mut logfile_path: PathBuf = dirs::get_log_dir(module).expect("Unable to get log dir to store logs in"); fs::create_dir_all(logfile_path.clone()).expect("Unable to create folder for logs"); - let filename = if !testing { + // Isolated profile roots (including new-style testing) use a bare filename + // — the directory already isolates. The `-testing` infix stays only in + // the legacy shared-root layout so existing log files keep matching. + let filename = if dirs::legacy_testing_suffix(profile).is_empty() { format!("{}_%Y-%m-%dT%H-%M-%S%z.log", module) } else { format!("{}-testing_%Y-%m-%dT%H-%M-%S%z.log", module) @@ -94,6 +98,6 @@ mod tests { #[ignore] #[test] fn test_setup_logger() { - setup_logger("aw-server-rust", true, true).unwrap(); + setup_logger("aw-server-rust", "testing", true).unwrap(); } } diff --git a/aw-server/src/main.rs b/aw-server/src/main.rs index 9e6e6a1f..f703f093 100644 --- a/aw-server/src/main.rs +++ b/aw-server/src/main.rs @@ -21,7 +21,13 @@ static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; #[derive(Parser)] #[clap(version = crate_version!(), author = "Johan Bjäreholt, Erik Bjäreholt, et al.")] struct Opts { - /// Run in testing mode + /// Named instance profile (e.g. "default", "testing", "research"). + /// Drives the isolated directory root (and legacy testing-filename fallback). + /// Defaults to "testing" in debug builds, "default" in release builds. + #[clap(long)] + profile: Option, + + /// Run in testing mode (alias for --profile testing) #[clap(long)] testing: bool, @@ -80,21 +86,46 @@ async fn main() -> Result<(), rocket::Error> { #[cfg(any(feature = "encryption", feature = "encryption-vendored"))] std::env::remove_var("AW_DB_PASSWORD"); - let mut testing = opts.testing; + // Resolve profile: --profile wins; --testing is an alias for "testing"; + // debug builds default to "testing" (preserving prior behaviour). + let profile: String = match opts.profile { + Some(p) => { + if opts.testing && p != "testing" { + warn!( + "--testing and --profile {} both given; --profile wins, using '{}'", + p, p + ); + } + p + } + None => match std::env::var("AW_PROFILE") { + // aw-qt exports AW_PROFILE for the modules it spawns, so a profile + // set on the launcher propagates without every module growing a flag. + Ok(p) if !p.is_empty() => p, + _ => { + if opts.testing || cfg!(debug_assertions) { + "testing".to_string() + } else { + "default".to_string() + } + } + }, + }; - // Always override environment if --testing is specified - if !testing && cfg!(debug_assertions) { - testing = true; - } + dirs::validate_profile(&profile).unwrap_or_else(|e| panic!("Invalid profile name: {e}")); + // set_profile must run before setup_logger: get_log_dir reads the + // process-global profile via appname(), and named profiles otherwise + // land in the shared cache dir (ActivityWatch/activitywatch#1399). + config::set_profile(profile.clone()); - logging::setup_logger("aw-server-rust", testing, opts.verbose) + logging::setup_logger("aw-server-rust", &profile, opts.verbose) .expect("Failed to setup logging"); - if testing { - info!("Running server in Testing mode"); + if profile != "default" { + info!("Running server with profile '{}'", profile); } - let mut config = config::create_config(testing, opts.config.as_deref()); + let mut config = config::create_config(&profile, opts.config.as_deref()); // set host if overridden if let Some(host) = opts.host { @@ -133,7 +164,7 @@ async fn main() -> Result<(), rocket::Error> { let db_path: String = if let Some(dbpath) = opts.dbpath.clone() { dbpath } else { - dirs::db_path(testing) + dirs::db_path(&profile) .expect("Failed to get db path") .to_str() .unwrap() diff --git a/aw-sync/src/dirs.rs b/aw-sync/src/dirs.rs index 2744bba6..a2d37dd1 100644 --- a/aw-sync/src/dirs.rs +++ b/aw-sync/src/dirs.rs @@ -3,27 +3,69 @@ use std::error::Error; use std::fs; use std::path::PathBuf; -// TODO: This could be refactored to share logic with aw-server/src/dirs.rs +/// Resolve the instance profile. +/// `--profile` wins, then `AW_PROFILE`, then `--testing` → `"testing"`, else `"default"`. +#[allow(dead_code)] // used by the aw-sync binary; the lib copy is unused +pub fn resolve_profile( + cli_profile: Option<&str>, + testing: bool, + env_profile: Option<&str>, +) -> String { + if let Some(p) = cli_profile { + if !p.is_empty() { + return p.to_string(); + } + } + if let Some(p) = env_profile { + if !p.is_empty() { + return p.to_string(); + } + } + if testing { + "testing".to_string() + } else { + "default".to_string() + } +} + +/// aw-sync's own config dir: `{appname}/aw-sync`. +/// +/// Uses the same profile appname as aw-server so a named profile (e.g. +/// `research`) does not share prod's sync config. `testing` follows the +/// same new-root-plus-legacy-fallback rule as aw-server. // TODO: add proper config support #[cfg(not(target_os = "android"))] #[allow(dead_code)] pub fn get_config_dir() -> Result> { - let dir = dirs::config_dir() - .ok_or("Unable to read user config dir")? - .join("activitywatch") - .join("aw-sync"); + let dir = sync_config_dir(&aw_server::dirs::appname())?; fs::create_dir_all(&dir)?; Ok(dir) } +/// Path construction only — does not create directories (so tests stay off-disk). +#[cfg(not(target_os = "android"))] +fn sync_config_dir(appname: &str) -> Result> { + Ok(dirs::config_dir() + .ok_or("Unable to read user config dir")? + .join(appname) + .join("aw-sync")) +} + #[cfg(not(target_os = "android"))] pub fn get_server_config_path(testing: bool) -> Result { - let dir = aw_server::dirs::get_config_dir()?; - Ok(dir.join(if testing { - "config-testing.toml" + let profile = aw_server::config::get_profile(); + // If set_profile hasn't run yet, honour the legacy testing bool so + // `--testing` still finds the testing config (legacy or new root). + let effective = if profile == "default" && testing { + "testing" } else { - "config.toml" - })) + profile + }; + let dir = dirs::config_dir() + .ok_or(())? + .join(aw_server::dirs::appname_for(effective)) + .join("aw-server-rust"); + Ok(dir.join(aw_server::config::config_filename(effective))) } pub fn get_sync_dir() -> Result> { @@ -34,3 +76,82 @@ pub fn get_sync_dir() -> Result> { let home_dir = home_dir().ok_or("Unable to read home_dir")?; Ok(home_dir.join("ActivityWatchSync")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_profile_cli_wins_over_env_and_testing() { + assert_eq!( + resolve_profile(Some("research"), true, Some("testing")), + "research" + ); + } + + #[test] + fn resolve_profile_env_wins_over_testing() { + assert_eq!(resolve_profile(None, true, Some("research")), "research"); + } + + #[test] + fn resolve_profile_testing_alias_and_default() { + assert_eq!(resolve_profile(None, true, None), "testing"); + assert_eq!(resolve_profile(None, false, None), "default"); + assert_eq!(resolve_profile(Some(""), false, Some("")), "default"); + } + + #[cfg(not(target_os = "android"))] + #[test] + fn sync_config_dir_is_isolated_per_named_profile() { + use aw_server::dirs::appname_for_in; + use std::fs; + let root = std::env::temp_dir().join(format!( + "aw-sync-profile-tests-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let data = root.join("data"); + let config = root.join("config"); + let cache = root.join("cache"); + fs::create_dir_all(&data).unwrap(); + fs::create_dir_all(&config).unwrap(); + fs::create_dir_all(&cache).unwrap(); + + let default_app = appname_for_in("default", &data, &config, &cache); + let testing_app = appname_for_in("testing", &data, &config, &cache); + let research_app = appname_for_in("research", &data, &config, &cache); + + assert_eq!(default_app, "activitywatch"); + // Fresh setup: testing is a sibling root, not the shared one. + assert_eq!(testing_app, "activitywatch-testing"); + assert_eq!(research_app, "activitywatch-research"); + assert_ne!(testing_app, default_app); + assert_ne!(research_app, default_app); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn server_config_filename_default_and_research_are_bare() { + // Isolated roots use config.toml. Suffixed config-testing.toml is + // only for the legacy shared-root layout (dirs.rs tests). + assert_eq!(aw_server::config::config_filename("default"), "config.toml"); + assert_eq!( + aw_server::config::config_filename("research"), + "config.toml" + ); + } + + #[cfg(not(target_os = "android"))] + #[test] + fn server_config_path_default_is_config_toml() { + let production = get_server_config_path(false).unwrap(); + assert!( + production.ends_with("config.toml"), + "default should read config.toml, got {production:?}" + ); + } +} diff --git a/aw-sync/src/main.rs b/aw-sync/src/main.rs index 0740781d..02cfde09 100644 --- a/aw-sync/src/main.rs +++ b/aw-sync/src/main.rs @@ -48,7 +48,14 @@ struct Opts { #[clap(short = 'c', long = "config")] config: Option, + /// Named instance profile (e.g. "default", "testing", "research"). + /// `--testing` is an alias for `--profile testing`. Also reads AW_PROFILE, + /// which aw-qt exports for the modules it spawns. + #[clap(long)] + profile: Option, + /// Convenience option for using the default testing host and port. + /// Alias for `--profile testing`. #[clap(long)] testing: bool, @@ -153,7 +160,24 @@ fn main() -> Result<(), Box> { info!("Started aw-sync..."); - aw_server::logging::setup_logger("aw-sync", opts.testing, verbose)?; + let env_profile = std::env::var("AW_PROFILE").ok(); + let profile = dirs::resolve_profile( + opts.profile.as_deref(), + opts.testing, + env_profile.as_deref(), + ); + aw_server::dirs::validate_profile(&profile) + .unwrap_or_else(|e| panic!("Invalid profile name: {e}")); + aw_server::config::set_profile(profile.clone()); + + aw_server::logging::setup_logger("aw-sync", &profile, verbose)?; + + if opts.testing && opts.profile.as_deref().is_some_and(|p| p != "testing") { + warn!("--testing and --profile {profile} both given; --profile wins, using '{profile}'"); + } + if profile != "default" { + info!("Running aw-sync with profile '{profile}'"); + } // if sync_dir, set env var if let Some(sync_dir) = opts.sync_dir { @@ -165,10 +189,13 @@ fn main() -> Result<(), Box> { std::env::set_var("AW_SYNC_DIR", sync_dir); } + // Named profiles (and `--profile testing` without `--testing`) must use the + // profile's own server config / port default, not the CLI testing bool. + let testing = profile == "testing"; let server_config = if util::is_loopback_host(&opts.host) { - util::get_server_config(opts.testing, opts.config.as_deref())? + util::get_server_config(testing, opts.config.as_deref())? } else { - util::ServerConfig::default_for(opts.testing) + util::ServerConfig::default_for(testing) }; let port = opts.port.unwrap_or(server_config.port);