1use std::{
17 collections::BTreeSet,
18 path::{Path, PathBuf},
19};
20
21use repo::{ThreadId, ThreadIdError, shell_quote};
22use serde::Serialize;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct FanoutNodeSpec {
31 pub thread: String,
32 pub path: PathBuf,
33 pub title: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct FanoutLaneAvailability {
39 pub thread: String,
40 pub has_live_owner: bool,
42 pub thread_ref_exists: bool,
44 pub active_thread_record: bool,
46 pub resolved_path: PathBuf,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum FanoutLanePreflightBlock {
53 LiveOwner { thread: String },
54 ThreadExists { thread: String },
55 ActiveThreadRecord { thread: String },
56 DuplicatePath { thread: String },
57}
58
59impl FanoutLanePreflightBlock {
60 pub fn kind(&self) -> &'static str {
61 match self {
62 Self::LiveOwner { .. } => "agent_fanout_live_owner",
63 Self::ThreadExists { .. } | Self::ActiveThreadRecord { .. } => {
64 "agent_fanout_thread_exists"
65 }
66 Self::DuplicatePath { .. } => "agent_fanout_duplicate_path",
67 }
68 }
69
70 pub fn thread(&self) -> &str {
71 match self {
72 Self::LiveOwner { thread }
73 | Self::ThreadExists { thread }
74 | Self::ActiveThreadRecord { thread }
75 | Self::DuplicatePath { thread } => thread,
76 }
77 }
78}
79
80pub fn check_fanout_start_preflight(
85 lanes: &[FanoutLaneAvailability],
86) -> Result<(), FanoutLanePreflightBlock> {
87 let mut seen_paths = BTreeSet::new();
88 for lane in lanes {
89 if lane.has_live_owner {
90 return Err(FanoutLanePreflightBlock::LiveOwner {
91 thread: lane.thread.clone(),
92 });
93 }
94 if lane.thread_ref_exists {
95 return Err(FanoutLanePreflightBlock::ThreadExists {
96 thread: lane.thread.clone(),
97 });
98 }
99 if lane.active_thread_record {
100 return Err(FanoutLanePreflightBlock::ActiveThreadRecord {
101 thread: lane.thread.clone(),
102 });
103 }
104 if !seen_paths.insert(lane.resolved_path.clone()) {
105 return Err(FanoutLanePreflightBlock::DuplicatePath {
106 thread: lane.thread.clone(),
107 });
108 }
109 }
110 Ok(())
111}
112
113impl FanoutNodeSpec {
114 pub fn to_lane_arg(&self) -> String {
116 format!("{}={}:{}", self.thread, self.path.display(), self.title)
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct FanoutPlanRequest {
126 pub title: String,
127 pub lanes: Vec<String>,
129 pub coordination_discussion_id: Option<String>,
130 pub base_state: String,
132 pub base_root: String,
134 pub parent_thread: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct FanoutBaseFacts {
141 pub head_state_full: String,
143 pub head_tree_short: String,
145 pub head_thread: Option<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct FanoutBaseSelection {
152 pub base_state: String,
153 pub base_root: String,
154 pub parent_thread: String,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct FanoutPlan {
163 pub title: String,
164 pub parent_thread: String,
165 pub base_state: String,
166 pub base_root: String,
167 pub coordination_discussion_id: Option<String>,
168 pub nodes: Vec<FanoutNodeSpec>,
169 pub parent_body: String,
171 pub start_commands: Vec<FanoutCommandSpec>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
177pub struct FanoutCommandSpec {
178 pub lane_thread: String,
179 pub command: String,
180 pub argv: Vec<String>,
181}
182
183#[derive(Debug, Clone, Serialize)]
188pub struct FanoutPlanReport {
189 pub output_kind: &'static str,
190 pub title: String,
191 pub parent_thread: String,
192 pub base_state: String,
193 pub base_root: String,
194 pub coordination_discussion_id: Option<String>,
195 pub parent_task: Option<FanoutTaskPlaceholder>,
196 pub lanes: Vec<FanoutLaneReport>,
197 pub commands: Vec<FanoutCommandSpec>,
198}
199
200#[derive(Debug, Clone, Serialize)]
204pub struct FanoutTaskPlaceholder {}
205
206#[derive(Debug, Clone, Serialize)]
208pub struct FanoutLaneReport {
209 pub thread: String,
210 pub path: String,
211 pub title: String,
212 pub task: Option<FanoutTaskPlaceholder>,
213 pub session_id: Option<String>,
214 pub status: String,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
223pub enum FanoutPlanError {
224 LaneRequired,
226 LaneInvalid { raw: String },
228 InvalidThreadName { raw: String, source: ThreadIdError },
230 DuplicateThread { thread: String },
232}
233
234impl std::fmt::Display for FanoutPlanError {
235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236 match self {
237 Self::LaneRequired => write!(
238 f,
239 "agent fanout requires at least one --lane <thread>=<path>:<title>"
240 ),
241 Self::LaneInvalid { raw } => write!(f, "invalid fanout lane '{raw}'"),
242 Self::InvalidThreadName { source, .. } => write!(f, "{source}"),
243 Self::DuplicateThread { thread } => {
244 write!(f, "fanout lane '{thread}' is listed more than once")
245 }
246 }
247 }
248}
249
250impl std::error::Error for FanoutPlanError {
251 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
252 match self {
253 Self::InvalidThreadName { source, .. } => Some(source),
254 _ => None,
255 }
256 }
257}
258
259pub fn select_fanout_parent_thread(head_thread: Option<&str>) -> String {
267 match head_thread.map(str::trim).filter(|s| !s.is_empty()) {
268 Some(thread) => thread.to_string(),
269 None => "detached".to_string(),
270 }
271}
272
273pub fn select_fanout_base(facts: &FanoutBaseFacts) -> FanoutBaseSelection {
277 FanoutBaseSelection {
278 base_state: facts.head_state_full.clone(),
279 base_root: facts.head_tree_short.clone(),
280 parent_thread: select_fanout_parent_thread(facts.head_thread.as_deref()),
281 }
282}
283
284pub fn fanout_parent_body(nodes: &[FanoutNodeSpec]) -> String {
286 nodes
287 .iter()
288 .map(|node| format!("- {}: {}", node.thread, node.title))
289 .collect::<Vec<_>>()
290 .join("\n")
291}
292
293pub fn fanout_child_body(parent_task_id: &str) -> String {
295 format!("Fan-out child lane for parent task {parent_task_id}")
296}
297
298pub fn fanout_parent_delegated_by() -> &'static str {
300 "heddle agent fanout start"
301}
302
303pub fn fanout_start_attach_rule() -> &'static str {
305 "agent-fanout-start"
306}
307
308pub fn parse_fanout_lane(raw: &str) -> Result<FanoutNodeSpec, FanoutPlanError> {
314 let (thread, rest) = raw
315 .split_once('=')
316 .ok_or_else(|| FanoutPlanError::LaneInvalid {
317 raw: raw.to_string(),
318 })?;
319 let (path, title) = rest
320 .split_once(':')
321 .ok_or_else(|| FanoutPlanError::LaneInvalid {
322 raw: raw.to_string(),
323 })?;
324 let thread = thread.trim();
325 let path = path.trim();
326 let title = title.trim();
327 if path.is_empty() || title.is_empty() {
328 return Err(FanoutPlanError::LaneInvalid {
329 raw: raw.to_string(),
330 });
331 }
332 let validated = ThreadId::new(thread).map_err(|source| FanoutPlanError::InvalidThreadName {
333 raw: raw.to_string(),
334 source,
335 })?;
336 Ok(FanoutNodeSpec {
337 thread: validated.as_str().to_string(),
338 path: PathBuf::from(path),
339 title: title.to_string(),
340 })
341}
342
343pub fn parse_fanout_lanes(raw_lanes: &[String]) -> Result<Vec<FanoutNodeSpec>, FanoutPlanError> {
345 if raw_lanes.is_empty() {
346 return Err(FanoutPlanError::LaneRequired);
347 }
348 raw_lanes.iter().map(|raw| parse_fanout_lane(raw)).collect()
349}
350
351pub fn plan_fanout(request: &FanoutPlanRequest) -> Result<FanoutPlan, FanoutPlanError> {
361 let nodes = parse_fanout_lanes(&request.lanes)?;
362 ensure_unique_thread_names(&nodes)?;
363 let parent_body = fanout_parent_body(&nodes);
364 let start_commands = assemble_fanout_start_commands(
365 &request.title,
366 request.coordination_discussion_id.as_deref(),
367 &nodes,
368 );
369 Ok(FanoutPlan {
370 title: request.title.clone(),
371 parent_thread: request.parent_thread.clone(),
372 base_state: request.base_state.clone(),
373 base_root: request.base_root.clone(),
374 coordination_discussion_id: request.coordination_discussion_id.clone(),
375 nodes,
376 parent_body,
377 start_commands,
378 })
379}
380
381pub fn ensure_unique_thread_names(nodes: &[FanoutNodeSpec]) -> Result<(), FanoutPlanError> {
383 let mut seen = BTreeSet::new();
384 for node in nodes {
385 if !seen.insert(node.thread.as_str()) {
386 return Err(FanoutPlanError::DuplicateThread {
387 thread: node.thread.clone(),
388 });
389 }
390 }
391 Ok(())
392}
393
394pub fn assemble_fanout_start_commands(
396 title: &str,
397 coordination_discussion_id: Option<&str>,
398 nodes: &[FanoutNodeSpec],
399) -> Vec<FanoutCommandSpec> {
400 let mut argv = vec![
401 "heddle".to_string(),
402 "agent".to_string(),
403 "fanout".to_string(),
404 "start".to_string(),
405 "--title".to_string(),
406 title.to_string(),
407 ];
408 if let Some(discussion_id) = coordination_discussion_id {
409 argv.push("--coordination-discussion-id".to_string());
410 argv.push(discussion_id.to_string());
411 }
412 for node in nodes {
413 argv.push("--lane".to_string());
414 argv.push(node.to_lane_arg());
415 }
416 let command = argv
417 .iter()
418 .map(|arg| shell_quote(arg))
419 .collect::<Vec<_>>()
420 .join(" ");
421 vec![FanoutCommandSpec {
422 lane_thread: "all".to_string(),
423 command,
424 argv,
425 }]
426}
427
428pub fn assemble_fanout_plan_report(plan: &FanoutPlan) -> FanoutPlanReport {
430 FanoutPlanReport {
431 output_kind: "agent_fanout_plan",
432 title: plan.title.clone(),
433 parent_thread: plan.parent_thread.clone(),
434 base_state: plan.base_state.clone(),
435 base_root: plan.base_root.clone(),
436 coordination_discussion_id: plan.coordination_discussion_id.clone(),
437 parent_task: None,
438 lanes: plan
439 .nodes
440 .iter()
441 .map(|node| FanoutLaneReport {
442 thread: node.thread.clone(),
443 path: path_display(&node.path),
444 title: node.title.clone(),
445 task: None,
446 session_id: None,
447 status: "planned".to_string(),
448 })
449 .collect(),
450 commands: plan.start_commands.clone(),
451 }
452}
453
454fn path_display(path: &Path) -> String {
455 path.display().to_string()
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn sample_request(lanes: &[&str]) -> FanoutPlanRequest {
463 FanoutPlanRequest {
464 title: "Coordinate fanout".to_string(),
465 lanes: lanes.iter().map(|s| (*s).to_string()).collect(),
466 coordination_discussion_id: Some("discussion-123".to_string()),
467 base_state: "state-full".to_string(),
468 base_root: "tree-short".to_string(),
469 parent_thread: "main".to_string(),
470 }
471 }
472
473 #[test]
474 fn parse_lane_accepts_thread_path_title() {
475 let node = parse_fanout_lane("feature/a=../a:Implement a").unwrap();
476 assert_eq!(node.thread, "feature/a");
477 assert_eq!(node.path, PathBuf::from("../a"));
478 assert_eq!(node.title, "Implement a");
479 assert_eq!(node.to_lane_arg(), "feature/a=../a:Implement a");
480 }
481
482 #[test]
483 fn parse_lane_trims_whitespace() {
484 let node = parse_fanout_lane(" feature/b = ./b : Title B ").unwrap();
485 assert_eq!(node.thread, "feature/b");
486 assert_eq!(node.path, PathBuf::from("./b"));
487 assert_eq!(node.title, "Title B");
488 }
489
490 #[test]
491 fn parse_lane_rejects_missing_separators_and_empty_parts() {
492 assert!(matches!(
493 parse_fanout_lane("no-equals"),
494 Err(FanoutPlanError::LaneInvalid { .. })
495 ));
496 assert!(matches!(
497 parse_fanout_lane("thread=only-path"),
498 Err(FanoutPlanError::LaneInvalid { .. })
499 ));
500 assert!(matches!(
501 parse_fanout_lane("thread=:title"),
502 Err(FanoutPlanError::LaneInvalid { .. })
503 ));
504 assert!(matches!(
505 parse_fanout_lane("thread=path:"),
506 Err(FanoutPlanError::LaneInvalid { .. })
507 ));
508 }
509
510 #[test]
511 fn parse_lane_rejects_invalid_thread_name() {
512 let err = parse_fanout_lane("bad name=./p:Title").unwrap_err();
513 assert!(matches!(err, FanoutPlanError::InvalidThreadName { .. }));
514 }
515
516 #[test]
517 fn parse_lanes_requires_at_least_one() {
518 assert_eq!(parse_fanout_lanes(&[]), Err(FanoutPlanError::LaneRequired));
519 }
520
521 #[test]
522 fn select_parent_thread_attached_and_detached() {
523 assert_eq!(select_fanout_parent_thread(Some("main")), "main");
524 assert_eq!(select_fanout_parent_thread(Some(" ")), "detached");
525 assert_eq!(select_fanout_parent_thread(None), "detached");
526 }
527
528 #[test]
529 fn select_base_uses_head_facts() {
530 let selection = select_fanout_base(&FanoutBaseFacts {
531 head_state_full: "abc".into(),
532 head_tree_short: "def".into(),
533 head_thread: Some("main".into()),
534 });
535 assert_eq!(selection.base_state, "abc");
536 assert_eq!(selection.base_root, "def");
537 assert_eq!(selection.parent_thread, "main");
538
539 let detached = select_fanout_base(&FanoutBaseFacts {
540 head_state_full: "abc".into(),
541 head_tree_short: "def".into(),
542 head_thread: None,
543 });
544 assert_eq!(detached.parent_thread, "detached");
545 }
546
547 #[test]
548 fn parent_and_child_body_rules() {
549 let nodes = vec![
550 FanoutNodeSpec {
551 thread: "feature/a".into(),
552 path: PathBuf::from("../a"),
553 title: "Task A".into(),
554 },
555 FanoutNodeSpec {
556 thread: "feature/b".into(),
557 path: PathBuf::from("../b"),
558 title: "Task B".into(),
559 },
560 ];
561 assert_eq!(
562 fanout_parent_body(&nodes),
563 "- feature/a: Task A\n- feature/b: Task B"
564 );
565 assert_eq!(
566 fanout_child_body("task-1"),
567 "Fan-out child lane for parent task task-1"
568 );
569 assert_eq!(fanout_parent_delegated_by(), "heddle agent fanout start");
570 assert_eq!(fanout_start_attach_rule(), "agent-fanout-start");
571 }
572
573 #[test]
574 fn plan_fanout_builds_nodes_body_and_start_command() {
575 let plan = plan_fanout(&sample_request(&[
576 "feature/a=../a:Implement a",
577 "feature/b=../b:Implement b",
578 ]))
579 .unwrap();
580
581 assert_eq!(plan.nodes.len(), 2);
582 assert_eq!(plan.parent_thread, "main");
583 assert_eq!(plan.base_state, "state-full");
584 assert_eq!(plan.base_root, "tree-short");
585 assert_eq!(
586 plan.coordination_discussion_id.as_deref(),
587 Some("discussion-123")
588 );
589 assert!(plan.parent_body.contains("feature/a: Implement a"));
590 assert_eq!(plan.start_commands.len(), 1);
591 assert_eq!(plan.start_commands[0].lane_thread, "all");
592 let argv = &plan.start_commands[0].argv;
593 assert_eq!(argv[0], "heddle");
594 assert_eq!(argv[1], "agent");
595 assert_eq!(argv[2], "fanout");
596 assert_eq!(argv[3], "start");
597 assert!(argv.contains(&"--title".to_string()));
598 assert!(argv.contains(&"Coordinate fanout".to_string()));
599 assert!(argv.contains(&"--coordination-discussion-id".to_string()));
600 assert!(argv.contains(&"discussion-123".to_string()));
601 assert!(argv.contains(&"feature/a=../a:Implement a".to_string()));
602 assert!(argv.contains(&"feature/b=../b:Implement b".to_string()));
603 assert!(
604 plan.start_commands[0]
605 .command
606 .contains("agent fanout start")
607 );
608 }
609
610 #[test]
611 fn fanout_start_preflight_blocks_in_priority_order() {
612 let ok = FanoutLaneAvailability {
613 thread: "a".into(),
614 has_live_owner: false,
615 thread_ref_exists: false,
616 active_thread_record: false,
617 resolved_path: PathBuf::from("/tmp/a"),
618 };
619 assert!(check_fanout_start_preflight(std::slice::from_ref(&ok)).is_ok());
620
621 let mut live = ok.clone();
622 live.has_live_owner = true;
623 assert!(matches!(
624 check_fanout_start_preflight(&[live]),
625 Err(FanoutLanePreflightBlock::LiveOwner { .. })
626 ));
627
628 let mut exists = ok.clone();
629 exists.thread_ref_exists = true;
630 assert!(matches!(
631 check_fanout_start_preflight(&[exists]),
632 Err(FanoutLanePreflightBlock::ThreadExists { .. })
633 ));
634
635 let dup_a = ok.clone();
636 let mut dup_b = ok;
637 dup_b.thread = "b".into();
638 assert!(matches!(
640 check_fanout_start_preflight(&[dup_a, dup_b]),
641 Err(FanoutLanePreflightBlock::DuplicatePath { thread }) if thread == "b"
642 ));
643 }
644
645 #[test]
646 fn plan_fanout_rejects_duplicate_threads() {
647 let err = plan_fanout(&sample_request(&[
648 "feature/dup=../a:First",
649 "feature/dup=../b:Second",
650 ]))
651 .unwrap_err();
652 assert_eq!(
653 err,
654 FanoutPlanError::DuplicateThread {
655 thread: "feature/dup".into()
656 }
657 );
658 }
659
660 #[test]
661 fn assemble_plan_report_matches_dry_run_contract() {
662 let plan = plan_fanout(&sample_request(&["feature/a=../a:Implement a"])).unwrap();
663 let report = assemble_fanout_plan_report(&plan);
664 assert_eq!(report.output_kind, "agent_fanout_plan");
665 assert!(report.parent_task.is_none());
666 assert_eq!(report.lanes.len(), 1);
667 assert_eq!(report.lanes[0].status, "planned");
668 assert_eq!(report.lanes[0].thread, "feature/a");
669 assert_eq!(report.lanes[0].path, "../a");
670 assert!(report.lanes[0].task.is_none());
671 assert!(report.lanes[0].session_id.is_none());
672 assert_eq!(report.commands[0].argv[2], "fanout");
673 assert_eq!(report.commands[0].argv[3], "start");
674
675 let json = serde_json::to_value(&report).unwrap();
676 assert_eq!(json["output_kind"], "agent_fanout_plan");
677 assert!(json["parent_task"].is_null());
678 assert_eq!(json["lanes"][0]["status"], "planned");
679 assert!(json["lanes"][0]["task"].is_null());
680 }
681}