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 pub daemon_mode: bool,
23}
24
25impl Client {
26 pub async fn run(self) -> anyhow::Result<()> {
27 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 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 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 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 } else {
116 eprintln!("[tui] auto-join failed: {e}");
117 }
118 }
119 }
120 }
121}
122
123fn 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
141fn 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
148fn 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
162pub 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 #[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 #[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 #[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 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 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 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 inbound.await.ok();
372 Ok(())
373}