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