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 worker_profile: Option<String>,
21 pub agent: String,
22 pub role: String,
23 pub wrapper: String,
24}
25
26pub fn active_completion_strategy(config: &Config) -> CompletionStrategy {
29 config.workflow.states.iter()
30 .find(|s| s.id == "in_progress")
31 .and_then(|s| s.transitions.iter().find(|t| t.to == "implemented"))
32 .map(|t| t.completion.clone())
33 .unwrap_or(CompletionStrategy::None)
34}
35
36fn strategy_name(strategy: &CompletionStrategy) -> &'static str {
37 match strategy {
38 CompletionStrategy::Pr => "pr",
39 CompletionStrategy::Merge => "merge",
40 CompletionStrategy::Pull => "pull",
41 CompletionStrategy::PrOrEpicMerge => "pr_or_epic_merge",
42 CompletionStrategy::None => "none",
43 }
44}
45
46pub fn check_depends_on_rules(
54 strategy: &CompletionStrategy,
55 ticket_epic: Option<&str>,
56 ticket_target_branch: Option<&str>,
57 dep_ids: &[String],
58 all_tickets: &[crate::ticket_fmt::Ticket],
59 default_branch: &str,
60) -> Result<()> {
61 if dep_ids.is_empty() {
62 return Ok(());
63 }
64 match strategy {
65 CompletionStrategy::Pr | CompletionStrategy::None | CompletionStrategy::Pull => {
66 bail!(
67 "depends_on is not allowed under the {} completion strategy",
68 strategy_name(strategy)
69 );
70 }
71 CompletionStrategy::PrOrEpicMerge => {
72 if let Some(epic) = ticket_epic {
73 let mut offending: Vec<&str> = Vec::new();
75 for dep_id in dep_ids {
76 let dep = all_tickets.iter().find(|t| t.frontmatter.id == *dep_id)
77 .ok_or_else(|| anyhow::anyhow!("dep {dep_id} not found"))?;
78 if dep.frontmatter.epic.as_deref() != Some(epic) {
79 offending.push(dep_id.as_str());
80 }
81 }
82 if !offending.is_empty() {
83 bail!(
84 "pr_or_epic_merge requires all deps to share epic {epic}; offending deps: {}",
85 offending.join(", ")
86 );
87 }
88 }
89 }
91 CompletionStrategy::Merge => {
92 let ticket_target = ticket_target_branch.unwrap_or(default_branch);
93 let mut offending: Vec<&str> = Vec::new();
94 for dep_id in dep_ids {
95 let dep = all_tickets.iter().find(|t| t.frontmatter.id == *dep_id)
96 .ok_or_else(|| anyhow::anyhow!("dep {dep_id} not found"))?;
97 let dep_target = dep.frontmatter.target_branch.as_deref().unwrap_or(default_branch);
98 if dep_target != ticket_target {
99 offending.push(dep_id.as_str());
100 }
101 }
102 if !offending.is_empty() {
103 bail!(
104 "merge requires all deps to share target_branch {ticket_target}; offending deps: {}",
105 offending.join(", ")
106 );
107 }
108 }
109 }
110 Ok(())
111}
112
113pub fn validate_depends_on(config: &Config, tickets: &[Ticket]) -> Vec<(String, String)> {
116 let strategy = active_completion_strategy(config);
117 let mut violations: Vec<(String, String)> = Vec::new();
118 for ticket in tickets {
119 let fm = &ticket.frontmatter;
120 if fm.state == "closed" {
121 continue;
122 }
123 let dep_ids = match &fm.depends_on {
124 Some(deps) if !deps.is_empty() => deps,
125 _ => continue,
126 };
127 if let Err(e) = check_depends_on_rules(
128 &strategy,
129 fm.epic.as_deref(),
130 fm.target_branch.as_deref(),
131 dep_ids,
132 tickets,
133 &config.project.default_branch,
134 ) {
135 violations.push((format!("#{}", fm.id), e.to_string()));
136 }
137 }
138 violations
139}
140
141pub fn configured_agent_names(config: &Config) -> HashSet<String> {
144 let mut names: HashSet<String> = HashSet::new();
145 if let Some((agent, _)) = config.workers.default.split_once('/') {
146 names.insert(agent.to_string());
147 }
148 for state in &config.workflow.states {
150 if let Some(ref wp) = state.worker_profile {
151 if let Some((agent, _)) = wp.split_once('/') {
152 names.insert(agent.to_string());
153 }
154 }
155 }
156 names
157}
158
159pub fn validate_agent_name(config: &Config, name: &str) -> Result<()> {
162 if name == "-" {
163 return Ok(());
164 }
165 let configured = configured_agent_names(config);
166 if configured.contains(name) {
167 return Ok(());
168 }
169 let mut sorted: Vec<&String> = configured.iter().collect();
170 sorted.sort();
171 let list = sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
172 bail!("agent {name:?} is not configured in config.toml; known agents: [{list}]")
173}
174
175pub fn validate_owner(config: &Config, local: &LocalConfig, username: &str) -> Result<()> {
176 if username == "-" {
177 return Ok(());
178 }
179 let (collaborators, warnings) = crate::config::resolve_collaborators(config, local);
180 for w in &warnings {
181 #[allow(clippy::print_stderr)]
182 { eprintln!("{w}"); }
183 }
184 if collaborators.is_empty() {
185 return Ok(());
186 }
187 if collaborators.iter().any(|c| c == username) {
188 return Ok(());
189 }
190 let list = collaborators.join(", ");
191 bail!("unknown user '{username}'; valid collaborators: {list}");
192}
193
194fn is_external_worktree(dir: &Path) -> bool {
195 let s = dir.to_string_lossy();
196 s.starts_with('/') || s.starts_with("..")
197}
198
199fn gitignore_covers_dir(content: &str, dir: &str) -> bool {
200 let normalized_dir = dir.trim_matches('/');
201 content
202 .lines()
203 .map(|line| line.trim())
204 .filter(|line| !line.is_empty() && !line.starts_with('#'))
205 .any(|line| line.trim_matches('/') == normalized_dir)
206}
207
208pub fn validate_agents(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
214 let mut errors: Vec<String> = Vec::new();
215 let mut warnings: Vec<String> = Vec::new();
216 validate_agents_into(config, root, &mut errors, &mut warnings);
217 (errors, warnings)
218}
219
220fn validate_agents_into(config: &Config, root: &Path, errors: &mut Vec<String>, warnings: &mut Vec<String>) {
221 let names = configured_agent_names(config);
222
223 let builtins = wrapper::list_builtin_names().join(", ");
225 for name in &names {
226 match wrapper::resolve_wrapper(root, name) {
227 Ok(None) => errors.push(format!(
228 "agent '{}' not found: checked built-ins {{{builtins}}} and '.apm/agents/{}/'",
229 name, name
230 )),
231 Err(e) => errors.push(format!("agent '{name}': {e}")),
232 Ok(Some(wrapper::WrapperKind::Custom { manifest, .. })) => {
233 if let Some(m) = &manifest {
234 if m.parser == "external" && m.parser_command.is_none() {
235 errors.push(format!(
236 "agent '{name}': manifest.toml declares parser = \"external\" \
237 but parser_command is absent"
238 ));
239 }
240 }
241 }
242 Ok(Some(wrapper::WrapperKind::Builtin(_))) => {}
243 }
244 }
245
246 let agents_dir = root.join(".apm").join("agents");
248 let Ok(entries) = std::fs::read_dir(&agents_dir) else { return };
249
250 for entry in entries.filter_map(|e| e.ok()) {
251 let ft = match entry.file_type() {
252 Ok(ft) => ft,
253 Err(_) => continue,
254 };
255 if !ft.is_dir() {
256 continue;
257 }
258 let name = entry.file_name().to_string_lossy().to_string();
259
260 let wrapper_files: Vec<_> = std::fs::read_dir(entry.path())
262 .ok()
263 .into_iter()
264 .flatten()
265 .filter_map(|e| e.ok())
266 .filter(|e| e.file_name().to_string_lossy().starts_with("wrapper."))
267 .collect();
268
269 if !wrapper_files.is_empty() {
270 #[cfg(unix)]
271 {
272 use std::os::unix::fs::PermissionsExt;
273 let any_exec = wrapper_files.iter().any(|f| {
274 f.metadata()
275 .map(|m| m.permissions().mode() & 0o111 != 0)
276 .unwrap_or(false)
277 });
278 if !any_exec {
279 warnings.push(format!(
280 "agent '{name}': .apm/agents/{name}/wrapper.* exists but is not executable; run chmod +x"
281 ));
282 }
283 }
284 }
285
286 let manifest_path = entry.path().join("manifest.toml");
288 if manifest_path.exists() {
289 match parse_manifest(root, &name) {
290 Err(e) => {
291 errors.push(format!("agent '{name}': manifest.toml is not valid TOML: {e}"));
292 }
293 Ok(Some(manifest)) => {
294 if manifest.contract_version > 1 {
295 errors.push(format!(
296 "agent '{name}': manifest.toml declares contract_version {}; \
297 this APM build supports version 1 only — upgrade APM",
298 manifest.contract_version
299 ));
300 }
301 if let Ok(unknown) = manifest_unknown_keys(root, &name) {
302 for key in unknown {
303 warnings.push(format!(
304 "agent '{name}': manifest.toml: unknown key {key}"
305 ));
306 }
307 }
308 }
309 Ok(None) => {}
310 }
311 }
312 }
313}
314
315pub fn validate_config(config: &Config, root: &Path) -> Vec<String> {
316 let mut errors = validate_config_no_agents(config, root);
317 let (agent_errors, _) = validate_agents(config, root);
318 errors.extend(agent_errors);
319 errors
320}
321
322fn validate_config_no_agents(config: &Config, root: &Path) -> Vec<String> {
323 let mut errors: Vec<String> = Vec::new();
324
325 if config.workers.default.is_empty() {
326 errors.push(
327 "config: workers.default is not set — add `default = \"claude/coder\"` under [workers] in .apm/config.toml".into()
328 );
329 }
330
331 let state_ids: HashSet<&str> = config.workflow.states.iter()
332 .map(|s| s.id.as_str())
333 .collect();
334
335 let terminal_ids = config.terminal_state_ids();
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 for transition in &state.transitions {
382 if transition.to != "closed" && !state_ids.contains(transition.to.as_str()) {
385 errors.push(format!(
386 "config: state.{}.transition({}) — target state '{}' does not exist",
387 state.id, transition.to, transition.to
388 ));
389 }
390
391 if let Some(section) = &transition.context_section {
393 if has_sections && !section_names.contains(section.as_str()) {
394 errors.push(format!(
395 "config: state.{}.transition({}).context_section — unknown section '{}'",
396 state.id, transition.to, section
397 ));
398 }
399 }
400
401 if let Some(section) = &transition.focus_section {
403 if has_sections && !section_names.contains(section.as_str()) {
404 errors.push(format!(
405 "config: state.{}.transition({}).focus_section — unknown section '{}'",
406 state.id, transition.to, section
407 ));
408 }
409 }
410
411 if matches!(
413 transition.completion,
414 CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
415 ) {
416 if transition.on_failure.is_none() {
417 errors.push(format!(
418 "config: transition '{}' → '{}' uses completion '{}' but is missing \
419 `on_failure`; run `apm validate --fix` to add it",
420 state.id,
421 transition.to,
422 strategy_name(&transition.completion)
423 ));
424 } else if let Some(ref name) = transition.on_failure {
425 if name != "closed" && !state_ids.contains(name.as_str()) {
426 errors.push(format!(
427 "config: transition '{}' → '{}' has `on_failure = \"{}\"` but \
428 state \"{}\" is not declared in workflow.toml",
429 state.id, transition.to, name, name
430 ));
431 }
432 }
433 }
434
435 if matches!(
437 transition.completion,
438 CompletionStrategy::Pr | CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
439 ) && terminal_ids.contains(transition.to.as_str()) {
440 errors.push(format!(
441 "config: state.{}.transition({}) — completion {} targets terminal state {}; \
442 merging completions must target a non-terminal (review) state",
443 state.id,
444 transition.to,
445 strategy_name(&transition.completion),
446 transition.to
447 ));
448 }
449 }
450 }
451
452 {
459 let mut incoming: std::collections::HashMap<&str, Vec<(&str, &str)>> =
460 std::collections::HashMap::new();
461 for state in &config.workflow.states {
462 for transition in &state.transitions {
463 incoming
464 .entry(transition.to.as_str())
465 .or_default()
466 .push((state.id.as_str(), transition.trigger.as_str()));
467 }
468 }
469 for (dest, sources) in &incoming {
470 let has_command_start = sources.iter().any(|(_, t)| *t == "command:start");
471 let has_manual = sources.iter().any(|(_, t)| *t != "command:start");
472 if has_command_start && has_manual {
473 let src_list = sources
474 .iter()
475 .map(|(src, t)| format!("{src} (trigger: {t})"))
476 .collect::<Vec<_>>()
477 .join(", ");
478 errors.push(format!(
479 "config: state.{dest} — has both triggered and manual incoming \
480 transitions; a triggered destination must not also be reachable \
481 via manual transitions. Incoming from: {src_list}"
482 ));
483 }
484 }
485 }
486
487 for state in &config.workflow.states {
489 if let Some(wp) = &state.worker_profile {
490 let slash_count = wp.chars().filter(|&c| c == '/').count();
491 if slash_count != 1 {
492 errors.push(format!(
493 "config: state.{}.worker_profile — '{wp}' must contain exactly one '/' separator",
494 state.id
495 ));
496 } else if let Some((agent, role)) = wp.split_once('/') {
497 if agent.is_empty() || role.is_empty() {
498 errors.push(format!(
499 "config: state.{}.worker_profile — '{wp}' agent and role components must both be non-empty",
500 state.id
501 ));
502 } else if role == "worker" {
503 errors.push(format!(
504 "config: state.{}.worker_profile — role 'worker' is reserved as a process category; use a specific role name",
505 state.id
506 ));
507 }
508 }
509 }
510 }
511
512 {
514 let dispatch_states: HashSet<&str> = config.workflow.states.iter()
515 .filter(|s| s.worker_profile.is_some())
516 .map(|s| s.id.as_str())
517 .collect();
518 for state in &config.workflow.states {
519 for transition in &state.transitions {
520 if transition.trigger == "command:start"
521 && !dispatch_states.contains(transition.to.as_str())
522 {
523 errors.push(format!(
524 "config: state.{}.transition({}) — trigger 'command:start' targets \
525 state '{}' which has no worker_profile; the dispatcher has nothing to spawn",
526 state.id, transition.to, transition.to
527 ));
528 }
529 }
530 }
531 }
532
533 if !is_external_worktree(&config.worktrees.dir) {
534 let dir_str = config.worktrees.dir.to_string_lossy();
535 let gitignore = root.join(".gitignore");
536 match std::fs::read_to_string(&gitignore) {
537 Err(_) => errors.push(format!(
538 "config: worktrees.dir '{dir_str}' is in-repo but .gitignore is missing; \
539 run 'apm init' or add '/{dir_str}/' manually"
540 )),
541 Ok(content) if !gitignore_covers_dir(&content, &dir_str) => errors.push(format!(
542 "config: worktrees.dir '{dir_str}' is in-repo but .gitignore does not cover it; \
543 add '/{dir_str}/' or run 'apm init'"
544 )),
545 Ok(_) => {}
546 }
547 }
548
549 errors
550}
551
552pub fn verify_tickets(
553 root: &Path,
554 config: &Config,
555 tickets: &[Ticket],
556 merged: &HashSet<String>,
557) -> Vec<String> {
558 let valid_states: HashSet<&str> = config.workflow.states.iter()
559 .map(|s| s.id.as_str())
560 .collect();
561 let terminal = config.terminal_state_ids();
562
563 let in_progress_states: HashSet<&str> =
564 ["in_progress", "implemented"].iter().copied().collect();
565
566 let worktree_states: HashSet<&str> =
567 ["in_design", "in_progress"].iter().copied().collect();
568 let main_root = crate::git_util::main_worktree_root(root)
569 .unwrap_or_else(|| root.to_path_buf());
570 let worktrees_base = main_root.join(&config.worktrees.dir);
571
572 let mut issues: Vec<String> = Vec::new();
573
574 for t in tickets {
575 let fm = &t.frontmatter;
576
577 if terminal.contains(fm.state.as_str()) { continue; }
579
580 let prefix = format!("#{} [{}]", fm.id, fm.state);
581
582 if !valid_states.is_empty() && !valid_states.contains(fm.state.as_str()) {
584 issues.push(format!("{prefix}: unknown state {:?}", fm.state));
585 }
586
587 if let Some(name) = t.path.file_name().and_then(|n| n.to_str()) {
589 let expected_prefix = format!("{:04}", fm.id);
590 if !name.starts_with(&expected_prefix) {
591 issues.push(format!("{prefix}: id {} does not match filename {name}", fm.id));
592 }
593 }
594
595 if in_progress_states.contains(fm.state.as_str()) && fm.branch.is_none() {
597 issues.push(format!("{prefix}: state requires branch but none set"));
598 }
599
600 if let Some(branch) = &fm.branch {
602 if (fm.state == "in_progress" || fm.state == "implemented")
603 && merged.contains(branch.as_str())
604 {
605 issues.push(format!("{prefix}: branch {branch} is merged but ticket not closed"));
606 }
607 }
608
609 if worktree_states.contains(fm.state.as_str()) {
611 if let Some(branch) = &fm.branch {
612 let wt_name = branch.replace('/', "-");
613 let wt_path = worktrees_base.join(&wt_name);
614 if !wt_path.is_dir() {
615 issues.push(format!(
616 "{prefix}: worktree at {} is missing",
617 wt_path.display()
618 ));
619 }
620 }
621 }
622
623 if !t.body.contains("## Spec") {
625 issues.push(format!("{prefix}: missing ## Spec section"));
626 }
627
628 if !t.body.contains("## History") {
630 issues.push(format!("{prefix}: missing ## History section"));
631 }
632
633 if let Ok(doc) = t.document() {
635 for err in doc.validate(&config.ticket.sections) {
636 issues.push(format!("{prefix}: {err}"));
637 }
638 }
639
640 let agents_to_check: Vec<&str> = fm.agent
643 .as_deref()
644 .into_iter()
645 .chain(fm.agent_overrides.values().map(String::as_str))
646 .collect();
647
648 let configured_agents = configured_agent_names(config);
649
650 for name in agents_to_check {
651 match wrapper::resolve_wrapper(root, name) {
652 Ok(Some(_)) => {}
653 Ok(None) => issues.push(format!(
654 "ticket {}: agent {:?} is not a known built-in",
655 fm.id, name
656 )),
657 Err(e) => issues.push(format!(
658 "ticket {}: agent {:?}: {e}",
659 fm.id, name
660 )),
661 }
662 if !configured_agents.contains(name) {
663 issues.push(format!(
664 "ticket {}: agent {:?} is not configured in config.toml \
665 (add a worker_profile = \"<agent>/...\" on a spawn transition)",
666 fm.id, name
667 ));
668 }
669 }
670 }
671
672 issues.extend(verify_branch_file_invariant(root, config));
678
679 issues
680}
681
682fn verify_branch_file_invariant(root: &Path, config: &Config) -> Vec<String> {
685 let mut issues: Vec<String> = Vec::new();
686 let tickets_dir = config.tickets.dir.to_string_lossy().to_string();
687 let branches = match crate::git_util::ticket_branches(root) {
688 Ok(b) => b,
689 Err(_) => return issues,
690 };
691 for branch in &branches {
692 let suffix = branch.trim_start_matches("ticket/");
693 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
696 continue;
697 }
698 let expected_filename = format!("{suffix}.md");
699 let expected_path = format!("{tickets_dir}/{expected_filename}");
700
701 if crate::git_util::read_from_branch(root, branch, &expected_path).is_ok() {
703 continue;
704 }
705
706 let id_prefix: String = suffix.chars().take_while(|c| *c != '-').collect();
709 let files = crate::git_util::list_files_on_branch(root, branch, &tickets_dir)
710 .unwrap_or_default();
711 let id_matches: Vec<&String> = files.iter()
712 .filter(|f| {
713 let leaf = f.rsplit('/').next().unwrap_or("");
714 leaf.starts_with(&format!("{id_prefix}-")) && leaf.ends_with(".md")
715 })
716 .collect();
717
718 if id_matches.is_empty() {
719 issues.push(format!(
720 "branch {branch}: no ticket file at {expected_path} (orphaned branch — \
721 no tickets/{id_prefix}-*.md exists on this branch)"
722 ));
723 } else {
724 let found: Vec<String> = id_matches.iter().map(|s| (*s).clone()).collect();
725 issues.push(format!(
726 "branch {branch}: ticket file renamed — expected {expected_path}, \
727 found {} on branch. apm derives the filename from the branch \
728 suffix; rename the file back (or rename the branch) so it matches.",
729 found.join(", ")
730 ));
731 }
732 }
733 issues
734}
735
736pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec<String> {
737 let mut warnings = validate_warnings_no_agents(config, root);
738 let (_, agent_warnings) = validate_agents(config, root);
739 warnings.extend(agent_warnings);
740 warnings
741}
742
743fn validate_warnings_no_agents(config: &crate::config::Config, _root: &Path) -> Vec<String> {
744 let mut warnings = config.load_warnings.clone();
745 if let Some(container) = &config.workers.container {
746 if !container.is_empty() {
747 let docker_ok = std::process::Command::new("docker")
748 .arg("--version")
749 .output()
750 .map(|o| o.status.success())
751 .unwrap_or(false);
752 if !docker_ok {
753 warnings.push(
754 "workers.container is set but 'docker' is not in PATH".to_string()
755 );
756 }
757 }
758 }
759
760 let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
763 config.workflow.states.iter()
764 .map(|s| (s.id.as_str(), s))
765 .collect();
766
767 let agent_startable: Vec<&str> = config.workflow.states.iter()
768 .filter(|s| s.transitions.iter().any(|t| t.trigger == "command:start"))
769 .map(|s| s.id.as_str())
770 .collect();
771
772 if !agent_startable.is_empty() {
773 let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
774 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
775 let mut found_success = false;
776
777 for &start in &agent_startable {
778 if visited.insert(start) {
779 queue.push_back(start);
780 }
781 }
782
783 'bfs: while let Some(state_id) = queue.pop_front() {
784 let Some(state) = state_map.get(state_id) else { continue };
785 for t in &state.transitions {
786 let Some(&target) = state_map.get(t.to.as_str()) else { continue };
789 if resolve_outcome(t, target) == "success" {
790 found_success = true;
791 break 'bfs;
792 }
793 if !target.terminal && visited.insert(t.to.as_str()) {
794 queue.push_back(t.to.as_str());
795 }
796 }
797 }
798
799 if !found_success {
800 warnings.push(
801 "workflow has no reachable 'success' outcome from any agent-actionable state; \
802 workers may never complete successfully".to_string()
803 );
804 }
805 }
806
807 warnings
808}
809
810fn format_wrapper(root: &Path, agent: &str) -> String {
811 match wrapper::resolve_wrapper(root, agent) {
812 Ok(Some(wrapper::WrapperKind::Builtin(ref name))) => format!("builtin:{name}"),
813 Ok(Some(wrapper::WrapperKind::Custom { ref script_path, .. })) => {
814 script_path.to_string_lossy().into_owned()
815 }
816 Ok(None) => "(not found)".to_string(),
817 Err(_) => "(error)".to_string(),
818 }
819}
820
821pub fn audit_agent_resolution(config: &Config, root: &Path) -> Vec<TransitionAudit> {
823 let mut result = Vec::new();
824 let default_profile = config.workers.default.as_str();
825
826 for state in &config.workflow.states {
827 for transition in &state.transitions {
828 if transition.trigger != "command:start" {
829 continue;
830 }
831
832 let to_state_wp = config.workflow.states.iter()
833 .find(|s| s.id == transition.to)
834 .and_then(|s| s.worker_profile.as_deref());
835 let wp_str = to_state_wp.unwrap_or(default_profile);
836 let (agent, role) = wp_str.split_once('/')
837 .map(|(a, r)| (a.to_string(), r.to_string()))
838 .unwrap_or_else(|| ("claude".to_string(), "worker".to_string()));
839
840 let wrapper_str = format_wrapper(root, &agent);
841
842 result.push(TransitionAudit {
843 from_state: state.id.clone(),
844 to_state: transition.to.clone(),
845 worker_profile: to_state_wp.map(|s| s.to_string()),
846 agent,
847 role,
848 wrapper: wrapper_str,
849 });
850 }
851 }
852
853 result
854}
855
856pub fn validate_all(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
859 let mut errors = validate_config_no_agents(config, root);
860 let mut warnings = validate_warnings_no_agents(config, root);
861 let (agent_errors, agent_warnings) = validate_agents(config, root);
862 errors.extend(agent_errors);
863 warnings.extend(agent_warnings);
864 (errors, warnings)
865}
866
867#[cfg(test)]
868mod tests {
869 use super::*;
870 use crate::config::{Config, CompletionStrategy, LocalConfig};
871 use crate::ticket::Ticket;
872 use crate::git_util;
873 use std::path::Path;
874 use std::collections::HashSet;
875
876 fn git_cmd(dir: &std::path::Path, args: &[&str]) {
877 std::process::Command::new("git")
878 .args(args)
879 .current_dir(dir)
880 .env("GIT_AUTHOR_NAME", "test")
881 .env("GIT_AUTHOR_EMAIL", "test@test.com")
882 .env("GIT_COMMITTER_NAME", "test")
883 .env("GIT_COMMITTER_EMAIL", "test@test.com")
884 .status()
885 .unwrap();
886 }
887
888 fn setup_verify_repo() -> tempfile::TempDir {
889 let dir = tempfile::tempdir().unwrap();
890 let p = dir.path();
891
892 git_cmd(p, &["init", "-q", "-b", "main"]);
893 git_cmd(p, &["config", "user.email", "test@test.com"]);
894 git_cmd(p, &["config", "user.name", "test"]);
895
896 std::fs::create_dir_all(p.join(".apm")).unwrap();
897 std::fs::write(
898 p.join(".apm/config.toml"),
899 r#"[project]
900name = "test"
901
902[tickets]
903dir = "tickets"
904
905[workers]
906default = "claude/coder"
907
908[worktrees]
909dir = "worktrees"
910
911[[workflow.states]]
912id = "in_design"
913label = "In Design"
914
915[[workflow.states]]
916id = "in_progress"
917label = "In Progress"
918
919[[workflow.states]]
920id = "specd"
921label = "Specd"
922"#,
923 )
924 .unwrap();
925
926 git_cmd(p, &["add", ".apm/config.toml"]);
927 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
928
929 dir
930 }
931
932 fn make_verify_ticket(root: &std::path::Path, id: &str, state: &str, branch: Option<&str>) -> Ticket {
933 let branch_line = match branch {
934 Some(b) => format!("branch = \"{b}\"\n"),
935 None => String::new(),
936 };
937 let raw = format!(
938 "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{branch_line}+++\n\n## Spec\n\n## History\n"
939 );
940 let path = root.join("tickets").join(format!("{id}-test.md"));
941 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
942 std::fs::write(&path, &raw).unwrap();
943 Ticket::parse(&path, &raw).unwrap()
944 }
945
946 fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
947 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
948 let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
949 let raw = format!(
950 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
951 );
952 Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
953 }
954
955 fn strategy_config(completion: &str) -> Config {
956 let toml = format!(
957 r#"
958[project]
959name = "test"
960
961[tickets]
962dir = "tickets"
963
964[[workflow.states]]
965id = "in_progress"
966label = "In Progress"
967
968[[workflow.states.transitions]]
969to = "implemented"
970completion = "{completion}"
971
972[[workflow.states]]
973id = "implemented"
974label = "Implemented"
975terminal = true
976"#
977 );
978 toml::from_str(&toml).unwrap()
979 }
980
981 #[test]
982 fn strategy_finds_in_progress_to_implemented() {
983 let config = strategy_config("pr_or_epic_merge");
984 assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
985 }
986
987 #[test]
988 fn strategy_defaults_to_none_when_absent() {
989 let toml = r#"
990[project]
991name = "test"
992
993[tickets]
994dir = "tickets"
995
996[[workflow.states]]
997id = "new"
998label = "New"
999
1000[[workflow.states.transitions]]
1001to = "closed"
1002
1003[[workflow.states]]
1004id = "closed"
1005label = "Closed"
1006terminal = true
1007"#;
1008 let config: Config = toml::from_str(toml).unwrap();
1009 assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
1010 }
1011
1012 #[test]
1013 fn dep_rules_pr_rejects_dep() {
1014 let dep = make_ticket("dep1", None, None);
1015 let result = check_depends_on_rules(
1016 &CompletionStrategy::Pr,
1017 None,
1018 None,
1019 &["dep1".to_string()],
1020 &[dep],
1021 "main",
1022 );
1023 assert!(result.is_err());
1024 let msg = result.unwrap_err().to_string();
1025 assert!(msg.contains("pr"), "expected strategy name in: {msg}");
1026 }
1027
1028 #[test]
1029 fn dep_rules_none_rejects_dep() {
1030 let dep = make_ticket("dep1", None, None);
1031 let result = check_depends_on_rules(
1032 &CompletionStrategy::None,
1033 None,
1034 None,
1035 &["dep1".to_string()],
1036 &[dep],
1037 "main",
1038 );
1039 assert!(result.is_err());
1040 let msg = result.unwrap_err().to_string();
1041 assert!(msg.contains("none"), "expected strategy name in: {msg}");
1042 }
1043
1044 #[test]
1045 fn dep_rules_pr_or_epic_merge_same_epic_ok() {
1046 let dep = make_ticket("dep1", Some("abc"), None);
1047 let result = check_depends_on_rules(
1048 &CompletionStrategy::PrOrEpicMerge,
1049 Some("abc"),
1050 None,
1051 &["dep1".to_string()],
1052 &[dep],
1053 "main",
1054 );
1055 assert!(result.is_ok(), "expected Ok, got {result:?}");
1056 }
1057
1058 #[test]
1059 fn dep_rules_pr_or_epic_merge_different_epic_fails() {
1060 let dep = make_ticket("dep1", Some("xyz"), None);
1061 let result = check_depends_on_rules(
1062 &CompletionStrategy::PrOrEpicMerge,
1063 Some("abc"),
1064 None,
1065 &["dep1".to_string()],
1066 &[dep],
1067 "main",
1068 );
1069 assert!(result.is_err());
1070 let msg = result.unwrap_err().to_string();
1071 assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1072 }
1073
1074 #[test]
1075 fn dep_rules_pr_or_epic_merge_standalone_ticket_ok() {
1076 let dep = make_ticket("dep1", Some("abc"), None);
1079 let result = check_depends_on_rules(
1080 &CompletionStrategy::PrOrEpicMerge,
1081 None,
1082 None,
1083 &["dep1".to_string()],
1084 &[dep],
1085 "main",
1086 );
1087 assert!(result.is_ok(), "expected Ok for standalone ticket, got {result:?}");
1088 }
1089
1090 #[test]
1091 fn dep_rules_merge_both_default_branch_ok() {
1092 let dep = make_ticket("dep1", None, None);
1093 let result = check_depends_on_rules(
1094 &CompletionStrategy::Merge,
1095 None,
1096 None,
1097 &["dep1".to_string()],
1098 &[dep],
1099 "main",
1100 );
1101 assert!(result.is_ok(), "expected Ok, got {result:?}");
1102 }
1103
1104 #[test]
1105 fn dep_rules_merge_different_target_fails() {
1106 let dep = make_ticket("dep1", None, Some("epic/other"));
1107 let result = check_depends_on_rules(
1108 &CompletionStrategy::Merge,
1109 None,
1110 None,
1111 &["dep1".to_string()],
1112 &[dep],
1113 "main",
1114 );
1115 assert!(result.is_err());
1116 let msg = result.unwrap_err().to_string();
1117 assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1118 }
1119
1120 fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
1121 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1122 let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
1123 let deps_line = if depends_on.is_empty() {
1124 String::new()
1125 } else {
1126 let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
1127 format!("depends_on = [{}]\n", quoted.join(", "))
1128 };
1129 let raw = format!(
1130 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
1131 );
1132 Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
1133 }
1134
1135 #[test]
1136 fn validate_depends_on_no_deps_clean() {
1137 let config = strategy_config("pr_or_epic_merge");
1138 let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
1139 let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
1140 let result = validate_depends_on(&config, &[t1, t2]);
1141 assert!(result.is_empty(), "expected no violations, got {result:?}");
1142 }
1143
1144 #[test]
1145 fn validate_depends_on_closed_ticket_skipped() {
1146 let config = strategy_config("pr");
1147 let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
1148 let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
1149 let result = validate_depends_on(&config, &[dep, ticket]);
1150 assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
1151 }
1152
1153 #[test]
1154 fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
1155 let config = strategy_config("pr_or_epic_merge");
1156 let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
1157 let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
1158 let result = validate_depends_on(&config, &[dep, ticket]);
1159 assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
1160 }
1161
1162 #[test]
1163 fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
1164 let config = strategy_config("pr_or_epic_merge");
1165 let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
1166 let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
1167 let result = validate_depends_on(&config, &[dep, ticket]);
1168 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1169 assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
1170 }
1171
1172 #[test]
1173 fn validate_depends_on_merge_same_target_ok() {
1174 let config = strategy_config("merge");
1175 let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
1176 let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
1177 let result = validate_depends_on(&config, &[dep, ticket]);
1178 assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
1179 }
1180
1181 #[test]
1182 fn validate_depends_on_merge_different_target_fails() {
1183 let config = strategy_config("merge");
1184 let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
1185 let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
1186 let result = validate_depends_on(&config, &[dep, ticket]);
1187 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1188 assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
1189 }
1190
1191 #[test]
1192 fn validate_depends_on_pr_strategy_rejects_any_dep() {
1193 let config = strategy_config("pr");
1194 let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
1195 let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
1196 let result = validate_depends_on(&config, &[dep, ticket]);
1197 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1198 assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
1199 }
1200
1201 fn load_config(toml: &str) -> Config {
1202 toml::from_str(toml).expect("config parse failed")
1203 }
1204
1205 fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
1206 config.workflow.states.iter().map(|s| s.id.as_str()).collect()
1207 }
1208
1209 #[test]
1211 fn correct_config_passes() {
1212 let toml = r#"
1213[project]
1214name = "test"
1215
1216[tickets]
1217dir = "tickets"
1218
1219[workers]
1220default = "claude/coder"
1221
1222[[workflow.states]]
1223id = "new"
1224label = "New"
1225
1226[[workflow.states.transitions]]
1227to = "in_progress"
1228
1229[[workflow.states]]
1230id = "in_progress"
1231label = "In Progress"
1232terminal = false
1233
1234[[workflow.states.transitions]]
1235to = "closed"
1236
1237[[workflow.states]]
1238id = "closed"
1239label = "Closed"
1240terminal = true
1241"#;
1242 let config = load_config(toml);
1243 let errors = validate_config(&config, Path::new("/tmp"));
1244 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1245 }
1246
1247 #[test]
1249 fn transition_to_nonexistent_state_detected() {
1250 let toml = r#"
1251[project]
1252name = "test"
1253
1254[tickets]
1255dir = "tickets"
1256
1257[[workflow.states]]
1258id = "new"
1259label = "New"
1260
1261[[workflow.states.transitions]]
1262to = "ghost"
1263"#;
1264 let config = load_config(toml);
1265 let errors = validate_config(&config, Path::new("/tmp"));
1266 assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
1267 }
1268
1269 #[test]
1271 fn terminal_state_with_transitions_detected() {
1272 let toml = r#"
1273[project]
1274name = "test"
1275
1276[tickets]
1277dir = "tickets"
1278
1279[[workflow.states]]
1280id = "closed"
1281label = "Closed"
1282terminal = true
1283
1284[[workflow.states.transitions]]
1285to = "new"
1286
1287[[workflow.states]]
1288id = "new"
1289label = "New"
1290
1291[[workflow.states.transitions]]
1292to = "closed"
1293"#;
1294 let config = load_config(toml);
1295 let errors = validate_config(&config, Path::new("/tmp"));
1296 assert!(
1297 errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
1298 "expected terminal error in {errors:?}"
1299 );
1300 }
1301
1302 #[test]
1304 fn ticket_with_unknown_state_detected() {
1305 use crate::ticket::Ticket;
1306
1307 let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
1308 let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1309
1310 let known_states: std::collections::HashSet<&str> =
1311 ["new", "ready", "closed"].iter().copied().collect();
1312
1313 assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
1314 }
1315
1316 #[test]
1318 fn dead_end_non_terminal_detected() {
1319 let toml = r#"
1320[project]
1321name = "test"
1322
1323[tickets]
1324dir = "tickets"
1325
1326[[workflow.states]]
1327id = "stuck"
1328label = "Stuck"
1329
1330[[workflow.states]]
1331id = "closed"
1332label = "Closed"
1333terminal = true
1334"#;
1335 let config = load_config(toml);
1336 let errors = validate_config(&config, Path::new("/tmp"));
1337 assert!(
1338 errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
1339 "expected dead-end error in {errors:?}"
1340 );
1341 }
1342
1343 #[test]
1345 fn context_section_mismatch_detected() {
1346 let toml = r#"
1347[project]
1348name = "test"
1349
1350[tickets]
1351dir = "tickets"
1352
1353[[ticket.sections]]
1354name = "Problem"
1355type = "free"
1356
1357[[workflow.states]]
1358id = "new"
1359label = "New"
1360
1361[[workflow.states.transitions]]
1362to = "ready"
1363context_section = "NonExistent"
1364
1365[[workflow.states]]
1366id = "ready"
1367label = "Ready"
1368
1369[[workflow.states.transitions]]
1370to = "closed"
1371
1372[[workflow.states]]
1373id = "closed"
1374label = "Closed"
1375terminal = true
1376"#;
1377 let config = load_config(toml);
1378 let errors = validate_config(&config, Path::new("/tmp"));
1379 assert!(
1380 errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
1381 "expected context_section error in {errors:?}"
1382 );
1383 }
1384
1385 #[test]
1387 fn focus_section_mismatch_detected() {
1388 let toml = r#"
1389[project]
1390name = "test"
1391
1392[tickets]
1393dir = "tickets"
1394
1395[[ticket.sections]]
1396name = "Problem"
1397type = "free"
1398
1399[[workflow.states]]
1400id = "new"
1401label = "New"
1402
1403[[workflow.states.transitions]]
1404to = "ready"
1405focus_section = "BadSection"
1406
1407[[workflow.states]]
1408id = "ready"
1409label = "Ready"
1410
1411[[workflow.states.transitions]]
1412to = "closed"
1413
1414[[workflow.states]]
1415id = "closed"
1416label = "Closed"
1417terminal = true
1418"#;
1419 let config = load_config(toml);
1420 let errors = validate_config(&config, Path::new("/tmp"));
1421 assert!(
1422 errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
1423 "expected focus_section error in {errors:?}"
1424 );
1425 }
1426
1427 #[test]
1429 fn completion_pr_without_provider_detected() {
1430 let toml = r#"
1431[project]
1432name = "test"
1433
1434[tickets]
1435dir = "tickets"
1436
1437[[workflow.states]]
1438id = "new"
1439label = "New"
1440
1441[[workflow.states.transitions]]
1442to = "closed"
1443completion = "pr"
1444
1445[[workflow.states]]
1446id = "closed"
1447label = "Closed"
1448terminal = true
1449"#;
1450 let config = load_config(toml);
1451 let errors = validate_config(&config, Path::new("/tmp"));
1452 assert!(
1453 errors.iter().any(|e| e.contains("provider")),
1454 "expected provider error in {errors:?}"
1455 );
1456 }
1457
1458 #[test]
1460 fn completion_pr_with_provider_passes() {
1461 let toml = r#"
1462[project]
1463name = "test"
1464
1465[tickets]
1466dir = "tickets"
1467
1468[git_host]
1469provider = "github"
1470
1471[[workflow.states]]
1472id = "new"
1473label = "New"
1474
1475[[workflow.states.transitions]]
1476to = "closed"
1477completion = "pr"
1478
1479[[workflow.states]]
1480id = "closed"
1481label = "Closed"
1482terminal = true
1483"#;
1484 let config = load_config(toml);
1485 let errors = validate_config(&config, Path::new("/tmp"));
1486 assert!(
1487 !errors.iter().any(|e| e.contains("provider")),
1488 "unexpected provider error in {errors:?}"
1489 );
1490 }
1491
1492 #[test]
1494 fn context_section_skipped_when_no_sections_defined() {
1495 let toml = r#"
1496[project]
1497name = "test"
1498
1499[tickets]
1500dir = "tickets"
1501
1502[[workflow.states]]
1503id = "new"
1504label = "New"
1505
1506[[workflow.states.transitions]]
1507to = "closed"
1508context_section = "AnySection"
1509
1510[[workflow.states]]
1511id = "closed"
1512label = "Closed"
1513terminal = true
1514"#;
1515 let config = load_config(toml);
1516 let errors = validate_config(&config, Path::new("/tmp"));
1517 assert!(
1518 !errors.iter().any(|e| e.contains("context_section")),
1519 "unexpected context_section error in {errors:?}"
1520 );
1521 }
1522
1523 #[test]
1525 fn closed_state_not_flagged_as_unknown() {
1526 use crate::ticket::Ticket;
1527
1528 let toml = r#"
1530[project]
1531name = "test"
1532
1533[tickets]
1534dir = "tickets"
1535
1536[[workflow.states]]
1537id = "new"
1538label = "New"
1539
1540[[workflow.states.transitions]]
1541to = "done"
1542
1543[[workflow.states]]
1544id = "done"
1545label = "Done"
1546terminal = true
1547"#;
1548 let config = load_config(toml);
1549 let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
1550 .map(|s| s.id.as_str())
1551 .collect();
1552
1553 let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
1554 let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1555
1556 assert!(!state_ids.contains("closed"));
1558 let fm = &ticket.frontmatter;
1560 let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
1561 assert!(!flagged, "closed state should not be flagged as unknown");
1562 }
1563
1564 #[test]
1566 fn state_ids_helper() {
1567 let toml = r#"
1568[project]
1569name = "test"
1570
1571[tickets]
1572dir = "tickets"
1573
1574[[workflow.states]]
1575id = "new"
1576label = "New"
1577"#;
1578 let config = load_config(toml);
1579 let ids = state_ids(&config);
1580 assert!(ids.contains("new"));
1581 }
1582
1583 #[test]
1584 fn validate_warnings_no_container() {
1585 let toml = r#"
1586[project]
1587name = "test"
1588
1589[tickets]
1590dir = "tickets"
1591"#;
1592 let config = load_config(toml);
1593 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1594 assert!(warnings.is_empty());
1595 }
1596
1597 #[test]
1598 fn valid_collaborator_accepted() {
1599 let toml = r#"
1600[project]
1601name = "test"
1602collaborators = ["alice", "bob"]
1603
1604[tickets]
1605dir = "tickets"
1606"#;
1607 let config = load_config(toml);
1608 assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1609 }
1610
1611 #[test]
1612 fn unknown_user_rejected() {
1613 let toml = r#"
1614[project]
1615name = "test"
1616collaborators = ["alice", "bob"]
1617
1618[tickets]
1619dir = "tickets"
1620"#;
1621 let config = load_config(toml);
1622 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1623 let msg = err.to_string();
1624 assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
1625 assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
1626 }
1627
1628 #[test]
1629 fn empty_collaborators_skips_validation() {
1630 let toml = r#"
1631[project]
1632name = "test"
1633
1634[tickets]
1635dir = "tickets"
1636"#;
1637 let config = load_config(toml);
1638 assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1639 }
1640
1641 #[test]
1642 fn clear_owner_always_allowed() {
1643 let toml = r#"
1644[project]
1645name = "test"
1646collaborators = ["alice"]
1647
1648[tickets]
1649dir = "tickets"
1650"#;
1651 let config = load_config(toml);
1652 assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1653 }
1654
1655 #[test]
1656 fn github_mode_known_user_accepted() {
1657 let toml = r#"
1658[project]
1659name = "test"
1660collaborators = ["alice", "bob"]
1661
1662[tickets]
1663dir = "tickets"
1664
1665[git_host]
1666provider = "github"
1667repo = "org/repo"
1668"#;
1669 let config = load_config(toml);
1670 assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1672 }
1673
1674 #[test]
1675 fn github_mode_unknown_user_rejected() {
1676 let toml = r#"
1677[project]
1678name = "test"
1679collaborators = ["alice", "bob"]
1680
1681[tickets]
1682dir = "tickets"
1683
1684[git_host]
1685provider = "github"
1686repo = "org/repo"
1687"#;
1688 let config = load_config(toml);
1689 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1691 assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1692 }
1693
1694 #[test]
1695 fn github_mode_no_collaborators_skips_check() {
1696 let toml = r#"
1697[project]
1698name = "test"
1699
1700[tickets]
1701dir = "tickets"
1702
1703[git_host]
1704provider = "github"
1705repo = "org/repo"
1706"#;
1707 let config = load_config(toml);
1708 assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1710 }
1711
1712 #[test]
1713 fn github_mode_clear_owner_accepted() {
1714 let toml = r#"
1715[project]
1716name = "test"
1717collaborators = ["alice"]
1718
1719[tickets]
1720dir = "tickets"
1721
1722[git_host]
1723provider = "github"
1724repo = "org/repo"
1725"#;
1726 let config = load_config(toml);
1727 assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1728 }
1729
1730 #[test]
1731 fn non_github_mode_unknown_user_rejected() {
1732 let toml = r#"
1733[project]
1734name = "test"
1735collaborators = ["alice", "bob"]
1736
1737[tickets]
1738dir = "tickets"
1739"#;
1740 let config = load_config(toml);
1741 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1742 assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1743 }
1744
1745 #[test]
1746 fn validate_warnings_empty_container() {
1747 let toml = r#"
1748[project]
1749name = "test"
1750
1751[tickets]
1752dir = "tickets"
1753
1754[workers]
1755default = "claude/coder"
1756container = ""
1757"#;
1758 let config = load_config(toml);
1759 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1760 assert!(warnings.is_empty(), "empty container string should not warn");
1761 }
1762
1763 #[test]
1764 fn dead_end_workflow_warning_emitted() {
1765 let toml = r#"
1768[project]
1769name = "test"
1770
1771[tickets]
1772dir = "tickets"
1773
1774[[workflow.states]]
1775id = "start"
1776label = "Start"
1777
1778[[workflow.states.transitions]]
1779to = "middle"
1780trigger = "command:start"
1781
1782[[workflow.states]]
1783id = "middle"
1784label = "Middle"
1785
1786[[workflow.states.transitions]]
1787to = "start"
1788"#;
1789 let config = load_config(toml);
1790 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1791 assert!(
1792 warnings.iter().any(|w| w.contains("success")),
1793 "expected dead-end warning containing 'success'; got: {warnings:?}"
1794 );
1795 }
1796
1797 #[test]
1798 fn default_workflow_no_dead_end_warning() {
1799 let base = r#"
1802[project]
1803name = "test"
1804
1805[tickets]
1806dir = "tickets"
1807"#;
1808 let combined = format!("{}\n{}", base, crate::init::default_workflow_toml());
1809 let config: Config = toml::from_str(&combined).unwrap();
1810 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1811 assert!(
1812 !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")),
1813 "unexpected dead-end warning for default workflow; got: {warnings:?}"
1814 );
1815 }
1816
1817 #[test]
1818 fn worktree_missing_in_design() {
1819 let dir = setup_verify_repo();
1820 let root = dir.path();
1821 let config = Config::load(root).unwrap();
1822 let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1823
1824 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1825
1826 let main_root = git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1827 let wt_path = main_root.join("worktrees").join("ticket-abcd1234-test");
1828 let expected = format!(
1829 "#abcd1234 [in_design]: worktree at {} is missing",
1830 wt_path.display()
1831 );
1832 assert!(
1833 issues.iter().any(|i| i == &expected),
1834 "expected worktree missing issue; got: {issues:?}"
1835 );
1836 }
1837
1838 #[test]
1839 fn worktree_present_no_issue() {
1840 let dir = setup_verify_repo();
1841 let root = dir.path();
1842 let config = Config::load(root).unwrap();
1843 let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1844
1845 std::fs::create_dir_all(root.join("worktrees").join("ticket-abcd1234-test")).unwrap();
1846
1847 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1848 assert!(
1849 !issues.iter().any(|i| i.contains("worktree")),
1850 "unexpected worktree issue; got: {issues:?}"
1851 );
1852 }
1853
1854 #[test]
1855 fn worktree_check_skipped_for_other_states() {
1856 let dir = setup_verify_repo();
1857 let root = dir.path();
1858 let config = Config::load(root).unwrap();
1859 let ticket = make_verify_ticket(root, "abcd1234", "specd", Some("ticket/abcd1234-test"));
1860
1861 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1862 assert!(
1863 !issues.iter().any(|i| i.contains("worktree")),
1864 "unexpected worktree issue for specd state; got: {issues:?}"
1865 );
1866 }
1867
1868 fn in_repo_wt_config(dir: &str) -> Config {
1869 let toml = format!(
1870 r#"
1871[project]
1872name = "test"
1873
1874[tickets]
1875dir = "tickets"
1876
1877[worktrees]
1878dir = "{dir}"
1879"#
1880 );
1881 toml::from_str(&toml).expect("config parse failed")
1882 }
1883
1884 #[test]
1885 fn validate_config_gitignore_missing_in_repo_wt() {
1886 let tmp = tempfile::TempDir::new().unwrap();
1887 let config = in_repo_wt_config("worktrees");
1888 let errors = validate_config(&config, tmp.path());
1889 assert!(
1890 errors.iter().any(|e| e.contains("worktrees") && e.contains(".gitignore")),
1891 "expected gitignore missing error; got: {errors:?}"
1892 );
1893 }
1894
1895 #[test]
1896 fn validate_config_gitignore_covered_anchored_slash() {
1897 let tmp = tempfile::TempDir::new().unwrap();
1898 std::fs::write(tmp.path().join(".gitignore"), "/worktrees/\n").unwrap();
1899 let config = in_repo_wt_config("worktrees");
1900 let errors = validate_config(&config, tmp.path());
1901 assert!(
1902 !errors.iter().any(|e| e.contains("gitignore")),
1903 "unexpected gitignore error; got: {errors:?}"
1904 );
1905 }
1906
1907 #[test]
1908 fn validate_config_gitignore_covered_anchored_no_slash() {
1909 let tmp = tempfile::TempDir::new().unwrap();
1910 std::fs::write(tmp.path().join(".gitignore"), "/worktrees\n").unwrap();
1911 let config = in_repo_wt_config("worktrees");
1912 let errors = validate_config(&config, tmp.path());
1913 assert!(
1914 !errors.iter().any(|e| e.contains("gitignore")),
1915 "unexpected gitignore error; got: {errors:?}"
1916 );
1917 }
1918
1919 #[test]
1920 fn validate_config_gitignore_covered_unanchored_slash() {
1921 let tmp = tempfile::TempDir::new().unwrap();
1922 std::fs::write(tmp.path().join(".gitignore"), "worktrees/\n").unwrap();
1923 let config = in_repo_wt_config("worktrees");
1924 let errors = validate_config(&config, tmp.path());
1925 assert!(
1926 !errors.iter().any(|e| e.contains("gitignore")),
1927 "unexpected gitignore error; got: {errors:?}"
1928 );
1929 }
1930
1931 #[test]
1932 fn validate_config_gitignore_covered_bare() {
1933 let tmp = tempfile::TempDir::new().unwrap();
1934 std::fs::write(tmp.path().join(".gitignore"), "worktrees\n").unwrap();
1935 let config = in_repo_wt_config("worktrees");
1936 let errors = validate_config(&config, tmp.path());
1937 assert!(
1938 !errors.iter().any(|e| e.contains("gitignore")),
1939 "unexpected gitignore error; got: {errors:?}"
1940 );
1941 }
1942
1943 #[test]
1944 fn validate_config_gitignore_not_covered() {
1945 let tmp = tempfile::TempDir::new().unwrap();
1946 std::fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
1947 let config = in_repo_wt_config("worktrees");
1948 let errors = validate_config(&config, tmp.path());
1949 assert!(
1950 errors.iter().any(|e| e.contains("worktrees") && e.contains("gitignore")),
1951 "expected gitignore not covered error; got: {errors:?}"
1952 );
1953 }
1954
1955 #[test]
1956 fn validate_config_gitignore_no_false_positive() {
1957 let tmp = tempfile::TempDir::new().unwrap();
1958 std::fs::write(tmp.path().join(".gitignore"), "wt-old/\n").unwrap();
1959 let config = in_repo_wt_config("wt");
1960 let errors = validate_config(&config, tmp.path());
1961 assert!(
1962 errors.iter().any(|e| e.contains("wt") && e.contains("gitignore")),
1963 "wt-old should not match wt; got: {errors:?}"
1964 );
1965 }
1966
1967 #[test]
1968 fn validate_config_external_dotdot_no_check() {
1969 let tmp = tempfile::TempDir::new().unwrap();
1970 let config = in_repo_wt_config("../ext");
1972 let errors = validate_config(&config, tmp.path());
1973 assert!(
1974 !errors.iter().any(|e| e.contains("gitignore")),
1975 "external dotdot path should skip gitignore check; got: {errors:?}"
1976 );
1977 }
1978
1979 #[test]
1980 fn validate_config_external_absolute_no_check() {
1981 let tmp = tempfile::TempDir::new().unwrap();
1982 let config = in_repo_wt_config("/abs/path");
1984 let errors = validate_config(&config, tmp.path());
1985 assert!(
1986 !errors.iter().any(|e| e.contains("gitignore")),
1987 "absolute path should skip gitignore check; got: {errors:?}"
1988 );
1989 }
1990
1991 fn config_with_merge_transition(completion: &str, on_failure: Option<&str>, declare_failure_state: bool) -> Config {
1992 let on_failure_line = on_failure
1993 .map(|v| format!("on_failure = \"{v}\"\n"))
1994 .unwrap_or_default();
1995 let merge_failed_state = if declare_failure_state {
1996 r#"
1997[[workflow.states]]
1998id = "merge_failed"
1999label = "Merge failed"
2000
2001[[workflow.states.transitions]]
2002to = "closed"
2003"#
2004 } else {
2005 ""
2006 };
2007 let toml = format!(
2008 r#"
2009[project]
2010name = "test"
2011
2012[tickets]
2013dir = "tickets"
2014
2015[[workflow.states]]
2016id = "in_progress"
2017label = "In Progress"
2018
2019[[workflow.states.transitions]]
2020to = "implemented"
2021completion = "{completion}"
2022{on_failure_line}
2023[[workflow.states]]
2024id = "implemented"
2025label = "Implemented"
2026terminal = true
2027
2028[[workflow.states]]
2029id = "closed"
2030label = "Closed"
2031terminal = true
2032{merge_failed_state}
2033"#
2034 );
2035 toml::from_str(&toml).expect("config parse failed")
2036 }
2037
2038 #[test]
2039 fn test_on_failure_missing_for_merge() {
2040 let config = config_with_merge_transition("merge", None, false);
2041 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2042 assert!(
2043 errors.iter().any(|e| e.contains("missing `on_failure`")),
2044 "expected missing on_failure error; got: {errors:?}"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_on_failure_missing_for_pr_or_epic_merge() {
2050 let config = config_with_merge_transition("pr_or_epic_merge", None, false);
2052 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2053 assert!(
2054 errors.iter().any(|e| e.contains("missing `on_failure`")),
2055 "expected missing on_failure error for pr_or_epic_merge; got: {errors:?}"
2056 );
2057 }
2058
2059 #[test]
2060 fn test_on_failure_unknown_state() {
2061 let config = config_with_merge_transition("merge", Some("ghost_state"), false);
2062 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2063 assert!(
2064 errors.iter().any(|e| e.contains("ghost_state")),
2065 "expected unknown state error for ghost_state; got: {errors:?}"
2066 );
2067 }
2068
2069 #[test]
2070 fn test_on_failure_valid() {
2071 let config = config_with_merge_transition("merge", Some("merge_failed"), true);
2072 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2073 let on_failure_errors: Vec<&String> = errors.iter()
2074 .filter(|e| e.contains("on_failure") || e.contains("ghost_state") || e.contains("merge_failed"))
2075 .collect();
2076 assert!(
2077 on_failure_errors.is_empty(),
2078 "unexpected on_failure errors: {on_failure_errors:?}"
2079 );
2080 }
2081
2082 fn make_agent_verify_ticket(root: &std::path::Path, id: &str, state: &str, extra_fm: &str) -> Ticket {
2085 let raw = format!(
2086 "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{extra_fm}+++\n\n## Spec\n\n## History\n"
2087 );
2088 let path = root.join("tickets").join(format!("{id}-test.md"));
2089 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2090 std::fs::write(&path, &raw).unwrap();
2091 Ticket::parse(&path, &raw).unwrap()
2092 }
2093
2094 #[test]
2095 fn validate_unknown_frontmatter_agent_is_error() {
2096 let dir = setup_verify_repo();
2097 let root = dir.path();
2098 let config = Config::load(root).unwrap();
2099 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"nonexistent-bot\"\n");
2100
2101 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2102
2103 assert!(
2104 issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2105 "expected error with ticket id and agent name; got: {issues:?}"
2106 );
2107 }
2108
2109 #[test]
2110 fn validate_unknown_agent_in_overrides_is_error() {
2111 let dir = setup_verify_repo();
2112 let root = dir.path();
2113 let config = Config::load(root).unwrap();
2114 let ticket = make_agent_verify_ticket(
2115 root,
2116 "abcd1234",
2117 "specd",
2118 "[agent_overrides]\nimpl_agent = \"nonexistent-bot\"\n",
2119 );
2120
2121 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2122
2123 assert!(
2124 issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2125 "expected error with ticket id and agent name; got: {issues:?}"
2126 );
2127 }
2128
2129 #[test]
2130 fn verify_tickets_flags_renamed_ticket_file_on_branch() {
2131 let dir = setup_verify_repo();
2136 let p = dir.path();
2137
2138 let canonical_branch = "ticket/abcd1234-fix-login";
2139 git_cmd(p, &["checkout", "-b", canonical_branch]);
2140 std::fs::create_dir_all(p.join("tickets")).unwrap();
2141 std::fs::write(
2143 p.join("tickets/abcd1234-fix-login-and-stuff.md"),
2144 "+++\nid = \"abcd1234\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2145 )
2146 .unwrap();
2147 git_cmd(p, &["add", "tickets/"]);
2148 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "spec written"]);
2149 git_cmd(p, &["checkout", "main"]);
2150
2151 let config = Config::load(p).unwrap();
2152 let issues = verify_tickets(p, &config, &[], &HashSet::new());
2153
2154 assert!(
2155 issues.iter().any(|i| i.contains(canonical_branch) && i.contains("renamed")),
2156 "expected rename diagnostic for {canonical_branch}; got: {issues:?}"
2157 );
2158 }
2159
2160 #[test]
2161 fn verify_tickets_flags_orphan_branch_with_no_ticket_file() {
2162 let dir = setup_verify_repo();
2163 let p = dir.path();
2164
2165 let branch = "ticket/deadbeef-orphan";
2166 git_cmd(p, &["checkout", "-b", branch]);
2167 std::fs::write(p.join("dummy.txt"), "x").unwrap();
2169 git_cmd(p, &["add", "dummy.txt"]);
2170 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "no ticket file"]);
2171 git_cmd(p, &["checkout", "main"]);
2172
2173 let config = Config::load(p).unwrap();
2174 let issues = verify_tickets(p, &config, &[], &HashSet::new());
2175
2176 assert!(
2177 issues.iter().any(|i| i.contains(branch) && i.contains("orphaned")),
2178 "expected orphan diagnostic for {branch}; got: {issues:?}"
2179 );
2180 }
2181
2182 #[test]
2183 fn verify_tickets_quiet_when_branch_file_matches() {
2184 let dir = setup_verify_repo();
2185 let p = dir.path();
2186
2187 let branch = "ticket/cafe0001-clean-branch";
2188 git_cmd(p, &["checkout", "-b", branch]);
2189 std::fs::create_dir_all(p.join("tickets")).unwrap();
2190 std::fs::write(
2192 p.join("tickets/cafe0001-clean-branch.md"),
2193 "+++\nid = \"cafe0001\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2194 )
2195 .unwrap();
2196 git_cmd(p, &["add", "tickets/"]);
2197 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "canonical"]);
2198 git_cmd(p, &["checkout", "main"]);
2199
2200 let config = Config::load(p).unwrap();
2201 let issues = verify_tickets(p, &config, &[], &HashSet::new());
2202
2203 assert!(
2204 !issues.iter().any(|i| i.contains(branch)),
2205 "no branch-file issue expected for canonical layout; got: {issues:?}"
2206 );
2207 }
2208
2209 #[test]
2210 fn validate_known_frontmatter_agent_passes() {
2211 let dir = setup_verify_repo();
2212 let root = dir.path();
2213 let config = Config::load(root).unwrap();
2214 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"claude\"\n");
2215
2216 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2217
2218 assert!(
2219 !issues.iter().any(|i| i.contains("is not a known built-in")),
2220 "expected no agent error for known built-in; got: {issues:?}"
2221 );
2222 }
2223
2224 #[test]
2225 fn validate_agent_name_accepts_configured_spawn_agent() {
2226 let toml = r#"
2227[[workflow.states]]
2228id = "ready"
2229label = "Ready"
2230
2231[[workflow.states.transitions]]
2232to = "in_progress"
2233trigger = "command:start"
2234
2235[[workflow.states]]
2236id = "in_progress"
2237label = "In Progress"
2238worker_profile = "pi/worker"
2239terminal = true
2240"#;
2241 let config = audit_config(toml);
2242 validate_agent_name(&config, "pi").expect("pi should be a configured agent");
2243 }
2244
2245 #[test]
2246 fn validate_agent_name_rejects_unknown() {
2247 let config = audit_config("");
2248 let err = validate_agent_name(&config, "nonexistent").unwrap_err();
2249 let msg = err.to_string();
2250 assert!(msg.contains("nonexistent"), "got: {msg}");
2251 assert!(msg.contains("not configured in config.toml"), "got: {msg}");
2252 }
2253
2254 #[test]
2255 fn validate_agent_name_accepts_dash_sentinel() {
2256 let config = audit_config("");
2257 validate_agent_name(&config, "-").expect("dash should clear without validation");
2258 }
2259
2260 #[test]
2261 fn validate_ticket_agent_not_in_config_is_error() {
2262 let dir = setup_verify_repo();
2263 let root = dir.path();
2264 let config = Config::load(root).unwrap();
2265 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"phi4\"\n");
2268
2269 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2270
2271 assert!(
2272 issues.iter().any(|i| i.contains("abcd1234") && i.contains("not configured in config.toml")),
2273 "expected config-coverage error; got: {issues:?}"
2274 );
2275 }
2276
2277 fn audit_config(extra_toml: &str) -> Config {
2280 let base = r#"
2281[project]
2282name = "test"
2283
2284[tickets]
2285dir = "tickets"
2286
2287[worktrees]
2288dir = "../wt"
2289"#;
2290 toml::from_str(&format!("{base}{extra_toml}")).expect("config parse failed")
2291 }
2292
2293 #[test]
2294 fn audit_zero_spawn_transitions() {
2295 let toml = r#"
2296[[workflow.states]]
2297id = "new"
2298label = "New"
2299
2300[[workflow.states.transitions]]
2301to = "closed"
2302trigger = "command:review"
2303
2304[[workflow.states]]
2305id = "closed"
2306label = "Closed"
2307terminal = true
2308"#;
2309 let config = audit_config(toml);
2310 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2311 assert!(result.is_empty(), "expected 0 audits, got {result:?}");
2312 }
2313
2314 #[test]
2315 fn audit_default_agent_resolution() {
2316 let toml = r#"
2317[workers]
2318default = "claude/coder"
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, "expected 1 audit");
2336 let ta = &result[0];
2337 assert_eq!(ta.from_state, "ready");
2338 assert_eq!(ta.to_state, "in_progress");
2339 assert!(ta.worker_profile.is_none());
2340 assert_eq!(ta.agent, "claude");
2341 assert_eq!(ta.role, "coder");
2342 assert!(ta.wrapper.contains("claude"), "wrapper should mention claude: {}", ta.wrapper);
2343 }
2344
2345 #[test]
2346 fn audit_worker_profile_parsed() {
2347 let toml = r#"
2348[[workflow.states]]
2349id = "ready"
2350label = "Ready"
2351
2352[[workflow.states.transitions]]
2353to = "in_progress"
2354trigger = "command:start"
2355
2356[[workflow.states]]
2357id = "in_progress"
2358label = "In Progress"
2359worker_profile = "mock-happy/spec-writer"
2360terminal = true
2361"#;
2362 let config = audit_config(toml);
2363 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2364 assert_eq!(result.len(), 1);
2365 let ta = &result[0];
2366 assert_eq!(ta.worker_profile.as_deref(), Some("mock-happy/spec-writer"));
2367 assert_eq!(ta.agent, "mock-happy");
2368 assert_eq!(ta.role, "spec-writer");
2369 assert!(ta.wrapper.contains("mock-happy"), "wrapper: {}", ta.wrapper);
2370 }
2371
2372 #[test]
2373 fn audit_workers_default_agent() {
2374 let toml = r#"
2375[workers]
2376default = "mock-happy/worker"
2377
2378[[workflow.states]]
2379id = "ready"
2380label = "Ready"
2381
2382[[workflow.states.transitions]]
2383to = "in_progress"
2384trigger = "command:start"
2385
2386[[workflow.states]]
2387id = "in_progress"
2388label = "In Progress"
2389terminal = true
2390"#;
2391 let config = audit_config(toml);
2392 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2393 assert_eq!(result.len(), 1);
2394 let ta = &result[0];
2395 assert_eq!(ta.agent, "mock-happy");
2396 assert_eq!(ta.role, "worker");
2397 }
2398
2399 #[test]
2400 fn audit_no_worker_profiles_no_panic() {
2401 let toml = r#"
2402[workers]
2403default = "claude/coder"
2404
2405[[workflow.states]]
2406id = "ready"
2407label = "Ready"
2408
2409[[workflow.states.transitions]]
2410to = "in_progress"
2411trigger = "command:start"
2412
2413[[workflow.states]]
2414id = "in_progress"
2415label = "In Progress"
2416terminal = true
2417"#;
2418 let config = audit_config(toml);
2419 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2420 assert_eq!(result.len(), 1, "should not panic with no worker_profile");
2421 }
2422
2423 #[test]
2424 fn workers_default_absent_fails_validate() {
2425 let toml = r#"
2426[project]
2427name = "test"
2428
2429[tickets]
2430dir = "tickets"
2431
2432[[workflow.states]]
2433id = "new"
2434label = "New"
2435
2436[[workflow.states.transitions]]
2437to = "done"
2438
2439[[workflow.states]]
2440id = "done"
2441label = "Done"
2442terminal = true
2443"#;
2444 let config = load_config(toml);
2445 let errors = validate_config(&config, Path::new("/tmp"));
2446 assert!(
2447 errors.iter().any(|e| e.contains("workers.default")),
2448 "expected workers.default error when [workers] section is absent; got: {errors:?}"
2449 );
2450 }
2451
2452 #[test]
2453 fn workers_default_empty_fails_validate() {
2454 let toml = r#"
2455[project]
2456name = "test"
2457
2458[tickets]
2459dir = "tickets"
2460
2461[workers]
2462default = ""
2463
2464[[workflow.states]]
2465id = "new"
2466label = "New"
2467
2468[[workflow.states.transitions]]
2469to = "done"
2470
2471[[workflow.states]]
2472id = "done"
2473label = "Done"
2474terminal = true
2475"#;
2476 let config = load_config(toml);
2477 let errors = validate_config(&config, Path::new("/tmp"));
2478 assert!(
2479 errors.iter().any(|e| e.contains("workers.default")),
2480 "expected workers.default error when default = \"\"; got: {errors:?}"
2481 );
2482 }
2483
2484 #[test]
2485 fn merge_completion_targeting_terminal_rejected() {
2486 let toml = r#"
2487[project]
2488name = "test"
2489
2490[tickets]
2491dir = "tickets"
2492
2493[[workflow.states]]
2494id = "in_progress"
2495label = "In Progress"
2496
2497[[workflow.states.transitions]]
2498to = "done"
2499completion = "merge"
2500on_failure = "closed"
2501
2502[[workflow.states]]
2503id = "done"
2504label = "Done"
2505terminal = true
2506"#;
2507 let config = load_config(toml);
2508 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2509 assert!(
2510 errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2511 "expected terminal-state error; got: {errors:?}"
2512 );
2513 }
2514
2515 #[test]
2516 fn pr_or_epic_merge_targeting_terminal_rejected() {
2517 let toml = r#"
2518[project]
2519name = "test"
2520
2521[tickets]
2522dir = "tickets"
2523
2524[[workflow.states]]
2525id = "in_progress"
2526label = "In Progress"
2527
2528[[workflow.states.transitions]]
2529to = "done"
2530completion = "pr_or_epic_merge"
2531on_failure = "closed"
2532
2533[[workflow.states]]
2534id = "done"
2535label = "Done"
2536terminal = true
2537"#;
2538 let config = load_config(toml);
2539 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2540 assert!(
2541 errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2542 "expected terminal-state error; got: {errors:?}"
2543 );
2544 }
2545
2546 #[test]
2547 fn pr_completion_targeting_terminal_rejected() {
2548 let toml = r#"
2549[project]
2550name = "test"
2551
2552[tickets]
2553dir = "tickets"
2554
2555[git_host]
2556provider = "github"
2557
2558[[workflow.states]]
2559id = "in_progress"
2560label = "In Progress"
2561
2562[[workflow.states.transitions]]
2563to = "done"
2564completion = "pr"
2565
2566[[workflow.states]]
2567id = "done"
2568label = "Done"
2569terminal = true
2570"#;
2571 let config = load_config(toml);
2572 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2573 assert!(
2574 errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2575 "expected terminal-state error; got: {errors:?}"
2576 );
2577 }
2578
2579 #[test]
2580 fn merge_targeting_built_in_closed_rejected() {
2581 let toml = r#"
2583[project]
2584name = "test"
2585
2586[tickets]
2587dir = "tickets"
2588
2589[[workflow.states]]
2590id = "in_progress"
2591label = "In Progress"
2592
2593[[workflow.states.transitions]]
2594to = "closed"
2595completion = "merge"
2596on_failure = "review"
2597
2598[[workflow.states]]
2599id = "review"
2600label = "Review"
2601
2602[[workflow.states.transitions]]
2603to = "closed"
2604"#;
2605 let config = load_config(toml);
2606 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2607 assert!(
2608 errors.iter().any(|e| e.contains("state.in_progress.transition(closed)") && e.contains("targets terminal state")),
2609 "expected terminal-state error for built-in closed; got: {errors:?}"
2610 );
2611 }
2612
2613 #[test]
2614 fn merge_targeting_non_terminal_accepted() {
2615 let toml = r#"
2616[project]
2617name = "test"
2618
2619[tickets]
2620dir = "tickets"
2621
2622[[workflow.states]]
2623id = "in_progress"
2624label = "In Progress"
2625
2626[[workflow.states.transitions]]
2627to = "review"
2628completion = "merge"
2629on_failure = "closed"
2630
2631[[workflow.states]]
2632id = "review"
2633label = "Review"
2634
2635[[workflow.states.transitions]]
2636to = "closed"
2637"#;
2638 let config = load_config(toml);
2639 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2640 assert!(
2641 !errors.iter().any(|e| e.contains("targets terminal state")),
2642 "unexpected terminal-state error; got: {errors:?}"
2643 );
2644 }
2645
2646 #[test]
2649 fn trigger_uniqueness_two_manual_to_same_dest_ok() {
2650 let toml = r#"
2651[project]
2652name = "test"
2653
2654[tickets]
2655dir = "tickets"
2656
2657[[workflow.states]]
2658id = "a"
2659label = "A"
2660
2661[[workflow.states.transitions]]
2662to = "c"
2663trigger = "manual"
2664
2665[[workflow.states]]
2666id = "b"
2667label = "B"
2668
2669[[workflow.states.transitions]]
2670to = "c"
2671trigger = "manual"
2672
2673[[workflow.states]]
2674id = "c"
2675label = "C"
2676terminal = true
2677"#;
2678 let config = load_config(toml);
2679 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2680 assert!(
2681 !errors.iter().any(|e| e.contains("incoming transitions")),
2682 "two manual edges to same dest should not trigger Rule 1; got: {errors:?}"
2683 );
2684 }
2685
2686 #[test]
2687 fn trigger_uniqueness_command_start_plus_manual_same_dest_rejected() {
2688 let toml = r#"
2689[project]
2690name = "test"
2691
2692[tickets]
2693dir = "tickets"
2694
2695[[workflow.states]]
2696id = "src_start"
2697label = "Src Start"
2698
2699[[workflow.states.transitions]]
2700to = "dest"
2701trigger = "command:start"
2702
2703[[workflow.states]]
2704id = "src_manual"
2705label = "Src Manual"
2706
2707[[workflow.states.transitions]]
2708to = "dest"
2709trigger = "manual"
2710
2711[[workflow.states]]
2712id = "dest"
2713label = "Dest"
2714worker_profile = "claude/coder"
2715
2716[[workflow.states.transitions]]
2717to = "closed"
2718"#;
2719 let config = load_config(toml);
2720 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2721 let rule1_errors: Vec<&String> = errors.iter()
2722 .filter(|e| e.contains("incoming transitions"))
2723 .collect();
2724 assert!(
2725 !rule1_errors.is_empty(),
2726 "expected trigger-uniqueness error; got: {errors:?}"
2727 );
2728 let msg = rule1_errors[0];
2729 assert!(msg.contains("dest"), "expected dest in error: {msg}");
2730 assert!(msg.contains("src_start"), "expected src_start in error: {msg}");
2731 assert!(msg.contains("src_manual"), "expected src_manual in error: {msg}");
2732 }
2733
2734 #[test]
2735 fn trigger_uniqueness_two_command_start_same_dest_ok() {
2736 let toml = r#"
2737[project]
2738name = "test"
2739
2740[tickets]
2741dir = "tickets"
2742
2743[[workflow.states]]
2744id = "src_a"
2745label = "Src A"
2746
2747[[workflow.states.transitions]]
2748to = "dest"
2749trigger = "command:start"
2750
2751[[workflow.states]]
2752id = "src_b"
2753label = "Src B"
2754
2755[[workflow.states.transitions]]
2756to = "dest"
2757trigger = "command:start"
2758
2759[[workflow.states]]
2760id = "dest"
2761label = "Dest"
2762worker_profile = "claude/coder"
2763
2764[[workflow.states.transitions]]
2765to = "closed"
2766"#;
2767 let config = load_config(toml);
2768 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2769 let rule1_errors: Vec<&String> = errors.iter()
2770 .filter(|e| e.contains("triggered and manual"))
2771 .collect();
2772 assert!(
2773 rule1_errors.is_empty(),
2774 "two command:start incoming should pass — both imply dispatch; got: {errors:?}"
2775 );
2776 }
2777
2778 #[test]
2781 fn worker_profile_valid_passes() {
2782 let toml = r#"
2783[project]
2784name = "test"
2785
2786[tickets]
2787dir = "tickets"
2788
2789[[workflow.states]]
2790id = "active"
2791label = "Active"
2792worker_profile = "claude/coder"
2793
2794[[workflow.states.transitions]]
2795to = "closed"
2796"#;
2797 let config = load_config(toml);
2798 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2799 assert!(
2800 !errors.iter().any(|e| e.contains("worker_profile")),
2801 "valid worker_profile should not trigger Rule 2; got: {errors:?}"
2802 );
2803 }
2804
2805 #[test]
2806 fn worker_profile_reserved_role_rejected() {
2807 let toml = r#"
2808[project]
2809name = "test"
2810
2811[tickets]
2812dir = "tickets"
2813
2814[[workflow.states]]
2815id = "active"
2816label = "Active"
2817worker_profile = "claude/worker"
2818
2819[[workflow.states.transitions]]
2820to = "closed"
2821"#;
2822 let config = load_config(toml);
2823 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2824 assert!(
2825 errors.iter().any(|e| e.contains("worker_profile") && e.contains("worker")),
2826 "reserved role 'worker' should be rejected; got: {errors:?}"
2827 );
2828 }
2829
2830 #[test]
2831 fn worker_profile_no_slash_rejected() {
2832 let toml = r#"
2833[project]
2834name = "test"
2835
2836[tickets]
2837dir = "tickets"
2838
2839[[workflow.states]]
2840id = "active"
2841label = "Active"
2842worker_profile = "claudecoder"
2843
2844[[workflow.states.transitions]]
2845to = "closed"
2846"#;
2847 let config = load_config(toml);
2848 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2849 assert!(
2850 errors.iter().any(|e| e.contains("worker_profile") && e.contains("exactly one")),
2851 "missing slash should be rejected; got: {errors:?}"
2852 );
2853 }
2854
2855 #[test]
2856 fn worker_profile_empty_agent_rejected() {
2857 let toml = r#"
2858[project]
2859name = "test"
2860
2861[tickets]
2862dir = "tickets"
2863
2864[[workflow.states]]
2865id = "active"
2866label = "Active"
2867worker_profile = "/coder"
2868
2869[[workflow.states.transitions]]
2870to = "closed"
2871"#;
2872 let config = load_config(toml);
2873 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2874 assert!(
2875 errors.iter().any(|e| e.contains("worker_profile") && e.contains("non-empty")),
2876 "empty agent component should be rejected; got: {errors:?}"
2877 );
2878 }
2879
2880 #[test]
2881 fn worker_profile_empty_role_rejected() {
2882 let toml = r#"
2883[project]
2884name = "test"
2885
2886[tickets]
2887dir = "tickets"
2888
2889[[workflow.states]]
2890id = "active"
2891label = "Active"
2892worker_profile = "claude/"
2893
2894[[workflow.states.transitions]]
2895to = "closed"
2896"#;
2897 let config = load_config(toml);
2898 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2899 assert!(
2900 errors.iter().any(|e| e.contains("worker_profile") && e.contains("non-empty")),
2901 "empty role component should be rejected; got: {errors:?}"
2902 );
2903 }
2904
2905 #[test]
2908 fn command_start_missing_worker_profile_rejected() {
2909 let toml = r#"
2910[project]
2911name = "test"
2912
2913[tickets]
2914dir = "tickets"
2915
2916[[workflow.states]]
2917id = "src"
2918label = "Src"
2919
2920[[workflow.states.transitions]]
2921to = "dest"
2922trigger = "command:start"
2923
2924[[workflow.states]]
2925id = "dest"
2926label = "Dest"
2927
2928[[workflow.states.transitions]]
2929to = "closed"
2930"#;
2931 let config = load_config(toml);
2932 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2933 assert!(
2934 errors.iter().any(|e| e.contains("dest") && e.contains("worker_profile")),
2935 "command:start to state with no worker_profile should be rejected; got: {errors:?}"
2936 );
2937 }
2938
2939 #[test]
2940 fn default_workflow_passes() {
2941 let toml = r#"
2946[project]
2947name = "test"
2948
2949[tickets]
2950dir = "tickets"
2951
2952[[workflow.states]]
2953id = "groomed"
2954label = "Groomed"
2955
2956[[workflow.states.transitions]]
2957to = "in_design"
2958trigger = "command:start"
2959outcome = "needs_input"
2960
2961[[workflow.states.transitions]]
2962to = "closed"
2963trigger = "manual"
2964outcome = "cancelled"
2965
2966[[workflow.states]]
2967id = "in_design"
2968label = "In Design"
2969worker_profile = "claude/spec-writer"
2970
2971[[workflow.states.transitions]]
2972to = "specd"
2973trigger = "manual"
2974outcome = "success"
2975
2976[[workflow.states]]
2977id = "specd"
2978label = "Specd"
2979
2980[[workflow.states.transitions]]
2981to = "ready"
2982trigger = "manual"
2983outcome = "needs_input"
2984
2985[[workflow.states]]
2986id = "ready"
2987label = "Ready"
2988
2989[[workflow.states.transitions]]
2990to = "in_progress"
2991trigger = "command:start"
2992outcome = "needs_input"
2993
2994[[workflow.states.transitions]]
2995to = "closed"
2996trigger = "manual"
2997outcome = "cancelled"
2998
2999[[workflow.states]]
3000id = "in_progress"
3001label = "In Progress"
3002worker_profile = "claude/coder"
3003
3004[[workflow.states.transitions]]
3005to = "closed"
3006trigger = "manual"
3007outcome = "cancelled"
3008"#;
3009 let config = load_config(toml);
3010 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
3011 let new_rule_errors: Vec<&String> = errors.iter()
3012 .filter(|e| {
3013 e.contains("incoming transitions")
3014 || e.contains("worker_profile")
3015 || (e.contains("command:start") && e.contains("worker_profile"))
3016 })
3017 .collect();
3018 assert!(
3019 new_rule_errors.is_empty(),
3020 "default workflow structure should pass all new rules; got: {new_rule_errors:?}"
3021 );
3022 }
3023}