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