1use 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#[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
28pub 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#[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
53pub 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
79pub 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
112pub 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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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}