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    issues
595}
596
597pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec<String> {
598    let mut warnings = validate_warnings_no_agents(config, root);
599    let (_, agent_warnings) = validate_agents(config, root);
600    warnings.extend(agent_warnings);
601    warnings
602}
603
604fn validate_warnings_no_agents(config: &crate::config::Config, _root: &Path) -> Vec<String> {
605    let mut warnings = config.load_warnings.clone();
606    if let Some(container) = &config.workers.container {
607        if !container.is_empty() {
608            let docker_ok = std::process::Command::new("docker")
609                .arg("--version")
610                .output()
611                .map(|o| o.status.success())
612                .unwrap_or(false);
613            if !docker_ok {
614                warnings.push(
615                    "workers.container is set but 'docker' is not in PATH".to_string()
616                );
617            }
618        }
619    }
620
621    // Dead-end reachability check: warn when no agent-actionable state can reach a
622    // transition whose outcome resolves to "success".
623    let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
624        config.workflow.states.iter()
625            .map(|s| (s.id.as_str(), s))
626            .collect();
627
628    let agent_startable: Vec<&str> = config.workflow.states.iter()
629        .filter(|s| s.actionable.iter().any(|a| a == "agent" || a == "any"))
630        .map(|s| s.id.as_str())
631        .collect();
632
633    if !agent_startable.is_empty() {
634        let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
635        let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
636        let mut found_success = false;
637
638        for &start in &agent_startable {
639            if visited.insert(start) {
640                queue.push_back(start);
641            }
642        }
643
644        'bfs: while let Some(state_id) = queue.pop_front() {
645            let Some(state) = state_map.get(state_id) else { continue };
646            for t in &state.transitions {
647                // Unknown targets are reported as errors by validate_config; skip
648                // here so reachability isn't computed against malformed transitions.
649                let Some(&target) = state_map.get(t.to.as_str()) else { continue };
650                if resolve_outcome(t, target) == "success" {
651                    found_success = true;
652                    break 'bfs;
653                }
654                if !target.terminal && visited.insert(t.to.as_str()) {
655                    queue.push_back(t.to.as_str());
656                }
657            }
658        }
659
660        if !found_success {
661            warnings.push(
662                "workflow has no reachable 'success' outcome from any agent-actionable state; \
663                 workers may never complete successfully".to_string()
664            );
665        }
666    }
667
668    warnings
669}
670
671/// Resolve the effective agent name for a spawn transition.
672fn resolve_audit_agent(
673    transition_agent: Option<&str>,
674    profile: Option<&crate::config::WorkerProfileConfig>,
675    profile_name: Option<&str>,
676    workers: &crate::config::WorkersConfig,
677) -> FieldAudit {
678    if let Some(a) = transition_agent {
679        return FieldAudit { value: a.to_string(), source: "transition".to_string() };
680    }
681    if let Some(p) = profile {
682        if let Some(ref a) = p.agent {
683            let src = format!("profile:{}", profile_name.unwrap_or(""));
684            return FieldAudit { value: a.clone(), source: src };
685        }
686    }
687    if let Some(ref a) = workers.agent {
688        return FieldAudit { value: a.clone(), source: "workers".to_string() };
689    }
690    FieldAudit { value: "claude".to_string(), source: "default".to_string() }
691}
692
693/// Resolve the effective instructions for a spawn transition.
694fn resolve_audit_instructions(
695    transition_instructions: Option<&str>,
696    profile: Option<&crate::config::WorkerProfileConfig>,
697    profile_name: Option<&str>,
698    workers: &crate::config::WorkersConfig,
699    agent: &str,
700    role: &str,
701    root: &Path,
702) -> FieldAudit {
703    // Level 0: transition.instructions
704    if let Some(path) = transition_instructions {
705        return FieldAudit { value: path.to_string(), source: "transition".to_string() };
706    }
707    // Level 1: profile.instructions
708    if let Some(p) = profile {
709        if let Some(ref path) = p.instructions {
710            let src = format!("profile:{}", profile_name.unwrap_or(""));
711            return FieldAudit { value: path.clone(), source: src };
712        }
713    }
714    // Level 2: workers.instructions
715    if let Some(ref path) = workers.instructions {
716        return FieldAudit { value: path.clone(), source: "workers".to_string() };
717    }
718    // Level 3: .apm/agents/<agent>/apm.<role>.md
719    let per_agent = root.join(format!(".apm/agents/{agent}/apm.{role}.md"));
720    if per_agent.exists() {
721        let display = format!(".apm/agents/{agent}/apm.{role}.md");
722        return FieldAudit { value: display, source: "project-agent-file".to_string() };
723    }
724    // Level 4: built-in
725    if crate::start::resolve_builtin_instructions(agent, role).is_some() {
726        return FieldAudit {
727            value: format!("built-in:{agent}:{role}"),
728            source: "built-in".to_string(),
729        };
730    }
731    // Level 5: none
732    FieldAudit { value: "(unresolved)".to_string(), source: "none".to_string() }
733}
734
735/// Resolve the effective role prefix for a spawn transition.
736fn resolve_audit_role_prefix(
737    transition_role_prefix: Option<&str>,
738    profile: Option<&crate::config::WorkerProfileConfig>,
739    profile_name: Option<&str>,
740) -> FieldAudit {
741    if let Some(rp) = transition_role_prefix {
742        return FieldAudit { value: rp.to_string(), source: "transition".to_string() };
743    }
744    if let Some(p) = profile {
745        if let Some(ref rp) = p.role_prefix {
746            let src = format!("profile:{}", profile_name.unwrap_or(""));
747            return FieldAudit { value: rp.clone(), source: src };
748        }
749    }
750    FieldAudit {
751        value: "You are a Worker agent assigned to ticket #<id>.".to_string(),
752        source: "default".to_string(),
753    }
754}
755
756/// Format the wrapper for display.
757fn format_wrapper(root: &Path, agent: &str) -> String {
758    match wrapper::resolve_wrapper(root, agent) {
759        Ok(Some(wrapper::WrapperKind::Builtin(ref name))) => format!("builtin:{name}"),
760        Ok(Some(wrapper::WrapperKind::Custom { ref script_path, .. })) => {
761            script_path.to_string_lossy().into_owned()
762        }
763        Ok(None) => "(not found)".to_string(),
764        Err(_) => "(error)".to_string(),
765    }
766}
767
768/// Build an agent-resolution audit for every `command:start` spawn transition in the config.
769pub fn audit_agent_resolution(config: &Config, root: &Path) -> Vec<TransitionAudit> {
770    let mut result = Vec::new();
771
772    for state in &config.workflow.states {
773        for transition in &state.transitions {
774            if transition.trigger != "command:start" {
775                continue;
776            }
777
778            let profile_name = transition.profile.clone();
779            let profile = profile_name
780                .as_deref()
781                .and_then(|name| config.worker_profiles.get(name));
782            let profile_missing = profile_name.is_some() && profile.is_none();
783
784            let (agent, instructions, role_prefix) = if profile_missing {
785                let not_found = || FieldAudit {
786                    value: "(profile not found)".to_string(),
787                    source: "none".to_string(),
788                };
789                (not_found(), not_found(), not_found())
790            } else {
791                let agent_audit = resolve_audit_agent(
792                    transition.agent.as_deref(),
793                    profile,
794                    profile_name.as_deref(),
795                    &config.workers,
796                );
797                let role = profile
798                    .and_then(|p| p.role.as_deref())
799                    .unwrap_or("worker");
800                let instructions_audit = resolve_audit_instructions(
801                    transition.instructions.as_deref(),
802                    profile,
803                    profile_name.as_deref(),
804                    &config.workers,
805                    &agent_audit.value,
806                    role,
807                    root,
808                );
809                let role_prefix_audit = resolve_audit_role_prefix(
810                    transition.role_prefix.as_deref(),
811                    profile,
812                    profile_name.as_deref(),
813                );
814                (agent_audit, instructions_audit, role_prefix_audit)
815            };
816
817            let agent_val = if profile_missing { "claude".to_string() } else { agent.value.clone() };
818            let wrapper_str = format_wrapper(root, &agent_val);
819
820            result.push(TransitionAudit {
821                from_state: state.id.clone(),
822                to_state: transition.to.clone(),
823                profile: profile_name,
824                agent,
825                instructions,
826                role_prefix,
827                wrapper: wrapper_str,
828            });
829        }
830    }
831
832    result
833}
834
835/// Single-pass equivalent of calling `validate_config` followed by
836/// `validate_warnings` — runs the agent-directory scan once and dedupes.
837pub fn validate_all(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
838    let mut errors = validate_config_no_agents(config, root);
839    let mut warnings = validate_warnings_no_agents(config, root);
840    let (agent_errors, agent_warnings) = validate_agents(config, root);
841    errors.extend(agent_errors);
842    warnings.extend(agent_warnings);
843    (errors, warnings)
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::config::{Config, CompletionStrategy, LocalConfig};
850    use crate::ticket::Ticket;
851    use crate::git_util;
852    use std::path::Path;
853    use std::collections::HashSet;
854
855    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
856        std::process::Command::new("git")
857            .args(args)
858            .current_dir(dir)
859            .env("GIT_AUTHOR_NAME", "test")
860            .env("GIT_AUTHOR_EMAIL", "test@test.com")
861            .env("GIT_COMMITTER_NAME", "test")
862            .env("GIT_COMMITTER_EMAIL", "test@test.com")
863            .status()
864            .unwrap();
865    }
866
867    fn setup_verify_repo() -> tempfile::TempDir {
868        let dir = tempfile::tempdir().unwrap();
869        let p = dir.path();
870
871        git_cmd(p, &["init", "-q", "-b", "main"]);
872        git_cmd(p, &["config", "user.email", "test@test.com"]);
873        git_cmd(p, &["config", "user.name", "test"]);
874
875        std::fs::create_dir_all(p.join(".apm")).unwrap();
876        std::fs::write(
877            p.join(".apm/config.toml"),
878            r#"[project]
879name = "test"
880
881[tickets]
882dir = "tickets"
883
884[worktrees]
885dir = "worktrees"
886
887[[workflow.states]]
888id = "in_design"
889label = "In Design"
890
891[[workflow.states]]
892id = "in_progress"
893label = "In Progress"
894
895[[workflow.states]]
896id = "specd"
897label = "Specd"
898"#,
899        )
900        .unwrap();
901
902        git_cmd(p, &["add", ".apm/config.toml"]);
903        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
904
905        dir
906    }
907
908    fn make_verify_ticket(root: &std::path::Path, id: &str, state: &str, branch: Option<&str>) -> Ticket {
909        let branch_line = match branch {
910            Some(b) => format!("branch = \"{b}\"\n"),
911            None => String::new(),
912        };
913        let raw = format!(
914            "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{branch_line}+++\n\n## Spec\n\n## History\n"
915        );
916        let path = root.join("tickets").join(format!("{id}-test.md"));
917        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
918        std::fs::write(&path, &raw).unwrap();
919        Ticket::parse(&path, &raw).unwrap()
920    }
921
922    fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
923        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
924        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
925        let raw = format!(
926            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
927        );
928        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
929    }
930
931    fn strategy_config(completion: &str) -> Config {
932        let toml = format!(
933            r#"
934[project]
935name = "test"
936
937[tickets]
938dir = "tickets"
939
940[[workflow.states]]
941id    = "in_progress"
942label = "In Progress"
943
944[[workflow.states.transitions]]
945to         = "implemented"
946completion = "{completion}"
947
948[[workflow.states]]
949id       = "implemented"
950label    = "Implemented"
951terminal = true
952"#
953        );
954        toml::from_str(&toml).unwrap()
955    }
956
957    #[test]
958    fn strategy_finds_in_progress_to_implemented() {
959        let config = strategy_config("pr_or_epic_merge");
960        assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
961    }
962
963    #[test]
964    fn strategy_defaults_to_none_when_absent() {
965        let toml = r#"
966[project]
967name = "test"
968
969[tickets]
970dir = "tickets"
971
972[[workflow.states]]
973id    = "new"
974label = "New"
975
976[[workflow.states.transitions]]
977to = "closed"
978
979[[workflow.states]]
980id       = "closed"
981label    = "Closed"
982terminal = true
983"#;
984        let config: Config = toml::from_str(toml).unwrap();
985        assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
986    }
987
988    #[test]
989    fn dep_rules_pr_rejects_dep() {
990        let dep = make_ticket("dep1", None, None);
991        let result = check_depends_on_rules(
992            &CompletionStrategy::Pr,
993            None,
994            None,
995            &["dep1".to_string()],
996            &[dep],
997            "main",
998        );
999        assert!(result.is_err());
1000        let msg = result.unwrap_err().to_string();
1001        assert!(msg.contains("pr"), "expected strategy name in: {msg}");
1002    }
1003
1004    #[test]
1005    fn dep_rules_none_rejects_dep() {
1006        let dep = make_ticket("dep1", None, None);
1007        let result = check_depends_on_rules(
1008            &CompletionStrategy::None,
1009            None,
1010            None,
1011            &["dep1".to_string()],
1012            &[dep],
1013            "main",
1014        );
1015        assert!(result.is_err());
1016        let msg = result.unwrap_err().to_string();
1017        assert!(msg.contains("none"), "expected strategy name in: {msg}");
1018    }
1019
1020    #[test]
1021    fn dep_rules_pr_or_epic_merge_same_epic_ok() {
1022        let dep = make_ticket("dep1", Some("abc"), None);
1023        let result = check_depends_on_rules(
1024            &CompletionStrategy::PrOrEpicMerge,
1025            Some("abc"),
1026            None,
1027            &["dep1".to_string()],
1028            &[dep],
1029            "main",
1030        );
1031        assert!(result.is_ok(), "expected Ok, got {result:?}");
1032    }
1033
1034    #[test]
1035    fn dep_rules_pr_or_epic_merge_different_epic_fails() {
1036        let dep = make_ticket("dep1", Some("xyz"), None);
1037        let result = check_depends_on_rules(
1038            &CompletionStrategy::PrOrEpicMerge,
1039            Some("abc"),
1040            None,
1041            &["dep1".to_string()],
1042            &[dep],
1043            "main",
1044        );
1045        assert!(result.is_err());
1046        let msg = result.unwrap_err().to_string();
1047        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1048    }
1049
1050    #[test]
1051    fn dep_rules_pr_or_epic_merge_standalone_ticket_ok() {
1052        // Standalone ticket (no epic) may have depends_on under pr_or_epic_merge;
1053        // it will use the independent PR path and depends_on still enforces ordering.
1054        let dep = make_ticket("dep1", Some("abc"), None);
1055        let result = check_depends_on_rules(
1056            &CompletionStrategy::PrOrEpicMerge,
1057            None,
1058            None,
1059            &["dep1".to_string()],
1060            &[dep],
1061            "main",
1062        );
1063        assert!(result.is_ok(), "expected Ok for standalone ticket, got {result:?}");
1064    }
1065
1066    #[test]
1067    fn dep_rules_merge_both_default_branch_ok() {
1068        let dep = make_ticket("dep1", None, None);
1069        let result = check_depends_on_rules(
1070            &CompletionStrategy::Merge,
1071            None,
1072            None,
1073            &["dep1".to_string()],
1074            &[dep],
1075            "main",
1076        );
1077        assert!(result.is_ok(), "expected Ok, got {result:?}");
1078    }
1079
1080    #[test]
1081    fn dep_rules_merge_different_target_fails() {
1082        let dep = make_ticket("dep1", None, Some("epic/other"));
1083        let result = check_depends_on_rules(
1084            &CompletionStrategy::Merge,
1085            None,
1086            None,
1087            &["dep1".to_string()],
1088            &[dep],
1089            "main",
1090        );
1091        assert!(result.is_err());
1092        let msg = result.unwrap_err().to_string();
1093        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1094    }
1095
1096    fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
1097        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1098        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
1099        let deps_line = if depends_on.is_empty() {
1100            String::new()
1101        } else {
1102            let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
1103            format!("depends_on = [{}]\n", quoted.join(", "))
1104        };
1105        let raw = format!(
1106            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
1107        );
1108        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
1109    }
1110
1111    #[test]
1112    fn validate_depends_on_no_deps_clean() {
1113        let config = strategy_config("pr_or_epic_merge");
1114        let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
1115        let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
1116        let result = validate_depends_on(&config, &[t1, t2]);
1117        assert!(result.is_empty(), "expected no violations, got {result:?}");
1118    }
1119
1120    #[test]
1121    fn validate_depends_on_closed_ticket_skipped() {
1122        let config = strategy_config("pr");
1123        let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
1124        let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
1125        let result = validate_depends_on(&config, &[dep, ticket]);
1126        assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
1127    }
1128
1129    #[test]
1130    fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
1131        let config = strategy_config("pr_or_epic_merge");
1132        let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
1133        let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
1134        let result = validate_depends_on(&config, &[dep, ticket]);
1135        assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
1136    }
1137
1138    #[test]
1139    fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
1140        let config = strategy_config("pr_or_epic_merge");
1141        let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
1142        let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
1143        let result = validate_depends_on(&config, &[dep, ticket]);
1144        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1145        assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
1146    }
1147
1148    #[test]
1149    fn validate_depends_on_merge_same_target_ok() {
1150        let config = strategy_config("merge");
1151        let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
1152        let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
1153        let result = validate_depends_on(&config, &[dep, ticket]);
1154        assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
1155    }
1156
1157    #[test]
1158    fn validate_depends_on_merge_different_target_fails() {
1159        let config = strategy_config("merge");
1160        let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
1161        let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
1162        let result = validate_depends_on(&config, &[dep, ticket]);
1163        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1164        assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
1165    }
1166
1167    #[test]
1168    fn validate_depends_on_pr_strategy_rejects_any_dep() {
1169        let config = strategy_config("pr");
1170        let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
1171        let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
1172        let result = validate_depends_on(&config, &[dep, ticket]);
1173        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1174        assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
1175    }
1176
1177    fn load_config(toml: &str) -> Config {
1178        toml::from_str(toml).expect("config parse failed")
1179    }
1180
1181    fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
1182        config.workflow.states.iter().map(|s| s.id.as_str()).collect()
1183    }
1184
1185    // Test 1: correct config passes all checks
1186    #[test]
1187    fn correct_config_passes() {
1188        let toml = r#"
1189[project]
1190name = "test"
1191
1192[tickets]
1193dir = "tickets"
1194
1195[[workflow.states]]
1196id    = "new"
1197label = "New"
1198
1199[[workflow.states.transitions]]
1200to = "in_progress"
1201
1202[[workflow.states]]
1203id       = "in_progress"
1204label    = "In Progress"
1205terminal = false
1206
1207[[workflow.states.transitions]]
1208to = "closed"
1209
1210[[workflow.states]]
1211id       = "closed"
1212label    = "Closed"
1213terminal = true
1214"#;
1215        let config = load_config(toml);
1216        let errors = validate_config(&config, Path::new("/tmp"));
1217        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1218    }
1219
1220    // Test 2: transition to non-existent state is detected
1221    #[test]
1222    fn transition_to_nonexistent_state_detected() {
1223        let toml = r#"
1224[project]
1225name = "test"
1226
1227[tickets]
1228dir = "tickets"
1229
1230[[workflow.states]]
1231id    = "new"
1232label = "New"
1233
1234[[workflow.states.transitions]]
1235to = "ghost"
1236"#;
1237        let config = load_config(toml);
1238        let errors = validate_config(&config, Path::new("/tmp"));
1239        assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
1240    }
1241
1242    // Test 3: terminal state with outgoing transitions is detected
1243    #[test]
1244    fn terminal_state_with_transitions_detected() {
1245        let toml = r#"
1246[project]
1247name = "test"
1248
1249[tickets]
1250dir = "tickets"
1251
1252[[workflow.states]]
1253id       = "closed"
1254label    = "Closed"
1255terminal = true
1256
1257[[workflow.states.transitions]]
1258to = "new"
1259
1260[[workflow.states]]
1261id    = "new"
1262label = "New"
1263
1264[[workflow.states.transitions]]
1265to = "closed"
1266"#;
1267        let config = load_config(toml);
1268        let errors = validate_config(&config, Path::new("/tmp"));
1269        assert!(
1270            errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
1271            "expected terminal error in {errors:?}"
1272        );
1273    }
1274
1275    // Test 5: ticket with unknown state is detected
1276    #[test]
1277    fn ticket_with_unknown_state_detected() {
1278        use crate::ticket::Ticket;
1279
1280        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
1281        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1282
1283        let known_states: std::collections::HashSet<&str> =
1284            ["new", "ready", "closed"].iter().copied().collect();
1285
1286        assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
1287    }
1288
1289    // Test 6: dead-end non-terminal state is detected
1290    #[test]
1291    fn dead_end_non_terminal_detected() {
1292        let toml = r#"
1293[project]
1294name = "test"
1295
1296[tickets]
1297dir = "tickets"
1298
1299[[workflow.states]]
1300id    = "stuck"
1301label = "Stuck"
1302
1303[[workflow.states]]
1304id       = "closed"
1305label    = "Closed"
1306terminal = true
1307"#;
1308        let config = load_config(toml);
1309        let errors = validate_config(&config, Path::new("/tmp"));
1310        assert!(
1311            errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
1312            "expected dead-end error in {errors:?}"
1313        );
1314    }
1315
1316    // Test 7: context_section mismatch is detected
1317    #[test]
1318    fn context_section_mismatch_detected() {
1319        let toml = r#"
1320[project]
1321name = "test"
1322
1323[tickets]
1324dir = "tickets"
1325
1326[[ticket.sections]]
1327name = "Problem"
1328type = "free"
1329
1330[[workflow.states]]
1331id    = "new"
1332label = "New"
1333
1334[[workflow.states.transitions]]
1335to              = "ready"
1336context_section = "NonExistent"
1337
1338[[workflow.states]]
1339id    = "ready"
1340label = "Ready"
1341
1342[[workflow.states.transitions]]
1343to = "closed"
1344
1345[[workflow.states]]
1346id       = "closed"
1347label    = "Closed"
1348terminal = true
1349"#;
1350        let config = load_config(toml);
1351        let errors = validate_config(&config, Path::new("/tmp"));
1352        assert!(
1353            errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
1354            "expected context_section error in {errors:?}"
1355        );
1356    }
1357
1358    // Test 8: focus_section mismatch is detected
1359    #[test]
1360    fn focus_section_mismatch_detected() {
1361        let toml = r#"
1362[project]
1363name = "test"
1364
1365[tickets]
1366dir = "tickets"
1367
1368[[ticket.sections]]
1369name = "Problem"
1370type = "free"
1371
1372[[workflow.states]]
1373id    = "new"
1374label = "New"
1375
1376[[workflow.states.transitions]]
1377to             = "ready"
1378focus_section  = "BadSection"
1379
1380[[workflow.states]]
1381id    = "ready"
1382label = "Ready"
1383
1384[[workflow.states.transitions]]
1385to = "closed"
1386
1387[[workflow.states]]
1388id       = "closed"
1389label    = "Closed"
1390terminal = true
1391"#;
1392        let config = load_config(toml);
1393        let errors = validate_config(&config, Path::new("/tmp"));
1394        assert!(
1395            errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
1396            "expected focus_section error in {errors:?}"
1397        );
1398    }
1399
1400    // Test 9: completion=pr without provider is detected
1401    #[test]
1402    fn completion_pr_without_provider_detected() {
1403        let toml = r#"
1404[project]
1405name = "test"
1406
1407[tickets]
1408dir = "tickets"
1409
1410[[workflow.states]]
1411id    = "new"
1412label = "New"
1413
1414[[workflow.states.transitions]]
1415to         = "closed"
1416completion = "pr"
1417
1418[[workflow.states]]
1419id       = "closed"
1420label    = "Closed"
1421terminal = true
1422"#;
1423        let config = load_config(toml);
1424        let errors = validate_config(&config, Path::new("/tmp"));
1425        assert!(
1426            errors.iter().any(|e| e.contains("provider")),
1427            "expected provider error in {errors:?}"
1428        );
1429    }
1430
1431    // Test 10: completion=pr with provider configured passes
1432    #[test]
1433    fn completion_pr_with_provider_passes() {
1434        let toml = r#"
1435[project]
1436name = "test"
1437
1438[tickets]
1439dir = "tickets"
1440
1441[git_host]
1442provider = "github"
1443
1444[[workflow.states]]
1445id    = "new"
1446label = "New"
1447
1448[[workflow.states.transitions]]
1449to         = "closed"
1450completion = "pr"
1451
1452[[workflow.states]]
1453id       = "closed"
1454label    = "Closed"
1455terminal = true
1456"#;
1457        let config = load_config(toml);
1458        let errors = validate_config(&config, Path::new("/tmp"));
1459        assert!(
1460            !errors.iter().any(|e| e.contains("provider")),
1461            "unexpected provider error in {errors:?}"
1462        );
1463    }
1464
1465    // Test 11: context_section with empty ticket.sections is skipped
1466    #[test]
1467    fn context_section_skipped_when_no_sections_defined() {
1468        let toml = r#"
1469[project]
1470name = "test"
1471
1472[tickets]
1473dir = "tickets"
1474
1475[[workflow.states]]
1476id    = "new"
1477label = "New"
1478
1479[[workflow.states.transitions]]
1480to              = "closed"
1481context_section = "AnySection"
1482
1483[[workflow.states]]
1484id       = "closed"
1485label    = "Closed"
1486terminal = true
1487"#;
1488        let config = load_config(toml);
1489        let errors = validate_config(&config, Path::new("/tmp"));
1490        assert!(
1491            !errors.iter().any(|e| e.contains("context_section")),
1492            "unexpected context_section error in {errors:?}"
1493        );
1494    }
1495
1496    // Test: closed state is not flagged as unknown even when absent from config
1497    #[test]
1498    fn closed_state_not_flagged_as_unknown() {
1499        use crate::ticket::Ticket;
1500
1501        // Config with no "closed" state
1502        let toml = r#"
1503[project]
1504name = "test"
1505
1506[tickets]
1507dir = "tickets"
1508
1509[[workflow.states]]
1510id    = "new"
1511label = "New"
1512
1513[[workflow.states.transitions]]
1514to = "done"
1515
1516[[workflow.states]]
1517id       = "done"
1518label    = "Done"
1519terminal = true
1520"#;
1521        let config = load_config(toml);
1522        let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
1523            .map(|s| s.id.as_str())
1524            .collect();
1525
1526        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
1527        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1528
1529        // "closed" is not in state_ids, but the validate logic skips it.
1530        assert!(!state_ids.contains("closed"));
1531        // Simulate the validate check: closed should be exempt.
1532        let fm = &ticket.frontmatter;
1533        let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
1534        assert!(!flagged, "closed state should not be flagged as unknown");
1535    }
1536
1537    // Test for state_ids helper (kept for compatibility)
1538    #[test]
1539    fn state_ids_helper() {
1540        let toml = r#"
1541[project]
1542name = "test"
1543
1544[tickets]
1545dir = "tickets"
1546
1547[[workflow.states]]
1548id    = "new"
1549label = "New"
1550"#;
1551        let config = load_config(toml);
1552        let ids = state_ids(&config);
1553        assert!(ids.contains("new"));
1554    }
1555
1556    #[test]
1557    fn validate_warnings_no_container() {
1558        let toml = r#"
1559[project]
1560name = "test"
1561
1562[tickets]
1563dir = "tickets"
1564"#;
1565        let config = load_config(toml);
1566        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1567        assert!(warnings.is_empty());
1568    }
1569
1570    #[test]
1571    fn valid_collaborator_accepted() {
1572        let toml = r#"
1573[project]
1574name = "test"
1575collaborators = ["alice", "bob"]
1576
1577[tickets]
1578dir = "tickets"
1579"#;
1580        let config = load_config(toml);
1581        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1582    }
1583
1584    #[test]
1585    fn unknown_user_rejected() {
1586        let toml = r#"
1587[project]
1588name = "test"
1589collaborators = ["alice", "bob"]
1590
1591[tickets]
1592dir = "tickets"
1593"#;
1594        let config = load_config(toml);
1595        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1596        let msg = err.to_string();
1597        assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
1598        assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
1599    }
1600
1601    #[test]
1602    fn empty_collaborators_skips_validation() {
1603        let toml = r#"
1604[project]
1605name = "test"
1606
1607[tickets]
1608dir = "tickets"
1609"#;
1610        let config = load_config(toml);
1611        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1612    }
1613
1614    #[test]
1615    fn clear_owner_always_allowed() {
1616        let toml = r#"
1617[project]
1618name = "test"
1619collaborators = ["alice"]
1620
1621[tickets]
1622dir = "tickets"
1623"#;
1624        let config = load_config(toml);
1625        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1626    }
1627
1628    #[test]
1629    fn github_mode_known_user_accepted() {
1630        let toml = r#"
1631[project]
1632name = "test"
1633collaborators = ["alice", "bob"]
1634
1635[tickets]
1636dir = "tickets"
1637
1638[git_host]
1639provider = "github"
1640repo = "org/repo"
1641"#;
1642        let config = load_config(toml);
1643        // No token in LocalConfig::default() — falls back to project.collaborators
1644        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1645    }
1646
1647    #[test]
1648    fn github_mode_unknown_user_rejected() {
1649        let toml = r#"
1650[project]
1651name = "test"
1652collaborators = ["alice", "bob"]
1653
1654[tickets]
1655dir = "tickets"
1656
1657[git_host]
1658provider = "github"
1659repo = "org/repo"
1660"#;
1661        let config = load_config(toml);
1662        // No token — falls back to project.collaborators; charlie is not in the list
1663        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1664        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1665    }
1666
1667    #[test]
1668    fn github_mode_no_collaborators_skips_check() {
1669        let toml = r#"
1670[project]
1671name = "test"
1672
1673[tickets]
1674dir = "tickets"
1675
1676[git_host]
1677provider = "github"
1678repo = "org/repo"
1679"#;
1680        let config = load_config(toml);
1681        // Empty collaborators list — no validation
1682        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1683    }
1684
1685    #[test]
1686    fn github_mode_clear_owner_accepted() {
1687        let toml = r#"
1688[project]
1689name = "test"
1690collaborators = ["alice"]
1691
1692[tickets]
1693dir = "tickets"
1694
1695[git_host]
1696provider = "github"
1697repo = "org/repo"
1698"#;
1699        let config = load_config(toml);
1700        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1701    }
1702
1703    #[test]
1704    fn non_github_mode_unknown_user_rejected() {
1705        let toml = r#"
1706[project]
1707name = "test"
1708collaborators = ["alice", "bob"]
1709
1710[tickets]
1711dir = "tickets"
1712"#;
1713        let config = load_config(toml);
1714        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1715        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1716    }
1717
1718    #[test]
1719    fn validate_warnings_empty_container() {
1720        let toml = r#"
1721[project]
1722name = "test"
1723
1724[tickets]
1725dir = "tickets"
1726
1727[workers]
1728container = ""
1729"#;
1730        let config = load_config(toml);
1731        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1732        assert!(warnings.is_empty(), "empty container string should not warn");
1733    }
1734
1735    #[test]
1736    fn dead_end_workflow_warning_emitted() {
1737        // A workflow where the only agent-actionable state cycles back to itself
1738        // with no completion strategy — no "success" outcome is reachable.
1739        let toml = r#"
1740[project]
1741name = "test"
1742
1743[tickets]
1744dir = "tickets"
1745
1746[[workflow.states]]
1747id         = "start"
1748label      = "Start"
1749actionable = ["agent"]
1750
1751[[workflow.states.transitions]]
1752to = "middle"
1753
1754[[workflow.states]]
1755id    = "middle"
1756label = "Middle"
1757
1758[[workflow.states.transitions]]
1759to = "start"
1760"#;
1761        let config = load_config(toml);
1762        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1763        assert!(
1764            warnings.iter().any(|w| w.contains("success")),
1765            "expected dead-end warning containing 'success'; got: {warnings:?}"
1766        );
1767    }
1768
1769    #[test]
1770    fn default_workflow_no_dead_end_warning() {
1771        // The default workflow has in_progress → implemented with completion = pr_or_epic_merge,
1772        // reachable from the agent-actionable "ready" state. No dead-end warning should fire.
1773        let base = r#"
1774[project]
1775name = "test"
1776
1777[tickets]
1778dir = "tickets"
1779"#;
1780        let combined = format!("{}\n{}", base, crate::init::default_workflow_toml());
1781        let config: Config = toml::from_str(&combined).unwrap();
1782        let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1783        assert!(
1784            !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")),
1785            "unexpected dead-end warning for default workflow; got: {warnings:?}"
1786        );
1787    }
1788
1789    #[test]
1790    fn worktree_missing_in_design() {
1791        let dir = setup_verify_repo();
1792        let root = dir.path();
1793        let config = Config::load(root).unwrap();
1794        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1795
1796        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1797
1798        let main_root = git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1799        let wt_path = main_root.join("worktrees").join("ticket-abcd1234-test");
1800        let expected = format!(
1801            "#abcd1234 [in_design]: worktree at {} is missing",
1802            wt_path.display()
1803        );
1804        assert!(
1805            issues.iter().any(|i| i == &expected),
1806            "expected worktree missing issue; got: {issues:?}"
1807        );
1808    }
1809
1810    #[test]
1811    fn worktree_present_no_issue() {
1812        let dir = setup_verify_repo();
1813        let root = dir.path();
1814        let config = Config::load(root).unwrap();
1815        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1816
1817        std::fs::create_dir_all(root.join("worktrees").join("ticket-abcd1234-test")).unwrap();
1818
1819        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1820        assert!(
1821            !issues.iter().any(|i| i.contains("worktree")),
1822            "unexpected worktree issue; got: {issues:?}"
1823        );
1824    }
1825
1826    #[test]
1827    fn worktree_check_skipped_for_other_states() {
1828        let dir = setup_verify_repo();
1829        let root = dir.path();
1830        let config = Config::load(root).unwrap();
1831        let ticket = make_verify_ticket(root, "abcd1234", "specd", Some("ticket/abcd1234-test"));
1832
1833        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1834        assert!(
1835            !issues.iter().any(|i| i.contains("worktree")),
1836            "unexpected worktree issue for specd state; got: {issues:?}"
1837        );
1838    }
1839
1840    fn in_repo_wt_config(dir: &str) -> Config {
1841        let toml = format!(
1842            r#"
1843[project]
1844name = "test"
1845
1846[tickets]
1847dir = "tickets"
1848
1849[worktrees]
1850dir = "{dir}"
1851"#
1852        );
1853        toml::from_str(&toml).expect("config parse failed")
1854    }
1855
1856    #[test]
1857    fn validate_config_gitignore_missing_in_repo_wt() {
1858        let tmp = tempfile::TempDir::new().unwrap();
1859        let config = in_repo_wt_config("worktrees");
1860        let errors = validate_config(&config, tmp.path());
1861        assert!(
1862            errors.iter().any(|e| e.contains("worktrees") && e.contains(".gitignore")),
1863            "expected gitignore missing error; got: {errors:?}"
1864        );
1865    }
1866
1867    #[test]
1868    fn validate_config_gitignore_covered_anchored_slash() {
1869        let tmp = tempfile::TempDir::new().unwrap();
1870        std::fs::write(tmp.path().join(".gitignore"), "/worktrees/\n").unwrap();
1871        let config = in_repo_wt_config("worktrees");
1872        let errors = validate_config(&config, tmp.path());
1873        assert!(
1874            !errors.iter().any(|e| e.contains("gitignore")),
1875            "unexpected gitignore error; got: {errors:?}"
1876        );
1877    }
1878
1879    #[test]
1880    fn validate_config_gitignore_covered_anchored_no_slash() {
1881        let tmp = tempfile::TempDir::new().unwrap();
1882        std::fs::write(tmp.path().join(".gitignore"), "/worktrees\n").unwrap();
1883        let config = in_repo_wt_config("worktrees");
1884        let errors = validate_config(&config, tmp.path());
1885        assert!(
1886            !errors.iter().any(|e| e.contains("gitignore")),
1887            "unexpected gitignore error; got: {errors:?}"
1888        );
1889    }
1890
1891    #[test]
1892    fn validate_config_gitignore_covered_unanchored_slash() {
1893        let tmp = tempfile::TempDir::new().unwrap();
1894        std::fs::write(tmp.path().join(".gitignore"), "worktrees/\n").unwrap();
1895        let config = in_repo_wt_config("worktrees");
1896        let errors = validate_config(&config, tmp.path());
1897        assert!(
1898            !errors.iter().any(|e| e.contains("gitignore")),
1899            "unexpected gitignore error; got: {errors:?}"
1900        );
1901    }
1902
1903    #[test]
1904    fn validate_config_gitignore_covered_bare() {
1905        let tmp = tempfile::TempDir::new().unwrap();
1906        std::fs::write(tmp.path().join(".gitignore"), "worktrees\n").unwrap();
1907        let config = in_repo_wt_config("worktrees");
1908        let errors = validate_config(&config, tmp.path());
1909        assert!(
1910            !errors.iter().any(|e| e.contains("gitignore")),
1911            "unexpected gitignore error; got: {errors:?}"
1912        );
1913    }
1914
1915    #[test]
1916    fn validate_config_gitignore_not_covered() {
1917        let tmp = tempfile::TempDir::new().unwrap();
1918        std::fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
1919        let config = in_repo_wt_config("worktrees");
1920        let errors = validate_config(&config, tmp.path());
1921        assert!(
1922            errors.iter().any(|e| e.contains("worktrees") && e.contains("gitignore")),
1923            "expected gitignore not covered error; got: {errors:?}"
1924        );
1925    }
1926
1927    #[test]
1928    fn validate_config_gitignore_no_false_positive() {
1929        let tmp = tempfile::TempDir::new().unwrap();
1930        std::fs::write(tmp.path().join(".gitignore"), "wt-old/\n").unwrap();
1931        let config = in_repo_wt_config("wt");
1932        let errors = validate_config(&config, tmp.path());
1933        assert!(
1934            errors.iter().any(|e| e.contains("wt") && e.contains("gitignore")),
1935            "wt-old should not match wt; got: {errors:?}"
1936        );
1937    }
1938
1939    #[test]
1940    fn validate_config_external_dotdot_no_check() {
1941        let tmp = tempfile::TempDir::new().unwrap();
1942        // No .gitignore at all
1943        let config = in_repo_wt_config("../ext");
1944        let errors = validate_config(&config, tmp.path());
1945        assert!(
1946            !errors.iter().any(|e| e.contains("gitignore")),
1947            "external dotdot path should skip gitignore check; got: {errors:?}"
1948        );
1949    }
1950
1951    #[test]
1952    fn validate_config_external_absolute_no_check() {
1953        let tmp = tempfile::TempDir::new().unwrap();
1954        // No .gitignore at all
1955        let config = in_repo_wt_config("/abs/path");
1956        let errors = validate_config(&config, tmp.path());
1957        assert!(
1958            !errors.iter().any(|e| e.contains("gitignore")),
1959            "absolute path should skip gitignore check; got: {errors:?}"
1960        );
1961    }
1962
1963    fn config_with_merge_transition(completion: &str, on_failure: Option<&str>, declare_failure_state: bool) -> Config {
1964        let on_failure_line = on_failure
1965            .map(|v| format!("on_failure = \"{v}\"\n"))
1966            .unwrap_or_default();
1967        let merge_failed_state = if declare_failure_state {
1968            r#"
1969[[workflow.states]]
1970id       = "merge_failed"
1971label    = "Merge failed"
1972
1973[[workflow.states.transitions]]
1974to = "closed"
1975"#
1976        } else {
1977            ""
1978        };
1979        let toml = format!(
1980            r#"
1981[project]
1982name = "test"
1983
1984[tickets]
1985dir = "tickets"
1986
1987[[workflow.states]]
1988id    = "in_progress"
1989label = "In Progress"
1990
1991[[workflow.states.transitions]]
1992to         = "implemented"
1993completion = "{completion}"
1994{on_failure_line}
1995[[workflow.states]]
1996id       = "implemented"
1997label    = "Implemented"
1998terminal = true
1999
2000[[workflow.states]]
2001id       = "closed"
2002label    = "Closed"
2003terminal = true
2004{merge_failed_state}
2005"#
2006        );
2007        toml::from_str(&toml).expect("config parse failed")
2008    }
2009
2010    #[test]
2011    fn test_on_failure_missing_for_merge() {
2012        let config = config_with_merge_transition("merge", None, false);
2013        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2014        assert!(
2015            errors.iter().any(|e| e.contains("missing `on_failure`")),
2016            "expected missing on_failure error; got: {errors:?}"
2017        );
2018    }
2019
2020    #[test]
2021    fn test_on_failure_missing_for_pr_or_epic_merge() {
2022        // No ticket with target_branch — rule fires on transition definition alone.
2023        let config = config_with_merge_transition("pr_or_epic_merge", None, false);
2024        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2025        assert!(
2026            errors.iter().any(|e| e.contains("missing `on_failure`")),
2027            "expected missing on_failure error for pr_or_epic_merge; got: {errors:?}"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_on_failure_unknown_state() {
2033        let config = config_with_merge_transition("merge", Some("ghost_state"), false);
2034        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2035        assert!(
2036            errors.iter().any(|e| e.contains("ghost_state")),
2037            "expected unknown state error for ghost_state; got: {errors:?}"
2038        );
2039    }
2040
2041    #[test]
2042    fn test_on_failure_valid() {
2043        let config = config_with_merge_transition("merge", Some("merge_failed"), true);
2044        let errors = validate_config(&config, std::path::Path::new("/tmp"));
2045        let on_failure_errors: Vec<&String> = errors.iter()
2046            .filter(|e| e.contains("on_failure") || e.contains("ghost_state") || e.contains("merge_failed"))
2047            .collect();
2048        assert!(
2049            on_failure_errors.is_empty(),
2050            "unexpected on_failure errors: {on_failure_errors:?}"
2051        );
2052    }
2053
2054    // --- frontmatter agent validation ---
2055
2056    fn make_agent_verify_ticket(root: &std::path::Path, id: &str, state: &str, extra_fm: &str) -> Ticket {
2057        let raw = format!(
2058            "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{extra_fm}+++\n\n## Spec\n\n## History\n"
2059        );
2060        let path = root.join("tickets").join(format!("{id}-test.md"));
2061        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2062        std::fs::write(&path, &raw).unwrap();
2063        Ticket::parse(&path, &raw).unwrap()
2064    }
2065
2066    #[test]
2067    fn validate_unknown_frontmatter_agent_is_error() {
2068        let dir = setup_verify_repo();
2069        let root = dir.path();
2070        let config = Config::load(root).unwrap();
2071        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"nonexistent-bot\"\n");
2072
2073        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2074
2075        assert!(
2076            issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2077            "expected error with ticket id and agent name; got: {issues:?}"
2078        );
2079    }
2080
2081    #[test]
2082    fn validate_unknown_agent_in_overrides_is_error() {
2083        let dir = setup_verify_repo();
2084        let root = dir.path();
2085        let config = Config::load(root).unwrap();
2086        let ticket = make_agent_verify_ticket(
2087            root,
2088            "abcd1234",
2089            "specd",
2090            "[agent_overrides]\nimpl_agent = \"nonexistent-bot\"\n",
2091        );
2092
2093        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2094
2095        assert!(
2096            issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2097            "expected error with ticket id and agent name; got: {issues:?}"
2098        );
2099    }
2100
2101    #[test]
2102    fn validate_known_frontmatter_agent_passes() {
2103        let dir = setup_verify_repo();
2104        let root = dir.path();
2105        let config = Config::load(root).unwrap();
2106        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"claude\"\n");
2107
2108        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2109
2110        assert!(
2111            !issues.iter().any(|i| i.contains("is not a known built-in")),
2112            "expected no agent error for known built-in; got: {issues:?}"
2113        );
2114    }
2115
2116    #[test]
2117    fn validate_agent_name_accepts_configured_profile_agent() {
2118        let toml = r#"
2119[worker_profiles.pi]
2120agent = "pi"
2121"#;
2122        let config = audit_config(toml);
2123        validate_agent_name(&config, "pi").expect("pi should be a configured agent");
2124    }
2125
2126    #[test]
2127    fn validate_agent_name_rejects_unknown() {
2128        let toml = r#"
2129[worker_profiles.pi]
2130agent = "pi"
2131"#;
2132        let config = audit_config(toml);
2133        let err = validate_agent_name(&config, "nonexistent").unwrap_err();
2134        let msg = err.to_string();
2135        assert!(msg.contains("nonexistent"), "got: {msg}");
2136        assert!(msg.contains("not configured in config.toml"), "got: {msg}");
2137    }
2138
2139    #[test]
2140    fn validate_agent_name_accepts_dash_sentinel() {
2141        let config = audit_config("");
2142        validate_agent_name(&config, "-").expect("dash should clear without validation");
2143    }
2144
2145    #[test]
2146    fn validate_ticket_agent_not_in_config_is_error() {
2147        let dir = setup_verify_repo();
2148        let root = dir.path();
2149        let config = Config::load(root).unwrap();
2150        // "claude" wrapper resolves but is not in this minimal config's worker_profiles;
2151        // setup_verify_repo's config also doesn't set [workers].agent, so the default
2152        // "claude" is the only configured name — pick something else that *does* resolve
2153        // as a wrapper to isolate the config-coverage check.
2154        let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"phi4\"\n");
2155
2156        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2157
2158        assert!(
2159            issues.iter().any(|i| i.contains("abcd1234") && i.contains("not configured in config.toml")),
2160            "expected config-coverage error; got: {issues:?}"
2161        );
2162    }
2163
2164    // --- audit_agent_resolution tests ---
2165
2166    fn audit_config(extra_toml: &str) -> Config {
2167        let base = r#"
2168[project]
2169name = "test"
2170
2171[tickets]
2172dir = "tickets"
2173
2174[worktrees]
2175dir = "../wt"
2176"#;
2177        toml::from_str(&format!("{base}{extra_toml}")).expect("config parse failed")
2178    }
2179
2180    #[test]
2181    fn audit_zero_spawn_transitions() {
2182        let toml = r#"
2183[[workflow.states]]
2184id    = "new"
2185label = "New"
2186
2187[[workflow.states.transitions]]
2188to      = "closed"
2189trigger = "command:review"
2190
2191[[workflow.states]]
2192id       = "closed"
2193label    = "Closed"
2194terminal = true
2195"#;
2196        let config = audit_config(toml);
2197        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2198        assert!(result.is_empty(), "expected 0 audits, got {result:?}");
2199    }
2200
2201    #[test]
2202    fn audit_default_agent_resolution() {
2203        let toml = r#"
2204[[workflow.states]]
2205id    = "ready"
2206label = "Ready"
2207
2208[[workflow.states.transitions]]
2209to      = "in_progress"
2210trigger = "command:start"
2211
2212[[workflow.states]]
2213id       = "in_progress"
2214label    = "In Progress"
2215terminal = true
2216"#;
2217        let config = audit_config(toml);
2218        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2219        assert_eq!(result.len(), 1, "expected 1 audit");
2220        let ta = &result[0];
2221        assert_eq!(ta.from_state, "ready");
2222        assert_eq!(ta.to_state, "in_progress");
2223        assert!(ta.profile.is_none());
2224        assert_eq!(ta.agent.value, "claude");
2225        assert_eq!(ta.agent.source, "default");
2226        assert_eq!(ta.role_prefix.source, "default");
2227        assert!(ta.wrapper.contains("claude"), "wrapper should mention claude: {}", ta.wrapper);
2228    }
2229
2230    #[test]
2231    fn audit_missing_profile_no_panic() {
2232        let toml = r#"
2233[[workflow.states]]
2234id    = "ready"
2235label = "Ready"
2236
2237[[workflow.states.transitions]]
2238to      = "in_progress"
2239trigger = "command:start"
2240profile = "nonexistent_profile"
2241
2242[[workflow.states]]
2243id       = "in_progress"
2244label    = "In Progress"
2245terminal = true
2246"#;
2247        let config = audit_config(toml);
2248        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2249        assert_eq!(result.len(), 1, "expected 1 audit even with missing profile");
2250        let ta = &result[0];
2251        assert_eq!(ta.profile.as_deref(), Some("nonexistent_profile"));
2252        assert_eq!(ta.agent.value, "(profile not found)");
2253        assert_eq!(ta.instructions.value, "(profile not found)");
2254        assert_eq!(ta.role_prefix.value, "(profile not found)");
2255    }
2256
2257    #[test]
2258    fn audit_transition_instructions_source() {
2259        let toml = r#"
2260[workers]
2261agent = "claude"
2262
2263[[workflow.states]]
2264id    = "ready"
2265label = "Ready"
2266
2267[[workflow.states.transitions]]
2268to           = "in_progress"
2269trigger      = "command:start"
2270instructions = ".apm/agents/default/apm.worker.md"
2271
2272[[workflow.states]]
2273id       = "in_progress"
2274label    = "In Progress"
2275terminal = true
2276"#;
2277        let config = audit_config(toml);
2278        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2279        assert_eq!(result.len(), 1);
2280        let ta = &result[0];
2281        assert_eq!(ta.instructions.source, "transition");
2282        assert_eq!(ta.instructions.value, ".apm/agents/default/apm.worker.md");
2283    }
2284
2285    #[test]
2286    fn audit_workers_agent_source() {
2287        let toml = r#"
2288[workers]
2289agent = "mock-happy"
2290
2291[[workflow.states]]
2292id    = "ready"
2293label = "Ready"
2294
2295[[workflow.states.transitions]]
2296to      = "in_progress"
2297trigger = "command:start"
2298
2299[[workflow.states]]
2300id       = "in_progress"
2301label    = "In Progress"
2302terminal = true
2303"#;
2304        let config = audit_config(toml);
2305        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2306        assert_eq!(result.len(), 1);
2307        let ta = &result[0];
2308        assert_eq!(ta.agent.source, "workers");
2309        assert_eq!(ta.agent.value, "mock-happy");
2310    }
2311
2312    #[test]
2313    fn audit_builtin_instructions_source() {
2314        // No on-disk project-agent-file for "claude"/"worker" in /tmp,
2315        // so falls through to built-in.
2316        let toml = r#"
2317[workers]
2318agent = "claude"
2319
2320[[workflow.states]]
2321id    = "ready"
2322label = "Ready"
2323
2324[[workflow.states.transitions]]
2325to      = "in_progress"
2326trigger = "command:start"
2327
2328[[workflow.states]]
2329id       = "in_progress"
2330label    = "In Progress"
2331terminal = true
2332"#;
2333        let config = audit_config(toml);
2334        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2335        assert_eq!(result.len(), 1);
2336        let ta = &result[0];
2337        assert_eq!(ta.instructions.source, "built-in");
2338        assert!(ta.instructions.value.starts_with("built-in:claude:"), "got {}", ta.instructions.value);
2339    }
2340
2341    #[test]
2342    fn audit_empty_worker_profiles_no_panic() {
2343        // worker_profiles is empty — must not panic
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        assert!(config.worker_profiles.is_empty());
2360        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2361        assert_eq!(result.len(), 1, "should not panic with empty worker_profiles");
2362    }
2363
2364    #[test]
2365    fn audit_transition_role_prefix_source() {
2366        let toml = r#"
2367[[workflow.states]]
2368id    = "ready"
2369label = "Ready"
2370
2371[[workflow.states.transitions]]
2372to          = "in_progress"
2373trigger     = "command:start"
2374role_prefix = "You are a Spec-Writer agent assigned to ticket #<id>."
2375
2376[[workflow.states]]
2377id       = "in_progress"
2378label    = "In Progress"
2379terminal = true
2380"#;
2381        let config = audit_config(toml);
2382        let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2383        assert_eq!(result.len(), 1);
2384        let ta = &result[0];
2385        assert_eq!(ta.role_prefix.source, "transition");
2386        assert!(ta.role_prefix.value.contains("Spec-Writer"));
2387    }
2388}