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