Skip to main content

apm_core/
validate.rs

1use crate::config::{resolve_outcome, CompletionStrategy, Config, LocalConfig};
2use crate::ticket_fmt::Ticket;
3use crate::wrapper;
4use crate::wrapper::custom::{parse_manifest, manifest_unknown_keys};
5use anyhow::{bail, Result};
6use serde::Serialize;
7use std::collections::HashSet;
8use std::path::Path;
9
10#[derive(Debug, Serialize)]
11pub struct FieldAudit {
12    pub value: String,
13    pub source: String,
14}
15
16#[derive(Debug, Serialize)]
17pub struct TransitionAudit {
18    pub from_state: String,
19    pub to_state: String,
20    pub worker_profile: Option<String>,
21    pub agent: String,
22    pub role: String,
23    pub wrapper: String,
24}
25
26/// Return the completion strategy configured for the `in_progress → implemented`
27/// transition.  Falls back to `None` when the transition is absent.
28pub fn active_completion_strategy(config: &Config) -> CompletionStrategy {
29    config.workflow.states.iter()
30        .find(|s| s.id == "in_progress")
31        .and_then(|s| s.transitions.iter().find(|t| t.to == "implemented"))
32        .map(|t| t.completion.clone())
33        .unwrap_or(CompletionStrategy::None)
34}
35
36fn strategy_name(strategy: &CompletionStrategy) -> &'static str {
37    match strategy {
38        CompletionStrategy::Pr => "pr",
39        CompletionStrategy::Merge => "merge",
40        CompletionStrategy::Pull => "pull",
41        CompletionStrategy::PrOrEpicMerge => "pr_or_epic_merge",
42        CompletionStrategy::None => "none",
43    }
44}
45
46/// Validate that `dep_ids` satisfy the dependency rules for `strategy`.
47///
48/// - `ticket_epic`: epic ID of the ticket being written (None if no epic)
49/// - `ticket_target_branch`: target_branch of the ticket (None = default branch)
50/// - `dep_ids`: the proposed dependency list (empty slice → always Ok)
51/// - `all_tickets`: all known tickets (used to look up dep metadata)
52/// - `default_branch`: project default branch name
53pub fn check_depends_on_rules(
54    strategy: &CompletionStrategy,
55    ticket_epic: Option<&str>,
56    ticket_target_branch: Option<&str>,
57    dep_ids: &[String],
58    all_tickets: &[crate::ticket_fmt::Ticket],
59    default_branch: &str,
60) -> Result<()> {
61    if dep_ids.is_empty() {
62        return Ok(());
63    }
64    match strategy {
65        CompletionStrategy::Pr | CompletionStrategy::None | CompletionStrategy::Pull => {
66            bail!(
67                "depends_on is not allowed under the {} completion strategy",
68                strategy_name(strategy)
69            );
70        }
71        CompletionStrategy::PrOrEpicMerge => {
72            if let Some(epic) = ticket_epic {
73                // Epic ticket: all deps must belong to the same epic
74                let mut offending: Vec<&str> = Vec::new();
75                for dep_id in dep_ids {
76                    let dep = all_tickets.iter().find(|t| t.frontmatter.id == *dep_id)
77                        .ok_or_else(|| anyhow::anyhow!("dep {dep_id} not found"))?;
78                    if dep.frontmatter.epic.as_deref() != Some(epic) {
79                        offending.push(dep_id.as_str());
80                    }
81                }
82                if !offending.is_empty() {
83                    bail!(
84                        "pr_or_epic_merge requires all deps to share epic {epic}; offending deps: {}",
85                        offending.join(", ")
86                    );
87                }
88            }
89            // Standalone ticket (no epic): allowed, will use the PR path independently
90        }
91        CompletionStrategy::Merge => {
92            let ticket_target = ticket_target_branch.unwrap_or(default_branch);
93            let mut offending: Vec<&str> = Vec::new();
94            for dep_id in dep_ids {
95                let dep = all_tickets.iter().find(|t| t.frontmatter.id == *dep_id)
96                    .ok_or_else(|| anyhow::anyhow!("dep {dep_id} not found"))?;
97                let dep_target = dep.frontmatter.target_branch.as_deref().unwrap_or(default_branch);
98                if dep_target != ticket_target {
99                    offending.push(dep_id.as_str());
100                }
101            }
102            if !offending.is_empty() {
103                bail!(
104                    "merge requires all deps to share target_branch {ticket_target}; offending deps: {}",
105                    offending.join(", ")
106                );
107            }
108        }
109    }
110    Ok(())
111}
112
113/// Walk every non-closed ticket and return a vec of `(subject, message)` pairs
114/// for each ticket whose `depends_on` violates the active completion strategy rule.
115pub fn validate_depends_on(config: &Config, tickets: &[Ticket]) -> Vec<(String, String)> {
116    let strategy = active_completion_strategy(config);
117    let mut violations: Vec<(String, String)> = Vec::new();
118    for ticket in tickets {
119        let fm = &ticket.frontmatter;
120        if fm.state == "closed" {
121            continue;
122        }
123        let dep_ids = match &fm.depends_on {
124            Some(deps) if !deps.is_empty() => deps,
125            _ => continue,
126        };
127        if let Err(e) = check_depends_on_rules(
128            &strategy,
129            fm.epic.as_deref(),
130            fm.target_branch.as_deref(),
131            dep_ids,
132            tickets,
133            &config.project.default_branch,
134        ) {
135            violations.push((format!("#{}", fm.id), e.to_string()));
136        }
137    }
138    violations
139}
140
141/// Return the set of agent names configured — the agent from `[workers].default`
142/// plus the agent part of every `worker_profile` on spawn transitions.
143pub fn configured_agent_names(config: &Config) -> HashSet<String> {
144    let mut names: HashSet<String> = HashSet::new();
145    let primary = config.workers.default.as_deref()
146        .and_then(|s| s.split_once('/').map(|(a, _)| a.to_string()))
147        .unwrap_or_else(|| "claude".to_string());
148    names.insert(primary);
149    for state in &config.workflow.states {
150        for transition in &state.transitions {
151            if let Some(ref wp) = transition.worker_profile {
152                if let Some((agent, _)) = wp.split_once('/') {
153                    names.insert(agent.to_string());
154                }
155            }
156        }
157    }
158    names
159}
160
161/// Validate that `name` matches an agent configured in config.toml. Accepts
162/// `"-"` (the sentinel used to clear the field) without error.
163pub fn validate_agent_name(config: &Config, name: &str) -> Result<()> {
164    if name == "-" {
165        return Ok(());
166    }
167    let configured = configured_agent_names(config);
168    if configured.contains(name) {
169        return Ok(());
170    }
171    let mut sorted: Vec<&String> = configured.iter().collect();
172    sorted.sort();
173    let list = sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
174    bail!("agent {name:?} is not configured in config.toml; known agents: [{list}]")
175}
176
177pub fn validate_owner(config: &Config, local: &LocalConfig, username: &str) -> Result<()> {
178    if username == "-" {
179        return Ok(());
180    }
181    let (collaborators, warnings) = crate::config::resolve_collaborators(config, local);
182    for w in &warnings {
183        #[allow(clippy::print_stderr)]
184        { eprintln!("{w}"); }
185    }
186    if collaborators.is_empty() {
187        return Ok(());
188    }
189    if collaborators.iter().any(|c| c == username) {
190        return Ok(());
191    }
192    let list = collaborators.join(", ");
193    bail!("unknown user '{username}'; valid collaborators: {list}");
194}
195
196fn is_external_worktree(dir: &Path) -> bool {
197    let s = dir.to_string_lossy();
198    s.starts_with('/') || s.starts_with("..")
199}
200
201fn gitignore_covers_dir(content: &str, dir: &str) -> bool {
202    let normalized_dir = dir.trim_matches('/');
203    content
204        .lines()
205        .map(|line| line.trim())
206        .filter(|line| !line.is_empty() && !line.starts_with('#'))
207        .any(|line| line.trim_matches('/') == normalized_dir)
208}
209
210/// Layer 1 of the two-layer manifest validation design.
211/// Validates all configured agent names and scans `.apm/agents/` for issues.
212/// Returns (errors, warnings) so callers can route them — keeps this single
213/// directory scan from running twice when both validate_config and
214/// validate_warnings are invoked.
215pub fn validate_agents(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
216    let mut errors: Vec<String> = Vec::new();
217    let mut warnings: Vec<String> = Vec::new();
218    validate_agents_into(config, root, &mut errors, &mut warnings);
219    (errors, warnings)
220}
221
222fn validate_agents_into(config: &Config, root: &Path, errors: &mut Vec<String>, warnings: &mut Vec<String>) {
223    let names = configured_agent_names(config);
224
225    // Validate each configured agent name (Layer 1 error check)
226    let builtins = wrapper::list_builtin_names().join(", ");
227    for name in &names {
228        match wrapper::resolve_wrapper(root, name) {
229            Ok(None) => errors.push(format!(
230                "agent '{}' not found: checked built-ins {{{builtins}}} and '.apm/agents/{}/'",
231                name, name
232            )),
233            Err(e) => errors.push(format!("agent '{name}': {e}")),
234            Ok(Some(wrapper::WrapperKind::Custom { manifest, .. })) => {
235                if let Some(m) = &manifest {
236                    if m.parser == "external" && m.parser_command.is_none() {
237                        errors.push(format!(
238                            "agent '{name}': manifest.toml declares parser = \"external\" \
239                             but parser_command is absent"
240                        ));
241                    }
242                }
243            }
244            Ok(Some(wrapper::WrapperKind::Builtin(_))) => {}
245        }
246    }
247
248    // Scan .apm/agents/ for per-directory warnings and errors
249    let agents_dir = root.join(".apm").join("agents");
250    let Ok(entries) = std::fs::read_dir(&agents_dir) else { return };
251
252    for entry in entries.filter_map(|e| e.ok()) {
253        let ft = match entry.file_type() {
254            Ok(ft) => ft,
255            Err(_) => continue,
256        };
257        if !ft.is_dir() {
258            continue;
259        }
260        let name = entry.file_name().to_string_lossy().to_string();
261
262        // Check for non-executable wrapper.* files (Unix only)
263        let wrapper_files: Vec<_> = std::fs::read_dir(entry.path())
264            .ok()
265            .into_iter()
266            .flatten()
267            .filter_map(|e| e.ok())
268            .filter(|e| e.file_name().to_string_lossy().starts_with("wrapper."))
269            .collect();
270
271        if !wrapper_files.is_empty() {
272            #[cfg(unix)]
273            {
274                use std::os::unix::fs::PermissionsExt;
275                let any_exec = wrapper_files.iter().any(|f| {
276                    f.metadata()
277                        .map(|m| m.permissions().mode() & 0o111 != 0)
278                        .unwrap_or(false)
279                });
280                if !any_exec {
281                    warnings.push(format!(
282                        "agent '{name}': .apm/agents/{name}/wrapper.* exists but is not executable; run chmod +x"
283                    ));
284                }
285            }
286        }
287
288        // Check manifest.toml
289        let manifest_path = entry.path().join("manifest.toml");
290        if manifest_path.exists() {
291            match parse_manifest(root, &name) {
292                Err(e) => {
293                    errors.push(format!("agent '{name}': manifest.toml is not valid TOML: {e}"));
294                }
295                Ok(Some(manifest)) => {
296                    if manifest.contract_version > 1 {
297                        errors.push(format!(
298                            "agent '{name}': manifest.toml declares contract_version {}; \
299                             this APM build supports version 1 only — upgrade APM",
300                            manifest.contract_version
301                        ));
302                    }
303                    if let Ok(unknown) = manifest_unknown_keys(root, &name) {
304                        for key in unknown {
305                            warnings.push(format!(
306                                "agent '{name}': manifest.toml: unknown key {key}"
307                            ));
308                        }
309                    }
310                }
311                Ok(None) => {}
312            }
313        }
314    }
315}
316
317pub fn validate_config(config: &Config, root: &Path) -> Vec<String> {
318    let mut errors = validate_config_no_agents(config, root);
319    let (agent_errors, _) = validate_agents(config, root);
320    errors.extend(agent_errors);
321    errors
322}
323
324fn validate_config_no_agents(config: &Config, root: &Path) -> Vec<String> {
325    let mut errors: Vec<String> = Vec::new();
326
327    let state_ids: HashSet<&str> = config.workflow.states.iter()
328        .map(|s| s.id.as_str())
329        .collect();
330
331    let terminal_ids = config.terminal_state_ids();
332
333    let section_names: HashSet<&str> = config.ticket.sections.iter()
334        .map(|s| s.name.as_str())
335        .collect();
336    let has_sections = !section_names.is_empty();
337
338    // Check whether any transition requires a provider.
339    let needs_provider = config.workflow.states.iter()
340        .flat_map(|s| s.transitions.iter())
341        .any(|t| matches!(t.completion, CompletionStrategy::Pr | CompletionStrategy::Merge));
342
343    let provider_ok = config.git_host.provider.as_ref()
344        .map(|p| !p.is_empty())
345        .unwrap_or(false);
346
347    if needs_provider && !provider_ok {
348        errors.push(
349            "config: workflow — completion 'pr' or 'merge' requires [git_host] with a provider".into()
350        );
351    }
352
353    // At least one non-terminal state.
354    let has_non_terminal = config.workflow.states.iter().any(|s| !s.terminal);
355    if !has_non_terminal {
356        errors.push("config: workflow — no non-terminal state exists".into());
357    }
358
359    for state in &config.workflow.states {
360        // Terminal state with outgoing transitions.
361        if state.terminal && !state.transitions.is_empty() {
362            errors.push(format!(
363                "config: state.{} — terminal but has {} outgoing transition(s)",
364                state.id,
365                state.transitions.len()
366            ));
367        }
368
369        // Non-terminal state with no outgoing transitions (tickets will be stranded).
370        if !state.terminal && state.transitions.is_empty() {
371            errors.push(format!(
372                "config: state.{} — no outgoing transitions (tickets will be stranded)",
373                state.id
374            ));
375        }
376
377        for transition in &state.transitions {
378            // Transition target must exist.  "closed" is a built-in terminal state
379            // that is always valid even when absent from [[workflow.states]].
380            if transition.to != "closed" && !state_ids.contains(transition.to.as_str()) {
381                errors.push(format!(
382                    "config: state.{}.transition({}) — target state '{}' does not exist",
383                    state.id, transition.to, transition.to
384                ));
385            }
386
387            // context_section must match a known ticket section.
388            if let Some(section) = &transition.context_section {
389                if has_sections && !section_names.contains(section.as_str()) {
390                    errors.push(format!(
391                        "config: state.{}.transition({}).context_section — unknown section '{}'",
392                        state.id, transition.to, section
393                    ));
394                }
395            }
396
397            // focus_section must match a known ticket section.
398            if let Some(section) = &transition.focus_section {
399                if has_sections && !section_names.contains(section.as_str()) {
400                    errors.push(format!(
401                        "config: state.{}.transition({}).focus_section — unknown section '{}'",
402                        state.id, transition.to, section
403                    ));
404                }
405            }
406
407            // Merge/PrOrEpicMerge transitions require on_failure.
408            if matches!(
409                transition.completion,
410                CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
411            ) {
412                if transition.on_failure.is_none() {
413                    errors.push(format!(
414                        "config: transition '{}' → '{}' uses completion '{}' but is missing \
415                         `on_failure`; run `apm validate --fix` to add it",
416                        state.id,
417                        transition.to,
418                        strategy_name(&transition.completion)
419                    ));
420                } else if let Some(ref name) = transition.on_failure {
421                    if name != "closed" && !state_ids.contains(name.as_str()) {
422                        errors.push(format!(
423                            "config: transition '{}' → '{}' has `on_failure = \"{}\"` but \
424                             state \"{}\" is not declared in workflow.toml",
425                            state.id, transition.to, name, name
426                        ));
427                    }
428                }
429            }
430
431            // Merging completions must not target a terminal state.
432            if matches!(
433                transition.completion,
434                CompletionStrategy::Pr | CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
435            ) && terminal_ids.contains(transition.to.as_str()) {
436                errors.push(format!(
437                    "config: state.{}.transition({}) — completion {} targets terminal state {}; \
438                     merging completions must target a non-terminal (review) state",
439                    state.id,
440                    transition.to,
441                    strategy_name(&transition.completion),
442                    transition.to
443                ));
444            }
445        }
446    }
447
448    if !is_external_worktree(&config.worktrees.dir) {
449        let dir_str = config.worktrees.dir.to_string_lossy();
450        let gitignore = root.join(".gitignore");
451        match std::fs::read_to_string(&gitignore) {
452            Err(_) => errors.push(format!(
453                "config: worktrees.dir '{dir_str}' is in-repo but .gitignore is missing; \
454                 run 'apm init' or add '/{dir_str}/' manually"
455            )),
456            Ok(content) if !gitignore_covers_dir(&content, &dir_str) => errors.push(format!(
457                "config: worktrees.dir '{dir_str}' is in-repo but .gitignore does not cover it; \
458                 add '/{dir_str}/' or run 'apm init'"
459            )),
460            Ok(_) => {}
461        }
462    }
463
464    errors
465}
466
467pub fn verify_tickets(
468    root: &Path,
469    config: &Config,
470    tickets: &[Ticket],
471    merged: &HashSet<String>,
472) -> Vec<String> {
473    let valid_states: HashSet<&str> = config.workflow.states.iter()
474        .map(|s| s.id.as_str())
475        .collect();
476    let terminal = config.terminal_state_ids();
477
478    let in_progress_states: HashSet<&str> =
479        ["in_progress", "implemented"].iter().copied().collect();
480
481    let worktree_states: HashSet<&str> =
482        ["in_design", "in_progress"].iter().copied().collect();
483    let main_root = crate::git_util::main_worktree_root(root)
484        .unwrap_or_else(|| root.to_path_buf());
485    let worktrees_base = main_root.join(&config.worktrees.dir);
486
487    let mut issues: Vec<String> = Vec::new();
488
489    for t in tickets {
490        let fm = &t.frontmatter;
491
492        // Skip terminal-state tickets.
493        if terminal.contains(fm.state.as_str()) { continue; }
494
495        let prefix = format!("#{} [{}]", fm.id, fm.state);
496
497        // State value not in config.
498        if !valid_states.is_empty() && !valid_states.contains(fm.state.as_str()) {
499            issues.push(format!("{prefix}: unknown state {:?}", fm.state));
500        }
501
502        // Frontmatter id doesn't match filename numeric prefix.
503        if let Some(name) = t.path.file_name().and_then(|n| n.to_str()) {
504            let expected_prefix = format!("{:04}", fm.id);
505            if !name.starts_with(&expected_prefix) {
506                issues.push(format!("{prefix}: id {} does not match filename {name}", fm.id));
507            }
508        }
509
510        // in_progress/implemented with no branch.
511        if in_progress_states.contains(fm.state.as_str()) && fm.branch.is_none() {
512            issues.push(format!("{prefix}: state requires branch but none set"));
513        }
514
515        // Branch merged but ticket not yet closed.
516        if let Some(branch) = &fm.branch {
517            if (fm.state == "in_progress" || fm.state == "implemented")
518                && merged.contains(branch.as_str())
519            {
520                issues.push(format!("{prefix}: branch {branch} is merged but ticket not closed"));
521            }
522        }
523
524        // in_design/in_progress with missing worktree directory.
525        if worktree_states.contains(fm.state.as_str()) {
526            if let Some(branch) = &fm.branch {
527                let wt_name = branch.replace('/', "-");
528                let wt_path = worktrees_base.join(&wt_name);
529                if !wt_path.is_dir() {
530                    issues.push(format!(
531                        "{prefix}: worktree at {} is missing",
532                        wt_path.display()
533                    ));
534                }
535            }
536        }
537
538        // Missing ## Spec section.
539        if !t.body.contains("## Spec") {
540            issues.push(format!("{prefix}: missing ## Spec section"));
541        }
542
543        // Missing ## History section.
544        if !t.body.contains("## History") {
545            issues.push(format!("{prefix}: missing ## History section"));
546        }
547
548        // Validate document structure (required sections non-empty, AC items present).
549        if let Ok(doc) = t.document() {
550            for err in doc.validate(&config.ticket.sections) {
551                issues.push(format!("{prefix}: {err}"));
552            }
553        }
554
555        // Validate frontmatter agent names against known built-ins and
556        // against the agents configured in config.toml.
557        let agents_to_check: Vec<&str> = fm.agent
558            .as_deref()
559            .into_iter()
560            .chain(fm.agent_overrides.values().map(String::as_str))
561            .collect();
562
563        let configured_agents = configured_agent_names(config);
564
565        for name in agents_to_check {
566            match wrapper::resolve_wrapper(root, name) {
567                Ok(Some(_)) => {}
568                Ok(None) => issues.push(format!(
569                    "ticket {}: agent {:?} is not a known built-in",
570                    fm.id, name
571                )),
572                Err(e) => issues.push(format!(
573                    "ticket {}: agent {:?}: {e}",
574                    fm.id, name
575                )),
576            }
577            if !configured_agents.contains(name) {
578                issues.push(format!(
579                    "ticket {}: agent {:?} is not configured in config.toml \
580                     (add a worker_profile = \"<agent>/...\" on a spawn transition)",
581                    fm.id, name
582                ));
583            }
584        }
585    }
586
587    // Branch ↔ filename invariant: apm derives the ticket file path from the
588    // branch suffix (`ticket/<suffix>` → `tickets/<suffix>.md`). When a worker
589    // agent renames the file, `load_all_from_git` silently drops the ticket
590    // and it disappears from `apm list`. Catch it here by walking the
591    // ticket branches directly rather than the loaded list.
592    issues.extend(verify_branch_file_invariant(root, config));
593
594    issues
595}
596
597/// Walk every `ticket/*` branch and report file-layout problems that
598/// would otherwise make the ticket invisible to `apm list`.
599fn verify_branch_file_invariant(root: &Path, config: &Config) -> Vec<String> {
600    let mut issues: Vec<String> = Vec::new();
601    let tickets_dir = config.tickets.dir.to_string_lossy().to_string();
602    let branches = match crate::git_util::ticket_branches(root) {
603        Ok(b) => b,
604        Err(_) => return issues,
605    };
606    for branch in &branches {
607        let suffix = branch.trim_start_matches("ticket/");
608        // Skip bare 8-hex refs (created transiently by fetch); they aren't
609        // real ticket branches.
610        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
611            continue;
612        }
613        let expected_filename = format!("{suffix}.md");
614        let expected_path = format!("{tickets_dir}/{expected_filename}");
615
616        // If the expected file exists on the branch, this branch is fine.
617        if crate::git_util::read_from_branch(root, branch, &expected_path).is_ok() {
618            continue;
619        }
620
621        // Otherwise, look for any `tickets/<id>-*.md` on the branch so we
622        // can distinguish a rename from an orphan.
623        let id_prefix: String = suffix.chars().take_while(|c| *c != '-').collect();
624        let files = crate::git_util::list_files_on_branch(root, branch, &tickets_dir)
625            .unwrap_or_default();
626        let id_matches: Vec<&String> = files.iter()
627            .filter(|f| {
628                let leaf = f.rsplit('/').next().unwrap_or("");
629                leaf.starts_with(&format!("{id_prefix}-")) && leaf.ends_with(".md")
630            })
631            .collect();
632
633        if id_matches.is_empty() {
634            issues.push(format!(
635                "branch {branch}: no ticket file at {expected_path} (orphaned branch — \
636                 no tickets/{id_prefix}-*.md exists on this branch)"
637            ));
638        } else {
639            let found: Vec<String> = id_matches.iter().map(|s| (*s).clone()).collect();
640            issues.push(format!(
641                "branch {branch}: ticket file renamed — expected {expected_path}, \
642                 found {} on branch. apm derives the filename from the branch \
643                 suffix; rename the file back (or rename the branch) so it matches.",
644                found.join(", ")
645            ));
646        }
647    }
648    issues
649}
650
651pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec<String> {
652    let mut warnings = validate_warnings_no_agents(config, root);
653    let (_, agent_warnings) = validate_agents(config, root);
654    warnings.extend(agent_warnings);
655    warnings
656}
657
658fn validate_warnings_no_agents(config: &crate::config::Config, _root: &Path) -> Vec<String> {
659    let mut warnings = config.load_warnings.clone();
660    if let Some(container) = &config.workers.container {
661        if !container.is_empty() {
662            let docker_ok = std::process::Command::new("docker")
663                .arg("--version")
664                .output()
665                .map(|o| o.status.success())
666                .unwrap_or(false);
667            if !docker_ok {
668                warnings.push(
669                    "workers.container is set but 'docker' is not in PATH".to_string()
670                );
671            }
672        }
673    }
674
675    // Dead-end reachability check: warn when no agent-actionable state can reach a
676    // transition whose outcome resolves to "success".
677    let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
678        config.workflow.states.iter()
679            .map(|s| (s.id.as_str(), s))
680            .collect();
681
682    let agent_startable: Vec<&str> = config.workflow.states.iter()
683        .filter(|s| s.actionable.iter().any(|a| a == "agent" || a == "any"))
684        .map(|s| s.id.as_str())
685        .collect();
686
687    if !agent_startable.is_empty() {
688        let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
689        let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
690        let mut found_success = false;
691
692        for &start in &agent_startable {
693            if visited.insert(start) {
694                queue.push_back(start);
695            }
696        }
697
698        'bfs: while let Some(state_id) = queue.pop_front() {
699            let Some(state) = state_map.get(state_id) else { continue };
700            for t in &state.transitions {
701                // Unknown targets are reported as errors by validate_config; skip
702                // here so reachability isn't computed against malformed transitions.
703                let Some(&target) = state_map.get(t.to.as_str()) else { continue };
704                if resolve_outcome(t, target) == "success" {
705                    found_success = true;
706                    break 'bfs;
707                }
708                if !target.terminal && visited.insert(t.to.as_str()) {
709                    queue.push_back(t.to.as_str());
710                }
711            }
712        }
713
714        if !found_success {
715            warnings.push(
716                "workflow has no reachable 'success' outcome from any agent-actionable state; \
717                 workers may never complete successfully".to_string()
718            );
719        }
720    }
721
722    warnings
723}
724
725fn format_wrapper(root: &Path, agent: &str) -> String {
726    match wrapper::resolve_wrapper(root, agent) {
727        Ok(Some(wrapper::WrapperKind::Builtin(ref name))) => format!("builtin:{name}"),
728        Ok(Some(wrapper::WrapperKind::Custom { ref script_path, .. })) => {
729            script_path.to_string_lossy().into_owned()
730        }
731        Ok(None) => "(not found)".to_string(),
732        Err(_) => "(error)".to_string(),
733    }
734}
735
736/// Build an agent-resolution audit for every `command:start` spawn transition in the config.
737pub fn audit_agent_resolution(config: &Config, root: &Path) -> Vec<TransitionAudit> {
738    let mut result = Vec::new();
739    let default_profile = config.workers.default.as_deref().unwrap_or("claude/coder");
740
741    for state in &config.workflow.states {
742        for transition in &state.transitions {
743            if transition.trigger != "command:start" {
744                continue;
745            }
746
747            let wp_str = transition.worker_profile.as_deref().unwrap_or(default_profile);
748            let (agent, role) = wp_str.split_once('/')
749                .map(|(a, r)| (a.to_string(), r.to_string()))
750                .unwrap_or_else(|| ("claude".to_string(), "worker".to_string()));
751
752            let wrapper_str = format_wrapper(root, &agent);
753
754            result.push(TransitionAudit {
755                from_state: state.id.clone(),
756                to_state: transition.to.clone(),
757                worker_profile: transition.worker_profile.clone(),
758                agent,
759                role,
760                wrapper: wrapper_str,
761            });
762        }
763    }
764
765    result
766}
767
768/// Single-pass equivalent of calling `validate_config` followed by
769/// `validate_warnings` — runs the agent-directory scan once and dedupes.
770pub fn validate_all(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
771    let mut errors = validate_config_no_agents(config, root);
772    let mut warnings = validate_warnings_no_agents(config, root);
773    let (agent_errors, agent_warnings) = validate_agents(config, root);
774    errors.extend(agent_errors);
775    warnings.extend(agent_warnings);
776    (errors, warnings)
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use crate::config::{Config, CompletionStrategy, LocalConfig};
783    use crate::ticket::Ticket;
784    use crate::git_util;
785    use std::path::Path;
786    use std::collections::HashSet;
787
788    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
789        std::process::Command::new("git")
790            .args(args)
791            .current_dir(dir)
792            .env("GIT_AUTHOR_NAME", "test")
793            .env("GIT_AUTHOR_EMAIL", "test@test.com")
794            .env("GIT_COMMITTER_NAME", "test")
795            .env("GIT_COMMITTER_EMAIL", "test@test.com")
796            .status()
797            .unwrap();
798    }
799
800    fn setup_verify_repo() -> tempfile::TempDir {
801        let dir = tempfile::tempdir().unwrap();
802        let p = dir.path();
803
804        git_cmd(p, &["init", "-q", "-b", "main"]);
805        git_cmd(p, &["config", "user.email", "test@test.com"]);
806        git_cmd(p, &["config", "user.name", "test"]);
807
808        std::fs::create_dir_all(p.join(".apm")).unwrap();
809        std::fs::write(
810            p.join(".apm/config.toml"),
811            r#"[project]
812name = "test"
813
814[tickets]
815dir = "tickets"
816
817[worktrees]
818dir = "worktrees"
819
820[[workflow.states]]
821id = "in_design"
822label = "In Design"
823
824[[workflow.states]]
825id = "in_progress"
826label = "In Progress"
827
828[[workflow.states]]
829id = "specd"
830label = "Specd"
831"#,
832        )
833        .unwrap();
834
835        git_cmd(p, &["add", ".apm/config.toml"]);
836        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
837
838        dir
839    }
840
841    fn make_verify_ticket(root: &std::path::Path, id: &str, state: &str, branch: Option<&str>) -> Ticket {
842        let branch_line = match branch {
843            Some(b) => format!("branch = \"{b}\"\n"),
844            None => String::new(),
845        };
846        let raw = format!(
847            "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{branch_line}+++\n\n## Spec\n\n## History\n"
848        );
849        let path = root.join("tickets").join(format!("{id}-test.md"));
850        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
851        std::fs::write(&path, &raw).unwrap();
852        Ticket::parse(&path, &raw).unwrap()
853    }
854
855    fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
856        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
857        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
858        let raw = format!(
859            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
860        );
861        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
862    }
863
864    fn strategy_config(completion: &str) -> Config {
865        let toml = format!(
866            r#"
867[project]
868name = "test"
869
870[tickets]
871dir = "tickets"
872
873[[workflow.states]]
874id    = "in_progress"
875label = "In Progress"
876
877[[workflow.states.transitions]]
878to         = "implemented"
879completion = "{completion}"
880
881[[workflow.states]]
882id       = "implemented"
883label    = "Implemented"
884terminal = true
885"#
886        );
887        toml::from_str(&toml).unwrap()
888    }
889
890    #[test]
891    fn strategy_finds_in_progress_to_implemented() {
892        let config = strategy_config("pr_or_epic_merge");
893        assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
894    }
895
896    #[test]
897    fn strategy_defaults_to_none_when_absent() {
898        let toml = r#"
899[project]
900name = "test"
901
902[tickets]
903dir = "tickets"
904
905[[workflow.states]]
906id    = "new"
907label = "New"
908
909[[workflow.states.transitions]]
910to = "closed"
911
912[[workflow.states]]
913id       = "closed"
914label    = "Closed"
915terminal = true
916"#;
917        let config: Config = toml::from_str(toml).unwrap();
918        assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
919    }
920
921    #[test]
922    fn dep_rules_pr_rejects_dep() {
923        let dep = make_ticket("dep1", None, None);
924        let result = check_depends_on_rules(
925            &CompletionStrategy::Pr,
926            None,
927            None,
928            &["dep1".to_string()],
929            &[dep],
930            "main",
931        );
932        assert!(result.is_err());
933        let msg = result.unwrap_err().to_string();
934        assert!(msg.contains("pr"), "expected strategy name in: {msg}");
935    }
936
937    #[test]
938    fn dep_rules_none_rejects_dep() {
939        let dep = make_ticket("dep1", None, None);
940        let result = check_depends_on_rules(
941            &CompletionStrategy::None,
942            None,
943            None,
944            &["dep1".to_string()],
945            &[dep],
946            "main",
947        );
948        assert!(result.is_err());
949        let msg = result.unwrap_err().to_string();
950        assert!(msg.contains("none"), "expected strategy name in: {msg}");
951    }
952
953    #[test]
954    fn dep_rules_pr_or_epic_merge_same_epic_ok() {
955        let dep = make_ticket("dep1", Some("abc"), None);
956        let result = check_depends_on_rules(
957            &CompletionStrategy::PrOrEpicMerge,
958            Some("abc"),
959            None,
960            &["dep1".to_string()],
961            &[dep],
962            "main",
963        );
964        assert!(result.is_ok(), "expected Ok, got {result:?}");
965    }
966
967    #[test]
968    fn dep_rules_pr_or_epic_merge_different_epic_fails() {
969        let dep = make_ticket("dep1", Some("xyz"), None);
970        let result = check_depends_on_rules(
971            &CompletionStrategy::PrOrEpicMerge,
972            Some("abc"),
973            None,
974            &["dep1".to_string()],
975            &[dep],
976            "main",
977        );
978        assert!(result.is_err());
979        let msg = result.unwrap_err().to_string();
980        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
981    }
982
983    #[test]
984    fn dep_rules_pr_or_epic_merge_standalone_ticket_ok() {
985        // Standalone ticket (no epic) may have depends_on under pr_or_epic_merge;
986        // it will use the independent PR path and depends_on still enforces ordering.
987        let dep = make_ticket("dep1", Some("abc"), None);
988        let result = check_depends_on_rules(
989            &CompletionStrategy::PrOrEpicMerge,
990            None,
991            None,
992            &["dep1".to_string()],
993            &[dep],
994            "main",
995        );
996        assert!(result.is_ok(), "expected Ok for standalone ticket, got {result:?}");
997    }
998
999    #[test]
1000    fn dep_rules_merge_both_default_branch_ok() {
1001        let dep = make_ticket("dep1", None, None);
1002        let result = check_depends_on_rules(
1003            &CompletionStrategy::Merge,
1004            None,
1005            None,
1006            &["dep1".to_string()],
1007            &[dep],
1008            "main",
1009        );
1010        assert!(result.is_ok(), "expected Ok, got {result:?}");
1011    }
1012
1013    #[test]
1014    fn dep_rules_merge_different_target_fails() {
1015        let dep = make_ticket("dep1", None, Some("epic/other"));
1016        let result = check_depends_on_rules(
1017            &CompletionStrategy::Merge,
1018            None,
1019            None,
1020            &["dep1".to_string()],
1021            &[dep],
1022            "main",
1023        );
1024        assert!(result.is_err());
1025        let msg = result.unwrap_err().to_string();
1026        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1027    }
1028
1029    fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
1030        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1031        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
1032        let deps_line = if depends_on.is_empty() {
1033            String::new()
1034        } else {
1035            let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
1036            format!("depends_on = [{}]\n", quoted.join(", "))
1037        };
1038        let raw = format!(
1039            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
1040        );
1041        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
1042    }
1043
1044    #[test]
1045    fn validate_depends_on_no_deps_clean() {
1046        let config = strategy_config("pr_or_epic_merge");
1047        let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
1048        let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
1049        let result = validate_depends_on(&config, &[t1, t2]);
1050        assert!(result.is_empty(), "expected no violations, got {result:?}");
1051    }
1052
1053    #[test]
1054    fn validate_depends_on_closed_ticket_skipped() {
1055        let config = strategy_config("pr");
1056        let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
1057        let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
1058        let result = validate_depends_on(&config, &[dep, ticket]);
1059        assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
1060    }
1061
1062    #[test]
1063    fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
1064        let config = strategy_config("pr_or_epic_merge");
1065        let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
1066        let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
1067        let result = validate_depends_on(&config, &[dep, ticket]);
1068        assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
1069    }
1070
1071    #[test]
1072    fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
1073        let config = strategy_config("pr_or_epic_merge");
1074        let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
1075        let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
1076        let result = validate_depends_on(&config, &[dep, ticket]);
1077        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1078        assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
1079    }
1080
1081    #[test]
1082    fn validate_depends_on_merge_same_target_ok() {
1083        let config = strategy_config("merge");
1084        let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
1085        let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
1086        let result = validate_depends_on(&config, &[dep, ticket]);
1087        assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
1088    }
1089
1090    #[test]
1091    fn validate_depends_on_merge_different_target_fails() {
1092        let config = strategy_config("merge");
1093        let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
1094        let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
1095        let result = validate_depends_on(&config, &[dep, ticket]);
1096        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1097        assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
1098    }
1099
1100    #[test]
1101    fn validate_depends_on_pr_strategy_rejects_any_dep() {
1102        let config = strategy_config("pr");
1103        let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
1104        let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
1105        let result = validate_depends_on(&config, &[dep, ticket]);
1106        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1107        assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
1108    }
1109
1110    fn load_config(toml: &str) -> Config {
1111        toml::from_str(toml).expect("config parse failed")
1112    }
1113
1114    fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
1115        config.workflow.states.iter().map(|s| s.id.as_str()).collect()
1116    }
1117
1118    // Test 1: correct config passes all checks
1119    #[test]
1120    fn correct_config_passes() {
1121        let toml = r#"
1122[project]
1123name = "test"
1124
1125[tickets]
1126dir = "tickets"
1127
1128[[workflow.states]]
1129id    = "new"
1130label = "New"
1131
1132[[workflow.states.transitions]]
1133to = "in_progress"
1134
1135[[workflow.states]]
1136id       = "in_progress"
1137label    = "In Progress"
1138terminal = false
1139
1140[[workflow.states.transitions]]
1141to = "closed"
1142
1143[[workflow.states]]
1144id       = "closed"
1145label    = "Closed"
1146terminal = true
1147"#;
1148        let config = load_config(toml);
1149        let errors = validate_config(&config, Path::new("/tmp"));
1150        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1151    }
1152
1153    // Test 2: transition to non-existent state is detected
1154    #[test]
1155    fn transition_to_nonexistent_state_detected() {
1156        let toml = r#"
1157[project]
1158name = "test"
1159
1160[tickets]
1161dir = "tickets"
1162
1163[[workflow.states]]
1164id    = "new"
1165label = "New"
1166
1167[[workflow.states.transitions]]
1168to = "ghost"
1169"#;
1170        let config = load_config(toml);
1171        let errors = validate_config(&config, Path::new("/tmp"));
1172        assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
1173    }
1174
1175    // Test 3: terminal state with outgoing transitions is detected
1176    #[test]
1177    fn terminal_state_with_transitions_detected() {
1178        let toml = r#"
1179[project]
1180name = "test"
1181
1182[tickets]
1183dir = "tickets"
1184
1185[[workflow.states]]
1186id       = "closed"
1187label    = "Closed"
1188terminal = true
1189
1190[[workflow.states.transitions]]
1191to = "new"
1192
1193[[workflow.states]]
1194id    = "new"
1195label = "New"
1196
1197[[workflow.states.transitions]]
1198to = "closed"
1199"#;
1200        let config = load_config(toml);
1201        let errors = validate_config(&config, Path::new("/tmp"));
1202        assert!(
1203            errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
1204            "expected terminal error in {errors:?}"
1205        );
1206    }
1207
1208    // Test 5: ticket with unknown state is detected
1209    #[test]
1210    fn ticket_with_unknown_state_detected() {
1211        use crate::ticket::Ticket;
1212
1213        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
1214        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1215
1216        let known_states: std::collections::HashSet<&str> =
1217            ["new", "ready", "closed"].iter().copied().collect();
1218
1219        assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
1220    }
1221
1222    // Test 6: dead-end non-terminal state is detected
1223    #[test]
1224    fn dead_end_non_terminal_detected() {
1225        let toml = r#"
1226[project]
1227name = "test"
1228
1229[tickets]
1230dir = "tickets"
1231
1232[[workflow.states]]
1233id    = "stuck"
1234label = "Stuck"
1235
1236[[workflow.states]]
1237id       = "closed"
1238label    = "Closed"
1239terminal = true
1240"#;
1241        let config = load_config(toml);
1242        let errors = validate_config(&config, Path::new("/tmp"));
1243        assert!(
1244            errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
1245            "expected dead-end error in {errors:?}"
1246        );
1247    }
1248
1249    // Test 7: context_section mismatch is detected
1250    #[test]
1251    fn context_section_mismatch_detected() {
1252        let toml = r#"
1253[project]
1254name = "test"
1255
1256[tickets]
1257dir = "tickets"
1258
1259[[ticket.sections]]
1260name = "Problem"
1261type = "free"
1262
1263[[workflow.states]]
1264id    = "new"
1265label = "New"
1266
1267[[workflow.states.transitions]]
1268to              = "ready"
1269context_section = "NonExistent"
1270
1271[[workflow.states]]
1272id    = "ready"
1273label = "Ready"
1274
1275[[workflow.states.transitions]]
1276to = "closed"
1277
1278[[workflow.states]]
1279id       = "closed"
1280label    = "Closed"
1281terminal = true
1282"#;
1283        let config = load_config(toml);
1284        let errors = validate_config(&config, Path::new("/tmp"));
1285        assert!(
1286            errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
1287            "expected context_section error in {errors:?}"
1288        );
1289    }
1290
1291    // Test 8: focus_section mismatch is detected
1292    #[test]
1293    fn focus_section_mismatch_detected() {
1294        let toml = r#"
1295[project]
1296name = "test"
1297
1298[tickets]
1299dir = "tickets"
1300
1301[[ticket.sections]]
1302name = "Problem"
1303type = "free"
1304
1305[[workflow.states]]
1306id    = "new"
1307label = "New"
1308
1309[[workflow.states.transitions]]
1310to             = "ready"
1311focus_section  = "BadSection"
1312
1313[[workflow.states]]
1314id    = "ready"
1315label = "Ready"
1316
1317[[workflow.states.transitions]]
1318to = "closed"
1319
1320[[workflow.states]]
1321id       = "closed"
1322label    = "Closed"
1323terminal = true
1324"#;
1325        let config = load_config(toml);
1326        let errors = validate_config(&config, Path::new("/tmp"));
1327        assert!(
1328            errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
1329            "expected focus_section error in {errors:?}"
1330        );
1331    }
1332
1333    // Test 9: completion=pr without provider is detected
1334    #[test]
1335    fn completion_pr_without_provider_detected() {
1336        let toml = r#"
1337[project]
1338name = "test"
1339
1340[tickets]
1341dir = "tickets"
1342
1343[[workflow.states]]
1344id    = "new"
1345label = "New"
1346
1347[[workflow.states.transitions]]
1348to         = "closed"
1349completion = "pr"
1350
1351[[workflow.states]]
1352id       = "closed"
1353label    = "Closed"
1354terminal = true
1355"#;
1356        let config = load_config(toml);
1357        let errors = validate_config(&config, Path::new("/tmp"));
1358        assert!(
1359            errors.iter().any(|e| e.contains("provider")),
1360            "expected provider error in {errors:?}"
1361        );
1362    }
1363
1364    // Test 10: completion=pr with provider configured passes
1365    #[test]
1366    fn completion_pr_with_provider_passes() {
1367        let toml = r#"
1368[project]
1369name = "test"
1370
1371[tickets]
1372dir = "tickets"
1373
1374[git_host]
1375provider = "github"
1376
1377[[workflow.states]]
1378id    = "new"
1379label = "New"
1380
1381[[workflow.states.transitions]]
1382to         = "closed"
1383completion = "pr"
1384
1385[[workflow.states]]
1386id       = "closed"
1387label    = "Closed"
1388terminal = true
1389"#;
1390        let config = load_config(toml);
1391        let errors = validate_config(&config, Path::new("/tmp"));
1392        assert!(
1393            !errors.iter().any(|e| e.contains("provider")),
1394            "unexpected provider error in {errors:?}"
1395        );
1396    }
1397
1398    // Test 11: context_section with empty ticket.sections is skipped
1399    #[test]
1400    fn context_section_skipped_when_no_sections_defined() {
1401        let toml = r#"
1402[project]
1403name = "test"
1404
1405[tickets]
1406dir = "tickets"
1407
1408[[workflow.states]]
1409id    = "new"
1410label = "New"
1411
1412[[workflow.states.transitions]]
1413to              = "closed"
1414context_section = "AnySection"
1415
1416[[workflow.states]]
1417id       = "closed"
1418label    = "Closed"
1419terminal = true
1420"#;
1421        let config = load_config(toml);
1422        let errors = validate_config(&config, Path::new("/tmp"));
1423        assert!(
1424            !errors.iter().any(|e| e.contains("context_section")),
1425            "unexpected context_section error in {errors:?}"
1426        );
1427    }
1428
1429    // Test: closed state is not flagged as unknown even when absent from config
1430    #[test]
1431    fn closed_state_not_flagged_as_unknown() {
1432        use crate::ticket::Ticket;
1433
1434        // Config with no "closed" state
1435        let toml = r#"
1436[project]
1437name = "test"
1438
1439[tickets]
1440dir = "tickets"
1441
1442[[workflow.states]]
1443id    = "new"
1444label = "New"
1445
1446[[workflow.states.transitions]]
1447to = "done"
1448
1449[[workflow.states]]
1450id       = "done"
1451label    = "Done"
1452terminal = true
1453"#;
1454        let config = load_config(toml);
1455        let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
1456            .map(|s| s.id.as_str())
1457            .collect();
1458
1459        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
1460        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1461
1462        // "closed" is not in state_ids, but the validate logic skips it.
1463        assert!(!state_ids.contains("closed"));
1464        // Simulate the validate check: closed should be exempt.
1465        let fm = &ticket.frontmatter;
1466        let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
1467        assert!(!flagged, "closed state should not be flagged as unknown");
1468    }
1469
1470    // Test for state_ids helper (kept for compatibility)
1471    #[test]
1472    fn state_ids_helper() {
1473        let toml = r#"
1474[project]
1475name = "test"
1476
1477[tickets]
1478dir = "tickets"
1479
1480[[workflow.states]]
1481id    = "new"
1482label = "New"
1483"#;
1484        let config = load_config(toml);
1485        let ids = state_ids(&config);
1486        assert!(ids.contains("new"));
1487    }
1488
1489    #[test]
1490    fn validate_warnings_no_container() {
1491        let toml = r#"
1492[project]
1493name = "test"
1494
1495[tickets]
1496dir = "tickets"
1497"#;
1498        let config = load_config(toml);
1499        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1500        assert!(warnings.is_empty());
1501    }
1502
1503    #[test]
1504    fn valid_collaborator_accepted() {
1505        let toml = r#"
1506[project]
1507name = "test"
1508collaborators = ["alice", "bob"]
1509
1510[tickets]
1511dir = "tickets"
1512"#;
1513        let config = load_config(toml);
1514        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1515    }
1516
1517    #[test]
1518    fn unknown_user_rejected() {
1519        let toml = r#"
1520[project]
1521name = "test"
1522collaborators = ["alice", "bob"]
1523
1524[tickets]
1525dir = "tickets"
1526"#;
1527        let config = load_config(toml);
1528        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1529        let msg = err.to_string();
1530        assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
1531        assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
1532    }
1533
1534    #[test]
1535    fn empty_collaborators_skips_validation() {
1536        let toml = r#"
1537[project]
1538name = "test"
1539
1540[tickets]
1541dir = "tickets"
1542"#;
1543        let config = load_config(toml);
1544        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1545    }
1546
1547    #[test]
1548    fn clear_owner_always_allowed() {
1549        let toml = r#"
1550[project]
1551name = "test"
1552collaborators = ["alice"]
1553
1554[tickets]
1555dir = "tickets"
1556"#;
1557        let config = load_config(toml);
1558        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1559    }
1560
1561    #[test]
1562    fn github_mode_known_user_accepted() {
1563        let toml = r#"
1564[project]
1565name = "test"
1566collaborators = ["alice", "bob"]
1567
1568[tickets]
1569dir = "tickets"
1570
1571[git_host]
1572provider = "github"
1573repo = "org/repo"
1574"#;
1575        let config = load_config(toml);
1576        // No token in LocalConfig::default() — falls back to project.collaborators
1577        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1578    }
1579
1580    #[test]
1581    fn github_mode_unknown_user_rejected() {
1582        let toml = r#"
1583[project]
1584name = "test"
1585collaborators = ["alice", "bob"]
1586
1587[tickets]
1588dir = "tickets"
1589
1590[git_host]
1591provider = "github"
1592repo = "org/repo"
1593"#;
1594        let config = load_config(toml);
1595        // No token — falls back to project.collaborators; charlie is not in the list
1596        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1597        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1598    }
1599
1600    #[test]
1601    fn github_mode_no_collaborators_skips_check() {
1602        let toml = r#"
1603[project]
1604name = "test"
1605
1606[tickets]
1607dir = "tickets"
1608
1609[git_host]
1610provider = "github"
1611repo = "org/repo"
1612"#;
1613        let config = load_config(toml);
1614        // Empty collaborators list — no validation
1615        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1616    }
1617
1618    #[test]
1619    fn github_mode_clear_owner_accepted() {
1620        let toml = r#"
1621[project]
1622name = "test"
1623collaborators = ["alice"]
1624
1625[tickets]
1626dir = "tickets"
1627
1628[git_host]
1629provider = "github"
1630repo = "org/repo"
1631"#;
1632        let config = load_config(toml);
1633        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1634    }
1635
1636    #[test]
1637    fn non_github_mode_unknown_user_rejected() {
1638        let toml = r#"
1639[project]
1640name = "test"
1641collaborators = ["alice", "bob"]
1642
1643[tickets]
1644dir = "tickets"
1645"#;
1646        let config = load_config(toml);
1647        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1648        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1649    }
1650
1651    #[test]
1652    fn validate_warnings_empty_container() {
1653        let toml = r#"
1654[project]
1655name = "test"
1656
1657[tickets]
1658dir = "tickets"
1659
1660[workers]
1661container = ""
1662"#;
1663        let config = load_config(toml);
1664        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1665        assert!(warnings.is_empty(), "empty container string should not warn");
1666    }
1667
1668    #[test]
1669    fn dead_end_workflow_warning_emitted() {
1670        // A workflow where the only agent-actionable state cycles back to itself
1671        // with no completion strategy — no "success" outcome is reachable.
1672        let toml = r#"
1673[project]
1674name = "test"
1675
1676[tickets]
1677dir = "tickets"
1678
1679[[workflow.states]]
1680id         = "start"
1681label      = "Start"
1682actionable = ["agent"]
1683
1684[[workflow.states.transitions]]
1685to = "middle"
1686
1687[[workflow.states]]
1688id    = "middle"
1689label = "Middle"
1690
1691[[workflow.states.transitions]]
1692to = "start"
1693"#;
1694        let config = load_config(toml);
1695        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1696        assert!(
1697            warnings.iter().any(|w| w.contains("success")),
1698            "expected dead-end warning containing 'success'; got: {warnings:?}"
1699        );
1700    }
1701
1702    #[test]
1703    fn default_workflow_no_dead_end_warning() {
1704        // The default workflow has in_progress → implemented with completion = pr_or_epic_merge,
1705        // reachable from the agent-actionable "ready" state. No dead-end warning should fire.
1706        let base = r#"
1707[project]
1708name = "test"
1709
1710[tickets]
1711dir = "tickets"
1712"#;
1713        let combined = format!("{}\n{}", base, crate::init::default_workflow_toml());
1714        let config: Config = toml::from_str(&combined).unwrap();
1715        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1716        assert!(
1717            !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")),
1718            "unexpected dead-end warning for default workflow; got: {warnings:?}"
1719        );
1720    }
1721
1722    #[test]
1723    fn worktree_missing_in_design() {
1724        let dir = setup_verify_repo();
1725        let root = dir.path();
1726        let config = Config::load(root).unwrap();
1727        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1728
1729        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1730
1731        let main_root = git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1732        let wt_path = main_root.join("worktrees").join("ticket-abcd1234-test");
1733        let expected = format!(
1734            "#abcd1234 [in_design]: worktree at {} is missing",
1735            wt_path.display()
1736        );
1737        assert!(
1738            issues.iter().any(|i| i == &expected),
1739            "expected worktree missing issue; got: {issues:?}"
1740        );
1741    }
1742
1743    #[test]
1744    fn worktree_present_no_issue() {
1745        let dir = setup_verify_repo();
1746        let root = dir.path();
1747        let config = Config::load(root).unwrap();
1748        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1749
1750        std::fs::create_dir_all(root.join("worktrees").join("ticket-abcd1234-test")).unwrap();
1751
1752        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1753        assert!(
1754            !issues.iter().any(|i| i.contains("worktree")),
1755            "unexpected worktree issue; got: {issues:?}"
1756        );
1757    }
1758
1759    #[test]
1760    fn worktree_check_skipped_for_other_states() {
1761        let dir = setup_verify_repo();
1762        let root = dir.path();
1763        let config = Config::load(root).unwrap();
1764        let ticket = make_verify_ticket(root, "abcd1234", "specd", Some("ticket/abcd1234-test"));
1765
1766        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1767        assert!(
1768            !issues.iter().any(|i| i.contains("worktree")),
1769            "unexpected worktree issue for specd state; got: {issues:?}"
1770        );
1771    }
1772
1773    fn in_repo_wt_config(dir: &str) -> Config {
1774        let toml = format!(
1775            r#"
1776[project]
1777name = "test"
1778
1779[tickets]
1780dir = "tickets"
1781
1782[worktrees]
1783dir = "{dir}"
1784"#
1785        );
1786        toml::from_str(&toml).expect("config parse failed")
1787    }
1788
1789    #[test]
1790    fn validate_config_gitignore_missing_in_repo_wt() {
1791        let tmp = tempfile::TempDir::new().unwrap();
1792        let config = in_repo_wt_config("worktrees");
1793        let errors = validate_config(&config, tmp.path());
1794        assert!(
1795            errors.iter().any(|e| e.contains("worktrees") && e.contains(".gitignore")),
1796            "expected gitignore missing error; got: {errors:?}"
1797        );
1798    }
1799
1800    #[test]
1801    fn validate_config_gitignore_covered_anchored_slash() {
1802        let tmp = tempfile::TempDir::new().unwrap();
1803        std::fs::write(tmp.path().join(".gitignore"), "/worktrees/\n").unwrap();
1804        let config = in_repo_wt_config("worktrees");
1805        let errors = validate_config(&config, tmp.path());
1806        assert!(
1807            !errors.iter().any(|e| e.contains("gitignore")),
1808            "unexpected gitignore error; got: {errors:?}"
1809        );
1810    }
1811
1812    #[test]
1813    fn validate_config_gitignore_covered_anchored_no_slash() {
1814        let tmp = tempfile::TempDir::new().unwrap();
1815        std::fs::write(tmp.path().join(".gitignore"), "/worktrees\n").unwrap();
1816        let config = in_repo_wt_config("worktrees");
1817        let errors = validate_config(&config, tmp.path());
1818        assert!(
1819            !errors.iter().any(|e| e.contains("gitignore")),
1820            "unexpected gitignore error; got: {errors:?}"
1821        );
1822    }
1823
1824    #[test]
1825    fn validate_config_gitignore_covered_unanchored_slash() {
1826        let tmp = tempfile::TempDir::new().unwrap();
1827        std::fs::write(tmp.path().join(".gitignore"), "worktrees/\n").unwrap();
1828        let config = in_repo_wt_config("worktrees");
1829        let errors = validate_config(&config, tmp.path());
1830        assert!(
1831            !errors.iter().any(|e| e.contains("gitignore")),
1832            "unexpected gitignore error; got: {errors:?}"
1833        );
1834    }
1835
1836    #[test]
1837    fn validate_config_gitignore_covered_bare() {
1838        let tmp = tempfile::TempDir::new().unwrap();
1839        std::fs::write(tmp.path().join(".gitignore"), "worktrees\n").unwrap();
1840        let config = in_repo_wt_config("worktrees");
1841        let errors = validate_config(&config, tmp.path());
1842        assert!(
1843            !errors.iter().any(|e| e.contains("gitignore")),
1844            "unexpected gitignore error; got: {errors:?}"
1845        );
1846    }
1847
1848    #[test]
1849    fn validate_config_gitignore_not_covered() {
1850        let tmp = tempfile::TempDir::new().unwrap();
1851        std::fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
1852        let config = in_repo_wt_config("worktrees");
1853        let errors = validate_config(&config, tmp.path());
1854        assert!(
1855            errors.iter().any(|e| e.contains("worktrees") && e.contains("gitignore")),
1856            "expected gitignore not covered error; got: {errors:?}"
1857        );
1858    }
1859
1860    #[test]
1861    fn validate_config_gitignore_no_false_positive() {
1862        let tmp = tempfile::TempDir::new().unwrap();
1863        std::fs::write(tmp.path().join(".gitignore"), "wt-old/\n").unwrap();
1864        let config = in_repo_wt_config("wt");
1865        let errors = validate_config(&config, tmp.path());
1866        assert!(
1867            errors.iter().any(|e| e.contains("wt") && e.contains("gitignore")),
1868            "wt-old should not match wt; got: {errors:?}"
1869        );
1870    }
1871
1872    #[test]
1873    fn validate_config_external_dotdot_no_check() {
1874        let tmp = tempfile::TempDir::new().unwrap();
1875        // No .gitignore at all
1876        let config = in_repo_wt_config("../ext");
1877        let errors = validate_config(&config, tmp.path());
1878        assert!(
1879            !errors.iter().any(|e| e.contains("gitignore")),
1880            "external dotdot path should skip gitignore check; got: {errors:?}"
1881        );
1882    }
1883
1884    #[test]
1885    fn validate_config_external_absolute_no_check() {
1886        let tmp = tempfile::TempDir::new().unwrap();
1887        // No .gitignore at all
1888        let config = in_repo_wt_config("/abs/path");
1889        let errors = validate_config(&config, tmp.path());
1890        assert!(
1891            !errors.iter().any(|e| e.contains("gitignore")),
1892            "absolute path should skip gitignore check; got: {errors:?}"
1893        );
1894    }
1895
1896    fn config_with_merge_transition(completion: &str, on_failure: Option<&str>, declare_failure_state: bool) -> Config {
1897        let on_failure_line = on_failure
1898            .map(|v| format!("on_failure = \"{v}\"\n"))
1899            .unwrap_or_default();
1900        let merge_failed_state = if declare_failure_state {
1901            r#"
1902[[workflow.states]]
1903id       = "merge_failed"
1904label    = "Merge failed"
1905
1906[[workflow.states.transitions]]
1907to = "closed"
1908"#
1909        } else {
1910            ""
1911        };
1912        let toml = format!(
1913            r#"
1914[project]
1915name = "test"
1916
1917[tickets]
1918dir = "tickets"
1919
1920[[workflow.states]]
1921id    = "in_progress"
1922label = "In Progress"
1923
1924[[workflow.states.transitions]]
1925to         = "implemented"
1926completion = "{completion}"
1927{on_failure_line}
1928[[workflow.states]]
1929id       = "implemented"
1930label    = "Implemented"
1931terminal = true
1932
1933[[workflow.states]]
1934id       = "closed"
1935label    = "Closed"
1936terminal = true
1937{merge_failed_state}
1938"#
1939        );
1940        toml::from_str(&toml).expect("config parse failed")
1941    }
1942
1943    #[test]
1944    fn test_on_failure_missing_for_merge() {
1945        let config = config_with_merge_transition("merge", None, false);
1946        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1947        assert!(
1948            errors.iter().any(|e| e.contains("missing `on_failure`")),
1949            "expected missing on_failure error; got: {errors:?}"
1950        );
1951    }
1952
1953    #[test]
1954    fn test_on_failure_missing_for_pr_or_epic_merge() {
1955        // No ticket with target_branch — rule fires on transition definition alone.
1956        let config = config_with_merge_transition("pr_or_epic_merge", None, false);
1957        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1958        assert!(
1959            errors.iter().any(|e| e.contains("missing `on_failure`")),
1960            "expected missing on_failure error for pr_or_epic_merge; got: {errors:?}"
1961        );
1962    }
1963
1964    #[test]
1965    fn test_on_failure_unknown_state() {
1966        let config = config_with_merge_transition("merge", Some("ghost_state"), false);
1967        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1968        assert!(
1969            errors.iter().any(|e| e.contains("ghost_state")),
1970            "expected unknown state error for ghost_state; got: {errors:?}"
1971        );
1972    }
1973
1974    #[test]
1975    fn test_on_failure_valid() {
1976        let config = config_with_merge_transition("merge", Some("merge_failed"), true);
1977        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1978        let on_failure_errors: Vec<&String> = errors.iter()
1979            .filter(|e| e.contains("on_failure") || e.contains("ghost_state") || e.contains("merge_failed"))
1980            .collect();
1981        assert!(
1982            on_failure_errors.is_empty(),
1983            "unexpected on_failure errors: {on_failure_errors:?}"
1984        );
1985    }
1986
1987    // --- frontmatter agent validation ---
1988
1989    fn make_agent_verify_ticket(root: &std::path::Path, id: &str, state: &str, extra_fm: &str) -> Ticket {
1990        let raw = format!(
1991            "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{extra_fm}+++\n\n## Spec\n\n## History\n"
1992        );
1993        let path = root.join("tickets").join(format!("{id}-test.md"));
1994        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1995        std::fs::write(&path, &raw).unwrap();
1996        Ticket::parse(&path, &raw).unwrap()
1997    }
1998
1999    #[test]
2000    fn validate_unknown_frontmatter_agent_is_error() {
2001        let dir = setup_verify_repo();
2002        let root = dir.path();
2003        let config = Config::load(root).unwrap();
2004        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"nonexistent-bot\"\n");
2005
2006        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2007
2008        assert!(
2009            issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2010            "expected error with ticket id and agent name; got: {issues:?}"
2011        );
2012    }
2013
2014    #[test]
2015    fn validate_unknown_agent_in_overrides_is_error() {
2016        let dir = setup_verify_repo();
2017        let root = dir.path();
2018        let config = Config::load(root).unwrap();
2019        let ticket = make_agent_verify_ticket(
2020            root,
2021            "abcd1234",
2022            "specd",
2023            "[agent_overrides]\nimpl_agent = \"nonexistent-bot\"\n",
2024        );
2025
2026        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2027
2028        assert!(
2029            issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2030            "expected error with ticket id and agent name; got: {issues:?}"
2031        );
2032    }
2033
2034    #[test]
2035    fn verify_tickets_flags_renamed_ticket_file_on_branch() {
2036        // Reproduce the pi-agent rename bug: branch is
2037        // `ticket/abcd1234-fix-login`, but the file on that branch is
2038        // `tickets/abcd1234-fix-login-and-stuff.md`. apm's loader silently
2039        // drops the ticket; the validator should flag it.
2040        let dir = setup_verify_repo();
2041        let p = dir.path();
2042
2043        let canonical_branch = "ticket/abcd1234-fix-login";
2044        git_cmd(p, &["checkout", "-b", canonical_branch]);
2045        std::fs::create_dir_all(p.join("tickets")).unwrap();
2046        // File written with a DIFFERENT slug than the branch suffix.
2047        std::fs::write(
2048            p.join("tickets/abcd1234-fix-login-and-stuff.md"),
2049            "+++\nid = \"abcd1234\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2050        )
2051        .unwrap();
2052        git_cmd(p, &["add", "tickets/"]);
2053        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "spec written"]);
2054        git_cmd(p, &["checkout", "main"]);
2055
2056        let config = Config::load(p).unwrap();
2057        let issues = verify_tickets(p, &config, &[], &HashSet::new());
2058
2059        assert!(
2060            issues.iter().any(|i| i.contains(canonical_branch) && i.contains("renamed")),
2061            "expected rename diagnostic for {canonical_branch}; got: {issues:?}"
2062        );
2063    }
2064
2065    #[test]
2066    fn verify_tickets_flags_orphan_branch_with_no_ticket_file() {
2067        let dir = setup_verify_repo();
2068        let p = dir.path();
2069
2070        let branch = "ticket/deadbeef-orphan";
2071        git_cmd(p, &["checkout", "-b", branch]);
2072        // No tickets/ directory at all on this branch.
2073        std::fs::write(p.join("dummy.txt"), "x").unwrap();
2074        git_cmd(p, &["add", "dummy.txt"]);
2075        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "no ticket file"]);
2076        git_cmd(p, &["checkout", "main"]);
2077
2078        let config = Config::load(p).unwrap();
2079        let issues = verify_tickets(p, &config, &[], &HashSet::new());
2080
2081        assert!(
2082            issues.iter().any(|i| i.contains(branch) && i.contains("orphaned")),
2083            "expected orphan diagnostic for {branch}; got: {issues:?}"
2084        );
2085    }
2086
2087    #[test]
2088    fn verify_tickets_quiet_when_branch_file_matches() {
2089        let dir = setup_verify_repo();
2090        let p = dir.path();
2091
2092        let branch = "ticket/cafe0001-clean-branch";
2093        git_cmd(p, &["checkout", "-b", branch]);
2094        std::fs::create_dir_all(p.join("tickets")).unwrap();
2095        // Canonical filename: matches the branch suffix exactly.
2096        std::fs::write(
2097            p.join("tickets/cafe0001-clean-branch.md"),
2098            "+++\nid = \"cafe0001\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2099        )
2100        .unwrap();
2101        git_cmd(p, &["add", "tickets/"]);
2102        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "canonical"]);
2103        git_cmd(p, &["checkout", "main"]);
2104
2105        let config = Config::load(p).unwrap();
2106        let issues = verify_tickets(p, &config, &[], &HashSet::new());
2107
2108        assert!(
2109            !issues.iter().any(|i| i.contains(branch)),
2110            "no branch-file issue expected for canonical layout; got: {issues:?}"
2111        );
2112    }
2113
2114    #[test]
2115    fn validate_known_frontmatter_agent_passes() {
2116        let dir = setup_verify_repo();
2117        let root = dir.path();
2118        let config = Config::load(root).unwrap();
2119        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"claude\"\n");
2120
2121        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2122
2123        assert!(
2124            !issues.iter().any(|i| i.contains("is not a known built-in")),
2125            "expected no agent error for known built-in; got: {issues:?}"
2126        );
2127    }
2128
2129    #[test]
2130    fn validate_agent_name_accepts_configured_spawn_agent() {
2131        let toml = r#"
2132[[workflow.states]]
2133id    = "ready"
2134label = "Ready"
2135
2136[[workflow.states.transitions]]
2137to             = "in_progress"
2138trigger        = "command:start"
2139worker_profile = "pi/worker"
2140
2141[[workflow.states]]
2142id       = "in_progress"
2143label    = "In Progress"
2144terminal = true
2145"#;
2146        let config = audit_config(toml);
2147        validate_agent_name(&config, "pi").expect("pi should be a configured agent");
2148    }
2149
2150    #[test]
2151    fn validate_agent_name_rejects_unknown() {
2152        let config = audit_config("");
2153        let err = validate_agent_name(&config, "nonexistent").unwrap_err();
2154        let msg = err.to_string();
2155        assert!(msg.contains("nonexistent"), "got: {msg}");
2156        assert!(msg.contains("not configured in config.toml"), "got: {msg}");
2157    }
2158
2159    #[test]
2160    fn validate_agent_name_accepts_dash_sentinel() {
2161        let config = audit_config("");
2162        validate_agent_name(&config, "-").expect("dash should clear without validation");
2163    }
2164
2165    #[test]
2166    fn validate_ticket_agent_not_in_config_is_error() {
2167        let dir = setup_verify_repo();
2168        let root = dir.path();
2169        let config = Config::load(root).unwrap();
2170        // "claude" is the default configured agent; pick something else that resolves
2171        // as a wrapper to isolate the config-coverage check.
2172        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"phi4\"\n");
2173
2174        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2175
2176        assert!(
2177            issues.iter().any(|i| i.contains("abcd1234") && i.contains("not configured in config.toml")),
2178            "expected config-coverage error; got: {issues:?}"
2179        );
2180    }
2181
2182    // --- audit_agent_resolution tests ---
2183
2184    fn audit_config(extra_toml: &str) -> Config {
2185        let base = r#"
2186[project]
2187name = "test"
2188
2189[tickets]
2190dir = "tickets"
2191
2192[worktrees]
2193dir = "../wt"
2194"#;
2195        toml::from_str(&format!("{base}{extra_toml}")).expect("config parse failed")
2196    }
2197
2198    #[test]
2199    fn audit_zero_spawn_transitions() {
2200        let toml = r#"
2201[[workflow.states]]
2202id    = "new"
2203label = "New"
2204
2205[[workflow.states.transitions]]
2206to      = "closed"
2207trigger = "command:review"
2208
2209[[workflow.states]]
2210id       = "closed"
2211label    = "Closed"
2212terminal = true
2213"#;
2214        let config = audit_config(toml);
2215        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2216        assert!(result.is_empty(), "expected 0 audits, got {result:?}");
2217    }
2218
2219    #[test]
2220    fn audit_default_agent_resolution() {
2221        let toml = r#"
2222[[workflow.states]]
2223id    = "ready"
2224label = "Ready"
2225
2226[[workflow.states.transitions]]
2227to      = "in_progress"
2228trigger = "command:start"
2229
2230[[workflow.states]]
2231id       = "in_progress"
2232label    = "In Progress"
2233terminal = true
2234"#;
2235        let config = audit_config(toml);
2236        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2237        assert_eq!(result.len(), 1, "expected 1 audit");
2238        let ta = &result[0];
2239        assert_eq!(ta.from_state, "ready");
2240        assert_eq!(ta.to_state, "in_progress");
2241        assert!(ta.worker_profile.is_none());
2242        assert_eq!(ta.agent, "claude");
2243        assert_eq!(ta.role, "coder");
2244        assert!(ta.wrapper.contains("claude"), "wrapper should mention claude: {}", ta.wrapper);
2245    }
2246
2247    #[test]
2248    fn audit_worker_profile_parsed() {
2249        let toml = r#"
2250[[workflow.states]]
2251id    = "ready"
2252label = "Ready"
2253
2254[[workflow.states.transitions]]
2255to             = "in_progress"
2256trigger        = "command:start"
2257worker_profile = "mock-happy/spec-writer"
2258
2259[[workflow.states]]
2260id       = "in_progress"
2261label    = "In Progress"
2262terminal = true
2263"#;
2264        let config = audit_config(toml);
2265        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2266        assert_eq!(result.len(), 1);
2267        let ta = &result[0];
2268        assert_eq!(ta.worker_profile.as_deref(), Some("mock-happy/spec-writer"));
2269        assert_eq!(ta.agent, "mock-happy");
2270        assert_eq!(ta.role, "spec-writer");
2271        assert!(ta.wrapper.contains("mock-happy"), "wrapper: {}", ta.wrapper);
2272    }
2273
2274    #[test]
2275    fn audit_workers_default_agent() {
2276        let toml = r#"
2277[workers]
2278default = "mock-happy/worker"
2279
2280[[workflow.states]]
2281id    = "ready"
2282label = "Ready"
2283
2284[[workflow.states.transitions]]
2285to      = "in_progress"
2286trigger = "command:start"
2287
2288[[workflow.states]]
2289id       = "in_progress"
2290label    = "In Progress"
2291terminal = true
2292"#;
2293        let config = audit_config(toml);
2294        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2295        assert_eq!(result.len(), 1);
2296        let ta = &result[0];
2297        assert_eq!(ta.agent, "mock-happy");
2298        assert_eq!(ta.role, "worker");
2299    }
2300
2301    #[test]
2302    fn audit_no_worker_profiles_no_panic() {
2303        let toml = r#"
2304[[workflow.states]]
2305id    = "ready"
2306label = "Ready"
2307
2308[[workflow.states.transitions]]
2309to      = "in_progress"
2310trigger = "command:start"
2311
2312[[workflow.states]]
2313id       = "in_progress"
2314label    = "In Progress"
2315terminal = true
2316"#;
2317        let config = audit_config(toml);
2318        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2319        assert_eq!(result.len(), 1, "should not panic with no worker_profile");
2320    }
2321
2322    #[test]
2323    fn merge_completion_targeting_terminal_rejected() {
2324        let toml = r#"
2325[project]
2326name = "test"
2327
2328[tickets]
2329dir = "tickets"
2330
2331[[workflow.states]]
2332id    = "in_progress"
2333label = "In Progress"
2334
2335[[workflow.states.transitions]]
2336to         = "done"
2337completion = "merge"
2338on_failure = "closed"
2339
2340[[workflow.states]]
2341id       = "done"
2342label    = "Done"
2343terminal = true
2344"#;
2345        let config = load_config(toml);
2346        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2347        assert!(
2348            errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2349            "expected terminal-state error; got: {errors:?}"
2350        );
2351    }
2352
2353    #[test]
2354    fn pr_or_epic_merge_targeting_terminal_rejected() {
2355        let toml = r#"
2356[project]
2357name = "test"
2358
2359[tickets]
2360dir = "tickets"
2361
2362[[workflow.states]]
2363id    = "in_progress"
2364label = "In Progress"
2365
2366[[workflow.states.transitions]]
2367to         = "done"
2368completion = "pr_or_epic_merge"
2369on_failure = "closed"
2370
2371[[workflow.states]]
2372id       = "done"
2373label    = "Done"
2374terminal = true
2375"#;
2376        let config = load_config(toml);
2377        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2378        assert!(
2379            errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2380            "expected terminal-state error; got: {errors:?}"
2381        );
2382    }
2383
2384    #[test]
2385    fn pr_completion_targeting_terminal_rejected() {
2386        let toml = r#"
2387[project]
2388name = "test"
2389
2390[tickets]
2391dir = "tickets"
2392
2393[git_host]
2394provider = "github"
2395
2396[[workflow.states]]
2397id    = "in_progress"
2398label = "In Progress"
2399
2400[[workflow.states.transitions]]
2401to         = "done"
2402completion = "pr"
2403
2404[[workflow.states]]
2405id       = "done"
2406label    = "Done"
2407terminal = true
2408"#;
2409        let config = load_config(toml);
2410        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2411        assert!(
2412            errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2413            "expected terminal-state error; got: {errors:?}"
2414        );
2415    }
2416
2417    #[test]
2418    fn merge_targeting_built_in_closed_rejected() {
2419        // "closed" is the built-in terminal state — absent from [[workflow.states]]
2420        let toml = r#"
2421[project]
2422name = "test"
2423
2424[tickets]
2425dir = "tickets"
2426
2427[[workflow.states]]
2428id    = "in_progress"
2429label = "In Progress"
2430
2431[[workflow.states.transitions]]
2432to         = "closed"
2433completion = "merge"
2434on_failure = "review"
2435
2436[[workflow.states]]
2437id    = "review"
2438label = "Review"
2439
2440[[workflow.states.transitions]]
2441to = "closed"
2442"#;
2443        let config = load_config(toml);
2444        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2445        assert!(
2446            errors.iter().any(|e| e.contains("state.in_progress.transition(closed)") && e.contains("targets terminal state")),
2447            "expected terminal-state error for built-in closed; got: {errors:?}"
2448        );
2449    }
2450
2451    #[test]
2452    fn merge_targeting_non_terminal_accepted() {
2453        let toml = r#"
2454[project]
2455name = "test"
2456
2457[tickets]
2458dir = "tickets"
2459
2460[[workflow.states]]
2461id    = "in_progress"
2462label = "In Progress"
2463
2464[[workflow.states.transitions]]
2465to         = "review"
2466completion = "merge"
2467on_failure = "closed"
2468
2469[[workflow.states]]
2470id    = "review"
2471label = "Review"
2472
2473[[workflow.states.transitions]]
2474to = "closed"
2475"#;
2476        let config = load_config(toml);
2477        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2478        assert!(
2479            !errors.iter().any(|e| e.contains("targets terminal state")),
2480            "unexpected terminal-state error; got: {errors:?}"
2481        );
2482    }
2483}