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 state-level `worker_profile`.
143pub fn configured_agent_names(config: &Config) -> HashSet<String> {
144    let mut names: HashSet<String> = HashSet::new();
145    if let Some((agent, _)) = config.workers.default.split_once('/') {
146        names.insert(agent.to_string());
147    }
148    // Walk state-level worker_profile fields
149    for state in &config.workflow.states {
150        if let Some(ref wp) = state.worker_profile {
151            if let Some((agent, _)) = wp.split_once('/') {
152                names.insert(agent.to_string());
153            }
154        }
155    }
156    names
157}
158
159/// Validate that `name` matches an agent configured in config.toml. Accepts
160/// `"-"` (the sentinel used to clear the field) without error.
161pub fn validate_agent_name(config: &Config, name: &str) -> Result<()> {
162    if name == "-" {
163        return Ok(());
164    }
165    let configured = configured_agent_names(config);
166    if configured.contains(name) {
167        return Ok(());
168    }
169    let mut sorted: Vec<&String> = configured.iter().collect();
170    sorted.sort();
171    let list = sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
172    bail!("agent {name:?} is not configured in config.toml; known agents: [{list}]")
173}
174
175pub fn validate_owner(config: &Config, local: &LocalConfig, username: &str) -> Result<()> {
176    if username == "-" {
177        return Ok(());
178    }
179    let (collaborators, warnings) = crate::config::resolve_collaborators(config, local);
180    for w in &warnings {
181        #[allow(clippy::print_stderr)]
182        { eprintln!("{w}"); }
183    }
184    if collaborators.is_empty() {
185        return Ok(());
186    }
187    if collaborators.iter().any(|c| c == username) {
188        return Ok(());
189    }
190    let list = collaborators.join(", ");
191    bail!("unknown user '{username}'; valid collaborators: {list}");
192}
193
194fn is_external_worktree(dir: &Path) -> bool {
195    let s = dir.to_string_lossy();
196    s.starts_with('/') || s.starts_with("..")
197}
198
199fn gitignore_covers_dir(content: &str, dir: &str) -> bool {
200    let normalized_dir = dir.trim_matches('/');
201    content
202        .lines()
203        .map(|line| line.trim())
204        .filter(|line| !line.is_empty() && !line.starts_with('#'))
205        .any(|line| line.trim_matches('/') == normalized_dir)
206}
207
208/// Layer 1 of the two-layer manifest validation design.
209/// Validates all configured agent names and scans `.apm/agents/` for issues.
210/// Returns (errors, warnings) so callers can route them — keeps this single
211/// directory scan from running twice when both validate_config and
212/// validate_warnings are invoked.
213pub fn validate_agents(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
214    let mut errors: Vec<String> = Vec::new();
215    let mut warnings: Vec<String> = Vec::new();
216    validate_agents_into(config, root, &mut errors, &mut warnings);
217    (errors, warnings)
218}
219
220fn validate_agents_into(config: &Config, root: &Path, errors: &mut Vec<String>, warnings: &mut Vec<String>) {
221    let names = configured_agent_names(config);
222
223    // Validate each configured agent name (Layer 1 error check)
224    let builtins = wrapper::list_builtin_names().join(", ");
225    for name in &names {
226        match wrapper::resolve_wrapper(root, name) {
227            Ok(None) => errors.push(format!(
228                "agent '{}' not found: checked built-ins {{{builtins}}} and '.apm/agents/{}/'",
229                name, name
230            )),
231            Err(e) => errors.push(format!("agent '{name}': {e}")),
232            Ok(Some(wrapper::WrapperKind::Custom { manifest, .. })) => {
233                if let Some(m) = &manifest {
234                    if m.parser == "external" && m.parser_command.is_none() {
235                        errors.push(format!(
236                            "agent '{name}': manifest.toml declares parser = \"external\" \
237                             but parser_command is absent"
238                        ));
239                    }
240                }
241            }
242            Ok(Some(wrapper::WrapperKind::Builtin(_))) => {}
243        }
244    }
245
246    // Scan .apm/agents/ for per-directory warnings and errors
247    let agents_dir = root.join(".apm").join("agents");
248    let Ok(entries) = std::fs::read_dir(&agents_dir) else { return };
249
250    for entry in entries.filter_map(|e| e.ok()) {
251        let ft = match entry.file_type() {
252            Ok(ft) => ft,
253            Err(_) => continue,
254        };
255        if !ft.is_dir() {
256            continue;
257        }
258        let name = entry.file_name().to_string_lossy().to_string();
259
260        // Check for non-executable wrapper.* files (Unix only)
261        let wrapper_files: Vec<_> = std::fs::read_dir(entry.path())
262            .ok()
263            .into_iter()
264            .flatten()
265            .filter_map(|e| e.ok())
266            .filter(|e| e.file_name().to_string_lossy().starts_with("wrapper."))
267            .collect();
268
269        if !wrapper_files.is_empty() {
270            #[cfg(unix)]
271            {
272                use std::os::unix::fs::PermissionsExt;
273                let any_exec = wrapper_files.iter().any(|f| {
274                    f.metadata()
275                        .map(|m| m.permissions().mode() & 0o111 != 0)
276                        .unwrap_or(false)
277                });
278                if !any_exec {
279                    warnings.push(format!(
280                        "agent '{name}': .apm/agents/{name}/wrapper.* exists but is not executable; run chmod +x"
281                    ));
282                }
283            }
284        }
285
286        // Check manifest.toml
287        let manifest_path = entry.path().join("manifest.toml");
288        if manifest_path.exists() {
289            match parse_manifest(root, &name) {
290                Err(e) => {
291                    errors.push(format!("agent '{name}': manifest.toml is not valid TOML: {e}"));
292                }
293                Ok(Some(manifest)) => {
294                    if manifest.contract_version > 1 {
295                        errors.push(format!(
296                            "agent '{name}': manifest.toml declares contract_version {}; \
297                             this APM build supports version 1 only — upgrade APM",
298                            manifest.contract_version
299                        ));
300                    }
301                    if let Ok(unknown) = manifest_unknown_keys(root, &name) {
302                        for key in unknown {
303                            warnings.push(format!(
304                                "agent '{name}': manifest.toml: unknown key {key}"
305                            ));
306                        }
307                    }
308                }
309                Ok(None) => {}
310            }
311        }
312    }
313}
314
315pub fn validate_config(config: &Config, root: &Path) -> Vec<String> {
316    let mut errors = validate_config_no_agents(config, root);
317    let (agent_errors, _) = validate_agents(config, root);
318    errors.extend(agent_errors);
319    errors
320}
321
322fn validate_config_no_agents(config: &Config, root: &Path) -> Vec<String> {
323    let mut errors: Vec<String> = Vec::new();
324
325    if config.workers.default.is_empty() {
326        errors.push(
327            "config: workers.default is not set — add `default = \"claude/coder\"` under [workers] in .apm/config.toml".into()
328        );
329    }
330
331    let state_ids: HashSet<&str> = config.workflow.states.iter()
332        .map(|s| s.id.as_str())
333        .collect();
334
335    let terminal_ids = config.terminal_state_ids();
336
337    let section_names: HashSet<&str> = config.ticket.sections.iter()
338        .map(|s| s.name.as_str())
339        .collect();
340    let has_sections = !section_names.is_empty();
341
342    // Check whether any transition requires a provider.
343    let needs_provider = config.workflow.states.iter()
344        .flat_map(|s| s.transitions.iter())
345        .any(|t| matches!(t.completion, CompletionStrategy::Pr | CompletionStrategy::Merge));
346
347    let provider_ok = config.git_host.provider.as_ref()
348        .map(|p| !p.is_empty())
349        .unwrap_or(false);
350
351    if needs_provider && !provider_ok {
352        errors.push(
353            "config: workflow — completion 'pr' or 'merge' requires [git_host] with a provider".into()
354        );
355    }
356
357    // At least one non-terminal state.
358    let has_non_terminal = config.workflow.states.iter().any(|s| !s.terminal);
359    if !has_non_terminal {
360        errors.push("config: workflow — no non-terminal state exists".into());
361    }
362
363    for state in &config.workflow.states {
364        // Terminal state with outgoing transitions.
365        if state.terminal && !state.transitions.is_empty() {
366            errors.push(format!(
367                "config: state.{} — terminal but has {} outgoing transition(s)",
368                state.id,
369                state.transitions.len()
370            ));
371        }
372
373        for transition in &state.transitions {
374            // Explicit transitions to terminal states are forbidden; terminal states are
375            // always reachable as a supervisor close action.
376            if terminal_ids.contains(transition.to.as_str()) {
377                errors.push(format!(
378                    "config: state.{}.transition({}) — explicit transitions to terminal states are not \
379                     allowed; {} is always reachable as a supervisor close action",
380                    state.id, transition.to, transition.to
381                ));
382            }
383
384            // Transition target must exist.
385            if !terminal_ids.contains(transition.to.as_str()) && !state_ids.contains(transition.to.as_str()) {
386                errors.push(format!(
387                    "config: state.{}.transition({}) — target state '{}' does not exist",
388                    state.id, transition.to, transition.to
389                ));
390            }
391
392            // context_section must match a known ticket section.
393            if let Some(section) = &transition.context_section {
394                if has_sections && !section_names.contains(section.as_str()) {
395                    errors.push(format!(
396                        "config: state.{}.transition({}).context_section — unknown section '{}'",
397                        state.id, transition.to, section
398                    ));
399                }
400            }
401
402            // focus_section must match a known ticket section.
403            if let Some(section) = &transition.focus_section {
404                if has_sections && !section_names.contains(section.as_str()) {
405                    errors.push(format!(
406                        "config: state.{}.transition({}).focus_section — unknown section '{}'",
407                        state.id, transition.to, section
408                    ));
409                }
410            }
411
412            // Merge/PrOrEpicMerge transitions require on_failure.
413            if matches!(
414                transition.completion,
415                CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
416            ) {
417                if transition.on_failure.is_none() {
418                    errors.push(format!(
419                        "config: transition '{}' → '{}' uses completion '{}' but is missing \
420                         `on_failure`; run `apm validate --fix` to add it",
421                        state.id,
422                        transition.to,
423                        strategy_name(&transition.completion)
424                    ));
425                } else if let Some(ref name) = transition.on_failure {
426                    if name != "closed" && !state_ids.contains(name.as_str()) {
427                        errors.push(format!(
428                            "config: transition '{}' → '{}' has `on_failure = \"{}\"` but \
429                             state \"{}\" is not declared in workflow.toml",
430                            state.id, transition.to, name, name
431                        ));
432                    }
433                }
434            }
435
436            // Merging completions must not target a terminal state.
437            if matches!(
438                transition.completion,
439                CompletionStrategy::Pr | CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
440            ) && terminal_ids.contains(transition.to.as_str()) {
441                errors.push(format!(
442                    "config: state.{}.transition({}) — completion {} targets terminal state {}; \
443                     merging completions must target a non-terminal (review) state",
444                    state.id,
445                    transition.to,
446                    strategy_name(&transition.completion),
447                    transition.to
448                ));
449            }
450        }
451    }
452
453    // Rule 1 — Trigger / manual separation.
454    // A state reached via a triggered transition (command:start) must not also
455    // be reached via a manual transition. Multiple triggered entries are fine
456    // (each implies a fresh dispatch); multiple manual entries are fine; the
457    // forbidden case is the mix, which makes "being in this state" ambiguous
458    // about whether a dispatch happened.
459    {
460        let mut incoming: std::collections::HashMap<&str, Vec<(&str, &str)>> =
461            std::collections::HashMap::new();
462        for state in &config.workflow.states {
463            for transition in &state.transitions {
464                incoming
465                    .entry(transition.to.as_str())
466                    .or_default()
467                    .push((state.id.as_str(), transition.trigger.as_str()));
468            }
469        }
470        for (dest, sources) in &incoming {
471            let has_command_start = sources.iter().any(|(_, t)| *t == "command:start");
472            let has_manual = sources.iter().any(|(_, t)| *t != "command:start");
473            if has_command_start && has_manual {
474                let src_list = sources
475                    .iter()
476                    .map(|(src, t)| format!("{src} (trigger: {t})"))
477                    .collect::<Vec<_>>()
478                    .join(", ");
479                errors.push(format!(
480                    "config: state.{dest} — has both triggered and manual incoming \
481                     transitions; a triggered destination must not also be reachable \
482                     via manual transitions. Incoming from: {src_list}"
483                ));
484            }
485        }
486    }
487
488    // Rule 2 — worker_profile shape.
489    for state in &config.workflow.states {
490        if let Some(wp) = &state.worker_profile {
491            let slash_count = wp.chars().filter(|&c| c == '/').count();
492            if slash_count != 1 {
493                errors.push(format!(
494                    "config: state.{}.worker_profile — '{wp}' must contain exactly one '/' separator",
495                    state.id
496                ));
497            } else if let Some((agent, role)) = wp.split_once('/') {
498                if agent.is_empty() || role.is_empty() {
499                    errors.push(format!(
500                        "config: state.{}.worker_profile — '{wp}' agent and role components must both be non-empty",
501                        state.id
502                    ));
503                } else if role == "worker" {
504                    errors.push(format!(
505                        "config: state.{}.worker_profile — role 'worker' is reserved as a process category; use a specific role name",
506                        state.id
507                    ));
508                }
509            }
510        }
511    }
512
513    // Rule 3 — command:start must target a dispatch-capable state (one with worker_profile).
514    {
515        let dispatch_states: HashSet<&str> = config.workflow.states.iter()
516            .filter(|s| s.worker_profile.is_some())
517            .map(|s| s.id.as_str())
518            .collect();
519        for state in &config.workflow.states {
520            for transition in &state.transitions {
521                if transition.trigger == "command:start"
522                    && !dispatch_states.contains(transition.to.as_str())
523                {
524                    errors.push(format!(
525                        "config: state.{}.transition({}) — trigger 'command:start' targets \
526                         state '{}' which has no worker_profile; the dispatcher has nothing to spawn",
527                        state.id, transition.to, transition.to
528                    ));
529                }
530            }
531        }
532    }
533
534    if !is_external_worktree(&config.worktrees.dir) {
535        let dir_str = config.worktrees.dir.to_string_lossy();
536        let gitignore = root.join(".gitignore");
537        match std::fs::read_to_string(&gitignore) {
538            Err(_) => errors.push(format!(
539                "config: worktrees.dir '{dir_str}' is in-repo but .gitignore is missing; \
540                 run 'apm init' or add '/{dir_str}/' manually"
541            )),
542            Ok(content) if !gitignore_covers_dir(&content, &dir_str) => errors.push(format!(
543                "config: worktrees.dir '{dir_str}' is in-repo but .gitignore does not cover it; \
544                 add '/{dir_str}/' or run 'apm init'"
545            )),
546            Ok(_) => {}
547        }
548    }
549
550    errors
551}
552
553pub fn verify_tickets(
554    root: &Path,
555    config: &Config,
556    tickets: &[Ticket],
557    merged: &HashSet<String>,
558) -> Vec<String> {
559    let valid_states: HashSet<&str> = config.workflow.states.iter()
560        .map(|s| s.id.as_str())
561        .collect();
562    let terminal = config.terminal_state_ids();
563
564    let in_progress_states: HashSet<&str> =
565        ["in_progress", "implemented"].iter().copied().collect();
566
567    let worktree_states: HashSet<&str> =
568        ["in_design", "in_progress"].iter().copied().collect();
569    let main_root = crate::git_util::main_worktree_root(root)
570        .unwrap_or_else(|| root.to_path_buf());
571    let worktrees_base = main_root.join(&config.worktrees.dir);
572
573    let mut issues: Vec<String> = Vec::new();
574
575    for t in tickets {
576        let fm = &t.frontmatter;
577
578        // Skip terminal-state tickets.
579        if terminal.contains(fm.state.as_str()) { continue; }
580
581        let prefix = format!("#{} [{}]", fm.id, fm.state);
582
583        // State value not in config.
584        if !valid_states.is_empty() && !valid_states.contains(fm.state.as_str()) {
585            issues.push(format!("{prefix}: unknown state {:?}", fm.state));
586        }
587
588        // Frontmatter id doesn't match filename numeric prefix.
589        if let Some(name) = t.path.file_name().and_then(|n| n.to_str()) {
590            let expected_prefix = format!("{:04}", fm.id);
591            if !name.starts_with(&expected_prefix) {
592                issues.push(format!("{prefix}: id {} does not match filename {name}", fm.id));
593            }
594        }
595
596        // in_progress/implemented with no branch.
597        if in_progress_states.contains(fm.state.as_str()) && fm.branch.is_none() {
598            issues.push(format!("{prefix}: state requires branch but none set"));
599        }
600
601        // Branch merged but ticket not yet closed.
602        if let Some(branch) = &fm.branch {
603            if (fm.state == "in_progress" || fm.state == "implemented")
604                && merged.contains(branch.as_str())
605            {
606                issues.push(format!("{prefix}: branch {branch} is merged but ticket not closed"));
607            }
608        }
609
610        // in_design/in_progress with missing worktree directory.
611        if worktree_states.contains(fm.state.as_str()) {
612            if let Some(branch) = &fm.branch {
613                let wt_name = branch.replace('/', "-");
614                let wt_path = worktrees_base.join(&wt_name);
615                if !wt_path.is_dir() {
616                    issues.push(format!(
617                        "{prefix}: worktree at {} is missing",
618                        wt_path.display()
619                    ));
620                }
621            }
622        }
623
624        // Missing ## Spec section.
625        if !t.body.contains("## Spec") {
626            issues.push(format!("{prefix}: missing ## Spec section"));
627        }
628
629        // Missing ## History section.
630        if !t.body.contains("## History") {
631            issues.push(format!("{prefix}: missing ## History section"));
632        }
633
634        // Validate document structure (required sections non-empty, AC items present).
635        if let Ok(doc) = t.document() {
636            for err in doc.validate(&config.ticket.sections) {
637                issues.push(format!("{prefix}: {err}"));
638            }
639        }
640
641        // Validate frontmatter agent names against known built-ins and
642        // against the agents configured in config.toml.
643        let agents_to_check: Vec<&str> = fm.agent
644            .as_deref()
645            .into_iter()
646            .chain(fm.agent_overrides.values().map(String::as_str))
647            .collect();
648
649        let configured_agents = configured_agent_names(config);
650
651        for name in agents_to_check {
652            match wrapper::resolve_wrapper(root, name) {
653                Ok(Some(_)) => {}
654                Ok(None) => issues.push(format!(
655                    "ticket {}: agent {:?} is not a known built-in",
656                    fm.id, name
657                )),
658                Err(e) => issues.push(format!(
659                    "ticket {}: agent {:?}: {e}",
660                    fm.id, name
661                )),
662            }
663            if !configured_agents.contains(name) {
664                issues.push(format!(
665                    "ticket {}: agent {:?} is not configured in config.toml \
666                     (add a worker_profile = \"<agent>/...\" on a spawn transition)",
667                    fm.id, name
668                ));
669            }
670        }
671    }
672
673    // Branch ↔ filename invariant: apm derives the ticket file path from the
674    // branch suffix (`ticket/<suffix>` → `tickets/<suffix>.md`). When a worker
675    // agent renames the file, `load_all_from_git` silently drops the ticket
676    // and it disappears from `apm list`. Catch it here by walking the
677    // ticket branches directly rather than the loaded list.
678    issues.extend(verify_branch_file_invariant(root, config));
679
680    issues
681}
682
683/// Walk every `ticket/*` branch and report file-layout problems that
684/// would otherwise make the ticket invisible to `apm list`.
685fn verify_branch_file_invariant(root: &Path, config: &Config) -> Vec<String> {
686    let mut issues: Vec<String> = Vec::new();
687    let tickets_dir = config.tickets.dir.to_string_lossy().to_string();
688    let branches = match crate::git_util::ticket_branches(root) {
689        Ok(b) => b,
690        Err(_) => return issues,
691    };
692    for branch in &branches {
693        let suffix = branch.trim_start_matches("ticket/");
694        // Skip bare 8-hex refs (created transiently by fetch); they aren't
695        // real ticket branches.
696        if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
697            continue;
698        }
699        let expected_filename = format!("{suffix}.md");
700        let expected_path = format!("{tickets_dir}/{expected_filename}");
701
702        // If the expected file exists on the branch, this branch is fine.
703        if crate::git_util::read_from_branch(root, branch, &expected_path).is_ok() {
704            continue;
705        }
706
707        // Otherwise, look for any `tickets/<id>-*.md` on the branch so we
708        // can distinguish a rename from an orphan.
709        let id_prefix: String = suffix.chars().take_while(|c| *c != '-').collect();
710        let files = crate::git_util::list_files_on_branch(root, branch, &tickets_dir)
711            .unwrap_or_default();
712        let id_matches: Vec<&String> = files.iter()
713            .filter(|f| {
714                let leaf = f.rsplit('/').next().unwrap_or("");
715                leaf.starts_with(&format!("{id_prefix}-")) && leaf.ends_with(".md")
716            })
717            .collect();
718
719        if id_matches.is_empty() {
720            issues.push(format!(
721                "branch {branch}: no ticket file at {expected_path} (orphaned branch — \
722                 no tickets/{id_prefix}-*.md exists on this branch)"
723            ));
724        } else {
725            let found: Vec<String> = id_matches.iter().map(|s| (*s).clone()).collect();
726            issues.push(format!(
727                "branch {branch}: ticket file renamed — expected {expected_path}, \
728                 found {} on branch. apm derives the filename from the branch \
729                 suffix; rename the file back (or rename the branch) so it matches.",
730                found.join(", ")
731            ));
732        }
733    }
734    issues
735}
736
737pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec<String> {
738    let mut warnings = validate_warnings_no_agents(config, root);
739    let (_, agent_warnings) = validate_agents(config, root);
740    warnings.extend(agent_warnings);
741    warnings
742}
743
744fn validate_warnings_no_agents(config: &crate::config::Config, _root: &Path) -> Vec<String> {
745    let mut warnings = config.load_warnings.clone();
746    if let Some(container) = &config.workers.container {
747        if !container.is_empty() {
748            let docker_ok = std::process::Command::new("docker")
749                .arg("--version")
750                .output()
751                .map(|o| o.status.success())
752                .unwrap_or(false);
753            if !docker_ok {
754                warnings.push(
755                    "workers.container is set but 'docker' is not in PATH".to_string()
756                );
757            }
758        }
759    }
760
761    // Dead-end reachability check: warn when no agent-actionable state can reach a
762    // transition whose outcome resolves to "success".
763    let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
764        config.workflow.states.iter()
765            .map(|s| (s.id.as_str(), s))
766            .collect();
767
768    let agent_startable: Vec<&str> = config.workflow.states.iter()
769        .filter(|s| s.transitions.iter().any(|t| t.trigger == "command:start"))
770        .map(|s| s.id.as_str())
771        .collect();
772
773    if !agent_startable.is_empty() {
774        let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
775        let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
776        let mut found_success = false;
777
778        for &start in &agent_startable {
779            if visited.insert(start) {
780                queue.push_back(start);
781            }
782        }
783
784        'bfs: while let Some(state_id) = queue.pop_front() {
785            let Some(state) = state_map.get(state_id) else { continue };
786            for t in &state.transitions {
787                // Unknown targets are reported as errors by validate_config; skip
788                // here so reachability isn't computed against malformed transitions.
789                let Some(&target) = state_map.get(t.to.as_str()) else { continue };
790                if resolve_outcome(t, target) == "success" {
791                    found_success = true;
792                    break 'bfs;
793                }
794                if !target.terminal && visited.insert(t.to.as_str()) {
795                    queue.push_back(t.to.as_str());
796                }
797            }
798        }
799
800        if !found_success {
801            warnings.push(
802                "workflow has no reachable 'success' outcome from any agent-actionable state; \
803                 workers may never complete successfully".to_string()
804            );
805        }
806    }
807
808    warnings
809}
810
811fn format_wrapper(root: &Path, agent: &str) -> String {
812    match wrapper::resolve_wrapper(root, agent) {
813        Ok(Some(wrapper::WrapperKind::Builtin(ref name))) => format!("builtin:{name}"),
814        Ok(Some(wrapper::WrapperKind::Custom { ref script_path, .. })) => {
815            script_path.to_string_lossy().into_owned()
816        }
817        Ok(None) => "(not found)".to_string(),
818        Err(_) => "(error)".to_string(),
819    }
820}
821
822/// Build an agent-resolution audit for every `command:start` spawn transition in the config.
823pub fn audit_agent_resolution(config: &Config, root: &Path) -> Vec<TransitionAudit> {
824    let mut result = Vec::new();
825    let default_profile = config.workers.default.as_str();
826
827    for state in &config.workflow.states {
828        for transition in &state.transitions {
829            if transition.trigger != "command:start" {
830                continue;
831            }
832
833            let to_state_wp = config.workflow.states.iter()
834                .find(|s| s.id == transition.to)
835                .and_then(|s| s.worker_profile.as_deref());
836            let wp_str = to_state_wp.unwrap_or(default_profile);
837            let (agent, role) = wp_str.split_once('/')
838                .map(|(a, r)| (a.to_string(), r.to_string()))
839                .unwrap_or_else(|| ("claude".to_string(), "worker".to_string()));
840
841            let wrapper_str = format_wrapper(root, &agent);
842
843            result.push(TransitionAudit {
844                from_state: state.id.clone(),
845                to_state: transition.to.clone(),
846                worker_profile: to_state_wp.map(|s| s.to_string()),
847                agent,
848                role,
849                wrapper: wrapper_str,
850            });
851        }
852    }
853
854    result
855}
856
857/// Single-pass equivalent of calling `validate_config` followed by
858/// `validate_warnings` — runs the agent-directory scan once and dedupes.
859pub fn validate_all(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
860    let mut errors = validate_config_no_agents(config, root);
861    let mut warnings = validate_warnings_no_agents(config, root);
862    let (agent_errors, agent_warnings) = validate_agents(config, root);
863    errors.extend(agent_errors);
864    warnings.extend(agent_warnings);
865    (errors, warnings)
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::config::{Config, CompletionStrategy, LocalConfig};
872    use crate::ticket::Ticket;
873    use crate::git_util;
874    use std::path::Path;
875    use std::collections::HashSet;
876
877    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
878        std::process::Command::new("git")
879            .args(args)
880            .current_dir(dir)
881            .env("GIT_AUTHOR_NAME", "test")
882            .env("GIT_AUTHOR_EMAIL", "test@test.com")
883            .env("GIT_COMMITTER_NAME", "test")
884            .env("GIT_COMMITTER_EMAIL", "test@test.com")
885            .status()
886            .unwrap();
887    }
888
889    fn setup_verify_repo() -> tempfile::TempDir {
890        let dir = tempfile::tempdir().unwrap();
891        let p = dir.path();
892
893        git_cmd(p, &["init", "-q", "-b", "main"]);
894        git_cmd(p, &["config", "user.email", "test@test.com"]);
895        git_cmd(p, &["config", "user.name", "test"]);
896
897        std::fs::create_dir_all(p.join(".apm")).unwrap();
898        std::fs::write(
899            p.join(".apm/config.toml"),
900            r#"[project]
901name = "test"
902
903[tickets]
904dir = "tickets"
905
906[workers]
907default = "claude/coder"
908
909[worktrees]
910dir = "worktrees"
911
912[[workflow.states]]
913id = "in_design"
914label = "In Design"
915
916[[workflow.states]]
917id = "in_progress"
918label = "In Progress"
919
920[[workflow.states]]
921id = "specd"
922label = "Specd"
923"#,
924        )
925        .unwrap();
926
927        git_cmd(p, &["add", ".apm/config.toml"]);
928        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
929
930        dir
931    }
932
933    fn make_verify_ticket(root: &std::path::Path, id: &str, state: &str, branch: Option<&str>) -> Ticket {
934        let branch_line = match branch {
935            Some(b) => format!("branch = \"{b}\"\n"),
936            None => String::new(),
937        };
938        let raw = format!(
939            "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{branch_line}+++\n\n## Spec\n\n## History\n"
940        );
941        let path = root.join("tickets").join(format!("{id}-test.md"));
942        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
943        std::fs::write(&path, &raw).unwrap();
944        Ticket::parse(&path, &raw).unwrap()
945    }
946
947    fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
948        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
949        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
950        let raw = format!(
951            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
952        );
953        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
954    }
955
956    fn strategy_config(completion: &str) -> Config {
957        let toml = format!(
958            r#"
959[project]
960name = "test"
961
962[tickets]
963dir = "tickets"
964
965[[workflow.states]]
966id    = "in_progress"
967label = "In Progress"
968
969[[workflow.states.transitions]]
970to         = "implemented"
971completion = "{completion}"
972
973[[workflow.states]]
974id       = "implemented"
975label    = "Implemented"
976terminal = true
977"#
978        );
979        toml::from_str(&toml).unwrap()
980    }
981
982    #[test]
983    fn strategy_finds_in_progress_to_implemented() {
984        let config = strategy_config("pr_or_epic_merge");
985        assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
986    }
987
988    #[test]
989    fn strategy_defaults_to_none_when_absent() {
990        let toml = r#"
991[project]
992name = "test"
993
994[tickets]
995dir = "tickets"
996
997[[workflow.states]]
998id    = "new"
999label = "New"
1000
1001[[workflow.states.transitions]]
1002to = "closed"
1003
1004[[workflow.states]]
1005id       = "closed"
1006label    = "Closed"
1007terminal = true
1008"#;
1009        let config: Config = toml::from_str(toml).unwrap();
1010        assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
1011    }
1012
1013    #[test]
1014    fn dep_rules_pr_rejects_dep() {
1015        let dep = make_ticket("dep1", None, None);
1016        let result = check_depends_on_rules(
1017            &CompletionStrategy::Pr,
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("pr"), "expected strategy name in: {msg}");
1027    }
1028
1029    #[test]
1030    fn dep_rules_none_rejects_dep() {
1031        let dep = make_ticket("dep1", None, None);
1032        let result = check_depends_on_rules(
1033            &CompletionStrategy::None,
1034            None,
1035            None,
1036            &["dep1".to_string()],
1037            &[dep],
1038            "main",
1039        );
1040        assert!(result.is_err());
1041        let msg = result.unwrap_err().to_string();
1042        assert!(msg.contains("none"), "expected strategy name in: {msg}");
1043    }
1044
1045    #[test]
1046    fn dep_rules_pr_or_epic_merge_same_epic_ok() {
1047        let dep = make_ticket("dep1", Some("abc"), None);
1048        let result = check_depends_on_rules(
1049            &CompletionStrategy::PrOrEpicMerge,
1050            Some("abc"),
1051            None,
1052            &["dep1".to_string()],
1053            &[dep],
1054            "main",
1055        );
1056        assert!(result.is_ok(), "expected Ok, got {result:?}");
1057    }
1058
1059    #[test]
1060    fn dep_rules_pr_or_epic_merge_different_epic_fails() {
1061        let dep = make_ticket("dep1", Some("xyz"), None);
1062        let result = check_depends_on_rules(
1063            &CompletionStrategy::PrOrEpicMerge,
1064            Some("abc"),
1065            None,
1066            &["dep1".to_string()],
1067            &[dep],
1068            "main",
1069        );
1070        assert!(result.is_err());
1071        let msg = result.unwrap_err().to_string();
1072        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1073    }
1074
1075    #[test]
1076    fn dep_rules_pr_or_epic_merge_standalone_ticket_ok() {
1077        // Standalone ticket (no epic) may have depends_on under pr_or_epic_merge;
1078        // it will use the independent PR path and depends_on still enforces ordering.
1079        let dep = make_ticket("dep1", Some("abc"), None);
1080        let result = check_depends_on_rules(
1081            &CompletionStrategy::PrOrEpicMerge,
1082            None,
1083            None,
1084            &["dep1".to_string()],
1085            &[dep],
1086            "main",
1087        );
1088        assert!(result.is_ok(), "expected Ok for standalone ticket, got {result:?}");
1089    }
1090
1091    #[test]
1092    fn dep_rules_merge_both_default_branch_ok() {
1093        let dep = make_ticket("dep1", None, None);
1094        let result = check_depends_on_rules(
1095            &CompletionStrategy::Merge,
1096            None,
1097            None,
1098            &["dep1".to_string()],
1099            &[dep],
1100            "main",
1101        );
1102        assert!(result.is_ok(), "expected Ok, got {result:?}");
1103    }
1104
1105    #[test]
1106    fn dep_rules_merge_different_target_fails() {
1107        let dep = make_ticket("dep1", None, Some("epic/other"));
1108        let result = check_depends_on_rules(
1109            &CompletionStrategy::Merge,
1110            None,
1111            None,
1112            &["dep1".to_string()],
1113            &[dep],
1114            "main",
1115        );
1116        assert!(result.is_err());
1117        let msg = result.unwrap_err().to_string();
1118        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1119    }
1120
1121    fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
1122        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1123        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
1124        let deps_line = if depends_on.is_empty() {
1125            String::new()
1126        } else {
1127            let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
1128            format!("depends_on = [{}]\n", quoted.join(", "))
1129        };
1130        let raw = format!(
1131            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
1132        );
1133        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
1134    }
1135
1136    #[test]
1137    fn validate_depends_on_no_deps_clean() {
1138        let config = strategy_config("pr_or_epic_merge");
1139        let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
1140        let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
1141        let result = validate_depends_on(&config, &[t1, t2]);
1142        assert!(result.is_empty(), "expected no violations, got {result:?}");
1143    }
1144
1145    #[test]
1146    fn validate_depends_on_closed_ticket_skipped() {
1147        let config = strategy_config("pr");
1148        let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
1149        let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
1150        let result = validate_depends_on(&config, &[dep, ticket]);
1151        assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
1152    }
1153
1154    #[test]
1155    fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
1156        let config = strategy_config("pr_or_epic_merge");
1157        let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
1158        let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
1159        let result = validate_depends_on(&config, &[dep, ticket]);
1160        assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
1161    }
1162
1163    #[test]
1164    fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
1165        let config = strategy_config("pr_or_epic_merge");
1166        let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
1167        let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
1168        let result = validate_depends_on(&config, &[dep, ticket]);
1169        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1170        assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
1171    }
1172
1173    #[test]
1174    fn validate_depends_on_merge_same_target_ok() {
1175        let config = strategy_config("merge");
1176        let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
1177        let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
1178        let result = validate_depends_on(&config, &[dep, ticket]);
1179        assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
1180    }
1181
1182    #[test]
1183    fn validate_depends_on_merge_different_target_fails() {
1184        let config = strategy_config("merge");
1185        let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
1186        let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
1187        let result = validate_depends_on(&config, &[dep, ticket]);
1188        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1189        assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
1190    }
1191
1192    #[test]
1193    fn validate_depends_on_pr_strategy_rejects_any_dep() {
1194        let config = strategy_config("pr");
1195        let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
1196        let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
1197        let result = validate_depends_on(&config, &[dep, ticket]);
1198        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1199        assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
1200    }
1201
1202    fn load_config(toml: &str) -> Config {
1203        toml::from_str(toml).expect("config parse failed")
1204    }
1205
1206    fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
1207        config.workflow.states.iter().map(|s| s.id.as_str()).collect()
1208    }
1209
1210    // Test 1: correct config passes all checks
1211    #[test]
1212    fn correct_config_passes() {
1213        let toml = r#"
1214[project]
1215name = "test"
1216
1217[tickets]
1218dir = "tickets"
1219
1220[workers]
1221default = "claude/coder"
1222
1223[[workflow.states]]
1224id    = "new"
1225label = "New"
1226
1227[[workflow.states.transitions]]
1228to = "in_progress"
1229
1230[[workflow.states]]
1231id       = "in_progress"
1232label    = "In Progress"
1233terminal = false
1234
1235[[workflow.states]]
1236id       = "closed"
1237label    = "Closed"
1238terminal = true
1239"#;
1240        let config = load_config(toml);
1241        let errors = validate_config(&config, Path::new("/tmp"));
1242        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1243    }
1244
1245    // Test 2: transition to non-existent state is detected
1246    #[test]
1247    fn transition_to_nonexistent_state_detected() {
1248        let toml = r#"
1249[project]
1250name = "test"
1251
1252[tickets]
1253dir = "tickets"
1254
1255[[workflow.states]]
1256id    = "new"
1257label = "New"
1258
1259[[workflow.states.transitions]]
1260to = "ghost"
1261"#;
1262        let config = load_config(toml);
1263        let errors = validate_config(&config, Path::new("/tmp"));
1264        assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
1265    }
1266
1267    // Test 3: terminal state with outgoing transitions is detected
1268    #[test]
1269    fn terminal_state_with_transitions_detected() {
1270        let toml = r#"
1271[project]
1272name = "test"
1273
1274[tickets]
1275dir = "tickets"
1276
1277[[workflow.states]]
1278id       = "closed"
1279label    = "Closed"
1280terminal = true
1281
1282[[workflow.states.transitions]]
1283to = "new"
1284
1285[[workflow.states]]
1286id    = "new"
1287label = "New"
1288
1289[[workflow.states.transitions]]
1290to = "closed"
1291"#;
1292        let config = load_config(toml);
1293        let errors = validate_config(&config, Path::new("/tmp"));
1294        assert!(
1295            errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
1296            "expected terminal error in {errors:?}"
1297        );
1298    }
1299
1300    // Test 5: ticket with unknown state is detected
1301    #[test]
1302    fn ticket_with_unknown_state_detected() {
1303        use crate::ticket::Ticket;
1304
1305        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
1306        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1307
1308        let known_states: std::collections::HashSet<&str> =
1309            ["new", "ready", "closed"].iter().copied().collect();
1310
1311        assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
1312    }
1313
1314    // Test 6: dead-end non-terminal state is no longer an error — implicit close
1315    // means any non-terminal state can always be closed by the supervisor.
1316    #[test]
1317    fn dead_end_non_terminal_not_an_error() {
1318        let toml = r#"
1319[project]
1320name = "test"
1321
1322[tickets]
1323dir = "tickets"
1324
1325[[workflow.states]]
1326id    = "stuck"
1327label = "Stuck"
1328
1329[[workflow.states]]
1330id       = "closed"
1331label    = "Closed"
1332terminal = true
1333"#;
1334        let config = load_config(toml);
1335        let errors = validate_config(&config, Path::new("/tmp"));
1336        assert!(
1337            !errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
1338            "dead-end should not be an error after implicit-close rule; got: {errors:?}"
1339        );
1340    }
1341
1342    // Test 6b: explicit terminal transition is rejected with a clear error naming
1343    // the source state and the terminal target.
1344    #[test]
1345    fn explicit_terminal_transition_rejected() {
1346        let toml = r#"
1347[project]
1348name = "test"
1349
1350[tickets]
1351dir = "tickets"
1352
1353[workers]
1354default = "claude/coder"
1355
1356[[workflow.states]]
1357id    = "new"
1358label = "New"
1359
1360[[workflow.states.transitions]]
1361to      = "closed"
1362trigger = "manual"
1363outcome = "cancelled"
1364
1365[[workflow.states]]
1366id       = "closed"
1367label    = "Closed"
1368terminal = true
1369"#;
1370        let config = load_config(toml);
1371        let errors = validate_config(&config, Path::new("/tmp"));
1372        assert!(
1373            errors.iter().any(|e| e.contains("state.new.transition(closed)") && e.contains("explicit transitions to terminal states")),
1374            "expected explicit-terminal-transition error naming source and target; got: {errors:?}"
1375        );
1376        assert!(
1377            errors.iter().any(|e| e.contains("closed") && e.contains("always reachable")),
1378            "error should mention that closed is always reachable; got: {errors:?}"
1379        );
1380    }
1381
1382    // Test 7: context_section mismatch is detected
1383    #[test]
1384    fn context_section_mismatch_detected() {
1385        let toml = r#"
1386[project]
1387name = "test"
1388
1389[tickets]
1390dir = "tickets"
1391
1392[[ticket.sections]]
1393name = "Problem"
1394type = "free"
1395
1396[[workflow.states]]
1397id    = "new"
1398label = "New"
1399
1400[[workflow.states.transitions]]
1401to              = "ready"
1402context_section = "NonExistent"
1403
1404[[workflow.states]]
1405id    = "ready"
1406label = "Ready"
1407
1408[[workflow.states.transitions]]
1409to = "closed"
1410
1411[[workflow.states]]
1412id       = "closed"
1413label    = "Closed"
1414terminal = true
1415"#;
1416        let config = load_config(toml);
1417        let errors = validate_config(&config, Path::new("/tmp"));
1418        assert!(
1419            errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
1420            "expected context_section error in {errors:?}"
1421        );
1422    }
1423
1424    // Test 8: focus_section mismatch is detected
1425    #[test]
1426    fn focus_section_mismatch_detected() {
1427        let toml = r#"
1428[project]
1429name = "test"
1430
1431[tickets]
1432dir = "tickets"
1433
1434[[ticket.sections]]
1435name = "Problem"
1436type = "free"
1437
1438[[workflow.states]]
1439id    = "new"
1440label = "New"
1441
1442[[workflow.states.transitions]]
1443to             = "ready"
1444focus_section  = "BadSection"
1445
1446[[workflow.states]]
1447id    = "ready"
1448label = "Ready"
1449
1450[[workflow.states.transitions]]
1451to = "closed"
1452
1453[[workflow.states]]
1454id       = "closed"
1455label    = "Closed"
1456terminal = true
1457"#;
1458        let config = load_config(toml);
1459        let errors = validate_config(&config, Path::new("/tmp"));
1460        assert!(
1461            errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
1462            "expected focus_section error in {errors:?}"
1463        );
1464    }
1465
1466    // Test 9: completion=pr without provider is detected
1467    #[test]
1468    fn completion_pr_without_provider_detected() {
1469        let toml = r#"
1470[project]
1471name = "test"
1472
1473[tickets]
1474dir = "tickets"
1475
1476[[workflow.states]]
1477id    = "new"
1478label = "New"
1479
1480[[workflow.states.transitions]]
1481to         = "closed"
1482completion = "pr"
1483
1484[[workflow.states]]
1485id       = "closed"
1486label    = "Closed"
1487terminal = true
1488"#;
1489        let config = load_config(toml);
1490        let errors = validate_config(&config, Path::new("/tmp"));
1491        assert!(
1492            errors.iter().any(|e| e.contains("provider")),
1493            "expected provider error in {errors:?}"
1494        );
1495    }
1496
1497    // Test 10: completion=pr with provider configured passes
1498    #[test]
1499    fn completion_pr_with_provider_passes() {
1500        let toml = r#"
1501[project]
1502name = "test"
1503
1504[tickets]
1505dir = "tickets"
1506
1507[git_host]
1508provider = "github"
1509
1510[[workflow.states]]
1511id    = "new"
1512label = "New"
1513
1514[[workflow.states.transitions]]
1515to         = "closed"
1516completion = "pr"
1517
1518[[workflow.states]]
1519id       = "closed"
1520label    = "Closed"
1521terminal = true
1522"#;
1523        let config = load_config(toml);
1524        let errors = validate_config(&config, Path::new("/tmp"));
1525        assert!(
1526            !errors.iter().any(|e| e.contains("provider")),
1527            "unexpected provider error in {errors:?}"
1528        );
1529    }
1530
1531    // Test 11: context_section with empty ticket.sections is skipped
1532    #[test]
1533    fn context_section_skipped_when_no_sections_defined() {
1534        let toml = r#"
1535[project]
1536name = "test"
1537
1538[tickets]
1539dir = "tickets"
1540
1541[[workflow.states]]
1542id    = "new"
1543label = "New"
1544
1545[[workflow.states.transitions]]
1546to              = "closed"
1547context_section = "AnySection"
1548
1549[[workflow.states]]
1550id       = "closed"
1551label    = "Closed"
1552terminal = true
1553"#;
1554        let config = load_config(toml);
1555        let errors = validate_config(&config, Path::new("/tmp"));
1556        assert!(
1557            !errors.iter().any(|e| e.contains("context_section")),
1558            "unexpected context_section error in {errors:?}"
1559        );
1560    }
1561
1562    // Test: closed state is not flagged as unknown even when absent from config
1563    #[test]
1564    fn closed_state_not_flagged_as_unknown() {
1565        use crate::ticket::Ticket;
1566
1567        // Config with no "closed" state
1568        let toml = r#"
1569[project]
1570name = "test"
1571
1572[tickets]
1573dir = "tickets"
1574
1575[[workflow.states]]
1576id    = "new"
1577label = "New"
1578
1579[[workflow.states.transitions]]
1580to = "done"
1581
1582[[workflow.states]]
1583id       = "done"
1584label    = "Done"
1585terminal = true
1586"#;
1587        let config = load_config(toml);
1588        let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
1589            .map(|s| s.id.as_str())
1590            .collect();
1591
1592        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
1593        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1594
1595        // "closed" is not in state_ids, but the validate logic skips it.
1596        assert!(!state_ids.contains("closed"));
1597        // Simulate the validate check: closed should be exempt.
1598        let fm = &ticket.frontmatter;
1599        let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
1600        assert!(!flagged, "closed state should not be flagged as unknown");
1601    }
1602
1603    // Test for state_ids helper (kept for compatibility)
1604    #[test]
1605    fn state_ids_helper() {
1606        let toml = r#"
1607[project]
1608name = "test"
1609
1610[tickets]
1611dir = "tickets"
1612
1613[[workflow.states]]
1614id    = "new"
1615label = "New"
1616"#;
1617        let config = load_config(toml);
1618        let ids = state_ids(&config);
1619        assert!(ids.contains("new"));
1620    }
1621
1622    #[test]
1623    fn validate_warnings_no_container() {
1624        let toml = r#"
1625[project]
1626name = "test"
1627
1628[tickets]
1629dir = "tickets"
1630"#;
1631        let config = load_config(toml);
1632        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1633        assert!(warnings.is_empty());
1634    }
1635
1636    #[test]
1637    fn valid_collaborator_accepted() {
1638        let toml = r#"
1639[project]
1640name = "test"
1641collaborators = ["alice", "bob"]
1642
1643[tickets]
1644dir = "tickets"
1645"#;
1646        let config = load_config(toml);
1647        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1648    }
1649
1650    #[test]
1651    fn unknown_user_rejected() {
1652        let toml = r#"
1653[project]
1654name = "test"
1655collaborators = ["alice", "bob"]
1656
1657[tickets]
1658dir = "tickets"
1659"#;
1660        let config = load_config(toml);
1661        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1662        let msg = err.to_string();
1663        assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
1664        assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
1665    }
1666
1667    #[test]
1668    fn empty_collaborators_skips_validation() {
1669        let toml = r#"
1670[project]
1671name = "test"
1672
1673[tickets]
1674dir = "tickets"
1675"#;
1676        let config = load_config(toml);
1677        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1678    }
1679
1680    #[test]
1681    fn clear_owner_always_allowed() {
1682        let toml = r#"
1683[project]
1684name = "test"
1685collaborators = ["alice"]
1686
1687[tickets]
1688dir = "tickets"
1689"#;
1690        let config = load_config(toml);
1691        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1692    }
1693
1694    #[test]
1695    fn github_mode_known_user_accepted() {
1696        let toml = r#"
1697[project]
1698name = "test"
1699collaborators = ["alice", "bob"]
1700
1701[tickets]
1702dir = "tickets"
1703
1704[git_host]
1705provider = "github"
1706repo = "org/repo"
1707"#;
1708        let config = load_config(toml);
1709        // No token in LocalConfig::default() — falls back to project.collaborators
1710        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1711    }
1712
1713    #[test]
1714    fn github_mode_unknown_user_rejected() {
1715        let toml = r#"
1716[project]
1717name = "test"
1718collaborators = ["alice", "bob"]
1719
1720[tickets]
1721dir = "tickets"
1722
1723[git_host]
1724provider = "github"
1725repo = "org/repo"
1726"#;
1727        let config = load_config(toml);
1728        // No token — falls back to project.collaborators; charlie is not in the list
1729        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1730        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1731    }
1732
1733    #[test]
1734    fn github_mode_no_collaborators_skips_check() {
1735        let toml = r#"
1736[project]
1737name = "test"
1738
1739[tickets]
1740dir = "tickets"
1741
1742[git_host]
1743provider = "github"
1744repo = "org/repo"
1745"#;
1746        let config = load_config(toml);
1747        // Empty collaborators list — no validation
1748        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1749    }
1750
1751    #[test]
1752    fn github_mode_clear_owner_accepted() {
1753        let toml = r#"
1754[project]
1755name = "test"
1756collaborators = ["alice"]
1757
1758[tickets]
1759dir = "tickets"
1760
1761[git_host]
1762provider = "github"
1763repo = "org/repo"
1764"#;
1765        let config = load_config(toml);
1766        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1767    }
1768
1769    #[test]
1770    fn non_github_mode_unknown_user_rejected() {
1771        let toml = r#"
1772[project]
1773name = "test"
1774collaborators = ["alice", "bob"]
1775
1776[tickets]
1777dir = "tickets"
1778"#;
1779        let config = load_config(toml);
1780        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1781        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1782    }
1783
1784    #[test]
1785    fn validate_warnings_empty_container() {
1786        let toml = r#"
1787[project]
1788name = "test"
1789
1790[tickets]
1791dir = "tickets"
1792
1793[workers]
1794default = "claude/coder"
1795container = ""
1796"#;
1797        let config = load_config(toml);
1798        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1799        assert!(warnings.is_empty(), "empty container string should not warn");
1800    }
1801
1802    #[test]
1803    fn dead_end_workflow_warning_emitted() {
1804        // A workflow where the only agent-actionable state cycles back to itself
1805        // with no completion strategy — no "success" outcome is reachable.
1806        let toml = r#"
1807[project]
1808name = "test"
1809
1810[tickets]
1811dir = "tickets"
1812
1813[[workflow.states]]
1814id    = "start"
1815label = "Start"
1816
1817[[workflow.states.transitions]]
1818to      = "middle"
1819trigger = "command:start"
1820
1821[[workflow.states]]
1822id    = "middle"
1823label = "Middle"
1824
1825[[workflow.states.transitions]]
1826to = "start"
1827"#;
1828        let config = load_config(toml);
1829        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1830        assert!(
1831            warnings.iter().any(|w| w.contains("success")),
1832            "expected dead-end warning containing 'success'; got: {warnings:?}"
1833        );
1834    }
1835
1836    #[test]
1837    fn default_workflow_no_dead_end_warning() {
1838        // The default workflow has in_progress → implemented with completion = pr_or_epic_merge,
1839        // reachable from the agent-actionable "ready" state. No dead-end warning should fire.
1840        let base = r#"
1841[project]
1842name = "test"
1843
1844[tickets]
1845dir = "tickets"
1846"#;
1847        let combined = format!("{}\n{}", base, crate::init::default_workflow_toml());
1848        let config: Config = toml::from_str(&combined).unwrap();
1849        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1850        assert!(
1851            !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")),
1852            "unexpected dead-end warning for default workflow; got: {warnings:?}"
1853        );
1854    }
1855
1856    #[test]
1857    fn worktree_missing_in_design() {
1858        let dir = setup_verify_repo();
1859        let root = dir.path();
1860        let config = Config::load(root).unwrap();
1861        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1862
1863        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1864
1865        let main_root = git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1866        let wt_path = main_root.join("worktrees").join("ticket-abcd1234-test");
1867        let expected = format!(
1868            "#abcd1234 [in_design]: worktree at {} is missing",
1869            wt_path.display()
1870        );
1871        assert!(
1872            issues.iter().any(|i| i == &expected),
1873            "expected worktree missing issue; got: {issues:?}"
1874        );
1875    }
1876
1877    #[test]
1878    fn worktree_present_no_issue() {
1879        let dir = setup_verify_repo();
1880        let root = dir.path();
1881        let config = Config::load(root).unwrap();
1882        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1883
1884        std::fs::create_dir_all(root.join("worktrees").join("ticket-abcd1234-test")).unwrap();
1885
1886        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1887        assert!(
1888            !issues.iter().any(|i| i.contains("worktree")),
1889            "unexpected worktree issue; got: {issues:?}"
1890        );
1891    }
1892
1893    #[test]
1894    fn worktree_check_skipped_for_other_states() {
1895        let dir = setup_verify_repo();
1896        let root = dir.path();
1897        let config = Config::load(root).unwrap();
1898        let ticket = make_verify_ticket(root, "abcd1234", "specd", Some("ticket/abcd1234-test"));
1899
1900        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1901        assert!(
1902            !issues.iter().any(|i| i.contains("worktree")),
1903            "unexpected worktree issue for specd state; got: {issues:?}"
1904        );
1905    }
1906
1907    fn in_repo_wt_config(dir: &str) -> Config {
1908        let toml = format!(
1909            r#"
1910[project]
1911name = "test"
1912
1913[tickets]
1914dir = "tickets"
1915
1916[worktrees]
1917dir = "{dir}"
1918"#
1919        );
1920        toml::from_str(&toml).expect("config parse failed")
1921    }
1922
1923    #[test]
1924    fn validate_config_gitignore_missing_in_repo_wt() {
1925        let tmp = tempfile::TempDir::new().unwrap();
1926        let config = in_repo_wt_config("worktrees");
1927        let errors = validate_config(&config, tmp.path());
1928        assert!(
1929            errors.iter().any(|e| e.contains("worktrees") && e.contains(".gitignore")),
1930            "expected gitignore missing error; got: {errors:?}"
1931        );
1932    }
1933
1934    #[test]
1935    fn validate_config_gitignore_covered_anchored_slash() {
1936        let tmp = tempfile::TempDir::new().unwrap();
1937        std::fs::write(tmp.path().join(".gitignore"), "/worktrees/\n").unwrap();
1938        let config = in_repo_wt_config("worktrees");
1939        let errors = validate_config(&config, tmp.path());
1940        assert!(
1941            !errors.iter().any(|e| e.contains("gitignore")),
1942            "unexpected gitignore error; got: {errors:?}"
1943        );
1944    }
1945
1946    #[test]
1947    fn validate_config_gitignore_covered_anchored_no_slash() {
1948        let tmp = tempfile::TempDir::new().unwrap();
1949        std::fs::write(tmp.path().join(".gitignore"), "/worktrees\n").unwrap();
1950        let config = in_repo_wt_config("worktrees");
1951        let errors = validate_config(&config, tmp.path());
1952        assert!(
1953            !errors.iter().any(|e| e.contains("gitignore")),
1954            "unexpected gitignore error; got: {errors:?}"
1955        );
1956    }
1957
1958    #[test]
1959    fn validate_config_gitignore_covered_unanchored_slash() {
1960        let tmp = tempfile::TempDir::new().unwrap();
1961        std::fs::write(tmp.path().join(".gitignore"), "worktrees/\n").unwrap();
1962        let config = in_repo_wt_config("worktrees");
1963        let errors = validate_config(&config, tmp.path());
1964        assert!(
1965            !errors.iter().any(|e| e.contains("gitignore")),
1966            "unexpected gitignore error; got: {errors:?}"
1967        );
1968    }
1969
1970    #[test]
1971    fn validate_config_gitignore_covered_bare() {
1972        let tmp = tempfile::TempDir::new().unwrap();
1973        std::fs::write(tmp.path().join(".gitignore"), "worktrees\n").unwrap();
1974        let config = in_repo_wt_config("worktrees");
1975        let errors = validate_config(&config, tmp.path());
1976        assert!(
1977            !errors.iter().any(|e| e.contains("gitignore")),
1978            "unexpected gitignore error; got: {errors:?}"
1979        );
1980    }
1981
1982    #[test]
1983    fn validate_config_gitignore_not_covered() {
1984        let tmp = tempfile::TempDir::new().unwrap();
1985        std::fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
1986        let config = in_repo_wt_config("worktrees");
1987        let errors = validate_config(&config, tmp.path());
1988        assert!(
1989            errors.iter().any(|e| e.contains("worktrees") && e.contains("gitignore")),
1990            "expected gitignore not covered error; got: {errors:?}"
1991        );
1992    }
1993
1994    #[test]
1995    fn validate_config_gitignore_no_false_positive() {
1996        let tmp = tempfile::TempDir::new().unwrap();
1997        std::fs::write(tmp.path().join(".gitignore"), "wt-old/\n").unwrap();
1998        let config = in_repo_wt_config("wt");
1999        let errors = validate_config(&config, tmp.path());
2000        assert!(
2001            errors.iter().any(|e| e.contains("wt") && e.contains("gitignore")),
2002            "wt-old should not match wt; got: {errors:?}"
2003        );
2004    }
2005
2006    #[test]
2007    fn validate_config_external_dotdot_no_check() {
2008        let tmp = tempfile::TempDir::new().unwrap();
2009        // No .gitignore at all
2010        let config = in_repo_wt_config("../ext");
2011        let errors = validate_config(&config, tmp.path());
2012        assert!(
2013            !errors.iter().any(|e| e.contains("gitignore")),
2014            "external dotdot path should skip gitignore check; got: {errors:?}"
2015        );
2016    }
2017
2018    #[test]
2019    fn validate_config_external_absolute_no_check() {
2020        let tmp = tempfile::TempDir::new().unwrap();
2021        // No .gitignore at all
2022        let config = in_repo_wt_config("/abs/path");
2023        let errors = validate_config(&config, tmp.path());
2024        assert!(
2025            !errors.iter().any(|e| e.contains("gitignore")),
2026            "absolute path should skip gitignore check; got: {errors:?}"
2027        );
2028    }
2029
2030    fn config_with_merge_transition(completion: &str, on_failure: Option<&str>, declare_failure_state: bool) -> Config {
2031        let on_failure_line = on_failure
2032            .map(|v| format!("on_failure = \"{v}\"\n"))
2033            .unwrap_or_default();
2034        let merge_failed_state = if declare_failure_state {
2035            r#"
2036[[workflow.states]]
2037id       = "merge_failed"
2038label    = "Merge failed"
2039"#
2040        } else {
2041            ""
2042        };
2043        let toml = format!(
2044            r#"
2045[project]
2046name = "test"
2047
2048[tickets]
2049dir = "tickets"
2050
2051[[workflow.states]]
2052id    = "in_progress"
2053label = "In Progress"
2054
2055[[workflow.states.transitions]]
2056to         = "implemented"
2057completion = "{completion}"
2058{on_failure_line}
2059[[workflow.states]]
2060id       = "implemented"
2061label    = "Implemented"
2062terminal = true
2063
2064[[workflow.states]]
2065id       = "closed"
2066label    = "Closed"
2067terminal = true
2068{merge_failed_state}
2069"#
2070        );
2071        toml::from_str(&toml).expect("config parse failed")
2072    }
2073
2074    #[test]
2075    fn test_on_failure_missing_for_merge() {
2076        let config = config_with_merge_transition("merge", None, false);
2077        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2078        assert!(
2079            errors.iter().any(|e| e.contains("missing `on_failure`")),
2080            "expected missing on_failure error; got: {errors:?}"
2081        );
2082    }
2083
2084    #[test]
2085    fn test_on_failure_missing_for_pr_or_epic_merge() {
2086        // No ticket with target_branch — rule fires on transition definition alone.
2087        let config = config_with_merge_transition("pr_or_epic_merge", None, false);
2088        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2089        assert!(
2090            errors.iter().any(|e| e.contains("missing `on_failure`")),
2091            "expected missing on_failure error for pr_or_epic_merge; got: {errors:?}"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_on_failure_unknown_state() {
2097        let config = config_with_merge_transition("merge", Some("ghost_state"), false);
2098        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2099        assert!(
2100            errors.iter().any(|e| e.contains("ghost_state")),
2101            "expected unknown state error for ghost_state; got: {errors:?}"
2102        );
2103    }
2104
2105    #[test]
2106    fn test_on_failure_valid() {
2107        let config = config_with_merge_transition("merge", Some("merge_failed"), true);
2108        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2109        let on_failure_errors: Vec<&String> = errors.iter()
2110            .filter(|e| e.contains("on_failure") || e.contains("ghost_state") || e.contains("merge_failed"))
2111            .collect();
2112        assert!(
2113            on_failure_errors.is_empty(),
2114            "unexpected on_failure errors: {on_failure_errors:?}"
2115        );
2116    }
2117
2118    // --- frontmatter agent validation ---
2119
2120    fn make_agent_verify_ticket(root: &std::path::Path, id: &str, state: &str, extra_fm: &str) -> Ticket {
2121        let raw = format!(
2122            "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{extra_fm}+++\n\n## Spec\n\n## History\n"
2123        );
2124        let path = root.join("tickets").join(format!("{id}-test.md"));
2125        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2126        std::fs::write(&path, &raw).unwrap();
2127        Ticket::parse(&path, &raw).unwrap()
2128    }
2129
2130    #[test]
2131    fn validate_unknown_frontmatter_agent_is_error() {
2132        let dir = setup_verify_repo();
2133        let root = dir.path();
2134        let config = Config::load(root).unwrap();
2135        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"nonexistent-bot\"\n");
2136
2137        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2138
2139        assert!(
2140            issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2141            "expected error with ticket id and agent name; got: {issues:?}"
2142        );
2143    }
2144
2145    #[test]
2146    fn validate_unknown_agent_in_overrides_is_error() {
2147        let dir = setup_verify_repo();
2148        let root = dir.path();
2149        let config = Config::load(root).unwrap();
2150        let ticket = make_agent_verify_ticket(
2151            root,
2152            "abcd1234",
2153            "specd",
2154            "[agent_overrides]\nimpl_agent = \"nonexistent-bot\"\n",
2155        );
2156
2157        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2158
2159        assert!(
2160            issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2161            "expected error with ticket id and agent name; got: {issues:?}"
2162        );
2163    }
2164
2165    #[test]
2166    fn verify_tickets_flags_renamed_ticket_file_on_branch() {
2167        // Reproduce the pi-agent rename bug: branch is
2168        // `ticket/abcd1234-fix-login`, but the file on that branch is
2169        // `tickets/abcd1234-fix-login-and-stuff.md`. apm's loader silently
2170        // drops the ticket; the validator should flag it.
2171        let dir = setup_verify_repo();
2172        let p = dir.path();
2173
2174        let canonical_branch = "ticket/abcd1234-fix-login";
2175        git_cmd(p, &["checkout", "-b", canonical_branch]);
2176        std::fs::create_dir_all(p.join("tickets")).unwrap();
2177        // File written with a DIFFERENT slug than the branch suffix.
2178        std::fs::write(
2179            p.join("tickets/abcd1234-fix-login-and-stuff.md"),
2180            "+++\nid = \"abcd1234\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2181        )
2182        .unwrap();
2183        git_cmd(p, &["add", "tickets/"]);
2184        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "spec written"]);
2185        git_cmd(p, &["checkout", "main"]);
2186
2187        let config = Config::load(p).unwrap();
2188        let issues = verify_tickets(p, &config, &[], &HashSet::new());
2189
2190        assert!(
2191            issues.iter().any(|i| i.contains(canonical_branch) && i.contains("renamed")),
2192            "expected rename diagnostic for {canonical_branch}; got: {issues:?}"
2193        );
2194    }
2195
2196    #[test]
2197    fn verify_tickets_flags_orphan_branch_with_no_ticket_file() {
2198        let dir = setup_verify_repo();
2199        let p = dir.path();
2200
2201        let branch = "ticket/deadbeef-orphan";
2202        git_cmd(p, &["checkout", "-b", branch]);
2203        // No tickets/ directory at all on this branch.
2204        std::fs::write(p.join("dummy.txt"), "x").unwrap();
2205        git_cmd(p, &["add", "dummy.txt"]);
2206        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "no ticket file"]);
2207        git_cmd(p, &["checkout", "main"]);
2208
2209        let config = Config::load(p).unwrap();
2210        let issues = verify_tickets(p, &config, &[], &HashSet::new());
2211
2212        assert!(
2213            issues.iter().any(|i| i.contains(branch) && i.contains("orphaned")),
2214            "expected orphan diagnostic for {branch}; got: {issues:?}"
2215        );
2216    }
2217
2218    #[test]
2219    fn verify_tickets_quiet_when_branch_file_matches() {
2220        let dir = setup_verify_repo();
2221        let p = dir.path();
2222
2223        let branch = "ticket/cafe0001-clean-branch";
2224        git_cmd(p, &["checkout", "-b", branch]);
2225        std::fs::create_dir_all(p.join("tickets")).unwrap();
2226        // Canonical filename: matches the branch suffix exactly.
2227        std::fs::write(
2228            p.join("tickets/cafe0001-clean-branch.md"),
2229            "+++\nid = \"cafe0001\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2230        )
2231        .unwrap();
2232        git_cmd(p, &["add", "tickets/"]);
2233        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "canonical"]);
2234        git_cmd(p, &["checkout", "main"]);
2235
2236        let config = Config::load(p).unwrap();
2237        let issues = verify_tickets(p, &config, &[], &HashSet::new());
2238
2239        assert!(
2240            !issues.iter().any(|i| i.contains(branch)),
2241            "no branch-file issue expected for canonical layout; got: {issues:?}"
2242        );
2243    }
2244
2245    #[test]
2246    fn validate_known_frontmatter_agent_passes() {
2247        let dir = setup_verify_repo();
2248        let root = dir.path();
2249        let config = Config::load(root).unwrap();
2250        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"claude\"\n");
2251
2252        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2253
2254        assert!(
2255            !issues.iter().any(|i| i.contains("is not a known built-in")),
2256            "expected no agent error for known built-in; got: {issues:?}"
2257        );
2258    }
2259
2260    #[test]
2261    fn validate_agent_name_accepts_configured_spawn_agent() {
2262        let toml = r#"
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"
2274worker_profile = "pi/worker"
2275terminal       = true
2276"#;
2277        let config = audit_config(toml);
2278        validate_agent_name(&config, "pi").expect("pi should be a configured agent");
2279    }
2280
2281    #[test]
2282    fn validate_agent_name_rejects_unknown() {
2283        let config = audit_config("");
2284        let err = validate_agent_name(&config, "nonexistent").unwrap_err();
2285        let msg = err.to_string();
2286        assert!(msg.contains("nonexistent"), "got: {msg}");
2287        assert!(msg.contains("not configured in config.toml"), "got: {msg}");
2288    }
2289
2290    #[test]
2291    fn validate_agent_name_accepts_dash_sentinel() {
2292        let config = audit_config("");
2293        validate_agent_name(&config, "-").expect("dash should clear without validation");
2294    }
2295
2296    #[test]
2297    fn validate_ticket_agent_not_in_config_is_error() {
2298        let dir = setup_verify_repo();
2299        let root = dir.path();
2300        let config = Config::load(root).unwrap();
2301        // "claude" is the default configured agent; pick something else that resolves
2302        // as a wrapper to isolate the config-coverage check.
2303        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"phi4\"\n");
2304
2305        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2306
2307        assert!(
2308            issues.iter().any(|i| i.contains("abcd1234") && i.contains("not configured in config.toml")),
2309            "expected config-coverage error; got: {issues:?}"
2310        );
2311    }
2312
2313    // --- audit_agent_resolution tests ---
2314
2315    fn audit_config(extra_toml: &str) -> Config {
2316        let base = r#"
2317[project]
2318name = "test"
2319
2320[tickets]
2321dir = "tickets"
2322
2323[worktrees]
2324dir = "../wt"
2325"#;
2326        toml::from_str(&format!("{base}{extra_toml}")).expect("config parse failed")
2327    }
2328
2329    #[test]
2330    fn audit_zero_spawn_transitions() {
2331        let toml = r#"
2332[[workflow.states]]
2333id    = "new"
2334label = "New"
2335
2336[[workflow.states.transitions]]
2337to      = "closed"
2338trigger = "command:review"
2339
2340[[workflow.states]]
2341id       = "closed"
2342label    = "Closed"
2343terminal = true
2344"#;
2345        let config = audit_config(toml);
2346        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2347        assert!(result.is_empty(), "expected 0 audits, got {result:?}");
2348    }
2349
2350    #[test]
2351    fn audit_default_agent_resolution() {
2352        let toml = r#"
2353[workers]
2354default = "claude/coder"
2355
2356[[workflow.states]]
2357id    = "ready"
2358label = "Ready"
2359
2360[[workflow.states.transitions]]
2361to      = "in_progress"
2362trigger = "command:start"
2363
2364[[workflow.states]]
2365id       = "in_progress"
2366label    = "In Progress"
2367terminal = true
2368"#;
2369        let config = audit_config(toml);
2370        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2371        assert_eq!(result.len(), 1, "expected 1 audit");
2372        let ta = &result[0];
2373        assert_eq!(ta.from_state, "ready");
2374        assert_eq!(ta.to_state, "in_progress");
2375        assert!(ta.worker_profile.is_none());
2376        assert_eq!(ta.agent, "claude");
2377        assert_eq!(ta.role, "coder");
2378        assert!(ta.wrapper.contains("claude"), "wrapper should mention claude: {}", ta.wrapper);
2379    }
2380
2381    #[test]
2382    fn audit_worker_profile_parsed() {
2383        let toml = r#"
2384[[workflow.states]]
2385id    = "ready"
2386label = "Ready"
2387
2388[[workflow.states.transitions]]
2389to      = "in_progress"
2390trigger = "command:start"
2391
2392[[workflow.states]]
2393id             = "in_progress"
2394label          = "In Progress"
2395worker_profile = "mock-happy/spec-writer"
2396terminal       = true
2397"#;
2398        let config = audit_config(toml);
2399        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2400        assert_eq!(result.len(), 1);
2401        let ta = &result[0];
2402        assert_eq!(ta.worker_profile.as_deref(), Some("mock-happy/spec-writer"));
2403        assert_eq!(ta.agent, "mock-happy");
2404        assert_eq!(ta.role, "spec-writer");
2405        assert!(ta.wrapper.contains("mock-happy"), "wrapper: {}", ta.wrapper);
2406    }
2407
2408    #[test]
2409    fn audit_workers_default_agent() {
2410        let toml = r#"
2411[workers]
2412default = "mock-happy/worker"
2413
2414[[workflow.states]]
2415id    = "ready"
2416label = "Ready"
2417
2418[[workflow.states.transitions]]
2419to      = "in_progress"
2420trigger = "command:start"
2421
2422[[workflow.states]]
2423id       = "in_progress"
2424label    = "In Progress"
2425terminal = true
2426"#;
2427        let config = audit_config(toml);
2428        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2429        assert_eq!(result.len(), 1);
2430        let ta = &result[0];
2431        assert_eq!(ta.agent, "mock-happy");
2432        assert_eq!(ta.role, "worker");
2433    }
2434
2435    #[test]
2436    fn audit_no_worker_profiles_no_panic() {
2437        let toml = r#"
2438[workers]
2439default = "claude/coder"
2440
2441[[workflow.states]]
2442id    = "ready"
2443label = "Ready"
2444
2445[[workflow.states.transitions]]
2446to      = "in_progress"
2447trigger = "command:start"
2448
2449[[workflow.states]]
2450id       = "in_progress"
2451label    = "In Progress"
2452terminal = true
2453"#;
2454        let config = audit_config(toml);
2455        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2456        assert_eq!(result.len(), 1, "should not panic with no worker_profile");
2457    }
2458
2459    #[test]
2460    fn workers_default_absent_fails_validate() {
2461        let toml = r#"
2462[project]
2463name = "test"
2464
2465[tickets]
2466dir = "tickets"
2467
2468[[workflow.states]]
2469id    = "new"
2470label = "New"
2471
2472[[workflow.states.transitions]]
2473to = "done"
2474
2475[[workflow.states]]
2476id       = "done"
2477label    = "Done"
2478terminal = true
2479"#;
2480        let config = load_config(toml);
2481        let errors = validate_config(&config, Path::new("/tmp"));
2482        assert!(
2483            errors.iter().any(|e| e.contains("workers.default")),
2484            "expected workers.default error when [workers] section is absent; got: {errors:?}"
2485        );
2486    }
2487
2488    #[test]
2489    fn workers_default_empty_fails_validate() {
2490        let toml = r#"
2491[project]
2492name = "test"
2493
2494[tickets]
2495dir = "tickets"
2496
2497[workers]
2498default = ""
2499
2500[[workflow.states]]
2501id    = "new"
2502label = "New"
2503
2504[[workflow.states.transitions]]
2505to = "done"
2506
2507[[workflow.states]]
2508id       = "done"
2509label    = "Done"
2510terminal = true
2511"#;
2512        let config = load_config(toml);
2513        let errors = validate_config(&config, Path::new("/tmp"));
2514        assert!(
2515            errors.iter().any(|e| e.contains("workers.default")),
2516            "expected workers.default error when default = \"\"; got: {errors:?}"
2517        );
2518    }
2519
2520    #[test]
2521    fn merge_completion_targeting_terminal_rejected() {
2522        let toml = r#"
2523[project]
2524name = "test"
2525
2526[tickets]
2527dir = "tickets"
2528
2529[[workflow.states]]
2530id    = "in_progress"
2531label = "In Progress"
2532
2533[[workflow.states.transitions]]
2534to         = "done"
2535completion = "merge"
2536on_failure = "closed"
2537
2538[[workflow.states]]
2539id       = "done"
2540label    = "Done"
2541terminal = true
2542"#;
2543        let config = load_config(toml);
2544        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2545        assert!(
2546            errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2547            "expected terminal-state error; got: {errors:?}"
2548        );
2549    }
2550
2551    #[test]
2552    fn pr_or_epic_merge_targeting_terminal_rejected() {
2553        let toml = r#"
2554[project]
2555name = "test"
2556
2557[tickets]
2558dir = "tickets"
2559
2560[[workflow.states]]
2561id    = "in_progress"
2562label = "In Progress"
2563
2564[[workflow.states.transitions]]
2565to         = "done"
2566completion = "pr_or_epic_merge"
2567on_failure = "closed"
2568
2569[[workflow.states]]
2570id       = "done"
2571label    = "Done"
2572terminal = true
2573"#;
2574        let config = load_config(toml);
2575        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2576        assert!(
2577            errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2578            "expected terminal-state error; got: {errors:?}"
2579        );
2580    }
2581
2582    #[test]
2583    fn pr_completion_targeting_terminal_rejected() {
2584        let toml = r#"
2585[project]
2586name = "test"
2587
2588[tickets]
2589dir = "tickets"
2590
2591[git_host]
2592provider = "github"
2593
2594[[workflow.states]]
2595id    = "in_progress"
2596label = "In Progress"
2597
2598[[workflow.states.transitions]]
2599to         = "done"
2600completion = "pr"
2601
2602[[workflow.states]]
2603id       = "done"
2604label    = "Done"
2605terminal = true
2606"#;
2607        let config = load_config(toml);
2608        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2609        assert!(
2610            errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2611            "expected terminal-state error; got: {errors:?}"
2612        );
2613    }
2614
2615    #[test]
2616    fn merge_targeting_built_in_closed_rejected() {
2617        // "closed" is the built-in terminal state — absent from [[workflow.states]]
2618        let toml = r#"
2619[project]
2620name = "test"
2621
2622[tickets]
2623dir = "tickets"
2624
2625[[workflow.states]]
2626id    = "in_progress"
2627label = "In Progress"
2628
2629[[workflow.states.transitions]]
2630to         = "closed"
2631completion = "merge"
2632on_failure = "review"
2633
2634[[workflow.states]]
2635id    = "review"
2636label = "Review"
2637
2638[[workflow.states.transitions]]
2639to = "closed"
2640"#;
2641        let config = load_config(toml);
2642        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2643        assert!(
2644            errors.iter().any(|e| e.contains("state.in_progress.transition(closed)") && e.contains("targets terminal state")),
2645            "expected terminal-state error for built-in closed; got: {errors:?}"
2646        );
2647    }
2648
2649    #[test]
2650    fn merge_targeting_non_terminal_accepted() {
2651        let toml = r#"
2652[project]
2653name = "test"
2654
2655[tickets]
2656dir = "tickets"
2657
2658[[workflow.states]]
2659id    = "in_progress"
2660label = "In Progress"
2661
2662[[workflow.states.transitions]]
2663to         = "review"
2664completion = "merge"
2665on_failure = "closed"
2666
2667[[workflow.states]]
2668id    = "review"
2669label = "Review"
2670
2671[[workflow.states.transitions]]
2672to = "closed"
2673"#;
2674        let config = load_config(toml);
2675        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2676        assert!(
2677            !errors.iter().any(|e| e.contains("targets terminal state")),
2678            "unexpected terminal-state error; got: {errors:?}"
2679        );
2680    }
2681
2682    // --- Rule 1: trigger uniqueness ---
2683
2684    #[test]
2685    fn trigger_uniqueness_two_manual_to_same_dest_ok() {
2686        let toml = r#"
2687[project]
2688name = "test"
2689
2690[tickets]
2691dir = "tickets"
2692
2693[[workflow.states]]
2694id    = "a"
2695label = "A"
2696
2697[[workflow.states.transitions]]
2698to      = "c"
2699trigger = "manual"
2700
2701[[workflow.states]]
2702id    = "b"
2703label = "B"
2704
2705[[workflow.states.transitions]]
2706to      = "c"
2707trigger = "manual"
2708
2709[[workflow.states]]
2710id       = "c"
2711label    = "C"
2712terminal = true
2713"#;
2714        let config = load_config(toml);
2715        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2716        assert!(
2717            !errors.iter().any(|e| e.contains("incoming transitions")),
2718            "two manual edges to same dest should not trigger Rule 1; got: {errors:?}"
2719        );
2720    }
2721
2722    #[test]
2723    fn trigger_uniqueness_command_start_plus_manual_same_dest_rejected() {
2724        let toml = r#"
2725[project]
2726name = "test"
2727
2728[tickets]
2729dir = "tickets"
2730
2731[[workflow.states]]
2732id    = "src_start"
2733label = "Src Start"
2734
2735[[workflow.states.transitions]]
2736to      = "dest"
2737trigger = "command:start"
2738
2739[[workflow.states]]
2740id    = "src_manual"
2741label = "Src Manual"
2742
2743[[workflow.states.transitions]]
2744to      = "dest"
2745trigger = "manual"
2746
2747[[workflow.states]]
2748id             = "dest"
2749label          = "Dest"
2750worker_profile = "claude/coder"
2751
2752[[workflow.states.transitions]]
2753to = "closed"
2754"#;
2755        let config = load_config(toml);
2756        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2757        let rule1_errors: Vec<&String> = errors.iter()
2758            .filter(|e| e.contains("incoming transitions"))
2759            .collect();
2760        assert!(
2761            !rule1_errors.is_empty(),
2762            "expected trigger-uniqueness error; got: {errors:?}"
2763        );
2764        let msg = rule1_errors[0];
2765        assert!(msg.contains("dest"), "expected dest in error: {msg}");
2766        assert!(msg.contains("src_start"), "expected src_start in error: {msg}");
2767        assert!(msg.contains("src_manual"), "expected src_manual in error: {msg}");
2768    }
2769
2770    #[test]
2771    fn trigger_uniqueness_two_command_start_same_dest_ok() {
2772        let toml = r#"
2773[project]
2774name = "test"
2775
2776[tickets]
2777dir = "tickets"
2778
2779[[workflow.states]]
2780id    = "src_a"
2781label = "Src A"
2782
2783[[workflow.states.transitions]]
2784to      = "dest"
2785trigger = "command:start"
2786
2787[[workflow.states]]
2788id    = "src_b"
2789label = "Src B"
2790
2791[[workflow.states.transitions]]
2792to      = "dest"
2793trigger = "command:start"
2794
2795[[workflow.states]]
2796id             = "dest"
2797label          = "Dest"
2798worker_profile = "claude/coder"
2799
2800[[workflow.states.transitions]]
2801to = "closed"
2802"#;
2803        let config = load_config(toml);
2804        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2805        let rule1_errors: Vec<&String> = errors.iter()
2806            .filter(|e| e.contains("triggered and manual"))
2807            .collect();
2808        assert!(
2809            rule1_errors.is_empty(),
2810            "two command:start incoming should pass — both imply dispatch; got: {errors:?}"
2811        );
2812    }
2813
2814    // --- Rule 2: worker_profile shape ---
2815
2816    #[test]
2817    fn worker_profile_valid_passes() {
2818        let toml = r#"
2819[project]
2820name = "test"
2821
2822[tickets]
2823dir = "tickets"
2824
2825[[workflow.states]]
2826id             = "active"
2827label          = "Active"
2828worker_profile = "claude/coder"
2829
2830[[workflow.states.transitions]]
2831to = "closed"
2832"#;
2833        let config = load_config(toml);
2834        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2835        assert!(
2836            !errors.iter().any(|e| e.contains("worker_profile")),
2837            "valid worker_profile should not trigger Rule 2; got: {errors:?}"
2838        );
2839    }
2840
2841    #[test]
2842    fn worker_profile_reserved_role_rejected() {
2843        let toml = r#"
2844[project]
2845name = "test"
2846
2847[tickets]
2848dir = "tickets"
2849
2850[[workflow.states]]
2851id             = "active"
2852label          = "Active"
2853worker_profile = "claude/worker"
2854
2855[[workflow.states.transitions]]
2856to = "closed"
2857"#;
2858        let config = load_config(toml);
2859        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2860        assert!(
2861            errors.iter().any(|e| e.contains("worker_profile") && e.contains("worker")),
2862            "reserved role 'worker' should be rejected; got: {errors:?}"
2863        );
2864    }
2865
2866    #[test]
2867    fn worker_profile_no_slash_rejected() {
2868        let toml = r#"
2869[project]
2870name = "test"
2871
2872[tickets]
2873dir = "tickets"
2874
2875[[workflow.states]]
2876id             = "active"
2877label          = "Active"
2878worker_profile = "claudecoder"
2879
2880[[workflow.states.transitions]]
2881to = "closed"
2882"#;
2883        let config = load_config(toml);
2884        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2885        assert!(
2886            errors.iter().any(|e| e.contains("worker_profile") && e.contains("exactly one")),
2887            "missing slash should be rejected; got: {errors:?}"
2888        );
2889    }
2890
2891    #[test]
2892    fn worker_profile_empty_agent_rejected() {
2893        let toml = r#"
2894[project]
2895name = "test"
2896
2897[tickets]
2898dir = "tickets"
2899
2900[[workflow.states]]
2901id             = "active"
2902label          = "Active"
2903worker_profile = "/coder"
2904
2905[[workflow.states.transitions]]
2906to = "closed"
2907"#;
2908        let config = load_config(toml);
2909        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2910        assert!(
2911            errors.iter().any(|e| e.contains("worker_profile") && e.contains("non-empty")),
2912            "empty agent component should be rejected; got: {errors:?}"
2913        );
2914    }
2915
2916    #[test]
2917    fn worker_profile_empty_role_rejected() {
2918        let toml = r#"
2919[project]
2920name = "test"
2921
2922[tickets]
2923dir = "tickets"
2924
2925[[workflow.states]]
2926id             = "active"
2927label          = "Active"
2928worker_profile = "claude/"
2929
2930[[workflow.states.transitions]]
2931to = "closed"
2932"#;
2933        let config = load_config(toml);
2934        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2935        assert!(
2936            errors.iter().any(|e| e.contains("worker_profile") && e.contains("non-empty")),
2937            "empty role component should be rejected; got: {errors:?}"
2938        );
2939    }
2940
2941    // --- Rule 3: command:start targets dispatch-capable state ---
2942
2943    #[test]
2944    fn command_start_missing_worker_profile_rejected() {
2945        let toml = r#"
2946[project]
2947name = "test"
2948
2949[tickets]
2950dir = "tickets"
2951
2952[[workflow.states]]
2953id    = "src"
2954label = "Src"
2955
2956[[workflow.states.transitions]]
2957to      = "dest"
2958trigger = "command:start"
2959
2960[[workflow.states]]
2961id    = "dest"
2962label = "Dest"
2963
2964[[workflow.states.transitions]]
2965to = "closed"
2966"#;
2967        let config = load_config(toml);
2968        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2969        assert!(
2970            errors.iter().any(|e| e.contains("dest") && e.contains("worker_profile")),
2971            "command:start to state with no worker_profile should be rejected; got: {errors:?}"
2972        );
2973    }
2974
2975    #[test]
2976    fn default_workflow_passes() {
2977        // Inline TOML replicating the key structural parts of the default workflow:
2978        // groomed → in_design via command:start, ready → in_progress via command:start,
2979        // with worker_profile on both targets. No state mixes triggered and manual
2980        // incoming transitions.
2981        let toml = r#"
2982[project]
2983name = "test"
2984
2985[tickets]
2986dir = "tickets"
2987
2988[[workflow.states]]
2989id    = "groomed"
2990label = "Groomed"
2991
2992[[workflow.states.transitions]]
2993to      = "in_design"
2994trigger = "command:start"
2995outcome = "needs_input"
2996
2997[[workflow.states.transitions]]
2998to      = "closed"
2999trigger = "manual"
3000outcome = "cancelled"
3001
3002[[workflow.states]]
3003id             = "in_design"
3004label          = "In Design"
3005worker_profile = "claude/spec-writer"
3006
3007[[workflow.states.transitions]]
3008to      = "specd"
3009trigger = "manual"
3010outcome = "success"
3011
3012[[workflow.states]]
3013id    = "specd"
3014label = "Specd"
3015
3016[[workflow.states.transitions]]
3017to      = "ready"
3018trigger = "manual"
3019outcome = "needs_input"
3020
3021[[workflow.states]]
3022id    = "ready"
3023label = "Ready"
3024
3025[[workflow.states.transitions]]
3026to      = "in_progress"
3027trigger = "command:start"
3028outcome = "needs_input"
3029
3030[[workflow.states.transitions]]
3031to      = "closed"
3032trigger = "manual"
3033outcome = "cancelled"
3034
3035[[workflow.states]]
3036id             = "in_progress"
3037label          = "In Progress"
3038worker_profile = "claude/coder"
3039
3040[[workflow.states.transitions]]
3041to      = "closed"
3042trigger = "manual"
3043outcome = "cancelled"
3044"#;
3045        let config = load_config(toml);
3046        let errors = validate_config_no_agents(&config, Path::new("/tmp"));
3047        let new_rule_errors: Vec<&String> = errors.iter()
3048            .filter(|e| {
3049                e.contains("incoming transitions")
3050                    || e.contains("worker_profile")
3051                    || (e.contains("command:start") && e.contains("worker_profile"))
3052            })
3053            .collect();
3054        assert!(
3055            new_rule_errors.is_empty(),
3056            "default workflow structure should pass all new rules; got: {new_rule_errors:?}"
3057        );
3058    }
3059}