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 #[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 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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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}