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