Skip to main content

batty_cli/team/
message.rs

1//! Team message types and command queue management.
2
3use std::fs::{File, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10/// A message in the command queue.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(tag = "type")]
13pub enum QueuedCommand {
14    #[serde(rename = "send")]
15    Send {
16        from: String,
17        to: String,
18        message: String,
19    },
20    #[serde(rename = "assign")]
21    Assign {
22        from: String,
23        engineer: String,
24        task: String,
25    },
26}
27
28/// Write a command to the queue file.
29pub fn enqueue_command(queue_path: &Path, cmd: &QueuedCommand) -> Result<()> {
30    if let Some(parent) = queue_path.parent() {
31        std::fs::create_dir_all(parent)?;
32    }
33    let json = serde_json::to_string(cmd)?;
34    let mut file = OpenOptions::new()
35        .create(true)
36        .append(true)
37        .open(queue_path)
38        .with_context(|| format!("failed to open command queue: {}", queue_path.display()))?;
39    writeln!(file, "{json}")?;
40    Ok(())
41}
42
43/// Read and drain all pending commands from the queue file.
44#[cfg(test)]
45pub fn drain_command_queue(queue_path: &Path) -> Result<Vec<QueuedCommand>> {
46    let commands = read_command_queue(queue_path)?;
47    if !commands.is_empty() {
48        write_command_queue(queue_path, &[])?;
49    }
50    Ok(commands)
51}
52
53/// Read all pending commands from the queue file without clearing it.
54pub fn read_command_queue(queue_path: &Path) -> Result<Vec<QueuedCommand>> {
55    if !queue_path.exists() {
56        return Ok(Vec::new());
57    }
58
59    let file = File::open(queue_path)
60        .with_context(|| format!("failed to open command queue: {}", queue_path.display()))?;
61    let reader = BufReader::new(file);
62
63    let mut commands = Vec::new();
64    for line in reader.lines() {
65        let line = line?;
66        let trimmed = line.trim();
67        if trimmed.is_empty() {
68            continue;
69        }
70        match serde_json::from_str::<QueuedCommand>(trimmed) {
71            Ok(cmd) => commands.push(cmd),
72            Err(e) => tracing::warn!(line = trimmed, error = %e, "skipping malformed command"),
73        }
74    }
75
76    Ok(commands)
77}
78
79/// Atomically rewrite the command queue with the remaining commands.
80pub fn write_command_queue(queue_path: &Path, commands: &[QueuedCommand]) -> Result<()> {
81    if let Some(parent) = queue_path.parent() {
82        std::fs::create_dir_all(parent)?;
83    }
84
85    let tmp_path = queue_path.with_extension("jsonl.tmp");
86    {
87        let mut file = OpenOptions::new()
88            .create(true)
89            .write(true)
90            .truncate(true)
91            .open(&tmp_path)
92            .with_context(|| {
93                format!("failed to open temp command queue: {}", tmp_path.display())
94            })?;
95        for cmd in commands {
96            let json = serde_json::to_string(cmd)?;
97            writeln!(file, "{json}")?;
98        }
99        file.flush()?;
100    }
101
102    std::fs::rename(&tmp_path, queue_path).with_context(|| {
103        format!(
104            "failed to replace command queue {} with {}",
105            queue_path.display(),
106            tmp_path.display()
107        )
108    })?;
109    Ok(())
110}
111
112/// Resolve the command queue path.
113pub fn command_queue_path(project_root: &Path) -> PathBuf {
114    project_root
115        .join(".batty")
116        .join("team_config")
117        .join("commands.jsonl")
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use serial_test::serial;
124
125    #[test]
126    fn send_command_roundtrip() {
127        let cmd = QueuedCommand::Send {
128            from: "human".into(),
129            to: "architect".into(),
130            message: "prioritize auth".into(),
131        };
132        let json = serde_json::to_string(&cmd).unwrap();
133        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
134        match parsed {
135            QueuedCommand::Send { from, to, message } => {
136                assert_eq!(from, "human");
137                assert_eq!(to, "architect");
138                assert_eq!(message, "prioritize auth");
139            }
140            _ => panic!("wrong variant"),
141        }
142    }
143
144    #[test]
145    fn assign_command_roundtrip() {
146        let cmd = QueuedCommand::Assign {
147            from: "black-lead".into(),
148            engineer: "eng-1-1".into(),
149            task: "fix bug".into(),
150        };
151        let json = serde_json::to_string(&cmd).unwrap();
152        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
153        match parsed {
154            QueuedCommand::Assign {
155                from,
156                engineer,
157                task,
158            } => {
159                assert_eq!(from, "black-lead");
160                assert_eq!(engineer, "eng-1-1");
161                assert_eq!(task, "fix bug");
162            }
163            _ => panic!("wrong variant"),
164        }
165    }
166
167    #[test]
168    fn enqueue_and_drain() {
169        let tmp = tempfile::tempdir().unwrap();
170        let queue = tmp.path().join("commands.jsonl");
171
172        enqueue_command(
173            &queue,
174            &QueuedCommand::Send {
175                from: "human".into(),
176                to: "arch".into(),
177                message: "hello".into(),
178            },
179        )
180        .unwrap();
181        enqueue_command(
182            &queue,
183            &QueuedCommand::Assign {
184                from: "black-lead".into(),
185                engineer: "eng-1".into(),
186                task: "work".into(),
187            },
188        )
189        .unwrap();
190
191        let commands = drain_command_queue(&queue).unwrap();
192        assert_eq!(commands.len(), 2);
193
194        // After drain, queue should be empty
195        let commands = drain_command_queue(&queue).unwrap();
196        assert!(commands.is_empty());
197    }
198
199    #[test]
200    fn drain_nonexistent_queue_returns_empty() {
201        let tmp = tempfile::tempdir().unwrap();
202        let queue = tmp.path().join("nonexistent.jsonl");
203        let commands = drain_command_queue(&queue).unwrap();
204        assert!(commands.is_empty());
205    }
206
207    #[test]
208    fn read_command_queue_keeps_file_contents_intact() {
209        let tmp = tempfile::tempdir().unwrap();
210        let queue = tmp.path().join("commands.jsonl");
211        enqueue_command(
212            &queue,
213            &QueuedCommand::Send {
214                from: "human".into(),
215                to: "arch".into(),
216                message: "hello".into(),
217            },
218        )
219        .unwrap();
220
221        let commands = read_command_queue(&queue).unwrap();
222        assert_eq!(commands.len(), 1);
223        let persisted = std::fs::read_to_string(&queue).unwrap();
224        assert!(persisted.contains("\"message\":\"hello\""));
225    }
226
227    #[test]
228    fn write_command_queue_rewrites_remaining_commands_atomically() {
229        let tmp = tempfile::tempdir().unwrap();
230        let queue = tmp.path().join("commands.jsonl");
231        enqueue_command(
232            &queue,
233            &QueuedCommand::Send {
234                from: "human".into(),
235                to: "arch".into(),
236                message: "hello".into(),
237            },
238        )
239        .unwrap();
240
241        write_command_queue(
242            &queue,
243            &[QueuedCommand::Assign {
244                from: "manager".into(),
245                engineer: "eng-1".into(),
246                task: "Task #1".into(),
247            }],
248        )
249        .unwrap();
250
251        let commands = read_command_queue(&queue).unwrap();
252        assert_eq!(commands.len(), 1);
253        match &commands[0] {
254            QueuedCommand::Assign { engineer, task, .. } => {
255                assert_eq!(engineer, "eng-1");
256                assert_eq!(task, "Task #1");
257            }
258            other => panic!("expected assign command after rewrite, got {other:?}"),
259        }
260    }
261
262    #[test]
263    fn drain_skips_malformed_lines() {
264        let tmp = tempfile::tempdir().unwrap();
265        let queue = tmp.path().join("commands.jsonl");
266        std::fs::write(
267            &queue,
268            "not json\n{\"type\":\"assign\",\"from\":\"manager\",\"engineer\":\"e1\",\"task\":\"t1\"}\n",
269        )
270        .unwrap();
271        let commands = drain_command_queue(&queue).unwrap();
272        assert_eq!(commands.len(), 1);
273    }
274
275    #[test]
276    fn test_drain_command_queue_skips_unknown_and_incomplete_commands() {
277        let tmp = tempfile::tempdir().unwrap();
278        let queue = tmp.path().join("commands.jsonl");
279        std::fs::write(
280            &queue,
281            concat!(
282                "{\"type\":\"noop\",\"from\":\"manager\"}\n",
283                "{\"type\":\"send\",\"from\":\"manager\",\"message\":\"missing recipient\"}\n",
284                "{\"type\":\"assign\",\"from\":\"manager\",\"engineer\":\"eng-1\"}\n",
285                "{\"type\":\"send\",\"from\":\"manager\",\"to\":\"architect\",\"message\":\"valid\"}\n",
286            ),
287        )
288        .unwrap();
289
290        let commands = drain_command_queue(&queue).unwrap();
291
292        assert_eq!(commands.len(), 1);
293        match &commands[0] {
294            QueuedCommand::Send { from, to, message } => {
295                assert_eq!(from, "manager");
296                assert_eq!(to, "architect");
297                assert_eq!(message, "valid");
298            }
299            other => panic!("expected valid send command, got {other:?}"),
300        }
301    }
302
303    // --- Additional serialization edge cases ---
304
305    #[test]
306    fn send_command_preserves_multiline_message() {
307        let cmd = QueuedCommand::Send {
308            from: "manager".into(),
309            to: "eng-1".into(),
310            message: "line one\nline two\nline three".into(),
311        };
312        let json = serde_json::to_string(&cmd).unwrap();
313        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
314        match parsed {
315            QueuedCommand::Send { message, .. } => {
316                assert_eq!(message, "line one\nline two\nline three");
317            }
318            _ => panic!("wrong variant"),
319        }
320    }
321
322    #[test]
323    fn send_command_preserves_empty_message() {
324        let cmd = QueuedCommand::Send {
325            from: "human".into(),
326            to: "architect".into(),
327            message: "".into(),
328        };
329        let json = serde_json::to_string(&cmd).unwrap();
330        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
331        match parsed {
332            QueuedCommand::Send { message, .. } => assert!(message.is_empty()),
333            _ => panic!("wrong variant"),
334        }
335    }
336
337    #[test]
338    fn send_command_preserves_unicode() {
339        let cmd = QueuedCommand::Send {
340            from: "人間".into(),
341            to: "アーキ".into(),
342            message: "日本語テスト 🚀".into(),
343        };
344        let json = serde_json::to_string(&cmd).unwrap();
345        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
346        match parsed {
347            QueuedCommand::Send { from, to, message } => {
348                assert_eq!(from, "人間");
349                assert_eq!(to, "アーキ");
350                assert_eq!(message, "日本語テスト 🚀");
351            }
352            _ => panic!("wrong variant"),
353        }
354    }
355
356    #[test]
357    fn assign_command_preserves_special_chars_in_task() {
358        let cmd = QueuedCommand::Assign {
359            from: "manager".into(),
360            engineer: "eng-1-1".into(),
361            task: "Task #42: Fix \"auth\" — it's broken!".into(),
362        };
363        let json = serde_json::to_string(&cmd).unwrap();
364        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
365        match parsed {
366            QueuedCommand::Assign { task, .. } => {
367                assert_eq!(task, "Task #42: Fix \"auth\" — it's broken!");
368            }
369            _ => panic!("wrong variant"),
370        }
371    }
372
373    // --- enqueue_command edge cases ---
374
375    #[test]
376    fn enqueue_creates_parent_directories() {
377        let tmp = tempfile::tempdir().unwrap();
378        let queue = tmp
379            .path()
380            .join("deep")
381            .join("nested")
382            .join("commands.jsonl");
383
384        enqueue_command(
385            &queue,
386            &QueuedCommand::Send {
387                from: "human".into(),
388                to: "arch".into(),
389                message: "hello".into(),
390            },
391        )
392        .unwrap();
393
394        assert!(queue.exists());
395        let commands = read_command_queue(&queue).unwrap();
396        assert_eq!(commands.len(), 1);
397    }
398
399    #[test]
400    fn enqueue_appends_multiple_commands_preserving_order() {
401        let tmp = tempfile::tempdir().unwrap();
402        let queue = tmp.path().join("commands.jsonl");
403
404        for i in 0..5 {
405            enqueue_command(
406                &queue,
407                &QueuedCommand::Send {
408                    from: "human".into(),
409                    to: "arch".into(),
410                    message: format!("msg-{i}"),
411                },
412            )
413            .unwrap();
414        }
415
416        let commands = read_command_queue(&queue).unwrap();
417        assert_eq!(commands.len(), 5);
418        for (i, cmd) in commands.iter().enumerate() {
419            match cmd {
420                QueuedCommand::Send { message, .. } => {
421                    assert_eq!(message, &format!("msg-{i}"));
422                }
423                _ => panic!("wrong variant at index {i}"),
424            }
425        }
426    }
427
428    // --- read_command_queue edge cases ---
429
430    #[test]
431    fn read_command_queue_skips_empty_lines() {
432        let tmp = tempfile::tempdir().unwrap();
433        let queue = tmp.path().join("commands.jsonl");
434        std::fs::write(
435            &queue,
436            "\n\n{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"hi\"}\n\n\n",
437        )
438        .unwrap();
439
440        let commands = read_command_queue(&queue).unwrap();
441        assert_eq!(commands.len(), 1);
442    }
443
444    #[test]
445    fn read_command_queue_skips_whitespace_only_lines() {
446        let tmp = tempfile::tempdir().unwrap();
447        let queue = tmp.path().join("commands.jsonl");
448        std::fs::write(
449            &queue,
450            "   \n\t\n{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"ok\"}\n  \n",
451        )
452        .unwrap();
453
454        let commands = read_command_queue(&queue).unwrap();
455        assert_eq!(commands.len(), 1);
456    }
457
458    #[test]
459    fn read_command_queue_mixed_valid_and_malformed() {
460        let tmp = tempfile::tempdir().unwrap();
461        let queue = tmp.path().join("commands.jsonl");
462        std::fs::write(
463            &queue,
464            concat!(
465                "{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"first\"}\n",
466                "garbage line\n",
467                "{\"type\":\"assign\",\"from\":\"m\",\"engineer\":\"e1\",\"task\":\"t1\"}\n",
468                "{\"invalid json\n",
469                "{\"type\":\"send\",\"from\":\"c\",\"to\":\"d\",\"message\":\"last\"}\n",
470            ),
471        )
472        .unwrap();
473
474        let commands = read_command_queue(&queue).unwrap();
475        assert_eq!(
476            commands.len(),
477            3,
478            "should parse 3 valid commands, skip 2 malformed"
479        );
480    }
481
482    // --- write_command_queue edge cases ---
483
484    #[test]
485    fn write_command_queue_empty_slice_creates_empty_file() {
486        let tmp = tempfile::tempdir().unwrap();
487        let queue = tmp.path().join("commands.jsonl");
488
489        // First enqueue something
490        enqueue_command(
491            &queue,
492            &QueuedCommand::Send {
493                from: "a".into(),
494                to: "b".into(),
495                message: "hello".into(),
496            },
497        )
498        .unwrap();
499
500        // Overwrite with empty
501        write_command_queue(&queue, &[]).unwrap();
502
503        let commands = read_command_queue(&queue).unwrap();
504        assert!(commands.is_empty());
505    }
506
507    #[test]
508    fn write_command_queue_creates_parent_dirs() {
509        let tmp = tempfile::tempdir().unwrap();
510        let queue = tmp.path().join("sub").join("dir").join("q.jsonl");
511
512        write_command_queue(
513            &queue,
514            &[QueuedCommand::Assign {
515                from: "m".into(),
516                engineer: "e1".into(),
517                task: "t1".into(),
518            }],
519        )
520        .unwrap();
521
522        let commands = read_command_queue(&queue).unwrap();
523        assert_eq!(commands.len(), 1);
524    }
525
526    // --- command_queue_path ---
527
528    #[test]
529    fn command_queue_path_returns_expected_path() {
530        let root = Path::new("/project");
531        let path = command_queue_path(root);
532        assert_eq!(
533            path,
534            PathBuf::from("/project/.batty/team_config/commands.jsonl")
535        );
536    }
537
538    #[test]
539    fn command_queue_path_with_trailing_slash() {
540        let root = Path::new("/project/");
541        let path = command_queue_path(root);
542        assert_eq!(
543            path,
544            PathBuf::from("/project/.batty/team_config/commands.jsonl")
545        );
546    }
547
548    // --- drain_command_queue ---
549
550    #[test]
551    fn drain_empties_queue_file_but_keeps_file() {
552        let tmp = tempfile::tempdir().unwrap();
553        let queue = tmp.path().join("commands.jsonl");
554
555        enqueue_command(
556            &queue,
557            &QueuedCommand::Send {
558                from: "a".into(),
559                to: "b".into(),
560                message: "msg".into(),
561            },
562        )
563        .unwrap();
564
565        let drained = drain_command_queue(&queue).unwrap();
566        assert_eq!(drained.len(), 1);
567
568        // File should still exist but be effectively empty
569        assert!(queue.exists());
570        let commands = read_command_queue(&queue).unwrap();
571        assert!(commands.is_empty());
572    }
573
574    #[test]
575    fn drain_twice_second_returns_empty() {
576        let tmp = tempfile::tempdir().unwrap();
577        let queue = tmp.path().join("commands.jsonl");
578
579        enqueue_command(
580            &queue,
581            &QueuedCommand::Assign {
582                from: "m".into(),
583                engineer: "e".into(),
584                task: "t".into(),
585            },
586        )
587        .unwrap();
588
589        let first = drain_command_queue(&queue).unwrap();
590        assert_eq!(first.len(), 1);
591
592        let second = drain_command_queue(&queue).unwrap();
593        assert!(second.is_empty());
594    }
595
596    // --- QueuedCommand Debug derive ---
597
598    #[test]
599    fn queued_command_debug_format() {
600        let cmd = QueuedCommand::Send {
601            from: "human".into(),
602            to: "arch".into(),
603            message: "test".into(),
604        };
605        let debug = format!("{cmd:?}");
606        assert!(debug.contains("Send"));
607        assert!(debug.contains("human"));
608    }
609
610    #[test]
611    fn queued_command_clone() {
612        let cmd = QueuedCommand::Assign {
613            from: "manager".into(),
614            engineer: "eng-1".into(),
615            task: "build feature".into(),
616        };
617        let cloned = cmd.clone();
618        let json_original = serde_json::to_string(&cmd).unwrap();
619        let json_cloned = serde_json::to_string(&cloned).unwrap();
620        assert_eq!(json_original, json_cloned);
621    }
622
623    // --- JSON tag format verification ---
624
625    #[test]
626    fn send_command_json_has_type_send_tag() {
627        let cmd = QueuedCommand::Send {
628            from: "a".into(),
629            to: "b".into(),
630            message: "c".into(),
631        };
632        let json = serde_json::to_string(&cmd).unwrap();
633        assert!(json.contains("\"type\":\"send\""), "got: {json}");
634    }
635
636    #[test]
637    fn assign_command_json_has_type_assign_tag() {
638        let cmd = QueuedCommand::Assign {
639            from: "a".into(),
640            engineer: "b".into(),
641            task: "c".into(),
642        };
643        let json = serde_json::to_string(&cmd).unwrap();
644        assert!(json.contains("\"type\":\"assign\""), "got: {json}");
645    }
646}