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