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    #[test]
124    fn send_command_roundtrip() {
125        let cmd = QueuedCommand::Send {
126            from: "human".into(),
127            to: "architect".into(),
128            message: "prioritize auth".into(),
129        };
130        let json = serde_json::to_string(&cmd).unwrap();
131        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
132        match parsed {
133            QueuedCommand::Send { from, to, message } => {
134                assert_eq!(from, "human");
135                assert_eq!(to, "architect");
136                assert_eq!(message, "prioritize auth");
137            }
138            _ => panic!("wrong variant"),
139        }
140    }
141
142    #[test]
143    fn assign_command_roundtrip() {
144        let cmd = QueuedCommand::Assign {
145            from: "black-lead".into(),
146            engineer: "eng-1-1".into(),
147            task: "fix bug".into(),
148        };
149        let json = serde_json::to_string(&cmd).unwrap();
150        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
151        match parsed {
152            QueuedCommand::Assign {
153                from,
154                engineer,
155                task,
156            } => {
157                assert_eq!(from, "black-lead");
158                assert_eq!(engineer, "eng-1-1");
159                assert_eq!(task, "fix bug");
160            }
161            _ => panic!("wrong variant"),
162        }
163    }
164
165    #[test]
166    fn enqueue_and_drain() {
167        let tmp = tempfile::tempdir().unwrap();
168        let queue = tmp.path().join("commands.jsonl");
169
170        enqueue_command(
171            &queue,
172            &QueuedCommand::Send {
173                from: "human".into(),
174                to: "arch".into(),
175                message: "hello".into(),
176            },
177        )
178        .unwrap();
179        enqueue_command(
180            &queue,
181            &QueuedCommand::Assign {
182                from: "black-lead".into(),
183                engineer: "eng-1".into(),
184                task: "work".into(),
185            },
186        )
187        .unwrap();
188
189        let commands = drain_command_queue(&queue).unwrap();
190        assert_eq!(commands.len(), 2);
191
192        // After drain, queue should be empty
193        let commands = drain_command_queue(&queue).unwrap();
194        assert!(commands.is_empty());
195    }
196
197    #[test]
198    fn drain_nonexistent_queue_returns_empty() {
199        let tmp = tempfile::tempdir().unwrap();
200        let queue = tmp.path().join("nonexistent.jsonl");
201        let commands = drain_command_queue(&queue).unwrap();
202        assert!(commands.is_empty());
203    }
204
205    #[test]
206    fn read_command_queue_keeps_file_contents_intact() {
207        let tmp = tempfile::tempdir().unwrap();
208        let queue = tmp.path().join("commands.jsonl");
209        enqueue_command(
210            &queue,
211            &QueuedCommand::Send {
212                from: "human".into(),
213                to: "arch".into(),
214                message: "hello".into(),
215            },
216        )
217        .unwrap();
218
219        let commands = read_command_queue(&queue).unwrap();
220        assert_eq!(commands.len(), 1);
221        let persisted = std::fs::read_to_string(&queue).unwrap();
222        assert!(persisted.contains("\"message\":\"hello\""));
223    }
224
225    #[test]
226    fn write_command_queue_rewrites_remaining_commands_atomically() {
227        let tmp = tempfile::tempdir().unwrap();
228        let queue = tmp.path().join("commands.jsonl");
229        enqueue_command(
230            &queue,
231            &QueuedCommand::Send {
232                from: "human".into(),
233                to: "arch".into(),
234                message: "hello".into(),
235            },
236        )
237        .unwrap();
238
239        write_command_queue(
240            &queue,
241            &[QueuedCommand::Assign {
242                from: "manager".into(),
243                engineer: "eng-1".into(),
244                task: "Task #1".into(),
245            }],
246        )
247        .unwrap();
248
249        let commands = read_command_queue(&queue).unwrap();
250        assert_eq!(commands.len(), 1);
251        match &commands[0] {
252            QueuedCommand::Assign { engineer, task, .. } => {
253                assert_eq!(engineer, "eng-1");
254                assert_eq!(task, "Task #1");
255            }
256            other => panic!("expected assign command after rewrite, got {other:?}"),
257        }
258    }
259
260    #[test]
261    fn drain_skips_malformed_lines() {
262        let tmp = tempfile::tempdir().unwrap();
263        let queue = tmp.path().join("commands.jsonl");
264        std::fs::write(
265            &queue,
266            "not json\n{\"type\":\"assign\",\"from\":\"manager\",\"engineer\":\"e1\",\"task\":\"t1\"}\n",
267        )
268        .unwrap();
269        let commands = drain_command_queue(&queue).unwrap();
270        assert_eq!(commands.len(), 1);
271    }
272
273    #[test]
274    fn test_drain_command_queue_skips_unknown_and_incomplete_commands() {
275        let tmp = tempfile::tempdir().unwrap();
276        let queue = tmp.path().join("commands.jsonl");
277        std::fs::write(
278            &queue,
279            concat!(
280                "{\"type\":\"noop\",\"from\":\"manager\"}\n",
281                "{\"type\":\"send\",\"from\":\"manager\",\"message\":\"missing recipient\"}\n",
282                "{\"type\":\"assign\",\"from\":\"manager\",\"engineer\":\"eng-1\"}\n",
283                "{\"type\":\"send\",\"from\":\"manager\",\"to\":\"architect\",\"message\":\"valid\"}\n",
284            ),
285        )
286        .unwrap();
287
288        let commands = drain_command_queue(&queue).unwrap();
289
290        assert_eq!(commands.len(), 1);
291        match &commands[0] {
292            QueuedCommand::Send { from, to, message } => {
293                assert_eq!(from, "manager");
294                assert_eq!(to, "architect");
295                assert_eq!(message, "valid");
296            }
297            other => panic!("expected valid send command, got {other:?}"),
298        }
299    }
300
301    // --- Additional serialization edge cases ---
302
303    #[test]
304    fn send_command_preserves_multiline_message() {
305        let cmd = QueuedCommand::Send {
306            from: "manager".into(),
307            to: "eng-1".into(),
308            message: "line one\nline two\nline three".into(),
309        };
310        let json = serde_json::to_string(&cmd).unwrap();
311        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
312        match parsed {
313            QueuedCommand::Send { message, .. } => {
314                assert_eq!(message, "line one\nline two\nline three");
315            }
316            _ => panic!("wrong variant"),
317        }
318    }
319
320    #[test]
321    fn send_command_preserves_empty_message() {
322        let cmd = QueuedCommand::Send {
323            from: "human".into(),
324            to: "architect".into(),
325            message: "".into(),
326        };
327        let json = serde_json::to_string(&cmd).unwrap();
328        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
329        match parsed {
330            QueuedCommand::Send { message, .. } => assert!(message.is_empty()),
331            _ => panic!("wrong variant"),
332        }
333    }
334
335    #[test]
336    fn send_command_preserves_unicode() {
337        let cmd = QueuedCommand::Send {
338            from: "人間".into(),
339            to: "アーキ".into(),
340            message: "日本語テスト 🚀".into(),
341        };
342        let json = serde_json::to_string(&cmd).unwrap();
343        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
344        match parsed {
345            QueuedCommand::Send { from, to, message } => {
346                assert_eq!(from, "人間");
347                assert_eq!(to, "アーキ");
348                assert_eq!(message, "日本語テスト 🚀");
349            }
350            _ => panic!("wrong variant"),
351        }
352    }
353
354    #[test]
355    fn assign_command_preserves_special_chars_in_task() {
356        let cmd = QueuedCommand::Assign {
357            from: "manager".into(),
358            engineer: "eng-1-1".into(),
359            task: "Task #42: Fix \"auth\" — it's broken!".into(),
360        };
361        let json = serde_json::to_string(&cmd).unwrap();
362        let parsed: QueuedCommand = serde_json::from_str(&json).unwrap();
363        match parsed {
364            QueuedCommand::Assign { task, .. } => {
365                assert_eq!(task, "Task #42: Fix \"auth\" — it's broken!");
366            }
367            _ => panic!("wrong variant"),
368        }
369    }
370
371    // --- enqueue_command edge cases ---
372
373    #[test]
374    fn enqueue_creates_parent_directories() {
375        let tmp = tempfile::tempdir().unwrap();
376        let queue = tmp
377            .path()
378            .join("deep")
379            .join("nested")
380            .join("commands.jsonl");
381
382        enqueue_command(
383            &queue,
384            &QueuedCommand::Send {
385                from: "human".into(),
386                to: "arch".into(),
387                message: "hello".into(),
388            },
389        )
390        .unwrap();
391
392        assert!(queue.exists());
393        let commands = read_command_queue(&queue).unwrap();
394        assert_eq!(commands.len(), 1);
395    }
396
397    #[test]
398    fn enqueue_appends_multiple_commands_preserving_order() {
399        let tmp = tempfile::tempdir().unwrap();
400        let queue = tmp.path().join("commands.jsonl");
401
402        for i in 0..5 {
403            enqueue_command(
404                &queue,
405                &QueuedCommand::Send {
406                    from: "human".into(),
407                    to: "arch".into(),
408                    message: format!("msg-{i}"),
409                },
410            )
411            .unwrap();
412        }
413
414        let commands = read_command_queue(&queue).unwrap();
415        assert_eq!(commands.len(), 5);
416        for (i, cmd) in commands.iter().enumerate() {
417            match cmd {
418                QueuedCommand::Send { message, .. } => {
419                    assert_eq!(message, &format!("msg-{i}"));
420                }
421                _ => panic!("wrong variant at index {i}"),
422            }
423        }
424    }
425
426    // --- read_command_queue edge cases ---
427
428    #[test]
429    fn read_command_queue_skips_empty_lines() {
430        let tmp = tempfile::tempdir().unwrap();
431        let queue = tmp.path().join("commands.jsonl");
432        std::fs::write(
433            &queue,
434            "\n\n{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"hi\"}\n\n\n",
435        )
436        .unwrap();
437
438        let commands = read_command_queue(&queue).unwrap();
439        assert_eq!(commands.len(), 1);
440    }
441
442    #[test]
443    fn read_command_queue_skips_whitespace_only_lines() {
444        let tmp = tempfile::tempdir().unwrap();
445        let queue = tmp.path().join("commands.jsonl");
446        std::fs::write(
447            &queue,
448            "   \n\t\n{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"ok\"}\n  \n",
449        )
450        .unwrap();
451
452        let commands = read_command_queue(&queue).unwrap();
453        assert_eq!(commands.len(), 1);
454    }
455
456    #[test]
457    fn read_command_queue_mixed_valid_and_malformed() {
458        let tmp = tempfile::tempdir().unwrap();
459        let queue = tmp.path().join("commands.jsonl");
460        std::fs::write(
461            &queue,
462            concat!(
463                "{\"type\":\"send\",\"from\":\"a\",\"to\":\"b\",\"message\":\"first\"}\n",
464                "garbage line\n",
465                "{\"type\":\"assign\",\"from\":\"m\",\"engineer\":\"e1\",\"task\":\"t1\"}\n",
466                "{\"invalid json\n",
467                "{\"type\":\"send\",\"from\":\"c\",\"to\":\"d\",\"message\":\"last\"}\n",
468            ),
469        )
470        .unwrap();
471
472        let commands = read_command_queue(&queue).unwrap();
473        assert_eq!(
474            commands.len(),
475            3,
476            "should parse 3 valid commands, skip 2 malformed"
477        );
478    }
479
480    // --- write_command_queue edge cases ---
481
482    #[test]
483    fn write_command_queue_empty_slice_creates_empty_file() {
484        let tmp = tempfile::tempdir().unwrap();
485        let queue = tmp.path().join("commands.jsonl");
486
487        // First enqueue something
488        enqueue_command(
489            &queue,
490            &QueuedCommand::Send {
491                from: "a".into(),
492                to: "b".into(),
493                message: "hello".into(),
494            },
495        )
496        .unwrap();
497
498        // Overwrite with empty
499        write_command_queue(&queue, &[]).unwrap();
500
501        let commands = read_command_queue(&queue).unwrap();
502        assert!(commands.is_empty());
503    }
504
505    #[test]
506    fn write_command_queue_creates_parent_dirs() {
507        let tmp = tempfile::tempdir().unwrap();
508        let queue = tmp.path().join("sub").join("dir").join("q.jsonl");
509
510        write_command_queue(
511            &queue,
512            &[QueuedCommand::Assign {
513                from: "m".into(),
514                engineer: "e1".into(),
515                task: "t1".into(),
516            }],
517        )
518        .unwrap();
519
520        let commands = read_command_queue(&queue).unwrap();
521        assert_eq!(commands.len(), 1);
522    }
523
524    // --- command_queue_path ---
525
526    #[test]
527    fn command_queue_path_returns_expected_path() {
528        let root = Path::new("/project");
529        let path = command_queue_path(root);
530        assert_eq!(
531            path,
532            PathBuf::from("/project/.batty/team_config/commands.jsonl")
533        );
534    }
535
536    #[test]
537    fn command_queue_path_with_trailing_slash() {
538        let root = Path::new("/project/");
539        let path = command_queue_path(root);
540        assert_eq!(
541            path,
542            PathBuf::from("/project/.batty/team_config/commands.jsonl")
543        );
544    }
545
546    // --- drain_command_queue ---
547
548    #[test]
549    fn drain_empties_queue_file_but_keeps_file() {
550        let tmp = tempfile::tempdir().unwrap();
551        let queue = tmp.path().join("commands.jsonl");
552
553        enqueue_command(
554            &queue,
555            &QueuedCommand::Send {
556                from: "a".into(),
557                to: "b".into(),
558                message: "msg".into(),
559            },
560        )
561        .unwrap();
562
563        let drained = drain_command_queue(&queue).unwrap();
564        assert_eq!(drained.len(), 1);
565
566        // File should still exist but be effectively empty
567        assert!(queue.exists());
568        let commands = read_command_queue(&queue).unwrap();
569        assert!(commands.is_empty());
570    }
571
572    #[test]
573    fn drain_twice_second_returns_empty() {
574        let tmp = tempfile::tempdir().unwrap();
575        let queue = tmp.path().join("commands.jsonl");
576
577        enqueue_command(
578            &queue,
579            &QueuedCommand::Assign {
580                from: "m".into(),
581                engineer: "e".into(),
582                task: "t".into(),
583            },
584        )
585        .unwrap();
586
587        let first = drain_command_queue(&queue).unwrap();
588        assert_eq!(first.len(), 1);
589
590        let second = drain_command_queue(&queue).unwrap();
591        assert!(second.is_empty());
592    }
593
594    // --- QueuedCommand Debug derive ---
595
596    #[test]
597    fn queued_command_debug_format() {
598        let cmd = QueuedCommand::Send {
599            from: "human".into(),
600            to: "arch".into(),
601            message: "test".into(),
602        };
603        let debug = format!("{cmd:?}");
604        assert!(debug.contains("Send"));
605        assert!(debug.contains("human"));
606    }
607
608    #[test]
609    fn queued_command_clone() {
610        let cmd = QueuedCommand::Assign {
611            from: "manager".into(),
612            engineer: "eng-1".into(),
613            task: "build feature".into(),
614        };
615        let cloned = cmd.clone();
616        let json_original = serde_json::to_string(&cmd).unwrap();
617        let json_cloned = serde_json::to_string(&cloned).unwrap();
618        assert_eq!(json_original, json_cloned);
619    }
620
621    // --- JSON tag format verification ---
622
623    #[test]
624    fn send_command_json_has_type_send_tag() {
625        let cmd = QueuedCommand::Send {
626            from: "a".into(),
627            to: "b".into(),
628            message: "c".into(),
629        };
630        let json = serde_json::to_string(&cmd).unwrap();
631        assert!(json.contains("\"type\":\"send\""), "got: {json}");
632    }
633
634    #[test]
635    fn assign_command_json_has_type_assign_tag() {
636        let cmd = QueuedCommand::Assign {
637            from: "a".into(),
638            engineer: "b".into(),
639            task: "c".into(),
640        };
641        let json = serde_json::to_string(&cmd).unwrap();
642        assert!(json.contains("\"type\":\"assign\""), "got: {json}");
643    }
644}