Skip to main content

room_cli/oneshot/
token.rs

1use std::path::{Path, PathBuf};
2
3use super::transport::join_session;
4
5/// Returns the canonical token file path: `/tmp/room-<room_id>-<username>.token`.
6///
7/// One file per (room, user) pair — multiple agents on the same machine never
8/// overwrite each other's tokens.
9pub fn token_file_path(room_id: &str, username: &str) -> PathBuf {
10    PathBuf::from(format!("/tmp/room-{room_id}-{username}.token"))
11}
12
13/// One-shot join subcommand: register username, receive token, write token file.
14///
15/// Writes to `/tmp/room-<room_id>-<username>.token` so agents sharing a machine
16/// do not clobber each other. Subsequent `send`, `poll`, and `watch` calls find
17/// the file automatically (single-agent) or via `--user <username>` (multi-agent).
18pub async fn cmd_join(room_id: &str, username: &str) -> anyhow::Result<()> {
19    let socket_path = PathBuf::from(format!("/tmp/room-{room_id}.sock"));
20    let (returned_user, token) = join_session(&socket_path, username).await?;
21    let token_data = serde_json::json!({"username": returned_user, "token": token});
22    let token_path = token_file_path(room_id, &returned_user);
23    std::fs::write(&token_path, format!("{token_data}\n"))?;
24    println!("{token_data}");
25    Ok(())
26}
27
28/// Look up the username associated with `token` by scanning stored token files for `room_id`.
29///
30/// `room join` writes `/tmp/room-<room_id>-<username>.token` for each session.
31/// This function finds the file whose `token` field matches the given value and
32/// returns the corresponding username. Used by `poll` and `watch` to resolve the
33/// cursor file path without requiring the caller to pass a username explicitly.
34pub fn username_from_token(room_id: &str, token: &str) -> anyhow::Result<String> {
35    let prefix = format!("room-{room_id}-");
36    let suffix = ".token";
37    let files: Vec<PathBuf> = std::fs::read_dir("/tmp")
38        .map_err(|e| anyhow::anyhow!("cannot read /tmp: {e}"))?
39        .filter_map(|e| e.ok())
40        .map(|e| e.path())
41        .filter(|p| {
42            p.file_name()
43                .and_then(|n| n.to_str())
44                .map(|n| n.starts_with(&prefix) && n.ends_with(suffix))
45                .unwrap_or(false)
46        })
47        .collect();
48
49    for path in files {
50        if let Ok(data) = std::fs::read_to_string(&path) {
51            if let Ok(v) = serde_json::from_str::<serde_json::Value>(data.trim()) {
52                if v["token"].as_str() == Some(token) {
53                    if let Some(u) = v["username"].as_str() {
54                        return Ok(u.to_owned());
55                    }
56                }
57            }
58        }
59    }
60
61    anyhow::bail!("token not recognised — run: room join {room_id} <username> to get a fresh token")
62}
63
64/// Read the cursor position from disk, returning `None` if the file is absent or empty.
65pub fn read_cursor(cursor_path: &Path) -> Option<String> {
66    std::fs::read_to_string(cursor_path)
67        .ok()
68        .map(|s| s.trim().to_owned())
69        .filter(|s| !s.is_empty())
70}
71
72/// Persist the cursor position to disk.
73pub fn write_cursor(cursor_path: &Path, id: &str) -> anyhow::Result<()> {
74    std::fs::write(cursor_path, id)?;
75    Ok(())
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::fs;
82    use tempfile::TempDir;
83
84    /// Write a token file into a temp dir.
85    fn write_token_file(dir: &std::path::Path, room_id: &str, username: &str, token: &str) {
86        let name = format!("room-{room_id}-{username}.token");
87        let data = serde_json::json!({"username": username, "token": token});
88        fs::write(dir.join(name), format!("{data}\n")).unwrap();
89    }
90
91    /// A version of username_from_token that scans a custom directory (for hermetic tests).
92    fn username_from_token_in(
93        dir: &std::path::Path,
94        room_id: &str,
95        token: &str,
96    ) -> anyhow::Result<String> {
97        let prefix = format!("room-{room_id}-");
98        let suffix = ".token";
99        let files: Vec<PathBuf> = fs::read_dir(dir)
100            .unwrap()
101            .filter_map(|e| e.ok())
102            .map(|e| e.path())
103            .filter(|p| {
104                p.file_name()
105                    .and_then(|n| n.to_str())
106                    .map(|n| n.starts_with(&prefix) && n.ends_with(suffix))
107                    .unwrap_or(false)
108            })
109            .collect();
110
111        for path in files {
112            if let Ok(data) = fs::read_to_string(&path) {
113                if let Ok(v) = serde_json::from_str::<serde_json::Value>(data.trim()) {
114                    if v["token"].as_str() == Some(token) {
115                        if let Some(u) = v["username"].as_str() {
116                            return Ok(u.to_owned());
117                        }
118                    }
119                }
120            }
121        }
122        anyhow::bail!("token not recognised — run: room join {room_id} <username>")
123    }
124
125    #[test]
126    fn token_file_path_is_per_user() {
127        let alice = token_file_path("myroom", "alice");
128        let bob = token_file_path("myroom", "bob");
129        assert_ne!(alice, bob);
130        assert!(alice.to_str().unwrap().contains("alice"));
131        assert!(bob.to_str().unwrap().contains("bob"));
132    }
133
134    #[test]
135    fn username_from_token_finds_correct_user() {
136        let dir = TempDir::new().unwrap();
137        write_token_file(dir.path(), "r1", "alice", "tok-alice");
138        let user = username_from_token_in(dir.path(), "r1", "tok-alice").unwrap();
139        assert_eq!(user, "alice");
140    }
141
142    #[test]
143    fn username_from_token_disambiguates_multiple_users() {
144        let dir = TempDir::new().unwrap();
145        write_token_file(dir.path(), "r2", "alice", "tok-alice");
146        write_token_file(dir.path(), "r2", "bob", "tok-bob");
147
148        assert_eq!(
149            username_from_token_in(dir.path(), "r2", "tok-alice").unwrap(),
150            "alice"
151        );
152        assert_eq!(
153            username_from_token_in(dir.path(), "r2", "tok-bob").unwrap(),
154            "bob"
155        );
156    }
157
158    #[test]
159    fn username_from_token_unknown_errors_with_join_hint() {
160        let dir = TempDir::new().unwrap();
161        let err = username_from_token_in(dir.path(), "r3", "not-a-real-token").unwrap_err();
162        assert!(
163            err.to_string().contains("room join"),
164            "expected 'room join' hint in: {err}"
165        );
166    }
167
168    #[test]
169    fn two_agents_tokens_do_not_collide() {
170        let dir = TempDir::new().unwrap();
171        write_token_file(dir.path(), "r4", "alice", "tok-alice");
172        write_token_file(dir.path(), "r4", "bob", "tok-bob");
173
174        assert_eq!(
175            username_from_token_in(dir.path(), "r4", "tok-alice").unwrap(),
176            "alice"
177        );
178        assert_eq!(
179            username_from_token_in(dir.path(), "r4", "tok-bob").unwrap(),
180            "bob"
181        );
182    }
183
184    #[test]
185    fn read_cursor_returns_none_when_file_absent() {
186        let dir = TempDir::new().unwrap();
187        let path = dir.path().join("cursor");
188        assert!(read_cursor(&path).is_none());
189    }
190
191    #[test]
192    fn write_then_read_cursor_round_trips() {
193        let dir = TempDir::new().unwrap();
194        let path = dir.path().join("cursor");
195        write_cursor(&path, "abc-123").unwrap();
196        assert_eq!(read_cursor(&path).unwrap(), "abc-123");
197    }
198}