Skip to main content

room_cli/
client.rs

1use std::path::PathBuf;
2
3use tokio::{
4    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
5    net::UnixStream,
6};
7
8use crate::{
9    message::Message,
10    oneshot::transport::{join_session_target, resolve_socket_target, SocketTarget},
11    paths, tui,
12};
13
14pub struct Client {
15    pub socket_path: PathBuf,
16    pub room_id: String,
17    pub username: String,
18    pub agent_mode: bool,
19    pub history_lines: usize,
20    /// When set, the client is connecting through the daemon and must prefix
21    /// the interactive handshake with `ROOM:<room_id>:`.
22    pub daemon_mode: bool,
23}
24
25impl Client {
26    pub async fn run(self) -> anyhow::Result<()> {
27        // Ensure a token exists for this room/user so that subsequent oneshot
28        // commands (send, poll, watch) work without a manual `room join`.
29        self.ensure_token().await;
30
31        let stream = UnixStream::connect(&self.socket_path).await?;
32        let (read_half, mut write_half) = stream.into_split();
33
34        // Handshake: prefer SESSION:<token> for authenticated interactive join,
35        // falling back to bare username (deprecated) if no token file exists.
36        let session_part = match read_token_for_session(&self.room_id, &self.username) {
37            Some(token) => format!("SESSION:{token}"),
38            None => {
39                eprintln!(
40                    "[tui] no token file found — falling back to unauthenticated join \
41                     (run `room join {}` to fix)",
42                    self.username
43                );
44                self.username.clone()
45            }
46        };
47        let handshake = if self.daemon_mode {
48            format!("ROOM:{}:{session_part}\n", self.room_id)
49        } else {
50            format!("{session_part}\n")
51        };
52        write_half.write_all(handshake.as_bytes()).await?;
53
54        let reader = BufReader::new(read_half);
55
56        if self.agent_mode {
57            run_agent(reader, write_half, &self.username, self.history_lines).await
58        } else {
59            tui::run(
60                reader,
61                write_half,
62                &self.room_id,
63                &self.username,
64                self.history_lines,
65                self.socket_path.clone(),
66            )
67            .await
68        }
69    }
70
71    /// Ensure a valid session token exists for this room/user pair.
72    ///
73    /// Always attempts a `JOIN:` handshake to acquire a fresh token. This handles
74    /// broker restarts (which invalidate old tokens) transparently. If the join
75    /// succeeds, the new token is written to `~/.room/state/`. If it fails with
76    /// "username_taken", the existing token file is assumed valid (the user is
77    /// already registered with the broker). Other errors are logged but not
78    /// propagated — the interactive session can proceed regardless.
79    async fn ensure_token(&self) {
80        if let Err(e) = paths::ensure_room_dirs() {
81            eprintln!("[tui] cannot create ~/.room dirs: {e}");
82            return;
83        }
84
85        let target = if self.daemon_mode {
86            SocketTarget {
87                path: self.socket_path.clone(),
88                daemon_room: Some(self.room_id.clone()),
89            }
90        } else {
91            resolve_socket_target(&self.room_id, None)
92        };
93        match join_session_target(&target, &self.username).await {
94            Ok((returned_user, token)) => {
95                let token_data = serde_json::json!({"username": returned_user, "token": token});
96                let path = paths::token_path(&self.room_id, &returned_user);
97                if let Err(e) = std::fs::write(&path, format!("{token_data}\n")) {
98                    eprintln!("[tui] failed to write token file: {e}");
99                }
100            }
101            Err(e) => {
102                let msg = e.to_string();
103                if msg.contains("already in use") {
104                    // Username is registered — existing token should be valid.
105                    let token_path = paths::token_path(&self.room_id, &self.username);
106                    if !has_valid_token_file(&token_path) {
107                        eprintln!(
108                            "[tui] username registered but no token file found — \
109                             run `room join {} {}` to recover",
110                            self.room_id, self.username
111                        );
112                    }
113                } else if msg.contains("cannot connect") {
114                    // Broker not running — token will be acquired on next session.
115                } else {
116                    eprintln!("[tui] auto-join failed: {e}");
117                }
118            }
119        }
120    }
121}
122
123/// Read a session token for the given room/user.
124///
125/// Checks the global token file (`~/.room/state/room-<username>.token`) first,
126/// then falls back to the per-room token file (`~/.room/state/room-<room_id>-<username>.token`).
127/// Returns `Some(token_uuid)` if a valid token file is found, `None` otherwise.
128fn read_token_for_session(room_id: &str, username: &str) -> Option<String> {
129    let candidates = [
130        paths::global_token_path(username),
131        paths::token_path(room_id, username),
132    ];
133    for path in &candidates {
134        if let Some(token) = read_token_from_file(path) {
135            return Some(token);
136        }
137    }
138    None
139}
140
141/// Extract the `token` field from a JSON token file, or `None` on any failure.
142fn read_token_from_file(path: &std::path::Path) -> Option<String> {
143    let data = std::fs::read_to_string(path).ok()?;
144    let v: serde_json::Value = serde_json::from_str(data.trim()).ok()?;
145    v["token"].as_str().map(|s| s.to_owned())
146}
147
148/// Check whether a token file exists and contains valid JSON with a `token` field.
149fn has_valid_token_file(path: &std::path::Path) -> bool {
150    if !path.exists() {
151        return false;
152    }
153    let Ok(data) = std::fs::read_to_string(path) else {
154        return false;
155    };
156    let Ok(v) = serde_json::from_str::<serde_json::Value>(data.trim()) else {
157        return false;
158    };
159    v["token"].as_str().is_some()
160}
161
162/// Resolve the default username from the `$USER` environment variable.
163///
164/// Returns `None` when `$USER` is not set or is empty.
165pub fn default_username() -> Option<String> {
166    std::env::var("USER").ok().filter(|s| !s.is_empty())
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use tempfile::TempDir;
173
174    #[test]
175    fn has_valid_token_file_returns_false_for_missing_file() {
176        let dir = TempDir::new().unwrap();
177        let path = dir.path().join("nonexistent.token");
178        assert!(!has_valid_token_file(&path));
179    }
180
181    #[test]
182    fn has_valid_token_file_returns_true_for_valid_file() {
183        let dir = TempDir::new().unwrap();
184        let path = dir.path().join("test.token");
185        let data = serde_json::json!({"username": "alice", "token": "tok-123"});
186        std::fs::write(&path, format!("{data}\n")).unwrap();
187        assert!(has_valid_token_file(&path));
188    }
189
190    #[test]
191    fn has_valid_token_file_returns_false_for_corrupt_json() {
192        let dir = TempDir::new().unwrap();
193        let path = dir.path().join("corrupt.token");
194        std::fs::write(&path, "not valid json").unwrap();
195        assert!(!has_valid_token_file(&path));
196    }
197
198    #[test]
199    fn has_valid_token_file_returns_false_for_missing_token_field() {
200        let dir = TempDir::new().unwrap();
201        let path = dir.path().join("no-token.token");
202        let data = serde_json::json!({"username": "alice"});
203        std::fs::write(&path, format!("{data}\n")).unwrap();
204        assert!(!has_valid_token_file(&path));
205    }
206
207    #[test]
208    fn default_username_returns_user_env_var() {
209        let prev = std::env::var("USER").ok();
210        std::env::set_var("USER", "testuser");
211        let result = default_username();
212        assert_eq!(result.as_deref(), Some("testuser"));
213        match prev {
214            Some(v) => std::env::set_var("USER", v),
215            None => std::env::remove_var("USER"),
216        }
217    }
218
219    /// has_valid_token_file returns false for empty file.
220    #[test]
221    fn has_valid_token_file_returns_false_for_empty_file() {
222        let dir = TempDir::new().unwrap();
223        let path = dir.path().join("empty.token");
224        std::fs::write(&path, "").unwrap();
225        assert!(!has_valid_token_file(&path));
226    }
227
228    /// has_valid_token_file returns false when token field is null.
229    #[test]
230    fn has_valid_token_file_returns_false_for_null_token() {
231        let dir = TempDir::new().unwrap();
232        let path = dir.path().join("null-token.token");
233        let data = serde_json::json!({"username": "alice", "token": null});
234        std::fs::write(&path, format!("{data}\n")).unwrap();
235        assert!(!has_valid_token_file(&path));
236    }
237
238    // ── read_token_from_file ─────────────────────────────────────────────
239
240    #[test]
241    fn read_token_from_file_returns_token_for_valid_file() {
242        let dir = TempDir::new().unwrap();
243        let path = dir.path().join("valid.token");
244        let data = serde_json::json!({"username": "alice", "token": "abc-123"});
245        std::fs::write(&path, format!("{data}\n")).unwrap();
246        assert_eq!(read_token_from_file(&path), Some("abc-123".to_owned()));
247    }
248
249    #[test]
250    fn read_token_from_file_returns_none_for_missing_file() {
251        let dir = TempDir::new().unwrap();
252        let path = dir.path().join("nope.token");
253        assert_eq!(read_token_from_file(&path), None);
254    }
255
256    #[test]
257    fn read_token_from_file_returns_none_for_corrupt_json() {
258        let dir = TempDir::new().unwrap();
259        let path = dir.path().join("corrupt.token");
260        std::fs::write(&path, "not json").unwrap();
261        assert_eq!(read_token_from_file(&path), None);
262    }
263
264    #[test]
265    fn read_token_from_file_returns_none_for_null_token() {
266        let dir = TempDir::new().unwrap();
267        let path = dir.path().join("null.token");
268        let data = serde_json::json!({"username": "alice", "token": null});
269        std::fs::write(&path, format!("{data}\n")).unwrap();
270        assert_eq!(read_token_from_file(&path), None);
271    }
272
273    #[test]
274    fn read_token_from_file_returns_none_for_missing_token_field() {
275        let dir = TempDir::new().unwrap();
276        let path = dir.path().join("no-field.token");
277        let data = serde_json::json!({"username": "alice"});
278        std::fs::write(&path, format!("{data}\n")).unwrap();
279        assert_eq!(read_token_from_file(&path), None);
280    }
281}
282
283async fn run_agent(
284    mut reader: BufReader<tokio::net::unix::OwnedReadHalf>,
285    mut write_half: tokio::net::unix::OwnedWriteHalf,
286    username: &str,
287    history_lines: usize,
288) -> anyhow::Result<()> {
289    // Buffer messages until we see our own join (signals end of history replay),
290    // then print the last `history_lines` buffered messages and stream the rest.
291    let username_owned = username.to_owned();
292
293    let inbound = tokio::spawn(async move {
294        let mut history_buf: Vec<String> = Vec::new();
295        let mut history_done = false;
296        let mut line = String::new();
297
298        loop {
299            line.clear();
300            match reader.read_line(&mut line).await {
301                Ok(0) => break,
302                Ok(_) => {
303                    let trimmed = line.trim();
304                    if trimmed.is_empty() {
305                        continue;
306                    }
307                    if history_done {
308                        println!("{trimmed}");
309                    } else {
310                        // Look for our own join event to mark end of history
311                        let is_own_join = serde_json::from_str::<Message>(trimmed)
312                            .ok()
313                            .map(|m| {
314                                matches!(&m, Message::Join { user, .. } if user == &username_owned)
315                            })
316                            .unwrap_or(false);
317
318                        if is_own_join {
319                            // Flush last N history entries
320                            let start = history_buf.len().saturating_sub(history_lines);
321                            for h in &history_buf[start..] {
322                                println!("{h}");
323                            }
324                            history_done = true;
325                            println!("{trimmed}");
326                        } else {
327                            history_buf.push(trimmed.to_owned());
328                        }
329                    }
330                }
331                Err(e) => {
332                    eprintln!("[agent] read error: {e}");
333                    break;
334                }
335            }
336        }
337    });
338
339    let _outbound = tokio::spawn(async move {
340        let stdin = tokio::io::stdin();
341        let mut stdin_reader = BufReader::new(stdin);
342        let mut line = String::new();
343        loop {
344            line.clear();
345            match stdin_reader.read_line(&mut line).await {
346                Ok(0) => break,
347                Ok(_) => {
348                    let trimmed = line.trim();
349                    if trimmed.is_empty() {
350                        continue;
351                    }
352                    if write_half
353                        .write_all(format!("{trimmed}\n").as_bytes())
354                        .await
355                        .is_err()
356                    {
357                        break;
358                    }
359                }
360                Err(e) => {
361                    eprintln!("[agent] stdin error: {e}");
362                    break;
363                }
364            }
365        }
366    });
367
368    // Stay alive until the broker closes the connection (inbound EOF),
369    // even if stdin is already exhausted.  This lets agents receive responses
370    // to messages they sent before their stdin closed.
371    inbound.await.ok();
372    Ok(())
373}