Skip to main content

batty_cli/team/
init.rs

1//! Team initialization, template management, and run export.
2
3use 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
13/// Returns `~/.batty/templates/`.
14pub 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/// Overrides collected from the interactive init wizard.
20#[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
40/// Scaffold `.batty/team_config/` with default team.yaml and prompt templates.
41pub 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
51/// Scaffold with optional interactive overrides applied to the template YAML.
52pub 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    // Install prompt .md files matching the template's roles
104    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    // Initialize kanban-md board in the team config directory
194    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
293/// Apply interactive overrides to template YAML content via text replacement.
294fn 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
556/// Export the current team config as a reusable template.
557pub 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        // kanban-md creates board/ directory; fallback creates kanban.md
826        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        // Research-specific .md files installed
1008        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        // Generic files NOT installed
1016        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        // Software-specific .md files installed
1030        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        // Batty-specific .md files installed
1047        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}