Skip to main content

room_cli/oneshot/
token.rs

1use std::path::{Path, PathBuf};
2
3use super::transport::global_join_session;
4use crate::paths;
5
6/// One-shot join subcommand: register username globally, receive token, write token file.
7///
8/// Writes to `~/.room/state/room-<username>.token`. The token is global —
9/// not tied to any specific room. Use `room subscribe <room>` to join rooms.
10///
11/// If the username is already registered, returns the existing token.
12///
13/// `socket` overrides the default socket path (auto-discovered if `None`).
14pub async fn cmd_join(username: &str, socket: Option<&std::path::Path>) -> anyhow::Result<()> {
15    paths::ensure_room_dirs().map_err(|e| anyhow::anyhow!("cannot create ~/.room: {e}"))?;
16    let socket_path = paths::effective_socket_path(socket);
17    let (returned_user, token) = global_join_session(&socket_path, username).await?;
18    let token_data = serde_json::json!({"username": returned_user, "token": token});
19    let token_path = paths::global_token_path(&returned_user);
20    std::fs::write(&token_path, format!("{token_data}\n"))?;
21    println!("{token_data}");
22    Ok(())
23}
24
25/// Look up the username associated with `token` by scanning global token files.
26///
27/// Scans `~/.room/state/room-<username>.token` files and returns the username
28/// whose token matches. Used by `poll`, `watch`, and `dm` to resolve the caller's
29/// identity without requiring a username argument.
30pub fn username_from_token(token: &str) -> anyhow::Result<String> {
31    let state_dir = paths::room_state_dir();
32    let prefix = "room-";
33    let suffix = ".token";
34    let files: Vec<PathBuf> = std::fs::read_dir(&state_dir)
35        .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", state_dir.display()))?
36        .filter_map(|e| e.ok())
37        .map(|e| e.path())
38        .filter(|p| {
39            p.file_name()
40                .and_then(|n| n.to_str())
41                .map(|n| n.starts_with(prefix) && n.ends_with(suffix))
42                .unwrap_or(false)
43        })
44        .collect();
45
46    for path in files {
47        if let Ok(data) = std::fs::read_to_string(&path) {
48            if let Ok(v) = serde_json::from_str::<serde_json::Value>(data.trim()) {
49                if v["token"].as_str() == Some(token) {
50                    if let Some(u) = v["username"].as_str() {
51                        return Ok(u.to_owned());
52                    }
53                }
54            }
55        }
56    }
57
58    anyhow::bail!("token not recognised — run: room join <username> to get a fresh token")
59}
60
61/// Read the cursor position from disk, returning `None` if the file is absent or empty.
62pub fn read_cursor(cursor_path: &Path) -> Option<String> {
63    std::fs::read_to_string(cursor_path)
64        .ok()
65        .map(|s| s.trim().to_owned())
66        .filter(|s| !s.is_empty())
67}
68
69/// Persist the cursor position to disk.
70///
71/// Creates parent directories if they do not exist (e.g. `~/.room/state/` on first run).
72pub fn write_cursor(cursor_path: &Path, id: &str) -> anyhow::Result<()> {
73    if let Some(parent) = cursor_path.parent() {
74        std::fs::create_dir_all(parent)?;
75    }
76    std::fs::write(cursor_path, id)?;
77    Ok(())
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use std::fs;
84    use tempfile::TempDir;
85
86    /// Write a global token file into a temp dir.
87    fn write_token_file(dir: &std::path::Path, username: &str, token: &str) {
88        let name = format!("room-{username}.token");
89        let data = serde_json::json!({"username": username, "token": token});
90        fs::write(dir.join(name), format!("{data}\n")).unwrap();
91    }
92
93    /// A version of username_from_token that scans a custom directory (for hermetic tests).
94    fn username_from_token_in(dir: &std::path::Path, token: &str) -> anyhow::Result<String> {
95        let prefix = "room-";
96        let suffix = ".token";
97        let files: Vec<PathBuf> = fs::read_dir(dir)
98            .unwrap()
99            .filter_map(|e| e.ok())
100            .map(|e| e.path())
101            .filter(|p| {
102                p.file_name()
103                    .and_then(|n| n.to_str())
104                    .map(|n| n.starts_with(prefix) && n.ends_with(suffix))
105                    .unwrap_or(false)
106            })
107            .collect();
108
109        for path in files {
110            if let Ok(data) = fs::read_to_string(&path) {
111                if let Ok(v) = serde_json::from_str::<serde_json::Value>(data.trim()) {
112                    if v["token"].as_str() == Some(token) {
113                        if let Some(u) = v["username"].as_str() {
114                            return Ok(u.to_owned());
115                        }
116                    }
117                }
118            }
119        }
120        anyhow::bail!("token not recognised — run: room join <username>")
121    }
122
123    #[test]
124    fn finds_correct_user() {
125        let dir = TempDir::new().unwrap();
126        write_token_file(dir.path(), "alice", "tok-alice");
127        let user = username_from_token_in(dir.path(), "tok-alice").unwrap();
128        assert_eq!(user, "alice");
129    }
130
131    #[test]
132    fn disambiguates_multiple_users() {
133        let dir = TempDir::new().unwrap();
134        write_token_file(dir.path(), "alice", "tok-alice");
135        write_token_file(dir.path(), "bob", "tok-bob");
136
137        assert_eq!(
138            username_from_token_in(dir.path(), "tok-alice").unwrap(),
139            "alice"
140        );
141        assert_eq!(
142            username_from_token_in(dir.path(), "tok-bob").unwrap(),
143            "bob"
144        );
145    }
146
147    #[test]
148    fn unknown_token_errors_with_join_hint() {
149        let dir = TempDir::new().unwrap();
150        let err = username_from_token_in(dir.path(), "not-a-real-token").unwrap_err();
151        assert!(
152            err.to_string().contains("room join"),
153            "expected 'room join' hint in: {err}"
154        );
155    }
156
157    #[test]
158    fn two_agents_tokens_do_not_collide() {
159        let dir = TempDir::new().unwrap();
160        write_token_file(dir.path(), "alice", "tok-alice");
161        write_token_file(dir.path(), "bob", "tok-bob");
162
163        assert_eq!(
164            username_from_token_in(dir.path(), "tok-alice").unwrap(),
165            "alice"
166        );
167        assert_eq!(
168            username_from_token_in(dir.path(), "tok-bob").unwrap(),
169            "bob"
170        );
171    }
172
173    #[test]
174    fn ignores_non_token_files() {
175        let dir = TempDir::new().unwrap();
176        // Write a non-token file that matches the prefix
177        fs::write(dir.path().join("room-lobby.sock"), "not a token").unwrap();
178        write_token_file(dir.path(), "bob", "tok-bob");
179
180        assert_eq!(
181            username_from_token_in(dir.path(), "tok-bob").unwrap(),
182            "bob"
183        );
184    }
185
186    #[test]
187    fn read_cursor_returns_none_when_file_absent() {
188        let dir = TempDir::new().unwrap();
189        let path = dir.path().join("cursor");
190        assert!(read_cursor(&path).is_none());
191    }
192
193    #[test]
194    fn write_then_read_cursor_round_trips() {
195        let dir = TempDir::new().unwrap();
196        let path = dir.path().join("cursor");
197        write_cursor(&path, "abc-123").unwrap();
198        assert_eq!(read_cursor(&path).unwrap(), "abc-123");
199    }
200}