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