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 for transition in &state.transitions {
374 if terminal_ids.contains(transition.to.as_str()) {
377 errors.push(format!(
378 "config: state.{}.transition({}) — explicit transitions to terminal states are not \
379 allowed; {} is always reachable as a supervisor close action",
380 state.id, transition.to, transition.to
381 ));
382 }
383
384 if !terminal_ids.contains(transition.to.as_str()) && !state_ids.contains(transition.to.as_str()) {
386 errors.push(format!(
387 "config: state.{}.transition({}) — target state '{}' does not exist",
388 state.id, transition.to, transition.to
389 ));
390 }
391
392 if let Some(section) = &transition.context_section {
394 if has_sections && !section_names.contains(section.as_str()) {
395 errors.push(format!(
396 "config: state.{}.transition({}).context_section — unknown section '{}'",
397 state.id, transition.to, section
398 ));
399 }
400 }
401
402 if let Some(section) = &transition.focus_section {
404 if has_sections && !section_names.contains(section.as_str()) {
405 errors.push(format!(
406 "config: state.{}.transition({}).focus_section — unknown section '{}'",
407 state.id, transition.to, section
408 ));
409 }
410 }
411
412 if matches!(
414 transition.completion,
415 CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
416 ) {
417 if transition.on_failure.is_none() {
418 errors.push(format!(
419 "config: transition '{}' → '{}' uses completion '{}' but is missing \
420 `on_failure`; run `apm validate --fix` to add it",
421 state.id,
422 transition.to,
423 strategy_name(&transition.completion)
424 ));
425 } else if let Some(ref name) = transition.on_failure {
426 if name != "closed" && !state_ids.contains(name.as_str()) {
427 errors.push(format!(
428 "config: transition '{}' → '{}' has `on_failure = \"{}\"` but \
429 state \"{}\" is not declared in workflow.toml",
430 state.id, transition.to, name, name
431 ));
432 }
433 }
434 }
435
436 if matches!(
438 transition.completion,
439 CompletionStrategy::Pr | CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
440 ) && terminal_ids.contains(transition.to.as_str()) {
441 errors.push(format!(
442 "config: state.{}.transition({}) — completion {} targets terminal state {}; \
443 merging completions must target a non-terminal (review) state",
444 state.id,
445 transition.to,
446 strategy_name(&transition.completion),
447 transition.to
448 ));
449 }
450 }
451 }
452
453 {
460 let mut incoming: std::collections::HashMap<&str, Vec<(&str, &str)>> =
461 std::collections::HashMap::new();
462 for state in &config.workflow.states {
463 for transition in &state.transitions {
464 incoming
465 .entry(transition.to.as_str())
466 .or_default()
467 .push((state.id.as_str(), transition.trigger.as_str()));
468 }
469 }
470 for (dest, sources) in &incoming {
471 let has_command_start = sources.iter().any(|(_, t)| *t == "command:start");
472 let has_manual = sources.iter().any(|(_, t)| *t != "command:start");
473 if has_command_start && has_manual {
474 let src_list = sources
475 .iter()
476 .map(|(src, t)| format!("{src} (trigger: {t})"))
477 .collect::<Vec<_>>()
478 .join(", ");
479 errors.push(format!(
480 "config: state.{dest} — has both triggered and manual incoming \
481 transitions; a triggered destination must not also be reachable \
482 via manual transitions. Incoming from: {src_list}"
483 ));
484 }
485 }
486 }
487
488 for state in &config.workflow.states {
490 if let Some(wp) = &state.worker_profile {
491 let slash_count = wp.chars().filter(|&c| c == '/').count();
492 if slash_count != 1 {
493 errors.push(format!(
494 "config: state.{}.worker_profile — '{wp}' must contain exactly one '/' separator",
495 state.id
496 ));
497 } else if let Some((agent, role)) = wp.split_once('/') {
498 if agent.is_empty() || role.is_empty() {
499 errors.push(format!(
500 "config: state.{}.worker_profile — '{wp}' agent and role components must both be non-empty",
501 state.id
502 ));
503 } else if role == "worker" {
504 errors.push(format!(
505 "config: state.{}.worker_profile — role 'worker' is reserved as a process category; use a specific role name",
506 state.id
507 ));
508 }
509 }
510 }
511 }
512
513 {
515 let dispatch_states: HashSet<&str> = config.workflow.states.iter()
516 .filter(|s| s.worker_profile.is_some())
517 .map(|s| s.id.as_str())
518 .collect();
519 for state in &config.workflow.states {
520 for transition in &state.transitions {
521 if transition.trigger == "command:start"
522 && !dispatch_states.contains(transition.to.as_str())
523 {
524 errors.push(format!(
525 "config: state.{}.transition({}) — trigger 'command:start' targets \
526 state '{}' which has no worker_profile; the dispatcher has nothing to spawn",
527 state.id, transition.to, transition.to
528 ));
529 }
530 }
531 }
532 }
533
534 if !is_external_worktree(&config.worktrees.dir) {
535 let dir_str = config.worktrees.dir.to_string_lossy();
536 let gitignore = root.join(".gitignore");
537 match std::fs::read_to_string(&gitignore) {
538 Err(_) => errors.push(format!(
539 "config: worktrees.dir '{dir_str}' is in-repo but .gitignore is missing; \
540 run 'apm init' or add '/{dir_str}/' manually"
541 )),
542 Ok(content) if !gitignore_covers_dir(&content, &dir_str) => errors.push(format!(
543 "config: worktrees.dir '{dir_str}' is in-repo but .gitignore does not cover it; \
544 add '/{dir_str}/' or run 'apm init'"
545 )),
546 Ok(_) => {}
547 }
548 }
549
550 errors
551}
552
553fn pre_validation_states<'a>(
560 barrier_state: &str,
561 workflow_states: &'a [crate::config::StateConfig],
562) -> HashSet<&'a str> {
563 let mut incoming: std::collections::HashMap<&str, HashSet<&str>> =
567 std::collections::HashMap::new();
568 for state in workflow_states {
569 for transition in &state.transitions {
570 incoming.entry(transition.to.as_str()).or_default().insert(state.id.as_str());
571 if let Some(ref failure_state) = transition.on_failure {
572 incoming.entry(failure_state.as_str()).or_default().insert(state.id.as_str());
573 }
574 }
575 }
576
577 let initial: Vec<&str> = workflow_states
582 .iter()
583 .filter(|s| !s.terminal && !incoming.contains_key(s.id.as_str()))
584 .map(|s| s.id.as_str())
585 .collect();
586
587 let mut pre_bfs: HashSet<&str> = HashSet::new();
589 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
590 for &start in &initial {
591 if pre_bfs.insert(start) {
592 queue.push_back(start);
593 }
594 }
595 while let Some(state_id) = queue.pop_front() {
596 let Some(state) = workflow_states.iter().find(|s| s.id.as_str() == state_id) else {
597 continue;
598 };
599 for transition in &state.transitions {
600 let to = transition.to.as_str();
601 if to == barrier_state {
602 continue;
603 }
604 if pre_bfs.insert(to) {
605 queue.push_back(to);
606 }
607 }
608 }
609
610 pre_bfs
615 .iter()
616 .copied()
617 .filter(|&state_id| match incoming.get(state_id) {
618 None => true,
619 Some(ins) => ins.iter().all(|&src| pre_bfs.contains(src)),
620 })
621 .collect()
622}
623
624pub fn verify_tickets(
625 root: &Path,
626 config: &Config,
627 tickets: &[Ticket],
628 merged: &HashSet<String>,
629) -> Vec<String> {
630 let valid_states: HashSet<&str> = config.workflow.states.iter()
631 .map(|s| s.id.as_str())
632 .collect();
633 let terminal = config.terminal_state_ids();
634
635 let in_progress_states: HashSet<&str> =
636 ["in_progress", "implemented"].iter().copied().collect();
637
638 let worktree_states: HashSet<&str> =
639 ["in_design", "in_progress"].iter().copied().collect();
640 let main_root = crate::git_util::main_worktree_root(root)
641 .unwrap_or_else(|| root.to_path_buf());
642 let worktrees_base = main_root.join(&config.worktrees.dir);
643
644 let mut issues: Vec<String> = Vec::new();
645
646 for t in tickets {
647 let fm = &t.frontmatter;
648
649 if terminal.contains(fm.state.as_str()) { continue; }
651
652 let prefix = format!("#{} [{}]", fm.id, fm.state);
653
654 if !valid_states.is_empty() && !valid_states.contains(fm.state.as_str()) {
656 issues.push(format!("{prefix}: unknown state {:?}", fm.state));
657 }
658
659 if let Some(name) = t.path.file_name().and_then(|n| n.to_str()) {
661 let expected_prefix = format!("{:04}", fm.id);
662 if !name.starts_with(&expected_prefix) {
663 issues.push(format!("{prefix}: id {} does not match filename {name}", fm.id));
664 }
665 }
666
667 if in_progress_states.contains(fm.state.as_str()) && fm.branch.is_none() {
669 issues.push(format!("{prefix}: state requires branch but none set"));
670 }
671
672 if let Some(branch) = &fm.branch {
674 if (fm.state == "in_progress" || fm.state == "implemented")
675 && merged.contains(branch.as_str())
676 {
677 issues.push(format!("{prefix}: branch {branch} is merged but ticket not closed"));
678 }
679 }
680
681 if worktree_states.contains(fm.state.as_str()) {
683 if let Some(branch) = &fm.branch {
684 let wt_name = branch.replace('/', "-");
685 let wt_path = worktrees_base.join(&wt_name);
686 if !wt_path.is_dir() {
687 issues.push(format!(
688 "{prefix}: worktree at {} is missing",
689 wt_path.display()
690 ));
691 }
692 }
693 }
694
695 if !t.body.contains("## Spec") {
697 issues.push(format!("{prefix}: missing ## Spec section"));
698 }
699
700 if !t.body.contains("## History") {
702 issues.push(format!("{prefix}: missing ## History section"));
703 }
704
705 if let Ok(doc) = t.document() {
708 let applicable: Vec<crate::config::TicketSection> = config.ticket.sections.iter()
709 .filter(|s| {
710 match &s.validate_from_state {
711 None => true,
712 Some(barrier) => {
713 let pre = pre_validation_states(barrier, &config.workflow.states);
714 !pre.contains(fm.state.as_str())
715 }
716 }
717 })
718 .cloned()
719 .collect();
720 for err in doc.validate(&applicable) {
721 issues.push(format!("{prefix}: {err}"));
722 }
723 }
724
725 let agents_to_check: Vec<&str> = fm.agent
728 .as_deref()
729 .into_iter()
730 .chain(fm.agent_overrides.values().map(String::as_str))
731 .collect();
732
733 let configured_agents = configured_agent_names(config);
734
735 for name in agents_to_check {
736 match wrapper::resolve_wrapper(root, name) {
737 Ok(Some(_)) => {}
738 Ok(None) => issues.push(format!(
739 "ticket {}: agent {:?} is not a known built-in",
740 fm.id, name
741 )),
742 Err(e) => issues.push(format!(
743 "ticket {}: agent {:?}: {e}",
744 fm.id, name
745 )),
746 }
747 if !configured_agents.contains(name) {
748 issues.push(format!(
749 "ticket {}: agent {:?} is not configured in config.toml \
750 (add a worker_profile = \"<agent>/...\" on a spawn transition)",
751 fm.id, name
752 ));
753 }
754 }
755 }
756
757 issues.extend(verify_branch_file_invariant(root, config));
763
764 issues
765}
766
767fn verify_branch_file_invariant(root: &Path, config: &Config) -> Vec<String> {
770 let mut issues: Vec<String> = Vec::new();
771 let tickets_dir = config.tickets.dir.to_string_lossy().to_string();
772 let branches = match crate::git_util::ticket_branches(root) {
773 Ok(b) => b,
774 Err(_) => return issues,
775 };
776 for branch in &branches {
777 let suffix = branch.trim_start_matches("ticket/");
778 if suffix.len() == 8 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
781 continue;
782 }
783 let expected_filename = format!("{suffix}.md");
784 let expected_path = format!("{tickets_dir}/{expected_filename}");
785
786 if crate::git_util::read_from_branch(root, branch, &expected_path).is_ok() {
788 continue;
789 }
790
791 let id_prefix: String = suffix.chars().take_while(|c| *c != '-').collect();
794 let files = crate::git_util::list_files_on_branch(root, branch, &tickets_dir)
795 .unwrap_or_default();
796 let id_matches: Vec<&String> = files.iter()
797 .filter(|f| {
798 let leaf = f.rsplit('/').next().unwrap_or("");
799 leaf.starts_with(&format!("{id_prefix}-")) && leaf.ends_with(".md")
800 })
801 .collect();
802
803 if id_matches.is_empty() {
804 issues.push(format!(
805 "branch {branch}: no ticket file at {expected_path} (orphaned branch — \
806 no tickets/{id_prefix}-*.md exists on this branch)"
807 ));
808 } else {
809 let found: Vec<String> = id_matches.iter().map(|s| (*s).clone()).collect();
810 issues.push(format!(
811 "branch {branch}: ticket file renamed — expected {expected_path}, \
812 found {} on branch. apm derives the filename from the branch \
813 suffix; rename the file back (or rename the branch) so it matches.",
814 found.join(", ")
815 ));
816 }
817 }
818 issues
819}
820
821pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec<String> {
822 let mut warnings = validate_warnings_no_agents(config, root);
823 let (_, agent_warnings) = validate_agents(config, root);
824 warnings.extend(agent_warnings);
825 warnings
826}
827
828fn validate_warnings_no_agents(config: &crate::config::Config, _root: &Path) -> Vec<String> {
829 let mut warnings = config.load_warnings.clone();
830 if let Some(container) = &config.workers.container {
831 if !container.is_empty() {
832 let docker_ok = std::process::Command::new("docker")
833 .arg("--version")
834 .output()
835 .map(|o| o.status.success())
836 .unwrap_or(false);
837 if !docker_ok {
838 warnings.push(
839 "workers.container is set but 'docker' is not in PATH".to_string()
840 );
841 }
842 }
843 }
844
845 let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
848 config.workflow.states.iter()
849 .map(|s| (s.id.as_str(), s))
850 .collect();
851
852 let agent_startable: Vec<&str> = config.workflow.states.iter()
853 .filter(|s| s.transitions.iter().any(|t| t.trigger == "command:start"))
854 .map(|s| s.id.as_str())
855 .collect();
856
857 if !agent_startable.is_empty() {
858 let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
859 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
860 let mut found_success = false;
861
862 for &start in &agent_startable {
863 if visited.insert(start) {
864 queue.push_back(start);
865 }
866 }
867
868 'bfs: while let Some(state_id) = queue.pop_front() {
869 let Some(state) = state_map.get(state_id) else { continue };
870 for t in &state.transitions {
871 let Some(&target) = state_map.get(t.to.as_str()) else { continue };
874 if resolve_outcome(t, target) == "success" {
875 found_success = true;
876 break 'bfs;
877 }
878 if !target.terminal && visited.insert(t.to.as_str()) {
879 queue.push_back(t.to.as_str());
880 }
881 }
882 }
883
884 if !found_success {
885 warnings.push(
886 "workflow has no reachable 'success' outcome from any agent-actionable state; \
887 workers may never complete successfully".to_string()
888 );
889 }
890 }
891
892 warnings
893}
894
895fn format_wrapper(root: &Path, agent: &str) -> String {
896 match wrapper::resolve_wrapper(root, agent) {
897 Ok(Some(wrapper::WrapperKind::Builtin(ref name))) => format!("builtin:{name}"),
898 Ok(Some(wrapper::WrapperKind::Custom { ref script_path, .. })) => {
899 script_path.to_string_lossy().into_owned()
900 }
901 Ok(None) => "(not found)".to_string(),
902 Err(_) => "(error)".to_string(),
903 }
904}
905
906pub fn audit_agent_resolution(config: &Config, root: &Path) -> Vec<TransitionAudit> {
908 let mut result = Vec::new();
909 let default_profile = config.workers.default.as_str();
910
911 for state in &config.workflow.states {
912 for transition in &state.transitions {
913 if transition.trigger != "command:start" {
914 continue;
915 }
916
917 let to_state_wp = config.workflow.states.iter()
918 .find(|s| s.id == transition.to)
919 .and_then(|s| s.worker_profile.as_deref());
920 let wp_str = to_state_wp.unwrap_or(default_profile);
921 let (agent, role) = wp_str.split_once('/')
922 .map(|(a, r)| (a.to_string(), r.to_string()))
923 .unwrap_or_else(|| ("claude".to_string(), "worker".to_string()));
924
925 let wrapper_str = format_wrapper(root, &agent);
926
927 result.push(TransitionAudit {
928 from_state: state.id.clone(),
929 to_state: transition.to.clone(),
930 worker_profile: to_state_wp.map(|s| s.to_string()),
931 agent,
932 role,
933 wrapper: wrapper_str,
934 });
935 }
936 }
937
938 result
939}
940
941pub fn validate_all(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
944 let mut errors = validate_config_no_agents(config, root);
945 let mut warnings = validate_warnings_no_agents(config, root);
946 let (agent_errors, agent_warnings) = validate_agents(config, root);
947 errors.extend(agent_errors);
948 warnings.extend(agent_warnings);
949 (errors, warnings)
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use crate::config::{Config, CompletionStrategy, LocalConfig};
956 use crate::ticket::Ticket;
957 use crate::git_util;
958 use std::path::Path;
959 use std::collections::HashSet;
960
961 fn git_cmd(dir: &std::path::Path, args: &[&str]) {
962 std::process::Command::new("git")
963 .args(args)
964 .current_dir(dir)
965 .env("GIT_AUTHOR_NAME", "test")
966 .env("GIT_AUTHOR_EMAIL", "test@test.com")
967 .env("GIT_COMMITTER_NAME", "test")
968 .env("GIT_COMMITTER_EMAIL", "test@test.com")
969 .status()
970 .unwrap();
971 }
972
973 fn setup_verify_repo() -> tempfile::TempDir {
974 let dir = tempfile::tempdir().unwrap();
975 let p = dir.path();
976
977 git_cmd(p, &["init", "-q", "-b", "main"]);
978 git_cmd(p, &["config", "user.email", "test@test.com"]);
979 git_cmd(p, &["config", "user.name", "test"]);
980
981 std::fs::create_dir_all(p.join(".apm")).unwrap();
982 std::fs::write(
983 p.join(".apm/config.toml"),
984 r#"[project]
985name = "test"
986
987[tickets]
988dir = "tickets"
989
990[workers]
991default = "claude/coder"
992
993[worktrees]
994dir = "worktrees"
995
996[[workflow.states]]
997id = "in_design"
998label = "In Design"
999
1000[[workflow.states]]
1001id = "in_progress"
1002label = "In Progress"
1003
1004[[workflow.states]]
1005id = "specd"
1006label = "Specd"
1007"#,
1008 )
1009 .unwrap();
1010
1011 git_cmd(p, &["add", ".apm/config.toml"]);
1012 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
1013
1014 dir
1015 }
1016
1017 fn make_verify_ticket(root: &std::path::Path, id: &str, state: &str, branch: Option<&str>) -> Ticket {
1018 let branch_line = match branch {
1019 Some(b) => format!("branch = \"{b}\"\n"),
1020 None => String::new(),
1021 };
1022 let raw = format!(
1023 "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{branch_line}+++\n\n## Spec\n\n## History\n"
1024 );
1025 let path = root.join("tickets").join(format!("{id}-test.md"));
1026 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1027 std::fs::write(&path, &raw).unwrap();
1028 Ticket::parse(&path, &raw).unwrap()
1029 }
1030
1031 fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
1032 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1033 let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
1034 let raw = format!(
1035 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
1036 );
1037 Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
1038 }
1039
1040 fn strategy_config(completion: &str) -> Config {
1041 let toml = format!(
1042 r#"
1043[project]
1044name = "test"
1045
1046[tickets]
1047dir = "tickets"
1048
1049[[workflow.states]]
1050id = "in_progress"
1051label = "In Progress"
1052
1053[[workflow.states.transitions]]
1054to = "implemented"
1055completion = "{completion}"
1056
1057[[workflow.states]]
1058id = "implemented"
1059label = "Implemented"
1060terminal = true
1061"#
1062 );
1063 toml::from_str(&toml).unwrap()
1064 }
1065
1066 #[test]
1067 fn strategy_finds_in_progress_to_implemented() {
1068 let config = strategy_config("pr_or_epic_merge");
1069 assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
1070 }
1071
1072 #[test]
1073 fn strategy_defaults_to_none_when_absent() {
1074 let toml = r#"
1075[project]
1076name = "test"
1077
1078[tickets]
1079dir = "tickets"
1080
1081[[workflow.states]]
1082id = "new"
1083label = "New"
1084
1085[[workflow.states.transitions]]
1086to = "closed"
1087
1088[[workflow.states]]
1089id = "closed"
1090label = "Closed"
1091terminal = true
1092"#;
1093 let config: Config = toml::from_str(toml).unwrap();
1094 assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
1095 }
1096
1097 #[test]
1098 fn dep_rules_pr_rejects_dep() {
1099 let dep = make_ticket("dep1", None, None);
1100 let result = check_depends_on_rules(
1101 &CompletionStrategy::Pr,
1102 None,
1103 None,
1104 &["dep1".to_string()],
1105 &[dep],
1106 "main",
1107 );
1108 assert!(result.is_err());
1109 let msg = result.unwrap_err().to_string();
1110 assert!(msg.contains("pr"), "expected strategy name in: {msg}");
1111 }
1112
1113 #[test]
1114 fn dep_rules_none_rejects_dep() {
1115 let dep = make_ticket("dep1", None, None);
1116 let result = check_depends_on_rules(
1117 &CompletionStrategy::None,
1118 None,
1119 None,
1120 &["dep1".to_string()],
1121 &[dep],
1122 "main",
1123 );
1124 assert!(result.is_err());
1125 let msg = result.unwrap_err().to_string();
1126 assert!(msg.contains("none"), "expected strategy name in: {msg}");
1127 }
1128
1129 #[test]
1130 fn dep_rules_pr_or_epic_merge_same_epic_ok() {
1131 let dep = make_ticket("dep1", Some("abc"), None);
1132 let result = check_depends_on_rules(
1133 &CompletionStrategy::PrOrEpicMerge,
1134 Some("abc"),
1135 None,
1136 &["dep1".to_string()],
1137 &[dep],
1138 "main",
1139 );
1140 assert!(result.is_ok(), "expected Ok, got {result:?}");
1141 }
1142
1143 #[test]
1144 fn dep_rules_pr_or_epic_merge_different_epic_fails() {
1145 let dep = make_ticket("dep1", Some("xyz"), None);
1146 let result = check_depends_on_rules(
1147 &CompletionStrategy::PrOrEpicMerge,
1148 Some("abc"),
1149 None,
1150 &["dep1".to_string()],
1151 &[dep],
1152 "main",
1153 );
1154 assert!(result.is_err());
1155 let msg = result.unwrap_err().to_string();
1156 assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1157 }
1158
1159 #[test]
1160 fn dep_rules_pr_or_epic_merge_standalone_ticket_ok() {
1161 let dep = make_ticket("dep1", Some("abc"), None);
1164 let result = check_depends_on_rules(
1165 &CompletionStrategy::PrOrEpicMerge,
1166 None,
1167 None,
1168 &["dep1".to_string()],
1169 &[dep],
1170 "main",
1171 );
1172 assert!(result.is_ok(), "expected Ok for standalone ticket, got {result:?}");
1173 }
1174
1175 #[test]
1176 fn dep_rules_merge_both_default_branch_ok() {
1177 let dep = make_ticket("dep1", None, None);
1178 let result = check_depends_on_rules(
1179 &CompletionStrategy::Merge,
1180 None,
1181 None,
1182 &["dep1".to_string()],
1183 &[dep],
1184 "main",
1185 );
1186 assert!(result.is_ok(), "expected Ok, got {result:?}");
1187 }
1188
1189 #[test]
1190 fn dep_rules_merge_different_target_fails() {
1191 let dep = make_ticket("dep1", None, Some("epic/other"));
1192 let result = check_depends_on_rules(
1193 &CompletionStrategy::Merge,
1194 None,
1195 None,
1196 &["dep1".to_string()],
1197 &[dep],
1198 "main",
1199 );
1200 assert!(result.is_err());
1201 let msg = result.unwrap_err().to_string();
1202 assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
1203 }
1204
1205 fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
1206 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
1207 let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
1208 let deps_line = if depends_on.is_empty() {
1209 String::new()
1210 } else {
1211 let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
1212 format!("depends_on = [{}]\n", quoted.join(", "))
1213 };
1214 let raw = format!(
1215 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
1216 );
1217 Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
1218 }
1219
1220 #[test]
1221 fn validate_depends_on_no_deps_clean() {
1222 let config = strategy_config("pr_or_epic_merge");
1223 let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
1224 let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
1225 let result = validate_depends_on(&config, &[t1, t2]);
1226 assert!(result.is_empty(), "expected no violations, got {result:?}");
1227 }
1228
1229 #[test]
1230 fn validate_depends_on_closed_ticket_skipped() {
1231 let config = strategy_config("pr");
1232 let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
1233 let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
1234 let result = validate_depends_on(&config, &[dep, ticket]);
1235 assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
1236 }
1237
1238 #[test]
1239 fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
1240 let config = strategy_config("pr_or_epic_merge");
1241 let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
1242 let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
1243 let result = validate_depends_on(&config, &[dep, ticket]);
1244 assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
1245 }
1246
1247 #[test]
1248 fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
1249 let config = strategy_config("pr_or_epic_merge");
1250 let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
1251 let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
1252 let result = validate_depends_on(&config, &[dep, ticket]);
1253 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1254 assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
1255 }
1256
1257 #[test]
1258 fn validate_depends_on_merge_same_target_ok() {
1259 let config = strategy_config("merge");
1260 let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
1261 let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
1262 let result = validate_depends_on(&config, &[dep, ticket]);
1263 assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
1264 }
1265
1266 #[test]
1267 fn validate_depends_on_merge_different_target_fails() {
1268 let config = strategy_config("merge");
1269 let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
1270 let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
1271 let result = validate_depends_on(&config, &[dep, ticket]);
1272 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1273 assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
1274 }
1275
1276 #[test]
1277 fn validate_depends_on_pr_strategy_rejects_any_dep() {
1278 let config = strategy_config("pr");
1279 let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
1280 let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
1281 let result = validate_depends_on(&config, &[dep, ticket]);
1282 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
1283 assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
1284 }
1285
1286 fn load_config(toml: &str) -> Config {
1287 toml::from_str(toml).expect("config parse failed")
1288 }
1289
1290 fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
1291 config.workflow.states.iter().map(|s| s.id.as_str()).collect()
1292 }
1293
1294 #[test]
1296 fn correct_config_passes() {
1297 let toml = r#"
1298[project]
1299name = "test"
1300
1301[tickets]
1302dir = "tickets"
1303
1304[workers]
1305default = "claude/coder"
1306
1307[[workflow.states]]
1308id = "new"
1309label = "New"
1310
1311[[workflow.states.transitions]]
1312to = "in_progress"
1313
1314[[workflow.states]]
1315id = "in_progress"
1316label = "In Progress"
1317terminal = false
1318
1319[[workflow.states]]
1320id = "closed"
1321label = "Closed"
1322terminal = true
1323"#;
1324 let config = load_config(toml);
1325 let errors = validate_config(&config, Path::new("/tmp"));
1326 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
1327 }
1328
1329 #[test]
1331 fn transition_to_nonexistent_state_detected() {
1332 let toml = r#"
1333[project]
1334name = "test"
1335
1336[tickets]
1337dir = "tickets"
1338
1339[[workflow.states]]
1340id = "new"
1341label = "New"
1342
1343[[workflow.states.transitions]]
1344to = "ghost"
1345"#;
1346 let config = load_config(toml);
1347 let errors = validate_config(&config, Path::new("/tmp"));
1348 assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
1349 }
1350
1351 #[test]
1353 fn terminal_state_with_transitions_detected() {
1354 let toml = r#"
1355[project]
1356name = "test"
1357
1358[tickets]
1359dir = "tickets"
1360
1361[[workflow.states]]
1362id = "closed"
1363label = "Closed"
1364terminal = true
1365
1366[[workflow.states.transitions]]
1367to = "new"
1368
1369[[workflow.states]]
1370id = "new"
1371label = "New"
1372
1373[[workflow.states.transitions]]
1374to = "closed"
1375"#;
1376 let config = load_config(toml);
1377 let errors = validate_config(&config, Path::new("/tmp"));
1378 assert!(
1379 errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
1380 "expected terminal error in {errors:?}"
1381 );
1382 }
1383
1384 #[test]
1386 fn ticket_with_unknown_state_detected() {
1387 use crate::ticket::Ticket;
1388
1389 let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
1390 let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1391
1392 let known_states: std::collections::HashSet<&str> =
1393 ["new", "ready", "closed"].iter().copied().collect();
1394
1395 assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
1396 }
1397
1398 #[test]
1401 fn dead_end_non_terminal_not_an_error() {
1402 let toml = r#"
1403[project]
1404name = "test"
1405
1406[tickets]
1407dir = "tickets"
1408
1409[[workflow.states]]
1410id = "stuck"
1411label = "Stuck"
1412
1413[[workflow.states]]
1414id = "closed"
1415label = "Closed"
1416terminal = true
1417"#;
1418 let config = load_config(toml);
1419 let errors = validate_config(&config, Path::new("/tmp"));
1420 assert!(
1421 !errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
1422 "dead-end should not be an error after implicit-close rule; got: {errors:?}"
1423 );
1424 }
1425
1426 #[test]
1429 fn explicit_terminal_transition_rejected() {
1430 let toml = r#"
1431[project]
1432name = "test"
1433
1434[tickets]
1435dir = "tickets"
1436
1437[workers]
1438default = "claude/coder"
1439
1440[[workflow.states]]
1441id = "new"
1442label = "New"
1443
1444[[workflow.states.transitions]]
1445to = "closed"
1446trigger = "manual"
1447outcome = "cancelled"
1448
1449[[workflow.states]]
1450id = "closed"
1451label = "Closed"
1452terminal = true
1453"#;
1454 let config = load_config(toml);
1455 let errors = validate_config(&config, Path::new("/tmp"));
1456 assert!(
1457 errors.iter().any(|e| e.contains("state.new.transition(closed)") && e.contains("explicit transitions to terminal states")),
1458 "expected explicit-terminal-transition error naming source and target; got: {errors:?}"
1459 );
1460 assert!(
1461 errors.iter().any(|e| e.contains("closed") && e.contains("always reachable")),
1462 "error should mention that closed is always reachable; got: {errors:?}"
1463 );
1464 }
1465
1466 #[test]
1468 fn context_section_mismatch_detected() {
1469 let toml = r#"
1470[project]
1471name = "test"
1472
1473[tickets]
1474dir = "tickets"
1475
1476[[ticket.sections]]
1477name = "Problem"
1478type = "free"
1479
1480[[workflow.states]]
1481id = "new"
1482label = "New"
1483
1484[[workflow.states.transitions]]
1485to = "ready"
1486context_section = "NonExistent"
1487
1488[[workflow.states]]
1489id = "ready"
1490label = "Ready"
1491
1492[[workflow.states.transitions]]
1493to = "closed"
1494
1495[[workflow.states]]
1496id = "closed"
1497label = "Closed"
1498terminal = true
1499"#;
1500 let config = load_config(toml);
1501 let errors = validate_config(&config, Path::new("/tmp"));
1502 assert!(
1503 errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
1504 "expected context_section error in {errors:?}"
1505 );
1506 }
1507
1508 #[test]
1510 fn focus_section_mismatch_detected() {
1511 let toml = r#"
1512[project]
1513name = "test"
1514
1515[tickets]
1516dir = "tickets"
1517
1518[[ticket.sections]]
1519name = "Problem"
1520type = "free"
1521
1522[[workflow.states]]
1523id = "new"
1524label = "New"
1525
1526[[workflow.states.transitions]]
1527to = "ready"
1528focus_section = "BadSection"
1529
1530[[workflow.states]]
1531id = "ready"
1532label = "Ready"
1533
1534[[workflow.states.transitions]]
1535to = "closed"
1536
1537[[workflow.states]]
1538id = "closed"
1539label = "Closed"
1540terminal = true
1541"#;
1542 let config = load_config(toml);
1543 let errors = validate_config(&config, Path::new("/tmp"));
1544 assert!(
1545 errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
1546 "expected focus_section error in {errors:?}"
1547 );
1548 }
1549
1550 #[test]
1552 fn completion_pr_without_provider_detected() {
1553 let toml = r#"
1554[project]
1555name = "test"
1556
1557[tickets]
1558dir = "tickets"
1559
1560[[workflow.states]]
1561id = "new"
1562label = "New"
1563
1564[[workflow.states.transitions]]
1565to = "closed"
1566completion = "pr"
1567
1568[[workflow.states]]
1569id = "closed"
1570label = "Closed"
1571terminal = true
1572"#;
1573 let config = load_config(toml);
1574 let errors = validate_config(&config, Path::new("/tmp"));
1575 assert!(
1576 errors.iter().any(|e| e.contains("provider")),
1577 "expected provider error in {errors:?}"
1578 );
1579 }
1580
1581 #[test]
1583 fn completion_pr_with_provider_passes() {
1584 let toml = r#"
1585[project]
1586name = "test"
1587
1588[tickets]
1589dir = "tickets"
1590
1591[git_host]
1592provider = "github"
1593
1594[[workflow.states]]
1595id = "new"
1596label = "New"
1597
1598[[workflow.states.transitions]]
1599to = "closed"
1600completion = "pr"
1601
1602[[workflow.states]]
1603id = "closed"
1604label = "Closed"
1605terminal = true
1606"#;
1607 let config = load_config(toml);
1608 let errors = validate_config(&config, Path::new("/tmp"));
1609 assert!(
1610 !errors.iter().any(|e| e.contains("provider")),
1611 "unexpected provider error in {errors:?}"
1612 );
1613 }
1614
1615 #[test]
1617 fn context_section_skipped_when_no_sections_defined() {
1618 let toml = r#"
1619[project]
1620name = "test"
1621
1622[tickets]
1623dir = "tickets"
1624
1625[[workflow.states]]
1626id = "new"
1627label = "New"
1628
1629[[workflow.states.transitions]]
1630to = "closed"
1631context_section = "AnySection"
1632
1633[[workflow.states]]
1634id = "closed"
1635label = "Closed"
1636terminal = true
1637"#;
1638 let config = load_config(toml);
1639 let errors = validate_config(&config, Path::new("/tmp"));
1640 assert!(
1641 !errors.iter().any(|e| e.contains("context_section")),
1642 "unexpected context_section error in {errors:?}"
1643 );
1644 }
1645
1646 #[test]
1648 fn closed_state_not_flagged_as_unknown() {
1649 use crate::ticket::Ticket;
1650
1651 let toml = r#"
1653[project]
1654name = "test"
1655
1656[tickets]
1657dir = "tickets"
1658
1659[[workflow.states]]
1660id = "new"
1661label = "New"
1662
1663[[workflow.states.transitions]]
1664to = "done"
1665
1666[[workflow.states]]
1667id = "done"
1668label = "Done"
1669terminal = true
1670"#;
1671 let config = load_config(toml);
1672 let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
1673 .map(|s| s.id.as_str())
1674 .collect();
1675
1676 let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
1677 let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1678
1679 assert!(!state_ids.contains("closed"));
1681 let fm = &ticket.frontmatter;
1683 let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
1684 assert!(!flagged, "closed state should not be flagged as unknown");
1685 }
1686
1687 #[test]
1689 fn state_ids_helper() {
1690 let toml = r#"
1691[project]
1692name = "test"
1693
1694[tickets]
1695dir = "tickets"
1696
1697[[workflow.states]]
1698id = "new"
1699label = "New"
1700"#;
1701 let config = load_config(toml);
1702 let ids = state_ids(&config);
1703 assert!(ids.contains("new"));
1704 }
1705
1706 #[test]
1707 fn validate_warnings_no_container() {
1708 let toml = r#"
1709[project]
1710name = "test"
1711
1712[tickets]
1713dir = "tickets"
1714"#;
1715 let config = load_config(toml);
1716 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1717 assert!(warnings.is_empty());
1718 }
1719
1720 #[test]
1721 fn valid_collaborator_accepted() {
1722 let toml = r#"
1723[project]
1724name = "test"
1725collaborators = ["alice", "bob"]
1726
1727[tickets]
1728dir = "tickets"
1729"#;
1730 let config = load_config(toml);
1731 assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1732 }
1733
1734 #[test]
1735 fn unknown_user_rejected() {
1736 let toml = r#"
1737[project]
1738name = "test"
1739collaborators = ["alice", "bob"]
1740
1741[tickets]
1742dir = "tickets"
1743"#;
1744 let config = load_config(toml);
1745 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1746 let msg = err.to_string();
1747 assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
1748 assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
1749 }
1750
1751 #[test]
1752 fn empty_collaborators_skips_validation() {
1753 let toml = r#"
1754[project]
1755name = "test"
1756
1757[tickets]
1758dir = "tickets"
1759"#;
1760 let config = load_config(toml);
1761 assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1762 }
1763
1764 #[test]
1765 fn clear_owner_always_allowed() {
1766 let toml = r#"
1767[project]
1768name = "test"
1769collaborators = ["alice"]
1770
1771[tickets]
1772dir = "tickets"
1773"#;
1774 let config = load_config(toml);
1775 assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1776 }
1777
1778 #[test]
1779 fn github_mode_known_user_accepted() {
1780 let toml = r#"
1781[project]
1782name = "test"
1783collaborators = ["alice", "bob"]
1784
1785[tickets]
1786dir = "tickets"
1787
1788[git_host]
1789provider = "github"
1790repo = "org/repo"
1791"#;
1792 let config = load_config(toml);
1793 assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1795 }
1796
1797 #[test]
1798 fn github_mode_unknown_user_rejected() {
1799 let toml = r#"
1800[project]
1801name = "test"
1802collaborators = ["alice", "bob"]
1803
1804[tickets]
1805dir = "tickets"
1806
1807[git_host]
1808provider = "github"
1809repo = "org/repo"
1810"#;
1811 let config = load_config(toml);
1812 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1814 assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1815 }
1816
1817 #[test]
1818 fn github_mode_no_collaborators_skips_check() {
1819 let toml = r#"
1820[project]
1821name = "test"
1822
1823[tickets]
1824dir = "tickets"
1825
1826[git_host]
1827provider = "github"
1828repo = "org/repo"
1829"#;
1830 let config = load_config(toml);
1831 assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1833 }
1834
1835 #[test]
1836 fn github_mode_clear_owner_accepted() {
1837 let toml = r#"
1838[project]
1839name = "test"
1840collaborators = ["alice"]
1841
1842[tickets]
1843dir = "tickets"
1844
1845[git_host]
1846provider = "github"
1847repo = "org/repo"
1848"#;
1849 let config = load_config(toml);
1850 assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1851 }
1852
1853 #[test]
1854 fn non_github_mode_unknown_user_rejected() {
1855 let toml = r#"
1856[project]
1857name = "test"
1858collaborators = ["alice", "bob"]
1859
1860[tickets]
1861dir = "tickets"
1862"#;
1863 let config = load_config(toml);
1864 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1865 assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1866 }
1867
1868 #[test]
1869 fn validate_warnings_empty_container() {
1870 let toml = r#"
1871[project]
1872name = "test"
1873
1874[tickets]
1875dir = "tickets"
1876
1877[workers]
1878default = "claude/coder"
1879container = ""
1880"#;
1881 let config = load_config(toml);
1882 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1883 assert!(warnings.is_empty(), "empty container string should not warn");
1884 }
1885
1886 #[test]
1887 fn dead_end_workflow_warning_emitted() {
1888 let toml = r#"
1891[project]
1892name = "test"
1893
1894[tickets]
1895dir = "tickets"
1896
1897[[workflow.states]]
1898id = "start"
1899label = "Start"
1900
1901[[workflow.states.transitions]]
1902to = "middle"
1903trigger = "command:start"
1904
1905[[workflow.states]]
1906id = "middle"
1907label = "Middle"
1908
1909[[workflow.states.transitions]]
1910to = "start"
1911"#;
1912 let config = load_config(toml);
1913 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1914 assert!(
1915 warnings.iter().any(|w| w.contains("success")),
1916 "expected dead-end warning containing 'success'; got: {warnings:?}"
1917 );
1918 }
1919
1920 #[test]
1921 fn default_workflow_no_dead_end_warning() {
1922 let base = r#"
1925[project]
1926name = "test"
1927
1928[tickets]
1929dir = "tickets"
1930"#;
1931 let combined = format!("{}\n{}", base, crate::init::default_workflow_toml());
1932 let config: Config = toml::from_str(&combined).unwrap();
1933 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1934 assert!(
1935 !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")),
1936 "unexpected dead-end warning for default workflow; got: {warnings:?}"
1937 );
1938 }
1939
1940 #[test]
1941 fn worktree_missing_in_design() {
1942 let dir = setup_verify_repo();
1943 let root = dir.path();
1944 let config = Config::load(root).unwrap();
1945 let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1946
1947 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1948
1949 let main_root = git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1950 let wt_path = main_root.join("worktrees").join("ticket-abcd1234-test");
1951 let expected = format!(
1952 "#abcd1234 [in_design]: worktree at {} is missing",
1953 wt_path.display()
1954 );
1955 assert!(
1956 issues.iter().any(|i| i == &expected),
1957 "expected worktree missing issue; got: {issues:?}"
1958 );
1959 }
1960
1961 #[test]
1962 fn worktree_present_no_issue() {
1963 let dir = setup_verify_repo();
1964 let root = dir.path();
1965 let config = Config::load(root).unwrap();
1966 let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1967
1968 std::fs::create_dir_all(root.join("worktrees").join("ticket-abcd1234-test")).unwrap();
1969
1970 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1971 assert!(
1972 !issues.iter().any(|i| i.contains("worktree")),
1973 "unexpected worktree issue; got: {issues:?}"
1974 );
1975 }
1976
1977 #[test]
1978 fn worktree_check_skipped_for_other_states() {
1979 let dir = setup_verify_repo();
1980 let root = dir.path();
1981 let config = Config::load(root).unwrap();
1982 let ticket = make_verify_ticket(root, "abcd1234", "specd", Some("ticket/abcd1234-test"));
1983
1984 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1985 assert!(
1986 !issues.iter().any(|i| i.contains("worktree")),
1987 "unexpected worktree issue for specd state; got: {issues:?}"
1988 );
1989 }
1990
1991 fn in_repo_wt_config(dir: &str) -> Config {
1992 let toml = format!(
1993 r#"
1994[project]
1995name = "test"
1996
1997[tickets]
1998dir = "tickets"
1999
2000[worktrees]
2001dir = "{dir}"
2002"#
2003 );
2004 toml::from_str(&toml).expect("config parse failed")
2005 }
2006
2007 #[test]
2008 fn validate_config_gitignore_missing_in_repo_wt() {
2009 let tmp = tempfile::TempDir::new().unwrap();
2010 let config = in_repo_wt_config("worktrees");
2011 let errors = validate_config(&config, tmp.path());
2012 assert!(
2013 errors.iter().any(|e| e.contains("worktrees") && e.contains(".gitignore")),
2014 "expected gitignore missing error; got: {errors:?}"
2015 );
2016 }
2017
2018 #[test]
2019 fn validate_config_gitignore_covered_anchored_slash() {
2020 let tmp = tempfile::TempDir::new().unwrap();
2021 std::fs::write(tmp.path().join(".gitignore"), "/worktrees/\n").unwrap();
2022 let config = in_repo_wt_config("worktrees");
2023 let errors = validate_config(&config, tmp.path());
2024 assert!(
2025 !errors.iter().any(|e| e.contains("gitignore")),
2026 "unexpected gitignore error; got: {errors:?}"
2027 );
2028 }
2029
2030 #[test]
2031 fn validate_config_gitignore_covered_anchored_no_slash() {
2032 let tmp = tempfile::TempDir::new().unwrap();
2033 std::fs::write(tmp.path().join(".gitignore"), "/worktrees\n").unwrap();
2034 let config = in_repo_wt_config("worktrees");
2035 let errors = validate_config(&config, tmp.path());
2036 assert!(
2037 !errors.iter().any(|e| e.contains("gitignore")),
2038 "unexpected gitignore error; got: {errors:?}"
2039 );
2040 }
2041
2042 #[test]
2043 fn validate_config_gitignore_covered_unanchored_slash() {
2044 let tmp = tempfile::TempDir::new().unwrap();
2045 std::fs::write(tmp.path().join(".gitignore"), "worktrees/\n").unwrap();
2046 let config = in_repo_wt_config("worktrees");
2047 let errors = validate_config(&config, tmp.path());
2048 assert!(
2049 !errors.iter().any(|e| e.contains("gitignore")),
2050 "unexpected gitignore error; got: {errors:?}"
2051 );
2052 }
2053
2054 #[test]
2055 fn validate_config_gitignore_covered_bare() {
2056 let tmp = tempfile::TempDir::new().unwrap();
2057 std::fs::write(tmp.path().join(".gitignore"), "worktrees\n").unwrap();
2058 let config = in_repo_wt_config("worktrees");
2059 let errors = validate_config(&config, tmp.path());
2060 assert!(
2061 !errors.iter().any(|e| e.contains("gitignore")),
2062 "unexpected gitignore error; got: {errors:?}"
2063 );
2064 }
2065
2066 #[test]
2067 fn validate_config_gitignore_not_covered() {
2068 let tmp = tempfile::TempDir::new().unwrap();
2069 std::fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
2070 let config = in_repo_wt_config("worktrees");
2071 let errors = validate_config(&config, tmp.path());
2072 assert!(
2073 errors.iter().any(|e| e.contains("worktrees") && e.contains("gitignore")),
2074 "expected gitignore not covered error; got: {errors:?}"
2075 );
2076 }
2077
2078 #[test]
2079 fn validate_config_gitignore_no_false_positive() {
2080 let tmp = tempfile::TempDir::new().unwrap();
2081 std::fs::write(tmp.path().join(".gitignore"), "wt-old/\n").unwrap();
2082 let config = in_repo_wt_config("wt");
2083 let errors = validate_config(&config, tmp.path());
2084 assert!(
2085 errors.iter().any(|e| e.contains("wt") && e.contains("gitignore")),
2086 "wt-old should not match wt; got: {errors:?}"
2087 );
2088 }
2089
2090 #[test]
2091 fn validate_config_external_dotdot_no_check() {
2092 let tmp = tempfile::TempDir::new().unwrap();
2093 let config = in_repo_wt_config("../ext");
2095 let errors = validate_config(&config, tmp.path());
2096 assert!(
2097 !errors.iter().any(|e| e.contains("gitignore")),
2098 "external dotdot path should skip gitignore check; got: {errors:?}"
2099 );
2100 }
2101
2102 #[test]
2103 fn validate_config_external_absolute_no_check() {
2104 let tmp = tempfile::TempDir::new().unwrap();
2105 let config = in_repo_wt_config("/abs/path");
2107 let errors = validate_config(&config, tmp.path());
2108 assert!(
2109 !errors.iter().any(|e| e.contains("gitignore")),
2110 "absolute path should skip gitignore check; got: {errors:?}"
2111 );
2112 }
2113
2114 fn config_with_merge_transition(completion: &str, on_failure: Option<&str>, declare_failure_state: bool) -> Config {
2115 let on_failure_line = on_failure
2116 .map(|v| format!("on_failure = \"{v}\"\n"))
2117 .unwrap_or_default();
2118 let merge_failed_state = if declare_failure_state {
2119 r#"
2120[[workflow.states]]
2121id = "merge_failed"
2122label = "Merge failed"
2123"#
2124 } else {
2125 ""
2126 };
2127 let toml = format!(
2128 r#"
2129[project]
2130name = "test"
2131
2132[tickets]
2133dir = "tickets"
2134
2135[[workflow.states]]
2136id = "in_progress"
2137label = "In Progress"
2138
2139[[workflow.states.transitions]]
2140to = "implemented"
2141completion = "{completion}"
2142{on_failure_line}
2143[[workflow.states]]
2144id = "implemented"
2145label = "Implemented"
2146terminal = true
2147
2148[[workflow.states]]
2149id = "closed"
2150label = "Closed"
2151terminal = true
2152{merge_failed_state}
2153"#
2154 );
2155 toml::from_str(&toml).expect("config parse failed")
2156 }
2157
2158 #[test]
2159 fn test_on_failure_missing_for_merge() {
2160 let config = config_with_merge_transition("merge", None, false);
2161 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2162 assert!(
2163 errors.iter().any(|e| e.contains("missing `on_failure`")),
2164 "expected missing on_failure error; got: {errors:?}"
2165 );
2166 }
2167
2168 #[test]
2169 fn test_on_failure_missing_for_pr_or_epic_merge() {
2170 let config = config_with_merge_transition("pr_or_epic_merge", None, false);
2172 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2173 assert!(
2174 errors.iter().any(|e| e.contains("missing `on_failure`")),
2175 "expected missing on_failure error for pr_or_epic_merge; got: {errors:?}"
2176 );
2177 }
2178
2179 #[test]
2180 fn test_on_failure_unknown_state() {
2181 let config = config_with_merge_transition("merge", Some("ghost_state"), false);
2182 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2183 assert!(
2184 errors.iter().any(|e| e.contains("ghost_state")),
2185 "expected unknown state error for ghost_state; got: {errors:?}"
2186 );
2187 }
2188
2189 #[test]
2190 fn test_on_failure_valid() {
2191 let config = config_with_merge_transition("merge", Some("merge_failed"), true);
2192 let errors = validate_config(&config, std::path::Path::new("/tmp"));
2193 let on_failure_errors: Vec<&String> = errors.iter()
2194 .filter(|e| e.contains("on_failure") || e.contains("ghost_state") || e.contains("merge_failed"))
2195 .collect();
2196 assert!(
2197 on_failure_errors.is_empty(),
2198 "unexpected on_failure errors: {on_failure_errors:?}"
2199 );
2200 }
2201
2202 fn make_agent_verify_ticket(root: &std::path::Path, id: &str, state: &str, extra_fm: &str) -> Ticket {
2205 let raw = format!(
2206 "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{extra_fm}+++\n\n## Spec\n\n## History\n"
2207 );
2208 let path = root.join("tickets").join(format!("{id}-test.md"));
2209 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2210 std::fs::write(&path, &raw).unwrap();
2211 Ticket::parse(&path, &raw).unwrap()
2212 }
2213
2214 #[test]
2215 fn validate_unknown_frontmatter_agent_is_error() {
2216 let dir = setup_verify_repo();
2217 let root = dir.path();
2218 let config = Config::load(root).unwrap();
2219 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"nonexistent-bot\"\n");
2220
2221 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2222
2223 assert!(
2224 issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2225 "expected error with ticket id and agent name; got: {issues:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn validate_unknown_agent_in_overrides_is_error() {
2231 let dir = setup_verify_repo();
2232 let root = dir.path();
2233 let config = Config::load(root).unwrap();
2234 let ticket = make_agent_verify_ticket(
2235 root,
2236 "abcd1234",
2237 "specd",
2238 "[agent_overrides]\nimpl_agent = \"nonexistent-bot\"\n",
2239 );
2240
2241 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2242
2243 assert!(
2244 issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
2245 "expected error with ticket id and agent name; got: {issues:?}"
2246 );
2247 }
2248
2249 #[test]
2250 fn verify_tickets_flags_renamed_ticket_file_on_branch() {
2251 let dir = setup_verify_repo();
2256 let p = dir.path();
2257
2258 let canonical_branch = "ticket/abcd1234-fix-login";
2259 git_cmd(p, &["checkout", "-b", canonical_branch]);
2260 std::fs::create_dir_all(p.join("tickets")).unwrap();
2261 std::fs::write(
2263 p.join("tickets/abcd1234-fix-login-and-stuff.md"),
2264 "+++\nid = \"abcd1234\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2265 )
2266 .unwrap();
2267 git_cmd(p, &["add", "tickets/"]);
2268 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "spec written"]);
2269 git_cmd(p, &["checkout", "main"]);
2270
2271 let config = Config::load(p).unwrap();
2272 let issues = verify_tickets(p, &config, &[], &HashSet::new());
2273
2274 assert!(
2275 issues.iter().any(|i| i.contains(canonical_branch) && i.contains("renamed")),
2276 "expected rename diagnostic for {canonical_branch}; got: {issues:?}"
2277 );
2278 }
2279
2280 #[test]
2281 fn verify_tickets_flags_orphan_branch_with_no_ticket_file() {
2282 let dir = setup_verify_repo();
2283 let p = dir.path();
2284
2285 let branch = "ticket/deadbeef-orphan";
2286 git_cmd(p, &["checkout", "-b", branch]);
2287 std::fs::write(p.join("dummy.txt"), "x").unwrap();
2289 git_cmd(p, &["add", "dummy.txt"]);
2290 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "no ticket file"]);
2291 git_cmd(p, &["checkout", "main"]);
2292
2293 let config = Config::load(p).unwrap();
2294 let issues = verify_tickets(p, &config, &[], &HashSet::new());
2295
2296 assert!(
2297 issues.iter().any(|i| i.contains(branch) && i.contains("orphaned")),
2298 "expected orphan diagnostic for {branch}; got: {issues:?}"
2299 );
2300 }
2301
2302 #[test]
2303 fn verify_tickets_quiet_when_branch_file_matches() {
2304 let dir = setup_verify_repo();
2305 let p = dir.path();
2306
2307 let branch = "ticket/cafe0001-clean-branch";
2308 git_cmd(p, &["checkout", "-b", branch]);
2309 std::fs::create_dir_all(p.join("tickets")).unwrap();
2310 std::fs::write(
2312 p.join("tickets/cafe0001-clean-branch.md"),
2313 "+++\nid = \"cafe0001\"\ntitle = \"x\"\nstate = \"new\"\n+++\n\n## Spec\n\n## History\n",
2314 )
2315 .unwrap();
2316 git_cmd(p, &["add", "tickets/"]);
2317 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "canonical"]);
2318 git_cmd(p, &["checkout", "main"]);
2319
2320 let config = Config::load(p).unwrap();
2321 let issues = verify_tickets(p, &config, &[], &HashSet::new());
2322
2323 assert!(
2324 !issues.iter().any(|i| i.contains(branch)),
2325 "no branch-file issue expected for canonical layout; got: {issues:?}"
2326 );
2327 }
2328
2329 #[test]
2330 fn validate_known_frontmatter_agent_passes() {
2331 let dir = setup_verify_repo();
2332 let root = dir.path();
2333 let config = Config::load(root).unwrap();
2334 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"claude\"\n");
2335
2336 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2337
2338 assert!(
2339 !issues.iter().any(|i| i.contains("is not a known built-in")),
2340 "expected no agent error for known built-in; got: {issues:?}"
2341 );
2342 }
2343
2344 #[test]
2345 fn validate_agent_name_accepts_configured_spawn_agent() {
2346 let toml = r#"
2347[[workflow.states]]
2348id = "ready"
2349label = "Ready"
2350
2351[[workflow.states.transitions]]
2352to = "in_progress"
2353trigger = "command:start"
2354
2355[[workflow.states]]
2356id = "in_progress"
2357label = "In Progress"
2358worker_profile = "pi/worker"
2359terminal = true
2360"#;
2361 let config = audit_config(toml);
2362 validate_agent_name(&config, "pi").expect("pi should be a configured agent");
2363 }
2364
2365 #[test]
2366 fn validate_agent_name_rejects_unknown() {
2367 let config = audit_config("");
2368 let err = validate_agent_name(&config, "nonexistent").unwrap_err();
2369 let msg = err.to_string();
2370 assert!(msg.contains("nonexistent"), "got: {msg}");
2371 assert!(msg.contains("not configured in config.toml"), "got: {msg}");
2372 }
2373
2374 #[test]
2375 fn validate_agent_name_accepts_dash_sentinel() {
2376 let config = audit_config("");
2377 validate_agent_name(&config, "-").expect("dash should clear without validation");
2378 }
2379
2380 #[test]
2381 fn validate_ticket_agent_not_in_config_is_error() {
2382 let dir = setup_verify_repo();
2383 let root = dir.path();
2384 let config = Config::load(root).unwrap();
2385 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"phi4\"\n");
2388
2389 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
2390
2391 assert!(
2392 issues.iter().any(|i| i.contains("abcd1234") && i.contains("not configured in config.toml")),
2393 "expected config-coverage error; got: {issues:?}"
2394 );
2395 }
2396
2397 fn audit_config(extra_toml: &str) -> Config {
2400 let base = r#"
2401[project]
2402name = "test"
2403
2404[tickets]
2405dir = "tickets"
2406
2407[worktrees]
2408dir = "../wt"
2409"#;
2410 toml::from_str(&format!("{base}{extra_toml}")).expect("config parse failed")
2411 }
2412
2413 #[test]
2414 fn audit_zero_spawn_transitions() {
2415 let toml = r#"
2416[[workflow.states]]
2417id = "new"
2418label = "New"
2419
2420[[workflow.states.transitions]]
2421to = "closed"
2422trigger = "command:review"
2423
2424[[workflow.states]]
2425id = "closed"
2426label = "Closed"
2427terminal = true
2428"#;
2429 let config = audit_config(toml);
2430 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2431 assert!(result.is_empty(), "expected 0 audits, got {result:?}");
2432 }
2433
2434 #[test]
2435 fn audit_default_agent_resolution() {
2436 let toml = r#"
2437[workers]
2438default = "claude/coder"
2439
2440[[workflow.states]]
2441id = "ready"
2442label = "Ready"
2443
2444[[workflow.states.transitions]]
2445to = "in_progress"
2446trigger = "command:start"
2447
2448[[workflow.states]]
2449id = "in_progress"
2450label = "In Progress"
2451terminal = true
2452"#;
2453 let config = audit_config(toml);
2454 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2455 assert_eq!(result.len(), 1, "expected 1 audit");
2456 let ta = &result[0];
2457 assert_eq!(ta.from_state, "ready");
2458 assert_eq!(ta.to_state, "in_progress");
2459 assert!(ta.worker_profile.is_none());
2460 assert_eq!(ta.agent, "claude");
2461 assert_eq!(ta.role, "coder");
2462 assert!(ta.wrapper.contains("claude"), "wrapper should mention claude: {}", ta.wrapper);
2463 }
2464
2465 #[test]
2466 fn audit_worker_profile_parsed() {
2467 let toml = r#"
2468[[workflow.states]]
2469id = "ready"
2470label = "Ready"
2471
2472[[workflow.states.transitions]]
2473to = "in_progress"
2474trigger = "command:start"
2475
2476[[workflow.states]]
2477id = "in_progress"
2478label = "In Progress"
2479worker_profile = "mock-happy/spec-writer"
2480terminal = true
2481"#;
2482 let config = audit_config(toml);
2483 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2484 assert_eq!(result.len(), 1);
2485 let ta = &result[0];
2486 assert_eq!(ta.worker_profile.as_deref(), Some("mock-happy/spec-writer"));
2487 assert_eq!(ta.agent, "mock-happy");
2488 assert_eq!(ta.role, "spec-writer");
2489 assert!(ta.wrapper.contains("mock-happy"), "wrapper: {}", ta.wrapper);
2490 }
2491
2492 #[test]
2493 fn audit_workers_default_agent() {
2494 let toml = r#"
2495[workers]
2496default = "mock-happy/worker"
2497
2498[[workflow.states]]
2499id = "ready"
2500label = "Ready"
2501
2502[[workflow.states.transitions]]
2503to = "in_progress"
2504trigger = "command:start"
2505
2506[[workflow.states]]
2507id = "in_progress"
2508label = "In Progress"
2509terminal = true
2510"#;
2511 let config = audit_config(toml);
2512 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2513 assert_eq!(result.len(), 1);
2514 let ta = &result[0];
2515 assert_eq!(ta.agent, "mock-happy");
2516 assert_eq!(ta.role, "worker");
2517 }
2518
2519 #[test]
2520 fn audit_no_worker_profiles_no_panic() {
2521 let toml = r#"
2522[workers]
2523default = "claude/coder"
2524
2525[[workflow.states]]
2526id = "ready"
2527label = "Ready"
2528
2529[[workflow.states.transitions]]
2530to = "in_progress"
2531trigger = "command:start"
2532
2533[[workflow.states]]
2534id = "in_progress"
2535label = "In Progress"
2536terminal = true
2537"#;
2538 let config = audit_config(toml);
2539 let result = super::audit_agent_resolution(&config, Path::new("/tmp"));
2540 assert_eq!(result.len(), 1, "should not panic with no worker_profile");
2541 }
2542
2543 #[test]
2544 fn workers_default_absent_fails_validate() {
2545 let toml = r#"
2546[project]
2547name = "test"
2548
2549[tickets]
2550dir = "tickets"
2551
2552[[workflow.states]]
2553id = "new"
2554label = "New"
2555
2556[[workflow.states.transitions]]
2557to = "done"
2558
2559[[workflow.states]]
2560id = "done"
2561label = "Done"
2562terminal = true
2563"#;
2564 let config = load_config(toml);
2565 let errors = validate_config(&config, Path::new("/tmp"));
2566 assert!(
2567 errors.iter().any(|e| e.contains("workers.default")),
2568 "expected workers.default error when [workers] section is absent; got: {errors:?}"
2569 );
2570 }
2571
2572 #[test]
2573 fn workers_default_empty_fails_validate() {
2574 let toml = r#"
2575[project]
2576name = "test"
2577
2578[tickets]
2579dir = "tickets"
2580
2581[workers]
2582default = ""
2583
2584[[workflow.states]]
2585id = "new"
2586label = "New"
2587
2588[[workflow.states.transitions]]
2589to = "done"
2590
2591[[workflow.states]]
2592id = "done"
2593label = "Done"
2594terminal = true
2595"#;
2596 let config = load_config(toml);
2597 let errors = validate_config(&config, Path::new("/tmp"));
2598 assert!(
2599 errors.iter().any(|e| e.contains("workers.default")),
2600 "expected workers.default error when default = \"\"; got: {errors:?}"
2601 );
2602 }
2603
2604 #[test]
2605 fn merge_completion_targeting_terminal_rejected() {
2606 let toml = r#"
2607[project]
2608name = "test"
2609
2610[tickets]
2611dir = "tickets"
2612
2613[[workflow.states]]
2614id = "in_progress"
2615label = "In Progress"
2616
2617[[workflow.states.transitions]]
2618to = "done"
2619completion = "merge"
2620on_failure = "closed"
2621
2622[[workflow.states]]
2623id = "done"
2624label = "Done"
2625terminal = true
2626"#;
2627 let config = load_config(toml);
2628 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2629 assert!(
2630 errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2631 "expected terminal-state error; got: {errors:?}"
2632 );
2633 }
2634
2635 #[test]
2636 fn pr_or_epic_merge_targeting_terminal_rejected() {
2637 let toml = r#"
2638[project]
2639name = "test"
2640
2641[tickets]
2642dir = "tickets"
2643
2644[[workflow.states]]
2645id = "in_progress"
2646label = "In Progress"
2647
2648[[workflow.states.transitions]]
2649to = "done"
2650completion = "pr_or_epic_merge"
2651on_failure = "closed"
2652
2653[[workflow.states]]
2654id = "done"
2655label = "Done"
2656terminal = true
2657"#;
2658 let config = load_config(toml);
2659 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2660 assert!(
2661 errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2662 "expected terminal-state error; got: {errors:?}"
2663 );
2664 }
2665
2666 #[test]
2667 fn pr_completion_targeting_terminal_rejected() {
2668 let toml = r#"
2669[project]
2670name = "test"
2671
2672[tickets]
2673dir = "tickets"
2674
2675[git_host]
2676provider = "github"
2677
2678[[workflow.states]]
2679id = "in_progress"
2680label = "In Progress"
2681
2682[[workflow.states.transitions]]
2683to = "done"
2684completion = "pr"
2685
2686[[workflow.states]]
2687id = "done"
2688label = "Done"
2689terminal = true
2690"#;
2691 let config = load_config(toml);
2692 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2693 assert!(
2694 errors.iter().any(|e| e.contains("state.in_progress.transition(done)") && e.contains("targets terminal state")),
2695 "expected terminal-state error; got: {errors:?}"
2696 );
2697 }
2698
2699 #[test]
2700 fn merge_targeting_built_in_closed_rejected() {
2701 let toml = r#"
2703[project]
2704name = "test"
2705
2706[tickets]
2707dir = "tickets"
2708
2709[[workflow.states]]
2710id = "in_progress"
2711label = "In Progress"
2712
2713[[workflow.states.transitions]]
2714to = "closed"
2715completion = "merge"
2716on_failure = "review"
2717
2718[[workflow.states]]
2719id = "review"
2720label = "Review"
2721
2722[[workflow.states.transitions]]
2723to = "closed"
2724"#;
2725 let config = load_config(toml);
2726 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2727 assert!(
2728 errors.iter().any(|e| e.contains("state.in_progress.transition(closed)") && e.contains("targets terminal state")),
2729 "expected terminal-state error for built-in closed; got: {errors:?}"
2730 );
2731 }
2732
2733 #[test]
2734 fn merge_targeting_non_terminal_accepted() {
2735 let toml = r#"
2736[project]
2737name = "test"
2738
2739[tickets]
2740dir = "tickets"
2741
2742[[workflow.states]]
2743id = "in_progress"
2744label = "In Progress"
2745
2746[[workflow.states.transitions]]
2747to = "review"
2748completion = "merge"
2749on_failure = "closed"
2750
2751[[workflow.states]]
2752id = "review"
2753label = "Review"
2754
2755[[workflow.states.transitions]]
2756to = "closed"
2757"#;
2758 let config = load_config(toml);
2759 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2760 assert!(
2761 !errors.iter().any(|e| e.contains("targets terminal state")),
2762 "unexpected terminal-state error; got: {errors:?}"
2763 );
2764 }
2765
2766 #[test]
2769 fn trigger_uniqueness_two_manual_to_same_dest_ok() {
2770 let toml = r#"
2771[project]
2772name = "test"
2773
2774[tickets]
2775dir = "tickets"
2776
2777[[workflow.states]]
2778id = "a"
2779label = "A"
2780
2781[[workflow.states.transitions]]
2782to = "c"
2783trigger = "manual"
2784
2785[[workflow.states]]
2786id = "b"
2787label = "B"
2788
2789[[workflow.states.transitions]]
2790to = "c"
2791trigger = "manual"
2792
2793[[workflow.states]]
2794id = "c"
2795label = "C"
2796terminal = true
2797"#;
2798 let config = load_config(toml);
2799 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2800 assert!(
2801 !errors.iter().any(|e| e.contains("incoming transitions")),
2802 "two manual edges to same dest should not trigger Rule 1; got: {errors:?}"
2803 );
2804 }
2805
2806 #[test]
2807 fn trigger_uniqueness_command_start_plus_manual_same_dest_rejected() {
2808 let toml = r#"
2809[project]
2810name = "test"
2811
2812[tickets]
2813dir = "tickets"
2814
2815[[workflow.states]]
2816id = "src_start"
2817label = "Src Start"
2818
2819[[workflow.states.transitions]]
2820to = "dest"
2821trigger = "command:start"
2822
2823[[workflow.states]]
2824id = "src_manual"
2825label = "Src Manual"
2826
2827[[workflow.states.transitions]]
2828to = "dest"
2829trigger = "manual"
2830
2831[[workflow.states]]
2832id = "dest"
2833label = "Dest"
2834worker_profile = "claude/coder"
2835
2836[[workflow.states.transitions]]
2837to = "closed"
2838"#;
2839 let config = load_config(toml);
2840 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2841 let rule1_errors: Vec<&String> = errors.iter()
2842 .filter(|e| e.contains("incoming transitions"))
2843 .collect();
2844 assert!(
2845 !rule1_errors.is_empty(),
2846 "expected trigger-uniqueness error; got: {errors:?}"
2847 );
2848 let msg = rule1_errors[0];
2849 assert!(msg.contains("dest"), "expected dest in error: {msg}");
2850 assert!(msg.contains("src_start"), "expected src_start in error: {msg}");
2851 assert!(msg.contains("src_manual"), "expected src_manual in error: {msg}");
2852 }
2853
2854 #[test]
2855 fn trigger_uniqueness_two_command_start_same_dest_ok() {
2856 let toml = r#"
2857[project]
2858name = "test"
2859
2860[tickets]
2861dir = "tickets"
2862
2863[[workflow.states]]
2864id = "src_a"
2865label = "Src A"
2866
2867[[workflow.states.transitions]]
2868to = "dest"
2869trigger = "command:start"
2870
2871[[workflow.states]]
2872id = "src_b"
2873label = "Src B"
2874
2875[[workflow.states.transitions]]
2876to = "dest"
2877trigger = "command:start"
2878
2879[[workflow.states]]
2880id = "dest"
2881label = "Dest"
2882worker_profile = "claude/coder"
2883
2884[[workflow.states.transitions]]
2885to = "closed"
2886"#;
2887 let config = load_config(toml);
2888 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2889 let rule1_errors: Vec<&String> = errors.iter()
2890 .filter(|e| e.contains("triggered and manual"))
2891 .collect();
2892 assert!(
2893 rule1_errors.is_empty(),
2894 "two command:start incoming should pass — both imply dispatch; got: {errors:?}"
2895 );
2896 }
2897
2898 #[test]
2901 fn worker_profile_valid_passes() {
2902 let toml = r#"
2903[project]
2904name = "test"
2905
2906[tickets]
2907dir = "tickets"
2908
2909[[workflow.states]]
2910id = "active"
2911label = "Active"
2912worker_profile = "claude/coder"
2913
2914[[workflow.states.transitions]]
2915to = "closed"
2916"#;
2917 let config = load_config(toml);
2918 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2919 assert!(
2920 !errors.iter().any(|e| e.contains("worker_profile")),
2921 "valid worker_profile should not trigger Rule 2; got: {errors:?}"
2922 );
2923 }
2924
2925 #[test]
2926 fn worker_profile_reserved_role_rejected() {
2927 let toml = r#"
2928[project]
2929name = "test"
2930
2931[tickets]
2932dir = "tickets"
2933
2934[[workflow.states]]
2935id = "active"
2936label = "Active"
2937worker_profile = "claude/worker"
2938
2939[[workflow.states.transitions]]
2940to = "closed"
2941"#;
2942 let config = load_config(toml);
2943 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2944 assert!(
2945 errors.iter().any(|e| e.contains("worker_profile") && e.contains("worker")),
2946 "reserved role 'worker' should be rejected; got: {errors:?}"
2947 );
2948 }
2949
2950 #[test]
2951 fn worker_profile_no_slash_rejected() {
2952 let toml = r#"
2953[project]
2954name = "test"
2955
2956[tickets]
2957dir = "tickets"
2958
2959[[workflow.states]]
2960id = "active"
2961label = "Active"
2962worker_profile = "claudecoder"
2963
2964[[workflow.states.transitions]]
2965to = "closed"
2966"#;
2967 let config = load_config(toml);
2968 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2969 assert!(
2970 errors.iter().any(|e| e.contains("worker_profile") && e.contains("exactly one")),
2971 "missing slash should be rejected; got: {errors:?}"
2972 );
2973 }
2974
2975 #[test]
2976 fn worker_profile_empty_agent_rejected() {
2977 let toml = r#"
2978[project]
2979name = "test"
2980
2981[tickets]
2982dir = "tickets"
2983
2984[[workflow.states]]
2985id = "active"
2986label = "Active"
2987worker_profile = "/coder"
2988
2989[[workflow.states.transitions]]
2990to = "closed"
2991"#;
2992 let config = load_config(toml);
2993 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
2994 assert!(
2995 errors.iter().any(|e| e.contains("worker_profile") && e.contains("non-empty")),
2996 "empty agent component should be rejected; got: {errors:?}"
2997 );
2998 }
2999
3000 #[test]
3001 fn worker_profile_empty_role_rejected() {
3002 let toml = r#"
3003[project]
3004name = "test"
3005
3006[tickets]
3007dir = "tickets"
3008
3009[[workflow.states]]
3010id = "active"
3011label = "Active"
3012worker_profile = "claude/"
3013
3014[[workflow.states.transitions]]
3015to = "closed"
3016"#;
3017 let config = load_config(toml);
3018 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
3019 assert!(
3020 errors.iter().any(|e| e.contains("worker_profile") && e.contains("non-empty")),
3021 "empty role component should be rejected; got: {errors:?}"
3022 );
3023 }
3024
3025 #[test]
3028 fn command_start_missing_worker_profile_rejected() {
3029 let toml = r#"
3030[project]
3031name = "test"
3032
3033[tickets]
3034dir = "tickets"
3035
3036[[workflow.states]]
3037id = "src"
3038label = "Src"
3039
3040[[workflow.states.transitions]]
3041to = "dest"
3042trigger = "command:start"
3043
3044[[workflow.states]]
3045id = "dest"
3046label = "Dest"
3047
3048[[workflow.states.transitions]]
3049to = "closed"
3050"#;
3051 let config = load_config(toml);
3052 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
3053 assert!(
3054 errors.iter().any(|e| e.contains("dest") && e.contains("worker_profile")),
3055 "command:start to state with no worker_profile should be rejected; got: {errors:?}"
3056 );
3057 }
3058
3059 fn default_workflow_config() -> Config {
3062 let base = "[project]\nname = \"test\"\n\n[tickets]\ndir = \"tickets\"\n\n[workers]\ndefault = \"claude/coder\"\n\n[worktrees]\ndir = \"../wt\"\n";
3063 let combined = format!("{}\n{}", base, crate::init::default_workflow_toml());
3064 toml::from_str(&combined).expect("default workflow config parse failed")
3065 }
3066
3067 #[test]
3068 fn pre_validation_states_default_workflow() {
3069 let config = default_workflow_config();
3070 let pre = super::pre_validation_states("specd", &config.workflow.states);
3071 let expected: HashSet<&str> = ["new", "groomed", "in_design", "question"].iter().copied().collect();
3072 assert_eq!(pre, expected,
3073 "pre_validation_states should be exactly {{new, groomed, in_design, question}}; got: {pre:?}");
3074 assert!(!pre.contains("closed"), "closed must not appear in pre_validation_states");
3075 assert!(!pre.contains("specd"), "specd (barrier) must not appear in pre_validation_states");
3076 }
3077
3078 fn make_verify_ticket_with_sections(
3079 root: &std::path::Path,
3080 id: &str,
3081 state: &str,
3082 ) -> crate::ticket::Ticket {
3083 let raw = format!(
3085 "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n+++\n\n## Spec\n\n### Problem\n\n### Acceptance criteria\n\n### Out of scope\n\n### Approach\n\n## History\n"
3086 );
3087 let path = root.join("tickets").join(format!("{id}-test.md"));
3088 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3089 std::fs::write(&path, &raw).unwrap();
3090 crate::ticket::Ticket::parse(&path, &raw).unwrap()
3091 }
3092
3093 fn setup_verify_repo_with_validate_from_state() -> tempfile::TempDir {
3094 let dir = tempfile::tempdir().unwrap();
3095 let p = dir.path();
3096
3097 git_cmd(p, &["init", "-q", "-b", "main"]);
3098 git_cmd(p, &["config", "user.email", "test@test.com"]);
3099 git_cmd(p, &["config", "user.name", "test"]);
3100
3101 std::fs::create_dir_all(p.join(".apm")).unwrap();
3102 std::fs::write(
3103 p.join(".apm/config.toml"),
3104 r#"[project]
3105name = "test"
3106
3107[tickets]
3108dir = "tickets"
3109
3110[workers]
3111default = "claude/coder"
3112
3113[worktrees]
3114dir = "../wt"
3115
3116[[workflow.states]]
3117id = "new"
3118label = "New"
3119
3120 [[workflow.states.transitions]]
3121 to = "specd"
3122 trigger = "manual"
3123
3124[[workflow.states]]
3125id = "specd"
3126label = "Specd"
3127
3128 [[workflow.states.transitions]]
3129 to = "ready"
3130 trigger = "manual"
3131
3132[[workflow.states]]
3133id = "ready"
3134label = "Ready"
3135worker_profile = "claude/coder"
3136
3137 [[workflow.states.transitions]]
3138 to = "in_progress"
3139 trigger = "command:start"
3140
3141[[workflow.states]]
3142id = "in_progress"
3143label = "In Progress"
3144worker_profile = "claude/coder"
3145"#,
3146 )
3147 .unwrap();
3148
3149 std::fs::write(
3151 p.join(".apm/ticket.toml"),
3152 r#"[[ticket.sections]]
3153name = "Problem"
3154type = "free"
3155required = true
3156validate_from_state = "specd"
3157
3158[[ticket.sections]]
3159name = "Acceptance criteria"
3160type = "tasks"
3161required = true
3162validate_from_state = "specd"
3163
3164[[ticket.sections]]
3165name = "Out of scope"
3166type = "free"
3167required = true
3168validate_from_state = "specd"
3169
3170[[ticket.sections]]
3171name = "Approach"
3172type = "free"
3173required = true
3174validate_from_state = "specd"
3175"#,
3176 )
3177 .unwrap();
3178
3179 git_cmd(p, &["add", ".apm/config.toml", ".apm/ticket.toml"]);
3180 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
3181
3182 dir
3183 }
3184
3185 #[test]
3186 fn new_state_ticket_skips_required_section_checks() {
3187 let dir = setup_verify_repo_with_validate_from_state();
3188 let root = dir.path();
3189 let config = Config::load(root).unwrap();
3190 let ticket = make_verify_ticket_with_sections(root, "abcd1234", "new");
3191
3192 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
3193
3194 let section_errors: Vec<&String> = issues.iter()
3195 .filter(|i| i.contains("section is empty") || i.contains("has no checklist items"))
3196 .collect();
3197 assert!(
3198 section_errors.is_empty(),
3199 "new-state ticket should not report required-section errors; got: {section_errors:?}"
3200 );
3201 }
3202
3203 #[test]
3204 fn specd_state_ticket_reports_required_section_errors() {
3205 let dir = setup_verify_repo_with_validate_from_state();
3206 let root = dir.path();
3207 let config = Config::load(root).unwrap();
3208 let ticket = make_verify_ticket_with_sections(root, "abcd1234", "specd");
3209
3210 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
3211
3212 let section_errors: Vec<&String> = issues.iter()
3213 .filter(|i| i.contains("section is empty") || i.contains("has no checklist items"))
3214 .collect();
3215 assert!(
3216 !section_errors.is_empty(),
3217 "specd-state ticket with empty required sections should report errors; got: {issues:?}"
3218 );
3219 }
3220
3221 #[test]
3222 fn ready_state_ticket_reports_required_section_errors() {
3223 let dir = setup_verify_repo_with_validate_from_state();
3224 let root = dir.path();
3225 let config = Config::load(root).unwrap();
3226 let ticket = make_verify_ticket_with_sections(root, "abcd1234", "ready");
3227
3228 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
3229
3230 let section_errors: Vec<&String> = issues.iter()
3231 .filter(|i| i.contains("section is empty") || i.contains("has no checklist items"))
3232 .collect();
3233 assert!(
3234 !section_errors.is_empty(),
3235 "ready-state ticket with empty required sections should report errors; got: {issues:?}"
3236 );
3237 }
3238
3239 #[test]
3240 fn empty_tasks_section_error_uses_section_name() {
3241 use crate::ticket::ticket_fmt::ValidationError;
3244 let custom_name = "My checklist";
3245 let err = ValidationError::EmptyTasksSection(custom_name.to_string());
3246 let msg = err.to_string();
3247 assert!(
3248 msg.contains(custom_name),
3249 "EmptyTasksSection should include the section name '{custom_name}'; got: {msg}"
3250 );
3251 assert!(
3252 msg.contains("no checklist items"),
3253 "EmptyTasksSection message should include 'no checklist items'; got: {msg}"
3254 );
3255 assert!(
3257 !msg.contains("Acceptance criteria"),
3258 "EmptyTasksSection must not hardcode 'Acceptance criteria'; got: {msg}"
3259 );
3260 }
3261
3262 #[test]
3263 fn default_workflow_passes() {
3264 let toml = r#"
3269[project]
3270name = "test"
3271
3272[tickets]
3273dir = "tickets"
3274
3275[[workflow.states]]
3276id = "groomed"
3277label = "Groomed"
3278
3279[[workflow.states.transitions]]
3280to = "in_design"
3281trigger = "command:start"
3282outcome = "needs_input"
3283
3284[[workflow.states.transitions]]
3285to = "closed"
3286trigger = "manual"
3287outcome = "cancelled"
3288
3289[[workflow.states]]
3290id = "in_design"
3291label = "In Design"
3292worker_profile = "claude/spec-writer"
3293
3294[[workflow.states.transitions]]
3295to = "specd"
3296trigger = "manual"
3297outcome = "success"
3298
3299[[workflow.states]]
3300id = "specd"
3301label = "Specd"
3302
3303[[workflow.states.transitions]]
3304to = "ready"
3305trigger = "manual"
3306outcome = "needs_input"
3307
3308[[workflow.states]]
3309id = "ready"
3310label = "Ready"
3311
3312[[workflow.states.transitions]]
3313to = "in_progress"
3314trigger = "command:start"
3315outcome = "needs_input"
3316
3317[[workflow.states.transitions]]
3318to = "closed"
3319trigger = "manual"
3320outcome = "cancelled"
3321
3322[[workflow.states]]
3323id = "in_progress"
3324label = "In Progress"
3325worker_profile = "claude/coder"
3326
3327[[workflow.states.transitions]]
3328to = "closed"
3329trigger = "manual"
3330outcome = "cancelled"
3331"#;
3332 let config = load_config(toml);
3333 let errors = validate_config_no_agents(&config, Path::new("/tmp"));
3334 let new_rule_errors: Vec<&String> = errors.iter()
3335 .filter(|e| {
3336 e.contains("incoming transitions")
3337 || e.contains("worker_profile")
3338 || (e.contains("command:start") && e.contains("worker_profile"))
3339 })
3340 .collect();
3341 assert!(
3342 new_rule_errors.is_empty(),
3343 "default workflow structure should pass all new rules; got: {new_rule_errors:?}"
3344 );
3345 }
3346}