1use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use tracing::{info, warn};
7
8use super::{
9 TEAM_CONFIG_FILE, daemon_log_path, now_unix, orchestrator_log_path, team_config_dir,
10 team_config_path, team_events_path,
11};
12
13pub fn templates_base_dir() -> Result<PathBuf> {
15 let home = std::env::var("HOME").context("cannot determine home directory")?;
16 Ok(PathBuf::from(home).join(".batty").join("templates"))
17}
18
19#[derive(Debug, Default)]
21pub struct InitOverrides {
22 pub orchestrator_pane: Option<bool>,
23 pub auto_dispatch: Option<bool>,
24 pub use_worktrees: Option<bool>,
25 pub timeout_nudges: Option<bool>,
26 pub standups: Option<bool>,
27 pub triage_interventions: Option<bool>,
28 pub review_interventions: Option<bool>,
29 pub owned_task_interventions: Option<bool>,
30 pub manager_dispatch_interventions: Option<bool>,
31 pub architect_utilization_interventions: Option<bool>,
32 pub auto_merge_enabled: Option<bool>,
33 pub standup_interval_secs: Option<u64>,
34 pub nudge_interval_secs: Option<u64>,
35 pub stall_threshold_secs: Option<u64>,
36 pub review_nudge_threshold_secs: Option<u64>,
37 pub review_timeout_secs: Option<u64>,
38}
39
40pub fn init_team(
42 project_root: &Path,
43 template: &str,
44 project_name: Option<&str>,
45 agent: Option<&str>,
46 force: bool,
47) -> Result<Vec<PathBuf>> {
48 init_team_with_overrides(project_root, template, project_name, agent, force, None)
49}
50
51pub fn init_team_with_overrides(
53 project_root: &Path,
54 template: &str,
55 project_name: Option<&str>,
56 agent: Option<&str>,
57 force: bool,
58 overrides: Option<&InitOverrides>,
59) -> Result<Vec<PathBuf>> {
60 let config_dir = team_config_dir(project_root);
61 std::fs::create_dir_all(&config_dir)
62 .with_context(|| format!("failed to create {}", config_dir.display()))?;
63
64 let mut created = Vec::new();
65
66 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
67 if yaml_path.exists() && !force {
68 bail!(
69 "team config already exists at {}; remove it first or use --force",
70 yaml_path.display()
71 );
72 }
73
74 let yaml_content = match template {
75 "solo" => include_str!("templates/team_solo.yaml"),
76 "pair" => include_str!("templates/team_pair.yaml"),
77 "squad" => include_str!("templates/team_squad.yaml"),
78 "large" => include_str!("templates/team_large.yaml"),
79 "research" => include_str!("templates/team_research.yaml"),
80 "software" => include_str!("templates/team_software.yaml"),
81 "cleanroom" => include_str!("templates/team_cleanroom.yaml"),
82 "batty" => include_str!("templates/team_batty.yaml"),
83 _ => include_str!("templates/team_simple.yaml"),
84 };
85 let mut yaml_content = yaml_content.to_string();
86 if let Some(name) = project_name {
87 if let Some(end) = yaml_content.find('\n') {
88 yaml_content = format!("name: {name}{}", &yaml_content[end..]);
89 }
90 }
91 if let Some(agent_name) = agent {
92 yaml_content = yaml_content
93 .replace("agent: claude", &format!("agent: {agent_name}"))
94 .replace("agent: codex", &format!("agent: {agent_name}"));
95 }
96 if let Some(ov) = overrides {
97 yaml_content = apply_init_overrides(&yaml_content, ov);
98 }
99 std::fs::write(&yaml_path, &yaml_content)
100 .with_context(|| format!("failed to write {}", yaml_path.display()))?;
101 created.push(yaml_path);
102
103 let prompt_files: &[(&str, &str)] = match template {
105 "research" => &[
106 (
107 "research_lead.md",
108 include_str!("templates/research_lead.md"),
109 ),
110 ("sub_lead.md", include_str!("templates/sub_lead.md")),
111 ("researcher.md", include_str!("templates/researcher.md")),
112 ],
113 "software" => &[
114 ("tech_lead.md", include_str!("templates/tech_lead.md")),
115 ("eng_manager.md", include_str!("templates/eng_manager.md")),
116 ("developer.md", include_str!("templates/developer.md")),
117 ],
118 "batty" => &[
119 (
120 "batty_architect.md",
121 include_str!("templates/batty_architect.md"),
122 ),
123 (
124 "batty_manager.md",
125 include_str!("templates/batty_manager.md"),
126 ),
127 (
128 "batty_engineer.md",
129 include_str!("templates/batty_engineer.md"),
130 ),
131 ],
132 "cleanroom" => &[
133 (
134 "batty_decompiler.md",
135 include_str!("templates/batty_decompiler.md"),
136 ),
137 (
138 "batty_spec_writer.md",
139 include_str!("templates/batty_spec_writer.md"),
140 ),
141 (
142 "batty_test_writer.md",
143 include_str!("templates/batty_test_writer.md"),
144 ),
145 (
146 "batty_implementer.md",
147 include_str!("templates/batty_implementer.md"),
148 ),
149 ],
150 _ => &[
151 ("architect.md", include_str!("templates/architect.md")),
152 ("manager.md", include_str!("templates/manager.md")),
153 ("engineer.md", include_str!("templates/engineer.md")),
154 ],
155 };
156
157 for (name, content) in prompt_files {
158 let path = config_dir.join(name);
159 if force || !path.exists() {
160 std::fs::write(&path, content)
161 .with_context(|| format!("failed to write {}", path.display()))?;
162 created.push(path);
163 }
164 }
165
166 let directive_files = [
167 (
168 "replenishment_context.md",
169 include_str!("templates/replenishment_context.md"),
170 ),
171 (
172 "review_policy.md",
173 include_str!("templates/review_policy.md"),
174 ),
175 (
176 "escalation_policy.md",
177 include_str!("templates/escalation_policy.md"),
178 ),
179 ];
180 for (name, content) in directive_files {
181 let path = config_dir.join(name);
182 if force || !path.exists() {
183 std::fs::write(&path, content)
184 .with_context(|| format!("failed to write {}", path.display()))?;
185 created.push(path);
186 }
187 }
188
189 if template == "cleanroom" {
190 scaffold_cleanroom_assets(project_root, force, &mut created)?;
191 }
192
193 let board_dir = config_dir.join("board");
195 if !board_dir.exists() {
196 let output = std::process::Command::new("kanban-md")
197 .args(["init", "--dir", &board_dir.to_string_lossy()])
198 .output();
199 match output {
200 Ok(out) if out.status.success() => {
201 created.push(board_dir);
202 }
203 Ok(out) => {
204 let stderr = String::from_utf8_lossy(&out.stderr);
205 warn!("kanban-md init failed: {stderr}; falling back to plain kanban.md");
206 let kanban_path = config_dir.join("kanban.md");
207 std::fs::write(
208 &kanban_path,
209 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
210 )?;
211 created.push(kanban_path);
212 }
213 Err(_) => {
214 warn!("kanban-md not found; falling back to plain kanban.md");
215 let kanban_path = config_dir.join("kanban.md");
216 std::fs::write(
217 &kanban_path,
218 "# Kanban Board\n\n## Backlog\n\n## In Progress\n\n## Done\n",
219 )?;
220 created.push(kanban_path);
221 }
222 }
223 }
224
225 info!(dir = %config_dir.display(), files = created.len(), "scaffolded team config");
226 Ok(created)
227}
228
229fn scaffold_cleanroom_assets(
230 project_root: &Path,
231 force: bool,
232 created: &mut Vec<PathBuf>,
233) -> Result<()> {
234 let analysis_dir = project_root.join("analysis");
235 let implementation_dir = project_root.join("implementation");
236 let planning_dir = project_root.join("planning");
237 let specs_dir = project_root.join("specs");
238
239 create_dir_if_missing(&analysis_dir, created)?;
240 create_dir_if_missing(&implementation_dir, created)?;
241 create_dir_if_missing(&planning_dir, created)?;
242 create_dir_if_missing(&specs_dir, created)?;
243
244 write_scaffold_file(
245 &project_root.join("PARITY.md"),
246 include_str!("templates/cleanroom_PARITY.md"),
247 force,
248 created,
249 )?;
250 write_scaffold_file(
251 &project_root.join("SPEC.md"),
252 include_str!("templates/cleanroom_SPEC.md"),
253 force,
254 created,
255 )?;
256 write_scaffold_file(
257 &planning_dir.join("cleanroom-process.md"),
258 include_str!("templates/cleanroom_process.md"),
259 force,
260 created,
261 )?;
262
263 Ok(())
264}
265
266fn create_dir_if_missing(path: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
267 if !path.exists() {
268 std::fs::create_dir_all(path)
269 .with_context(|| format!("failed to create {}", path.display()))?;
270 created.push(path.to_path_buf());
271 }
272 Ok(())
273}
274
275fn write_scaffold_file(
276 path: &Path,
277 content: &str,
278 force: bool,
279 created: &mut Vec<PathBuf>,
280) -> Result<()> {
281 if force || !path.exists() {
282 if let Some(parent) = path.parent() {
283 std::fs::create_dir_all(parent)
284 .with_context(|| format!("failed to create {}", parent.display()))?;
285 }
286 std::fs::write(path, content)
287 .with_context(|| format!("failed to write {}", path.display()))?;
288 created.push(path.to_path_buf());
289 }
290 Ok(())
291}
292
293fn apply_init_overrides(yaml: &str, ov: &InitOverrides) -> String {
295 let mut doc: serde_yaml::Value = match serde_yaml::from_str(yaml) {
296 Ok(v) => v,
297 Err(_) => return yaml.to_string(),
298 };
299 let map = match doc.as_mapping_mut() {
300 Some(m) => m,
301 None => return yaml.to_string(),
302 };
303
304 fn set_bool(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<bool>) {
305 if let Some(v) = val {
306 let sec = map
307 .entry(serde_yaml::Value::String(section.to_string()))
308 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
309 if let Some(m) = sec.as_mapping_mut() {
310 m.insert(
311 serde_yaml::Value::String(key.to_string()),
312 serde_yaml::Value::Bool(v),
313 );
314 }
315 }
316 }
317
318 fn set_u64(map: &mut serde_yaml::Mapping, section: &str, key: &str, val: Option<u64>) {
319 if let Some(v) = val {
320 let sec = map
321 .entry(serde_yaml::Value::String(section.to_string()))
322 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
323 if let Some(m) = sec.as_mapping_mut() {
324 m.insert(
325 serde_yaml::Value::String(key.to_string()),
326 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
327 );
328 }
329 }
330 }
331
332 if let Some(v) = ov.orchestrator_pane {
333 map.insert(
334 serde_yaml::Value::String("orchestrator_pane".to_string()),
335 serde_yaml::Value::Bool(v),
336 );
337 }
338
339 set_bool(map, "board", "auto_dispatch", ov.auto_dispatch);
340 set_u64(map, "standup", "interval_secs", ov.standup_interval_secs);
341 set_bool(map, "automation", "timeout_nudges", ov.timeout_nudges);
342 set_bool(map, "automation", "standups", ov.standups);
343 set_bool(
344 map,
345 "automation",
346 "triage_interventions",
347 ov.triage_interventions,
348 );
349 set_bool(
350 map,
351 "automation",
352 "review_interventions",
353 ov.review_interventions,
354 );
355 set_bool(
356 map,
357 "automation",
358 "owned_task_interventions",
359 ov.owned_task_interventions,
360 );
361 set_bool(
362 map,
363 "automation",
364 "manager_dispatch_interventions",
365 ov.manager_dispatch_interventions,
366 );
367 set_bool(
368 map,
369 "automation",
370 "architect_utilization_interventions",
371 ov.architect_utilization_interventions,
372 );
373 set_u64(
374 map,
375 "workflow_policy",
376 "stall_threshold_secs",
377 ov.stall_threshold_secs,
378 );
379 set_u64(
380 map,
381 "workflow_policy",
382 "review_nudge_threshold_secs",
383 ov.review_nudge_threshold_secs,
384 );
385 set_u64(
386 map,
387 "workflow_policy",
388 "review_timeout_secs",
389 ov.review_timeout_secs,
390 );
391
392 if let Some(v) = ov.auto_merge_enabled {
393 let sec = map
394 .entry(serde_yaml::Value::String("workflow_policy".to_string()))
395 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
396 if let Some(wp) = sec.as_mapping_mut() {
397 let am = wp
398 .entry(serde_yaml::Value::String("auto_merge".to_string()))
399 .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
400 if let Some(m) = am.as_mapping_mut() {
401 m.insert(
402 serde_yaml::Value::String("enabled".to_string()),
403 serde_yaml::Value::Bool(v),
404 );
405 }
406 }
407 }
408
409 if ov.use_worktrees.is_some() || ov.nudge_interval_secs.is_some() {
410 if let Some(roles) = map
411 .get_mut(serde_yaml::Value::String("roles".to_string()))
412 .and_then(|v| v.as_sequence_mut())
413 {
414 for role in roles.iter_mut() {
415 if let Some(m) = role.as_mapping_mut() {
416 let role_type = m
417 .get(serde_yaml::Value::String("role_type".to_string()))
418 .and_then(|v| v.as_str())
419 .map(str::to_owned);
420
421 if role_type.as_deref() == Some("engineer") {
422 if let Some(v) = ov.use_worktrees {
423 m.insert(
424 serde_yaml::Value::String("use_worktrees".to_string()),
425 serde_yaml::Value::Bool(v),
426 );
427 }
428 }
429 if role_type.as_deref() == Some("architect") {
430 if let Some(v) = ov.nudge_interval_secs {
431 m.insert(
432 serde_yaml::Value::String("nudge_interval_secs".to_string()),
433 serde_yaml::Value::Number(serde_yaml::Number::from(v)),
434 );
435 }
436 }
437 }
438 }
439 }
440 }
441
442 serde_yaml::to_string(&doc).unwrap_or_else(|_| yaml.to_string())
443}
444
445pub fn list_available_templates() -> Result<Vec<String>> {
446 let templates_dir = templates_base_dir()?;
447 if !templates_dir.is_dir() {
448 bail!(
449 "no templates directory found at {}",
450 templates_dir.display()
451 );
452 }
453
454 let mut templates = Vec::new();
455 for entry in std::fs::read_dir(&templates_dir)
456 .with_context(|| format!("failed to read {}", templates_dir.display()))?
457 {
458 let entry = entry?;
459 if entry.path().is_dir() {
460 templates.push(entry.file_name().to_string_lossy().into_owned());
461 }
462 }
463 templates.sort();
464 Ok(templates)
465}
466
467const TEMPLATE_PROJECT_ROOT_DIR: &str = "project_root";
468
469fn copy_template_dir(src: &Path, dst: &Path, created: &mut Vec<PathBuf>) -> Result<()> {
470 std::fs::create_dir_all(dst).with_context(|| format!("failed to create {}", dst.display()))?;
471 for entry in
472 std::fs::read_dir(src).with_context(|| format!("failed to read {}", src.display()))?
473 {
474 let entry = entry?;
475 let src_path = entry.path();
476 let dst_path = dst.join(entry.file_name());
477 if src_path.is_dir() {
478 copy_template_dir(&src_path, &dst_path, created)?;
479 } else {
480 std::fs::copy(&src_path, &dst_path).with_context(|| {
481 format!(
482 "failed to copy template file from {} to {}",
483 src_path.display(),
484 dst_path.display()
485 )
486 })?;
487 created.push(dst_path);
488 }
489 }
490 Ok(())
491}
492
493pub fn init_from_template(project_root: &Path, template_name: &str) -> Result<Vec<PathBuf>> {
494 let templates_dir = templates_base_dir()?;
495 if !templates_dir.is_dir() {
496 bail!(
497 "no templates directory found at {}",
498 templates_dir.display()
499 );
500 }
501
502 let available = list_available_templates()?;
503 if !available.iter().any(|name| name == template_name) {
504 let available_display = if available.is_empty() {
505 "(none)".to_string()
506 } else {
507 available.join(", ")
508 };
509 bail!(
510 "template '{}' not found in {}; available templates: {}",
511 template_name,
512 templates_dir.display(),
513 available_display
514 );
515 }
516
517 let config_dir = team_config_dir(project_root);
518 let yaml_path = config_dir.join(TEAM_CONFIG_FILE);
519 if yaml_path.exists() {
520 bail!(
521 "team config already exists at {}; remove it first or edit directly",
522 yaml_path.display()
523 );
524 }
525
526 let source_dir = templates_dir.join(template_name);
527 let mut created = Vec::new();
528 std::fs::create_dir_all(&config_dir)
529 .with_context(|| format!("failed to create {}", config_dir.display()))?;
530 for entry in std::fs::read_dir(&source_dir)
531 .with_context(|| format!("failed to read {}", source_dir.display()))?
532 {
533 let entry = entry?;
534 let src_path = entry.path();
535 let file_name = entry.file_name();
536 if file_name == TEMPLATE_PROJECT_ROOT_DIR {
537 copy_template_dir(&src_path, project_root, &mut created)?;
538 } else if src_path.is_dir() {
539 copy_template_dir(&src_path, &config_dir.join(file_name), &mut created)?;
540 } else {
541 let dst_path = config_dir.join(file_name);
542 copy_template_file(&src_path, &dst_path)?;
543 created.push(dst_path);
544 }
545 }
546 info!(
547 template = template_name,
548 source = %source_dir.display(),
549 dest = %config_dir.display(),
550 files = created.len(),
551 "copied team config from user template"
552 );
553 Ok(created)
554}
555
556pub fn export_template(project_root: &Path, name: &str) -> Result<usize> {
558 let config_dir = team_config_dir(project_root);
559 let team_yaml = config_dir.join(TEAM_CONFIG_FILE);
560 if !team_yaml.is_file() {
561 bail!("team config missing at {}", team_yaml.display());
562 }
563
564 let template_dir = templates_base_dir()?.join(name);
565 if template_dir.exists() {
566 eprintln!(
567 "warning: overwriting existing template at {}",
568 template_dir.display()
569 );
570 }
571 std::fs::create_dir_all(&template_dir)
572 .with_context(|| format!("failed to create {}", template_dir.display()))?;
573
574 let mut copied = 0usize;
575 copy_template_file(&team_yaml, &template_dir.join(TEAM_CONFIG_FILE))?;
576 copied += 1;
577
578 let mut prompt_paths = std::fs::read_dir(&config_dir)?
579 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
580 .filter(|path| path.extension().is_some_and(|ext| ext == "md"))
581 .collect::<Vec<_>>();
582 prompt_paths.sort();
583
584 for source in prompt_paths {
585 let file_name = source
586 .file_name()
587 .context("template source missing file name")?;
588 copy_template_file(&source, &template_dir.join(file_name))?;
589 copied += 1;
590 }
591
592 copied += export_project_template_assets(project_root, &template_dir)?;
593
594 Ok(copied)
595}
596
597fn export_project_template_assets(project_root: &Path, template_dir: &Path) -> Result<usize> {
598 let mut copied = 0usize;
599 let export_root = template_dir.join(TEMPLATE_PROJECT_ROOT_DIR);
600
601 let optional_dirs = [
602 (
603 project_root.join("analysis"),
604 export_root.join("analysis"),
605 false,
606 ),
607 (
608 project_root.join("implementation"),
609 export_root.join("implementation"),
610 false,
611 ),
612 (
613 project_root.join("planning"),
614 export_root.join("planning"),
615 true,
616 ),
617 ];
618 for (source, destination, cleanroom_only) in optional_dirs {
619 if cleanroom_only && source.file_name() == Some(std::ffi::OsStr::new("planning")) {
620 let cleanroom_doc = source.join("cleanroom-process.md");
621 if cleanroom_doc.is_file() {
622 copy_template_file(&cleanroom_doc, &destination.join("cleanroom-process.md"))?;
623 copied += 1;
624 }
625 continue;
626 }
627 if source.is_dir() {
628 let mut created = Vec::new();
629 copy_template_dir(&source, &destination, &mut created)?;
630 copied += count_files_in_dir(&source)?;
631 }
632 }
633
634 let optional_files = [
635 (
636 project_root.join("PARITY.md"),
637 export_root.join("PARITY.md"),
638 ),
639 (project_root.join("SPEC.md"), export_root.join("SPEC.md")),
640 ];
641 for (source, destination) in optional_files {
642 if source.is_file() {
643 copy_template_file(&source, &destination)?;
644 copied += 1;
645 }
646 }
647
648 Ok(copied)
649}
650
651pub fn export_run(project_root: &Path) -> Result<PathBuf> {
652 let team_yaml = team_config_path(project_root);
653 if !team_yaml.is_file() {
654 bail!("team config missing at {}", team_yaml.display());
655 }
656
657 let export_dir = create_run_export_dir(project_root)?;
658 copy_template_file(&team_yaml, &export_dir.join(TEAM_CONFIG_FILE))?;
659
660 copy_dir_if_exists(
661 &team_config_dir(project_root).join("board").join("tasks"),
662 &export_dir.join("board").join("tasks"),
663 )?;
664 copy_file_if_exists(
665 &team_events_path(project_root),
666 &export_dir.join("events.jsonl"),
667 )?;
668 copy_file_if_exists(
669 &daemon_log_path(project_root),
670 &export_dir.join("daemon.log"),
671 )?;
672 copy_file_if_exists(
673 &orchestrator_log_path(project_root),
674 &export_dir.join("orchestrator.log"),
675 )?;
676 copy_dir_if_exists(
677 &project_root.join(".batty").join("retrospectives"),
678 &export_dir.join("retrospectives"),
679 )?;
680 copy_file_if_exists(
681 &project_root.join(".batty").join("test_timing.jsonl"),
682 &export_dir.join("test_timing.jsonl"),
683 )?;
684
685 Ok(export_dir)
686}
687
688fn copy_template_file(source: &Path, destination: &Path) -> Result<()> {
689 if let Some(parent) = destination.parent() {
690 std::fs::create_dir_all(parent)
691 .with_context(|| format!("failed to create {}", parent.display()))?;
692 }
693 std::fs::copy(source, destination).with_context(|| {
694 format!(
695 "failed to copy {} to {}",
696 source.display(),
697 destination.display()
698 )
699 })?;
700 Ok(())
701}
702
703fn exports_dir(project_root: &Path) -> PathBuf {
704 project_root.join(".batty").join("exports")
705}
706
707fn create_run_export_dir(project_root: &Path) -> Result<PathBuf> {
708 let base = exports_dir(project_root);
709 std::fs::create_dir_all(&base)
710 .with_context(|| format!("failed to create {}", base.display()))?;
711
712 let timestamp = now_unix();
713 let primary = base.join(timestamp.to_string());
714 if !primary.exists() {
715 std::fs::create_dir(&primary)
716 .with_context(|| format!("failed to create {}", primary.display()))?;
717 return Ok(primary);
718 }
719
720 for suffix in 1.. {
721 let candidate = base.join(format!("{timestamp}-{suffix}"));
722 if candidate.exists() {
723 continue;
724 }
725 std::fs::create_dir(&candidate)
726 .with_context(|| format!("failed to create {}", candidate.display()))?;
727 return Ok(candidate);
728 }
729
730 unreachable!("infinite suffix iterator should always return or continue");
731}
732
733fn copy_file_if_exists(source: &Path, destination: &Path) -> Result<()> {
734 if source.is_file() {
735 copy_template_file(source, destination)?;
736 }
737 Ok(())
738}
739
740fn copy_dir_if_exists(source: &Path, destination: &Path) -> Result<()> {
741 if source.is_dir() {
742 let mut created = Vec::new();
743 copy_template_dir(source, destination, &mut created)?;
744 }
745 Ok(())
746}
747
748fn count_files_in_dir(path: &Path) -> Result<usize> {
749 let mut count = 0usize;
750 for entry in
751 std::fs::read_dir(path).with_context(|| format!("failed to read {}", path.display()))?
752 {
753 let entry = entry?;
754 let child = entry.path();
755 if child.is_dir() {
756 count += count_files_in_dir(&child)?;
757 } else {
758 count += 1;
759 }
760 }
761 Ok(count)
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use serial_test::serial;
768 use std::ffi::OsString;
769
770 use crate::team::{
771 daemon_log_path, orchestrator_log_path, team_config_dir, team_config_path, team_events_path,
772 };
773
774 struct HomeGuard {
775 original_home: Option<OsString>,
776 }
777
778 impl HomeGuard {
779 fn set(path: &Path) -> Self {
780 let original_home = std::env::var_os("HOME");
781 unsafe {
782 std::env::set_var("HOME", path);
783 }
784 Self { original_home }
785 }
786 }
787
788 impl Drop for HomeGuard {
789 fn drop(&mut self) {
790 match &self.original_home {
791 Some(home) => unsafe {
792 std::env::set_var("HOME", home);
793 },
794 None => unsafe {
795 std::env::remove_var("HOME");
796 },
797 }
798 }
799 }
800
801 #[test]
802 fn init_team_creates_scaffolding() {
803 let tmp = tempfile::tempdir().unwrap();
804 let created = init_team(tmp.path(), "simple", None, None, false).unwrap();
805 assert!(!created.is_empty());
806 assert!(team_config_path(tmp.path()).exists());
807 assert!(team_config_dir(tmp.path()).join("architect.md").exists());
808 assert!(team_config_dir(tmp.path()).join("manager.md").exists());
809 assert!(team_config_dir(tmp.path()).join("engineer.md").exists());
810 assert!(
811 team_config_dir(tmp.path())
812 .join("replenishment_context.md")
813 .exists()
814 );
815 assert!(
816 team_config_dir(tmp.path())
817 .join("review_policy.md")
818 .exists()
819 );
820 assert!(
821 team_config_dir(tmp.path())
822 .join("escalation_policy.md")
823 .exists()
824 );
825 let config = team_config_dir(tmp.path());
827 assert!(config.join("board").is_dir() || config.join("kanban.md").exists());
828 let team_yaml = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
829 assert!(team_yaml.contains("auto_respawn_on_crash: true"));
830 }
831
832 #[test]
833 fn init_team_refuses_if_exists() {
834 let tmp = tempfile::tempdir().unwrap();
835 init_team(tmp.path(), "simple", None, None, false).unwrap();
836 let result = init_team(tmp.path(), "simple", None, None, false);
837 assert!(result.is_err());
838 assert!(result.unwrap_err().to_string().contains("already exists"));
839 }
840
841 #[test]
842 #[serial]
843 fn init_from_template_copies_files() {
844 let project = tempfile::tempdir().unwrap();
845 let home = tempfile::tempdir().unwrap();
846 let _home_guard = HomeGuard::set(home.path());
847
848 let template_dir = home.path().join(".batty").join("templates").join("custom");
849 std::fs::create_dir_all(template_dir.join("board")).unwrap();
850 std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
851 std::fs::write(template_dir.join("architect.md"), "# Architect\n").unwrap();
852 std::fs::write(template_dir.join("board").join("task.md"), "task\n").unwrap();
853
854 let created = init_from_template(project.path(), "custom").unwrap();
855
856 assert!(!created.is_empty());
857 assert_eq!(
858 std::fs::read_to_string(team_config_path(project.path())).unwrap(),
859 "name: custom\nroles: []\n"
860 );
861 assert!(
862 team_config_dir(project.path())
863 .join("architect.md")
864 .exists()
865 );
866 assert!(
867 team_config_dir(project.path())
868 .join("board")
869 .join("task.md")
870 .exists()
871 );
872 }
873
874 #[test]
875 #[serial]
876 fn init_from_template_restores_project_root_assets() {
877 let project = tempfile::tempdir().unwrap();
878 let home = tempfile::tempdir().unwrap();
879 let _home_guard = HomeGuard::set(home.path());
880
881 let template_dir = home
882 .path()
883 .join(".batty")
884 .join("templates")
885 .join("cleanroom");
886 let export_root = template_dir.join(TEMPLATE_PROJECT_ROOT_DIR);
887 std::fs::create_dir_all(export_root.join("analysis")).unwrap();
888 std::fs::create_dir_all(export_root.join("implementation")).unwrap();
889 std::fs::create_dir_all(export_root.join("planning")).unwrap();
890 std::fs::write(template_dir.join("team.yaml"), "name: custom\nroles: []\n").unwrap();
891 std::fs::write(template_dir.join("batty_decompiler.md"), "# Decompiler\n").unwrap();
892 std::fs::write(export_root.join("PARITY.md"), "# Parity\n").unwrap();
893 std::fs::write(export_root.join("SPEC.md"), "# Spec\n").unwrap();
894 std::fs::write(
895 export_root.join("planning").join("cleanroom-process.md"),
896 "# Process\n",
897 )
898 .unwrap();
899
900 let created = init_from_template(project.path(), "cleanroom").unwrap();
901
902 assert!(!created.is_empty());
903 assert_eq!(
904 std::fs::read_to_string(team_config_path(project.path())).unwrap(),
905 "name: custom\nroles: []\n"
906 );
907 assert!(
908 team_config_dir(project.path())
909 .join("batty_decompiler.md")
910 .exists()
911 );
912 assert!(project.path().join("analysis").is_dir());
913 assert!(project.path().join("implementation").is_dir());
914 assert_eq!(
915 std::fs::read_to_string(project.path().join("PARITY.md")).unwrap(),
916 "# Parity\n"
917 );
918 assert_eq!(
919 std::fs::read_to_string(project.path().join("SPEC.md")).unwrap(),
920 "# Spec\n"
921 );
922 assert_eq!(
923 std::fs::read_to_string(project.path().join("planning").join("cleanroom-process.md"))
924 .unwrap(),
925 "# Process\n"
926 );
927 }
928
929 #[test]
930 #[serial]
931 fn init_from_template_missing_template_errors_with_available_list() {
932 let project = tempfile::tempdir().unwrap();
933 let home = tempfile::tempdir().unwrap();
934 let _home_guard = HomeGuard::set(home.path());
935
936 let templates_root = home.path().join(".batty").join("templates");
937 std::fs::create_dir_all(templates_root.join("alpha")).unwrap();
938 std::fs::create_dir_all(templates_root.join("beta")).unwrap();
939
940 let error = init_from_template(project.path(), "missing").unwrap_err();
941 let message = error.to_string();
942 assert!(message.contains("template 'missing' not found"));
943 assert!(message.contains("alpha"));
944 assert!(message.contains("beta"));
945 }
946
947 #[test]
948 #[serial]
949 fn init_from_template_errors_when_templates_dir_is_missing() {
950 let project = tempfile::tempdir().unwrap();
951 let home = tempfile::tempdir().unwrap();
952 let _home_guard = HomeGuard::set(home.path());
953
954 let error = init_from_template(project.path(), "missing").unwrap_err();
955 assert!(error.to_string().contains("no templates directory found"));
956 }
957
958 #[test]
959 fn init_team_large_template() {
960 let tmp = tempfile::tempdir().unwrap();
961 let created = init_team(tmp.path(), "large", None, None, false).unwrap();
962 assert!(!created.is_empty());
963 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
964 assert!(content.contains("instances: 3") || content.contains("instances: 5"));
965 }
966
967 #[test]
968 fn init_team_solo_template() {
969 let tmp = tempfile::tempdir().unwrap();
970 let created = init_team(tmp.path(), "solo", None, None, false).unwrap();
971 assert!(!created.is_empty());
972 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
973 assert!(content.contains("role_type: engineer"));
974 assert!(!content.contains("role_type: manager"));
975 }
976
977 #[test]
978 fn init_team_pair_template() {
979 let tmp = tempfile::tempdir().unwrap();
980 let created = init_team(tmp.path(), "pair", None, None, false).unwrap();
981 assert!(!created.is_empty());
982 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
983 assert!(content.contains("role_type: architect"));
984 assert!(content.contains("role_type: engineer"));
985 assert!(!content.contains("role_type: manager"));
986 }
987
988 #[test]
989 fn init_team_squad_template() {
990 let tmp = tempfile::tempdir().unwrap();
991 let created = init_team(tmp.path(), "squad", None, None, false).unwrap();
992 assert!(!created.is_empty());
993 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
994 assert!(content.contains("instances: 5"));
995 assert!(content.contains("layout:"));
996 }
997
998 #[test]
999 fn init_team_research_template() {
1000 let tmp = tempfile::tempdir().unwrap();
1001 let created = init_team(tmp.path(), "research", None, None, false).unwrap();
1002 assert!(!created.is_empty());
1003 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1004 assert!(content.contains("principal"));
1005 assert!(content.contains("sub-lead"));
1006 assert!(content.contains("researcher"));
1007 assert!(
1009 team_config_dir(tmp.path())
1010 .join("research_lead.md")
1011 .exists()
1012 );
1013 assert!(team_config_dir(tmp.path()).join("sub_lead.md").exists());
1014 assert!(team_config_dir(tmp.path()).join("researcher.md").exists());
1015 assert!(!team_config_dir(tmp.path()).join("architect.md").exists());
1017 }
1018
1019 #[test]
1020 fn init_team_software_template() {
1021 let tmp = tempfile::tempdir().unwrap();
1022 let created = init_team(tmp.path(), "software", None, None, false).unwrap();
1023 assert!(!created.is_empty());
1024 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1025 assert!(content.contains("tech-lead"));
1026 assert!(content.contains("backend-mgr"));
1027 assert!(content.contains("frontend-mgr"));
1028 assert!(content.contains("developer"));
1029 assert!(team_config_dir(tmp.path()).join("tech_lead.md").exists());
1031 assert!(team_config_dir(tmp.path()).join("eng_manager.md").exists());
1032 assert!(team_config_dir(tmp.path()).join("developer.md").exists());
1033 }
1034
1035 #[test]
1036 fn init_team_batty_template() {
1037 let tmp = tempfile::tempdir().unwrap();
1038 let created = init_team(tmp.path(), "batty", None, None, false).unwrap();
1039 assert!(!created.is_empty());
1040 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1041 assert!(content.contains("batty-dev"));
1042 assert!(content.contains("role_type: architect"));
1043 assert!(content.contains("role_type: manager"));
1044 assert!(content.contains("instances: 4"));
1045 assert!(content.contains("batty_architect.md"));
1046 assert!(
1048 team_config_dir(tmp.path())
1049 .join("batty_architect.md")
1050 .exists()
1051 );
1052 assert!(
1053 team_config_dir(tmp.path())
1054 .join("batty_manager.md")
1055 .exists()
1056 );
1057 assert!(
1058 team_config_dir(tmp.path())
1059 .join("batty_engineer.md")
1060 .exists()
1061 );
1062 assert!(
1063 team_config_dir(tmp.path())
1064 .join("review_policy.md")
1065 .exists()
1066 );
1067 }
1068
1069 #[test]
1070 fn init_team_cleanroom_template() {
1071 let tmp = tempfile::tempdir().unwrap();
1072 let created = init_team(tmp.path(), "cleanroom", None, None, false).unwrap();
1073 assert!(!created.is_empty());
1074
1075 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1076 assert!(content.contains("decompiler"));
1077 assert!(content.contains("spec-writer"));
1078 assert!(content.contains("test-writer"));
1079 assert!(content.contains("implementer"));
1080 assert!(content.contains("batty_decompiler.md"));
1081
1082 let config_dir = team_config_dir(tmp.path());
1083 assert!(config_dir.join("batty_decompiler.md").exists());
1084 assert!(config_dir.join("batty_spec_writer.md").exists());
1085 assert!(config_dir.join("batty_test_writer.md").exists());
1086 assert!(config_dir.join("batty_implementer.md").exists());
1087 assert!(tmp.path().join("analysis").is_dir());
1088 assert!(tmp.path().join("implementation").is_dir());
1089 assert!(tmp.path().join("PARITY.md").exists());
1090 assert!(tmp.path().join("SPEC.md").exists());
1091 assert!(
1092 tmp.path()
1093 .join("planning")
1094 .join("cleanroom-process.md")
1095 .exists()
1096 );
1097 }
1098
1099 #[test]
1100 fn init_team_cleanroom_template_scaffolds_parseable_parity_report() {
1101 let tmp = tempfile::tempdir().unwrap();
1102 init_team(tmp.path(), "cleanroom", None, None, false).unwrap();
1103
1104 let report = crate::team::parity::ParityReport::load(tmp.path()).unwrap();
1105 let summary = report.summary();
1106
1107 assert_eq!(report.metadata.project, "clean-room-project");
1108 assert_eq!(report.metadata.source_platform, "zx-spectrum-z80");
1109 assert_eq!(summary.total_behaviors, 3);
1110 assert_eq!(summary.spec_complete, 0);
1111 assert_eq!(summary.verified_pass, 0);
1112 }
1113
1114 #[test]
1115 fn init_with_agent_codex_sets_backend() {
1116 let tmp = tempfile::tempdir().unwrap();
1117 let _created = init_team(tmp.path(), "simple", None, Some("codex"), false).unwrap();
1118 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1119 assert!(
1120 content.contains("agent: codex"),
1121 "all agent fields should be codex"
1122 );
1123 assert!(
1124 !content.contains("agent: claude"),
1125 "no claude agents should remain"
1126 );
1127 }
1128
1129 #[test]
1130 fn init_with_agent_kiro_sets_backend() {
1131 let tmp = tempfile::tempdir().unwrap();
1132 let _created = init_team(tmp.path(), "pair", None, Some("kiro"), false).unwrap();
1133 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1134 assert!(
1135 content.contains("agent: kiro"),
1136 "all agent fields should be kiro"
1137 );
1138 assert!(
1139 !content.contains("agent: claude"),
1140 "no claude agents should remain"
1141 );
1142 assert!(
1143 !content.contains("agent: codex"),
1144 "no codex agents should remain"
1145 );
1146 }
1147
1148 #[test]
1149 fn init_default_agent_is_claude() {
1150 let tmp = tempfile::tempdir().unwrap();
1151 let _created = init_team(tmp.path(), "simple", None, None, false).unwrap();
1152 let content = std::fs::read_to_string(team_config_path(tmp.path())).unwrap();
1153 assert!(
1154 content.contains("agent: claude"),
1155 "default agent should be claude"
1156 );
1157 }
1158
1159 #[test]
1160 #[serial]
1161 fn export_template_creates_directory_and_copies_files() {
1162 let tmp = tempfile::tempdir().unwrap();
1163 let _home = HomeGuard::set(tmp.path());
1164 let project_root = tmp.path().join("project");
1165 let config_dir = team_config_dir(&project_root);
1166 std::fs::create_dir_all(&config_dir).unwrap();
1167 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1168 std::fs::write(config_dir.join("architect.md"), "architect prompt\n").unwrap();
1169
1170 let copied = export_template(&project_root, "demo-template").unwrap();
1171 let template_dir = templates_base_dir().unwrap().join("demo-template");
1172
1173 assert_eq!(copied, 2);
1174 assert_eq!(
1175 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
1176 "name: demo\n"
1177 );
1178 assert_eq!(
1179 std::fs::read_to_string(template_dir.join("architect.md")).unwrap(),
1180 "architect prompt\n"
1181 );
1182 }
1183
1184 #[test]
1185 #[serial]
1186 fn export_template_overwrites_existing() {
1187 let tmp = tempfile::tempdir().unwrap();
1188 let _home = HomeGuard::set(tmp.path());
1189 let project_root = tmp.path().join("project");
1190 let config_dir = team_config_dir(&project_root);
1191 std::fs::create_dir_all(&config_dir).unwrap();
1192 std::fs::write(config_dir.join("team.yaml"), "name: first\n").unwrap();
1193 std::fs::write(config_dir.join("manager.md"), "v1\n").unwrap();
1194
1195 export_template(&project_root, "demo-template").unwrap();
1196
1197 std::fs::write(config_dir.join("team.yaml"), "name: second\n").unwrap();
1198 std::fs::write(config_dir.join("manager.md"), "v2\n").unwrap();
1199
1200 let copied = export_template(&project_root, "demo-template").unwrap();
1201 let template_dir = templates_base_dir().unwrap().join("demo-template");
1202
1203 assert_eq!(copied, 2);
1204 assert_eq!(
1205 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
1206 "name: second\n"
1207 );
1208 assert_eq!(
1209 std::fs::read_to_string(template_dir.join("manager.md")).unwrap(),
1210 "v2\n"
1211 );
1212 }
1213
1214 #[test]
1215 #[serial]
1216 fn export_template_missing_team_yaml_errors() {
1217 let tmp = tempfile::tempdir().unwrap();
1218 let _home = HomeGuard::set(tmp.path());
1219 let project_root = tmp.path().join("project");
1220 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
1221
1222 let error = export_template(&project_root, "demo-template").unwrap_err();
1223
1224 assert!(error.to_string().contains("team config missing"));
1225 }
1226
1227 #[test]
1228 #[serial]
1229 fn export_template_includes_cleanroom_project_assets() {
1230 let tmp = tempfile::tempdir().unwrap();
1231 let _home = HomeGuard::set(tmp.path());
1232 let project_root = tmp.path().join("project");
1233 let config_dir = team_config_dir(&project_root);
1234 std::fs::create_dir_all(&config_dir).unwrap();
1235 std::fs::create_dir_all(project_root.join("analysis")).unwrap();
1236 std::fs::create_dir_all(project_root.join("implementation")).unwrap();
1237 std::fs::create_dir_all(project_root.join("planning")).unwrap();
1238 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1239 std::fs::write(config_dir.join("batty_decompiler.md"), "prompt\n").unwrap();
1240 std::fs::write(project_root.join("PARITY.md"), "# Parity\n").unwrap();
1241 std::fs::write(project_root.join("SPEC.md"), "# Spec\n").unwrap();
1242 std::fs::write(
1243 project_root.join("planning").join("cleanroom-process.md"),
1244 "# Process\n",
1245 )
1246 .unwrap();
1247
1248 let copied = export_template(&project_root, "cleanroom-template").unwrap();
1249 let template_dir = templates_base_dir().unwrap().join("cleanroom-template");
1250 let export_root = template_dir.join(TEMPLATE_PROJECT_ROOT_DIR);
1251
1252 assert_eq!(copied, 5);
1253 assert_eq!(
1254 std::fs::read_to_string(template_dir.join("team.yaml")).unwrap(),
1255 "name: demo\n"
1256 );
1257 assert_eq!(
1258 std::fs::read_to_string(template_dir.join("batty_decompiler.md")).unwrap(),
1259 "prompt\n"
1260 );
1261 assert_eq!(
1262 std::fs::read_to_string(export_root.join("PARITY.md")).unwrap(),
1263 "# Parity\n"
1264 );
1265 assert_eq!(
1266 std::fs::read_to_string(export_root.join("SPEC.md")).unwrap(),
1267 "# Spec\n"
1268 );
1269 assert_eq!(
1270 std::fs::read_to_string(export_root.join("planning").join("cleanroom-process.md"))
1271 .unwrap(),
1272 "# Process\n"
1273 );
1274 }
1275
1276 #[test]
1277 fn export_run_copies_requested_run_state_only() {
1278 let tmp = tempfile::tempdir().unwrap();
1279 let project_root = tmp.path().join("project");
1280 let config_dir = team_config_dir(&project_root);
1281 let tasks_dir = config_dir.join("board").join("tasks");
1282 let retrospectives_dir = project_root.join(".batty").join("retrospectives");
1283 let worktree_dir = project_root
1284 .join(".batty")
1285 .join("worktrees")
1286 .join("eng-1-1")
1287 .join(".codex")
1288 .join("sessions");
1289 std::fs::create_dir_all(&tasks_dir).unwrap();
1290 std::fs::create_dir_all(&retrospectives_dir).unwrap();
1291 std::fs::create_dir_all(&worktree_dir).unwrap();
1292
1293 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1294 std::fs::write(tasks_dir.join("001-task.md"), "---\nid: 1\n---\n").unwrap();
1295 std::fs::write(
1296 team_events_path(&project_root),
1297 "{\"event\":\"daemon_started\"}\n",
1298 )
1299 .unwrap();
1300 std::fs::write(daemon_log_path(&project_root), "daemon-log\n").unwrap();
1301 std::fs::write(orchestrator_log_path(&project_root), "orchestrator-log\n").unwrap();
1302 std::fs::write(retrospectives_dir.join("retro.md"), "# Retro\n").unwrap();
1303 std::fs::write(
1304 project_root.join(".batty").join("test_timing.jsonl"),
1305 "{\"task_id\":1}\n",
1306 )
1307 .unwrap();
1308 std::fs::write(worktree_dir.join("session.jsonl"), "secret\n").unwrap();
1309
1310 let export_dir = export_run(&project_root).unwrap();
1311
1312 assert_eq!(
1313 std::fs::read_to_string(export_dir.join("team.yaml")).unwrap(),
1314 "name: demo\n"
1315 );
1316 assert_eq!(
1317 std::fs::read_to_string(export_dir.join("board").join("tasks").join("001-task.md"))
1318 .unwrap(),
1319 "---\nid: 1\n---\n"
1320 );
1321 assert_eq!(
1322 std::fs::read_to_string(export_dir.join("events.jsonl")).unwrap(),
1323 "{\"event\":\"daemon_started\"}\n"
1324 );
1325 assert_eq!(
1326 std::fs::read_to_string(export_dir.join("daemon.log")).unwrap(),
1327 "daemon-log\n"
1328 );
1329 assert_eq!(
1330 std::fs::read_to_string(export_dir.join("orchestrator.log")).unwrap(),
1331 "orchestrator-log\n"
1332 );
1333 assert_eq!(
1334 std::fs::read_to_string(export_dir.join("retrospectives").join("retro.md")).unwrap(),
1335 "# Retro\n"
1336 );
1337 assert_eq!(
1338 std::fs::read_to_string(export_dir.join("test_timing.jsonl")).unwrap(),
1339 "{\"task_id\":1}\n"
1340 );
1341 assert!(!export_dir.join("worktrees").exists());
1342 assert!(!export_dir.join(".codex").exists());
1343 assert!(!export_dir.join("sessions").exists());
1344 }
1345
1346 #[test]
1347 fn export_run_skips_missing_optional_paths() {
1348 let tmp = tempfile::tempdir().unwrap();
1349 let project_root = tmp.path().join("project");
1350 let config_dir = team_config_dir(&project_root);
1351 std::fs::create_dir_all(&config_dir).unwrap();
1352 std::fs::write(config_dir.join("team.yaml"), "name: demo\n").unwrap();
1353
1354 let export_dir = export_run(&project_root).unwrap();
1355
1356 assert!(export_dir.join("team.yaml").is_file());
1357 assert!(!export_dir.join("board").exists());
1358 assert!(!export_dir.join("events.jsonl").exists());
1359 assert!(!export_dir.join("daemon.log").exists());
1360 assert!(!export_dir.join("orchestrator.log").exists());
1361 assert!(!export_dir.join("retrospectives").exists());
1362 assert!(!export_dir.join("test_timing.jsonl").exists());
1363 }
1364
1365 #[test]
1366 fn export_run_missing_team_yaml_errors() {
1367 let tmp = tempfile::tempdir().unwrap();
1368 let project_root = tmp.path().join("project");
1369 std::fs::create_dir_all(team_config_dir(&project_root)).unwrap();
1370
1371 let error = export_run(&project_root).unwrap_err();
1372
1373 assert!(error.to_string().contains("team config missing"));
1374 }
1375
1376 #[test]
1377 fn apply_init_overrides_sets_fields() {
1378 let yaml = include_str!("templates/team_simple.yaml");
1379 let ov = InitOverrides {
1380 orchestrator_pane: Some(false),
1381 auto_dispatch: Some(true),
1382 use_worktrees: Some(false),
1383 timeout_nudges: Some(false),
1384 standups: Some(false),
1385 auto_merge_enabled: Some(true),
1386 standup_interval_secs: Some(999),
1387 stall_threshold_secs: Some(123),
1388 review_nudge_threshold_secs: Some(456),
1389 review_timeout_secs: Some(789),
1390 nudge_interval_secs: Some(555),
1391 ..Default::default()
1392 };
1393 let result = apply_init_overrides(yaml, &ov);
1394 let doc: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
1395 let map = doc.as_mapping().unwrap();
1396
1397 assert_eq!(
1398 map.get(serde_yaml::Value::String("orchestrator_pane".into()))
1399 .and_then(|v| v.as_bool()),
1400 Some(false)
1401 );
1402
1403 let board = map
1404 .get(serde_yaml::Value::String("board".into()))
1405 .unwrap()
1406 .as_mapping()
1407 .unwrap();
1408 assert_eq!(
1409 board
1410 .get(serde_yaml::Value::String("auto_dispatch".into()))
1411 .and_then(|v| v.as_bool()),
1412 Some(true)
1413 );
1414
1415 let automation = map
1416 .get(serde_yaml::Value::String("automation".into()))
1417 .unwrap()
1418 .as_mapping()
1419 .unwrap();
1420 assert_eq!(
1421 automation
1422 .get(serde_yaml::Value::String("timeout_nudges".into()))
1423 .and_then(|v| v.as_bool()),
1424 Some(false)
1425 );
1426 assert_eq!(
1427 automation
1428 .get(serde_yaml::Value::String("standups".into()))
1429 .and_then(|v| v.as_bool()),
1430 Some(false)
1431 );
1432
1433 let standup = map
1434 .get(serde_yaml::Value::String("standup".into()))
1435 .unwrap()
1436 .as_mapping()
1437 .unwrap();
1438 assert_eq!(
1439 standup
1440 .get(serde_yaml::Value::String("interval_secs".into()))
1441 .and_then(|v| v.as_u64()),
1442 Some(999)
1443 );
1444
1445 let workflow_policy = map
1446 .get(serde_yaml::Value::String("workflow_policy".into()))
1447 .unwrap()
1448 .as_mapping()
1449 .unwrap();
1450 assert_eq!(
1451 workflow_policy
1452 .get(serde_yaml::Value::String("stall_threshold_secs".into()))
1453 .and_then(|v| v.as_u64()),
1454 Some(123)
1455 );
1456 assert_eq!(
1457 workflow_policy
1458 .get(serde_yaml::Value::String(
1459 "review_nudge_threshold_secs".into()
1460 ))
1461 .and_then(|v| v.as_u64()),
1462 Some(456)
1463 );
1464 assert_eq!(
1465 workflow_policy
1466 .get(serde_yaml::Value::String("review_timeout_secs".into()))
1467 .and_then(|v| v.as_u64()),
1468 Some(789)
1469 );
1470
1471 let auto_merge = workflow_policy
1472 .get(serde_yaml::Value::String("auto_merge".into()))
1473 .unwrap()
1474 .as_mapping()
1475 .unwrap();
1476 assert_eq!(
1477 auto_merge
1478 .get(serde_yaml::Value::String("enabled".into()))
1479 .and_then(|v| v.as_bool()),
1480 Some(true)
1481 );
1482 }
1483}