Skip to main content

apm_core/
validate.rs

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