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