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