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// ── Directory initialisation ──────────────────────────────────────────────────
149
150/// Ensure `~/.room/state/` and `~/.room/data/` exist.
151///
152/// Both directories are created with mode `0700` on Unix to protect token
153/// files from other users on the same machine. `recursive(true)` means the
154/// call is idempotent — safe to call on every daemon/broker start.
155pub fn ensure_room_dirs() -> std::io::Result<()> {
156    create_dir_0700(&room_state_dir())?;
157    create_dir_0700(&room_data_dir())?;
158    Ok(())
159}
160
161// ── Internals ────────────────────────────────────────────────────────────────
162
163fn home_dir() -> PathBuf {
164    std::env::var("HOME")
165        .map(PathBuf::from)
166        .unwrap_or_else(|_| PathBuf::from("/tmp"))
167}
168
169fn runtime_dir() -> PathBuf {
170    // macOS: $TMPDIR is per-user and secure (/var/folders/...)
171    // Linux: prefer $XDG_RUNTIME_DIR if set, fall back to /tmp
172    #[cfg(target_os = "macos")]
173    {
174        std::env::var("TMPDIR")
175            .map(PathBuf::from)
176            .unwrap_or_else(|_| PathBuf::from("/tmp"))
177    }
178    #[cfg(not(target_os = "macos"))]
179    {
180        std::env::var("XDG_RUNTIME_DIR")
181            .map(|d| PathBuf::from(d).join("room"))
182            .unwrap_or_else(|_| PathBuf::from("/tmp"))
183    }
184}
185
186fn create_dir_0700(path: &Path) -> std::io::Result<()> {
187    #[cfg(unix)]
188    {
189        std::fs::DirBuilder::new()
190            .recursive(true)
191            .mode(0o700)
192            .create(path)
193    }
194    #[cfg(not(unix))]
195    {
196        std::fs::create_dir_all(path)
197    }
198}
199
200// ── Tests ─────────────────────────────────────────────────────────────────────
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::sync::Mutex;
206
207    /// Serialises tests that read or write the `ROOM_SOCKET` environment
208    /// variable.  Env vars are process-global state — without this lock,
209    /// `cargo test` runs these tests in parallel and they race.
210    static ENV_LOCK: Mutex<()> = Mutex::new(());
211
212    #[test]
213    fn room_home_ends_with_dot_room() {
214        let h = room_home();
215        assert!(
216            h.ends_with(".room"),
217            "expected path ending in .room, got: {h:?}"
218        );
219    }
220
221    #[test]
222    fn room_state_dir_under_room_home() {
223        assert!(room_state_dir().starts_with(room_home()));
224        assert!(room_state_dir().ends_with("state"));
225    }
226
227    #[test]
228    fn room_data_dir_under_room_home() {
229        assert!(room_data_dir().starts_with(room_home()));
230        assert!(room_data_dir().ends_with("data"));
231    }
232
233    #[test]
234    fn token_path_is_per_room_and_user() {
235        let alice_r1 = token_path("room1", "alice");
236        let bob_r1 = token_path("room1", "bob");
237        let alice_r2 = token_path("room2", "alice");
238        assert_ne!(alice_r1, bob_r1);
239        assert_ne!(alice_r1, alice_r2);
240        assert!(alice_r1.to_str().unwrap().contains("alice"));
241        assert!(alice_r1.to_str().unwrap().contains("room1"));
242    }
243
244    #[test]
245    fn cursor_path_is_per_room_and_user() {
246        let p = cursor_path("myroom", "bob");
247        assert!(p.to_str().unwrap().contains("bob"));
248        assert!(p.to_str().unwrap().contains("myroom"));
249        assert!(p.to_str().unwrap().ends_with(".cursor"));
250    }
251
252    #[test]
253    fn broker_tokens_path_contains_room_id() {
254        let base = PathBuf::from("/tmp/state");
255        let p = broker_tokens_path(&base, "test-room");
256        assert_eq!(p, base.join("test-room.tokens"));
257    }
258
259    #[test]
260    fn broker_subscriptions_path_contains_room_id() {
261        let base = PathBuf::from("/tmp/state");
262        let p = broker_subscriptions_path(&base, "test-room");
263        assert_eq!(p, base.join("test-room.subscriptions"));
264    }
265
266    #[test]
267    fn create_dir_0700_is_idempotent() {
268        let dir = tempfile::TempDir::new().unwrap();
269        let target = dir.path().join("nested").join("deep");
270        create_dir_0700(&target).unwrap();
271        // Second call must not error (recursive=true).
272        create_dir_0700(&target).unwrap();
273        assert!(target.exists());
274    }
275
276    #[cfg(unix)]
277    #[test]
278    fn create_dir_0700_sets_correct_permissions() {
279        use std::os::unix::fs::PermissionsExt;
280        let dir = tempfile::TempDir::new().unwrap();
281        let target = dir.path().join("secret");
282        create_dir_0700(&target).unwrap();
283        let perms = std::fs::metadata(&target).unwrap().permissions();
284        assert_eq!(
285            perms.mode() & 0o777,
286            0o700,
287            "expected 0700, got {:o}",
288            perms.mode() & 0o777
289        );
290    }
291
292    // ── effective_socket_path ─────────────────────────────────────────────
293
294    #[test]
295    fn effective_socket_path_uses_env_var() {
296        let _lock = ENV_LOCK.lock().unwrap();
297        let key = "ROOM_SOCKET";
298        let prev = std::env::var(key).ok();
299        std::env::set_var(key, "/tmp/test-roomd.sock");
300        let result = effective_socket_path(None);
301        match prev {
302            Some(v) => std::env::set_var(key, v),
303            None => std::env::remove_var(key),
304        }
305        assert_eq!(result, PathBuf::from("/tmp/test-roomd.sock"));
306    }
307
308    #[test]
309    fn effective_socket_path_explicit_overrides_env() {
310        let _lock = ENV_LOCK.lock().unwrap();
311        let key = "ROOM_SOCKET";
312        let prev = std::env::var(key).ok();
313        std::env::set_var(key, "/tmp/env-roomd.sock");
314        let explicit = PathBuf::from("/tmp/explicit.sock");
315        let result = effective_socket_path(Some(&explicit));
316        match prev {
317            Some(v) => std::env::set_var(key, v),
318            None => std::env::remove_var(key),
319        }
320        assert_eq!(result, explicit);
321    }
322
323    #[test]
324    fn effective_socket_path_default_without_env() {
325        let _lock = ENV_LOCK.lock().unwrap();
326        let key = "ROOM_SOCKET";
327        let prev = std::env::var(key).ok();
328        std::env::remove_var(key);
329        let result = effective_socket_path(None);
330        match prev {
331            Some(v) => std::env::set_var(key, v),
332            None => std::env::remove_var(key),
333        }
334        assert_eq!(result, room_socket_path());
335    }
336
337    #[test]
338    fn room_runtime_dir_returns_absolute_path() {
339        let p = room_runtime_dir();
340        assert!(p.is_absolute(), "expected absolute path, got: {p:?}");
341    }
342
343    #[test]
344    fn legacy_token_dir_returns_valid_path() {
345        let p = legacy_token_dir();
346        // Must be absolute and non-empty.
347        assert!(p.is_absolute(), "expected absolute path, got: {p:?}");
348    }
349
350    #[test]
351    fn ensure_room_dirs_creates_state_and_data() {
352        // We cannot call ensure_room_dirs() directly without writing to ~/.room,
353        // so test the underlying create_dir_0700 with a temp directory.
354        let dir = tempfile::TempDir::new().unwrap();
355        let state = dir.path().join("state");
356        let data = dir.path().join("data");
357        create_dir_0700(&state).unwrap();
358        create_dir_0700(&data).unwrap();
359        assert!(state.exists());
360        assert!(data.exists());
361    }
362}