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