Skip to main content

room_cli/
paths.rs

1//! Room filesystem path resolution.
2//!
3//! All persistent state lives under `~/.room/`:
4//! - `~/.room/state/` — tokens, cursors, subscriptions (0700)
5//! - `~/.room/data/`  — chat files (default, overridable via `--data-dir`)
6//!
7//! Ephemeral runtime files (sockets, PID, meta) use the platform-native
8//! temporary directory:
9//! - macOS: `$TMPDIR` (per-user, e.g. `/var/folders/...`)
10//! - Linux: `$XDG_RUNTIME_DIR/room/` or `/tmp/` fallback
11
12use std::path::{Path, PathBuf};
13
14#[cfg(unix)]
15use std::os::unix::fs::DirBuilderExt;
16
17// ── Public path accessors ─────────────────────────────────────────────────────
18
19/// Root of all persistent room state: `~/.room/`.
20pub fn room_home() -> PathBuf {
21    home_dir().join(".room")
22}
23
24/// Directory for persistent state files (tokens, cursors, subscriptions).
25///
26/// Returns `~/.room/state/`.
27pub fn room_state_dir() -> PathBuf {
28    room_home().join("state")
29}
30
31/// Default directory for chat files: `~/.room/data/`.
32///
33/// Overridable at daemon startup with `--data-dir`.
34pub fn room_data_dir() -> PathBuf {
35    room_home().join("data")
36}
37
38/// Platform-native runtime directory for ephemeral room files (sockets,
39/// PID, meta).
40///
41/// - macOS: `$TMPDIR` (per-user, e.g. `/var/folders/...`)
42/// - Linux: `$XDG_RUNTIME_DIR/room/` or `/tmp/` fallback
43pub fn room_runtime_dir() -> PathBuf {
44    runtime_dir()
45}
46
47/// Platform-native socket path for the multi-room daemon.
48///
49/// - macOS: `$TMPDIR/roomd.sock`
50/// - Linux: `$XDG_RUNTIME_DIR/room/roomd.sock` (falls back to `/tmp/roomd.sock`)
51pub fn room_socket_path() -> PathBuf {
52    runtime_dir().join("roomd.sock")
53}
54
55/// Resolve the effective daemon socket path.
56///
57/// Resolution order:
58/// 1. `explicit` — caller-supplied path (e.g. from `--socket` flag).
59/// 2. `ROOM_SOCKET` environment variable.
60/// 3. Platform-native default (`room_socket_path()`).
61pub fn effective_socket_path(explicit: Option<&std::path::Path>) -> PathBuf {
62    if let Some(p) = explicit {
63        return p.to_owned();
64    }
65    if let Ok(p) = std::env::var("ROOM_SOCKET") {
66        if !p.is_empty() {
67            return PathBuf::from(p);
68        }
69    }
70    room_socket_path()
71}
72
73/// Platform-native socket path for a single-room broker.
74pub fn room_single_socket_path(room_id: &str) -> PathBuf {
75    runtime_dir().join(format!("room-{room_id}.sock"))
76}
77
78/// Platform-native meta file path for a single-room broker.
79pub fn room_meta_path(room_id: &str) -> PathBuf {
80    runtime_dir().join(format!("room-{room_id}.meta"))
81}
82
83/// Token file path for a given room/user pair (legacy, per-room tokens).
84///
85/// Returns `~/.room/state/room-<room_id>-<username>.token`.
86pub fn token_path(room_id: &str, username: &str) -> PathBuf {
87    room_state_dir().join(format!("room-{room_id}-{username}.token"))
88}
89
90/// Global token file path for a user (room-independent).
91///
92/// Returns `~/.room/state/room-<username>.token`.
93/// Used by `room join <username>` which issues a global token not tied to any room.
94pub fn global_token_path(username: &str) -> PathBuf {
95    room_state_dir().join(format!("room-{username}.token"))
96}
97
98/// Cursor file path for a given room/user pair.
99///
100/// Returns `~/.room/state/room-<room_id>-<username>.cursor`.
101pub fn cursor_path(room_id: &str, username: &str) -> PathBuf {
102    room_state_dir().join(format!("room-{room_id}-{username}.cursor"))
103}
104
105/// Broker token-map file path: `<state_dir>/<room_id>.tokens`.
106///
107/// The broker persists its in-memory `TokenMap` here on every token issuance.
108pub fn broker_tokens_path(state_dir: &Path, room_id: &str) -> PathBuf {
109    state_dir.join(format!("{room_id}.tokens"))
110}
111
112/// PID file for the daemon process: `~/.room/roomd.pid`.
113///
114/// Written by `ensure_daemon_running` when it auto-starts the daemon.
115/// Ephemeral — deleted on clean daemon shutdown, may linger after a crash.
116pub fn room_pid_path() -> PathBuf {
117    room_home().join("roomd.pid")
118}
119
120/// System-level token persistence path: `~/.room/state/tokens.json`.
121///
122/// Tokens in a daemon are system-level — a single token issued by `room join`
123/// in any room is valid in all rooms managed by the same daemon. This file
124/// stores the complete token → username mapping across all rooms.
125pub fn system_tokens_path() -> PathBuf {
126    room_state_dir().join("tokens.json")
127}
128
129/// Directory that contained per-room token files in older daemon versions.
130///
131/// Before `~/.room/state/` was introduced, `room join` wrote token files to
132/// the platform-native runtime directory (`$TMPDIR` on macOS,
133/// `$XDG_RUNTIME_DIR/room/` or `/tmp/` on Linux). The daemon scans this
134/// directory on every startup to import any tokens that pre-date the
135/// `~/.room/state/` migration, so existing clients do not need to re-join.
136pub fn legacy_token_dir() -> PathBuf {
137    runtime_dir()
138}
139
140/// Broker subscription-map file path: `<state_dir>/<room_id>.subscriptions`.
141///
142/// The broker persists per-user subscription tiers here on every mutation
143/// (subscribe, unsubscribe, auto-subscribe on @mention).
144pub fn broker_subscriptions_path(state_dir: &Path, room_id: &str) -> PathBuf {
145    state_dir.join(format!("{room_id}.subscriptions"))
146}
147
148/// Broker event-filter-map file path: `<state_dir>/<room_id>.event_filters`.
149///
150/// Persists per-user event type filters on every mutation. Used alongside
151/// the subscription tier to control which [`EventType`]s appear in poll results.
152pub fn broker_event_filters_path(state_dir: &Path, room_id: &str) -> PathBuf {
153    state_dir.join(format!("{room_id}.event_filters"))
154}
155
156// ── Directory initialisation ──────────────────────────────────────────────────
157
158/// Ensure `~/.room/state/` and `~/.room/data/` exist.
159///
160/// Both directories are created with mode `0700` on Unix to protect token
161/// files from other users on the same machine. `recursive(true)` means the
162/// call is idempotent — safe to call on every daemon/broker start.
163pub fn ensure_room_dirs() -> std::io::Result<()> {
164    create_dir_0700(&room_state_dir())?;
165    create_dir_0700(&room_data_dir())?;
166    Ok(())
167}
168
169// ── Internals ────────────────────────────────────────────────────────────────
170
171fn home_dir() -> PathBuf {
172    std::env::var("HOME")
173        .map(PathBuf::from)
174        .unwrap_or_else(|_| PathBuf::from("/tmp"))
175}
176
177fn runtime_dir() -> PathBuf {
178    // macOS: $TMPDIR is per-user and secure (/var/folders/...)
179    // Linux: prefer $XDG_RUNTIME_DIR if set, fall back to /tmp
180    #[cfg(target_os = "macos")]
181    {
182        std::env::var("TMPDIR")
183            .map(PathBuf::from)
184            .unwrap_or_else(|_| PathBuf::from("/tmp"))
185    }
186    #[cfg(not(target_os = "macos"))]
187    {
188        std::env::var("XDG_RUNTIME_DIR")
189            .map(|d| PathBuf::from(d).join("room"))
190            .unwrap_or_else(|_| PathBuf::from("/tmp"))
191    }
192}
193
194fn create_dir_0700(path: &Path) -> std::io::Result<()> {
195    #[cfg(unix)]
196    {
197        std::fs::DirBuilder::new()
198            .recursive(true)
199            .mode(0o700)
200            .create(path)
201    }
202    #[cfg(not(unix))]
203    {
204        std::fs::create_dir_all(path)
205    }
206}
207
208// ── Tests ─────────────────────────────────────────────────────────────────────
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use std::sync::Mutex;
214
215    /// Serialises tests that read or write the `ROOM_SOCKET` environment
216    /// variable.  Env vars are process-global state — without this lock,
217    /// `cargo test` runs these tests in parallel and they race.
218    static ENV_LOCK: Mutex<()> = Mutex::new(());
219
220    #[test]
221    fn room_home_ends_with_dot_room() {
222        let h = room_home();
223        assert!(
224            h.ends_with(".room"),
225            "expected path ending in .room, got: {h:?}"
226        );
227    }
228
229    #[test]
230    fn room_state_dir_under_room_home() {
231        assert!(room_state_dir().starts_with(room_home()));
232        assert!(room_state_dir().ends_with("state"));
233    }
234
235    #[test]
236    fn room_data_dir_under_room_home() {
237        assert!(room_data_dir().starts_with(room_home()));
238        assert!(room_data_dir().ends_with("data"));
239    }
240
241    #[test]
242    fn token_path_is_per_room_and_user() {
243        let alice_r1 = token_path("room1", "alice");
244        let bob_r1 = token_path("room1", "bob");
245        let alice_r2 = token_path("room2", "alice");
246        assert_ne!(alice_r1, bob_r1);
247        assert_ne!(alice_r1, alice_r2);
248        assert!(alice_r1.to_str().unwrap().contains("alice"));
249        assert!(alice_r1.to_str().unwrap().contains("room1"));
250    }
251
252    #[test]
253    fn cursor_path_is_per_room_and_user() {
254        let p = cursor_path("myroom", "bob");
255        assert!(p.to_str().unwrap().contains("bob"));
256        assert!(p.to_str().unwrap().contains("myroom"));
257        assert!(p.to_str().unwrap().ends_with(".cursor"));
258    }
259
260    #[test]
261    fn broker_tokens_path_contains_room_id() {
262        let base = PathBuf::from("/tmp/state");
263        let p = broker_tokens_path(&base, "test-room");
264        assert_eq!(p, base.join("test-room.tokens"));
265    }
266
267    #[test]
268    fn broker_subscriptions_path_contains_room_id() {
269        let base = PathBuf::from("/tmp/state");
270        let p = broker_subscriptions_path(&base, "test-room");
271        assert_eq!(p, base.join("test-room.subscriptions"));
272    }
273
274    #[test]
275    fn broker_event_filters_path_contains_room_id() {
276        let base = PathBuf::from("/tmp/state");
277        let p = broker_event_filters_path(&base, "test-room");
278        assert_eq!(p, base.join("test-room.event_filters"));
279    }
280
281    #[test]
282    fn create_dir_0700_is_idempotent() {
283        let dir = tempfile::TempDir::new().unwrap();
284        let target = dir.path().join("nested").join("deep");
285        create_dir_0700(&target).unwrap();
286        // Second call must not error (recursive=true).
287        create_dir_0700(&target).unwrap();
288        assert!(target.exists());
289    }
290
291    #[cfg(unix)]
292    #[test]
293    fn create_dir_0700_sets_correct_permissions() {
294        use std::os::unix::fs::PermissionsExt;
295        let dir = tempfile::TempDir::new().unwrap();
296        let target = dir.path().join("secret");
297        create_dir_0700(&target).unwrap();
298        let perms = std::fs::metadata(&target).unwrap().permissions();
299        assert_eq!(
300            perms.mode() & 0o777,
301            0o700,
302            "expected 0700, got {:o}",
303            perms.mode() & 0o777
304        );
305    }
306
307    // ── effective_socket_path ─────────────────────────────────────────────
308
309    #[test]
310    fn effective_socket_path_uses_env_var() {
311        let _lock = ENV_LOCK.lock().unwrap();
312        let key = "ROOM_SOCKET";
313        let prev = std::env::var(key).ok();
314        std::env::set_var(key, "/tmp/test-roomd.sock");
315        let result = effective_socket_path(None);
316        match prev {
317            Some(v) => std::env::set_var(key, v),
318            None => std::env::remove_var(key),
319        }
320        assert_eq!(result, PathBuf::from("/tmp/test-roomd.sock"));
321    }
322
323    #[test]
324    fn effective_socket_path_explicit_overrides_env() {
325        let _lock = ENV_LOCK.lock().unwrap();
326        let key = "ROOM_SOCKET";
327        let prev = std::env::var(key).ok();
328        std::env::set_var(key, "/tmp/env-roomd.sock");
329        let explicit = PathBuf::from("/tmp/explicit.sock");
330        let result = effective_socket_path(Some(&explicit));
331        match prev {
332            Some(v) => std::env::set_var(key, v),
333            None => std::env::remove_var(key),
334        }
335        assert_eq!(result, explicit);
336    }
337
338    #[test]
339    fn effective_socket_path_default_without_env() {
340        let _lock = ENV_LOCK.lock().unwrap();
341        let key = "ROOM_SOCKET";
342        let prev = std::env::var(key).ok();
343        std::env::remove_var(key);
344        let result = effective_socket_path(None);
345        match prev {
346            Some(v) => std::env::set_var(key, v),
347            None => std::env::remove_var(key),
348        }
349        assert_eq!(result, room_socket_path());
350    }
351
352    #[test]
353    fn room_runtime_dir_returns_absolute_path() {
354        let p = room_runtime_dir();
355        assert!(p.is_absolute(), "expected absolute path, got: {p:?}");
356    }
357
358    #[test]
359    fn legacy_token_dir_returns_valid_path() {
360        let p = legacy_token_dir();
361        // Must be absolute and non-empty.
362        assert!(p.is_absolute(), "expected absolute path, got: {p:?}");
363    }
364
365    #[test]
366    fn ensure_room_dirs_creates_state_and_data() {
367        // We cannot call ensure_room_dirs() directly without writing to ~/.room,
368        // so test the underlying create_dir_0700 with a temp directory.
369        let dir = tempfile::TempDir::new().unwrap();
370        let state = dir.path().join("state");
371        let data = dir.path().join("data");
372        create_dir_0700(&state).unwrap();
373        create_dir_0700(&data).unwrap();
374        assert!(state.exists());
375        assert!(data.exists());
376    }
377}