Skip to main content

apm_core/
validate.rs

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