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 std::collections::HashSet;
7use std::path::Path;
8
9pub fn active_completion_strategy(config: &Config) -> CompletionStrategy {
12 config.workflow.states.iter()
13 .find(|s| s.id == "in_progress")
14 .and_then(|s| s.transitions.iter().find(|t| t.to == "implemented"))
15 .map(|t| t.completion.clone())
16 .unwrap_or(CompletionStrategy::None)
17}
18
19fn strategy_name(strategy: &CompletionStrategy) -> &'static str {
20 match strategy {
21 CompletionStrategy::Pr => "pr",
22 CompletionStrategy::Merge => "merge",
23 CompletionStrategy::Pull => "pull",
24 CompletionStrategy::PrOrEpicMerge => "pr_or_epic_merge",
25 CompletionStrategy::None => "none",
26 }
27}
28
29pub fn check_depends_on_rules(
37 strategy: &CompletionStrategy,
38 ticket_epic: Option<&str>,
39 ticket_target_branch: Option<&str>,
40 dep_ids: &[String],
41 all_tickets: &[crate::ticket_fmt::Ticket],
42 default_branch: &str,
43) -> Result<()> {
44 if dep_ids.is_empty() {
45 return Ok(());
46 }
47 match strategy {
48 CompletionStrategy::Pr | CompletionStrategy::None | CompletionStrategy::Pull => {
49 bail!(
50 "depends_on is not allowed under the {} completion strategy",
51 strategy_name(strategy)
52 );
53 }
54 CompletionStrategy::PrOrEpicMerge => {
55 let Some(epic) = ticket_epic else {
56 bail!(
57 "pr_or_epic_merge requires the ticket to belong to an epic before depends_on can be set"
58 );
59 };
60 let mut offending: Vec<&str> = Vec::new();
61 for dep_id in dep_ids {
62 let dep = all_tickets.iter().find(|t| t.frontmatter.id == *dep_id)
63 .ok_or_else(|| anyhow::anyhow!("dep {dep_id} not found"))?;
64 if dep.frontmatter.epic.as_deref() != Some(epic) {
65 offending.push(dep_id.as_str());
66 }
67 }
68 if !offending.is_empty() {
69 bail!(
70 "pr_or_epic_merge requires all deps to share epic {epic}; offending deps: {}",
71 offending.join(", ")
72 );
73 }
74 }
75 CompletionStrategy::Merge => {
76 let ticket_target = ticket_target_branch.unwrap_or(default_branch);
77 let mut offending: Vec<&str> = Vec::new();
78 for dep_id in dep_ids {
79 let dep = all_tickets.iter().find(|t| t.frontmatter.id == *dep_id)
80 .ok_or_else(|| anyhow::anyhow!("dep {dep_id} not found"))?;
81 let dep_target = dep.frontmatter.target_branch.as_deref().unwrap_or(default_branch);
82 if dep_target != ticket_target {
83 offending.push(dep_id.as_str());
84 }
85 }
86 if !offending.is_empty() {
87 bail!(
88 "merge requires all deps to share target_branch {ticket_target}; offending deps: {}",
89 offending.join(", ")
90 );
91 }
92 }
93 }
94 Ok(())
95}
96
97pub fn validate_depends_on(config: &Config, tickets: &[Ticket]) -> Vec<(String, String)> {
100 let strategy = active_completion_strategy(config);
101 let mut violations: Vec<(String, String)> = Vec::new();
102 for ticket in tickets {
103 let fm = &ticket.frontmatter;
104 if fm.state == "closed" {
105 continue;
106 }
107 let dep_ids = match &fm.depends_on {
108 Some(deps) if !deps.is_empty() => deps,
109 _ => continue,
110 };
111 if let Err(e) = check_depends_on_rules(
112 &strategy,
113 fm.epic.as_deref(),
114 fm.target_branch.as_deref(),
115 dep_ids,
116 tickets,
117 &config.project.default_branch,
118 ) {
119 violations.push((format!("#{}", fm.id), e.to_string()));
120 }
121 }
122 violations
123}
124
125pub fn validate_owner(config: &Config, local: &LocalConfig, username: &str) -> Result<()> {
126 if username == "-" {
127 return Ok(());
128 }
129 let (collaborators, warnings) = crate::config::resolve_collaborators(config, local);
130 for w in &warnings {
131 #[allow(clippy::print_stderr)]
132 { eprintln!("{w}"); }
133 }
134 if collaborators.is_empty() {
135 return Ok(());
136 }
137 if collaborators.iter().any(|c| c == username) {
138 return Ok(());
139 }
140 let list = collaborators.join(", ");
141 bail!("unknown user '{username}'; valid collaborators: {list}");
142}
143
144fn is_external_worktree(dir: &Path) -> bool {
145 let s = dir.to_string_lossy();
146 s.starts_with('/') || s.starts_with("..")
147}
148
149fn gitignore_covers_dir(content: &str, dir: &str) -> bool {
150 let normalized_dir = dir.trim_matches('/');
151 content
152 .lines()
153 .map(|line| line.trim())
154 .filter(|line| !line.is_empty() && !line.starts_with('#'))
155 .any(|line| line.trim_matches('/') == normalized_dir)
156}
157
158pub fn validate_agents(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
164 let mut errors: Vec<String> = Vec::new();
165 let mut warnings: Vec<String> = Vec::new();
166 validate_agents_into(config, root, &mut errors, &mut warnings);
167 (errors, warnings)
168}
169
170fn validate_agents_into(config: &Config, root: &Path, errors: &mut Vec<String>, warnings: &mut Vec<String>) {
171 let mut names: std::collections::HashSet<String> = std::collections::HashSet::new();
173 let primary = config.workers.agent.clone()
174 .unwrap_or_else(|| "claude".to_string());
175 names.insert(primary);
176 for p in config.worker_profiles.values() {
177 if let Some(ref agent) = p.agent {
178 names.insert(agent.clone());
179 }
180 }
181
182 let builtins = wrapper::list_builtin_names().join(", ");
184 for name in &names {
185 match wrapper::resolve_wrapper(root, name) {
186 Ok(None) => errors.push(format!(
187 "agent '{}' not found: checked built-ins {{{builtins}}} and '.apm/agents/{}/'",
188 name, name
189 )),
190 Err(e) => errors.push(format!("agent '{name}': {e}")),
191 Ok(Some(wrapper::WrapperKind::Custom { manifest, .. })) => {
192 if let Some(m) = &manifest {
193 if m.parser == "external" && m.parser_command.is_none() {
194 errors.push(format!(
195 "agent '{name}': manifest.toml declares parser = \"external\" \
196 but parser_command is absent"
197 ));
198 }
199 }
200 }
201 Ok(Some(wrapper::WrapperKind::Builtin(_))) => {}
202 }
203 }
204
205 let agents_dir = root.join(".apm").join("agents");
207 let Ok(entries) = std::fs::read_dir(&agents_dir) else { return };
208
209 for entry in entries.filter_map(|e| e.ok()) {
210 let ft = match entry.file_type() {
211 Ok(ft) => ft,
212 Err(_) => continue,
213 };
214 if !ft.is_dir() {
215 continue;
216 }
217 let name = entry.file_name().to_string_lossy().to_string();
218
219 let wrapper_files: Vec<_> = std::fs::read_dir(entry.path())
221 .ok()
222 .into_iter()
223 .flatten()
224 .filter_map(|e| e.ok())
225 .filter(|e| e.file_name().to_string_lossy().starts_with("wrapper."))
226 .collect();
227
228 if !wrapper_files.is_empty() {
229 #[cfg(unix)]
230 {
231 use std::os::unix::fs::PermissionsExt;
232 let any_exec = wrapper_files.iter().any(|f| {
233 f.metadata()
234 .map(|m| m.permissions().mode() & 0o111 != 0)
235 .unwrap_or(false)
236 });
237 if !any_exec {
238 warnings.push(format!(
239 "agent '{name}': .apm/agents/{name}/wrapper.* exists but is not executable; run chmod +x"
240 ));
241 }
242 }
243 }
244
245 let manifest_path = entry.path().join("manifest.toml");
247 if manifest_path.exists() {
248 match parse_manifest(root, &name) {
249 Err(e) => {
250 errors.push(format!("agent '{name}': manifest.toml is not valid TOML: {e}"));
251 }
252 Ok(Some(manifest)) => {
253 if manifest.contract_version > 1 {
254 errors.push(format!(
255 "agent '{name}': manifest.toml declares contract_version {}; \
256 this APM build supports version 1 only — upgrade APM",
257 manifest.contract_version
258 ));
259 }
260 if let Ok(unknown) = manifest_unknown_keys(root, &name) {
261 for key in unknown {
262 warnings.push(format!(
263 "agent '{name}': manifest.toml: unknown key {key}"
264 ));
265 }
266 }
267 }
268 Ok(None) => {}
269 }
270 }
271 }
272}
273
274pub fn validate_config(config: &Config, root: &Path) -> Vec<String> {
275 let mut errors = validate_config_no_agents(config, root);
276 let (agent_errors, _) = validate_agents(config, root);
277 errors.extend(agent_errors);
278 errors
279}
280
281fn validate_config_no_agents(config: &Config, root: &Path) -> Vec<String> {
282 let mut errors: Vec<String> = Vec::new();
283
284 let state_ids: HashSet<&str> = config.workflow.states.iter()
285 .map(|s| s.id.as_str())
286 .collect();
287
288 let section_names: HashSet<&str> = config.ticket.sections.iter()
289 .map(|s| s.name.as_str())
290 .collect();
291 let has_sections = !section_names.is_empty();
292
293 let needs_provider = config.workflow.states.iter()
295 .flat_map(|s| s.transitions.iter())
296 .any(|t| matches!(t.completion, CompletionStrategy::Pr | CompletionStrategy::Merge));
297
298 let provider_ok = config.git_host.provider.as_ref()
299 .map(|p| !p.is_empty())
300 .unwrap_or(false);
301
302 if needs_provider && !provider_ok {
303 errors.push(
304 "config: workflow — completion 'pr' or 'merge' requires [git_host] with a provider".into()
305 );
306 }
307
308 let has_non_terminal = config.workflow.states.iter().any(|s| !s.terminal);
310 if !has_non_terminal {
311 errors.push("config: workflow — no non-terminal state exists".into());
312 }
313
314 for state in &config.workflow.states {
315 if state.terminal && !state.transitions.is_empty() {
317 errors.push(format!(
318 "config: state.{} — terminal but has {} outgoing transition(s)",
319 state.id,
320 state.transitions.len()
321 ));
322 }
323
324 if !state.terminal && state.transitions.is_empty() {
326 errors.push(format!(
327 "config: state.{} — no outgoing transitions (tickets will be stranded)",
328 state.id
329 ));
330 }
331
332 if let Some(instructions) = &state.instructions {
334 if !root.join(instructions).exists() {
335 errors.push(format!(
336 "config: state.{}.instructions — file not found: {}",
337 state.id, instructions
338 ));
339 }
340 }
341
342 for transition in &state.transitions {
343 if transition.to != "closed" && !state_ids.contains(transition.to.as_str()) {
346 errors.push(format!(
347 "config: state.{}.transition({}) — target state '{}' does not exist",
348 state.id, transition.to, transition.to
349 ));
350 }
351
352 if let Some(section) = &transition.context_section {
354 if has_sections && !section_names.contains(section.as_str()) {
355 errors.push(format!(
356 "config: state.{}.transition({}).context_section — unknown section '{}'",
357 state.id, transition.to, section
358 ));
359 }
360 }
361
362 if let Some(section) = &transition.focus_section {
364 if has_sections && !section_names.contains(section.as_str()) {
365 errors.push(format!(
366 "config: state.{}.transition({}).focus_section — unknown section '{}'",
367 state.id, transition.to, section
368 ));
369 }
370 }
371
372 if matches!(
374 transition.completion,
375 CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
376 ) {
377 if transition.on_failure.is_none() {
378 errors.push(format!(
379 "config: transition '{}' → '{}' uses completion '{}' but is missing \
380 `on_failure`; run `apm validate --fix` to add it",
381 state.id,
382 transition.to,
383 strategy_name(&transition.completion)
384 ));
385 } else if let Some(ref name) = transition.on_failure {
386 if name != "closed" && !state_ids.contains(name.as_str()) {
387 errors.push(format!(
388 "config: transition '{}' → '{}' has `on_failure = \"{}\"` but \
389 state \"{}\" is not declared in workflow.toml",
390 state.id, transition.to, name, name
391 ));
392 }
393 }
394 }
395 }
396 }
397
398 if let Some(ref path) = config.workers.instructions {
399 if !root.join(path).exists() {
400 errors.push(format!(
401 "config: [workers].instructions — file not found: {path}"
402 ));
403 }
404 }
405
406 if !is_external_worktree(&config.worktrees.dir) {
407 let dir_str = config.worktrees.dir.to_string_lossy();
408 let gitignore = root.join(".gitignore");
409 match std::fs::read_to_string(&gitignore) {
410 Err(_) => errors.push(format!(
411 "config: worktrees.dir '{dir_str}' is in-repo but .gitignore is missing; \
412 run 'apm init' or add '/{dir_str}/' manually"
413 )),
414 Ok(content) if !gitignore_covers_dir(&content, &dir_str) => errors.push(format!(
415 "config: worktrees.dir '{dir_str}' is in-repo but .gitignore does not cover it; \
416 add '/{dir_str}/' or run 'apm init'"
417 )),
418 Ok(_) => {}
419 }
420 }
421
422 errors
423}
424
425pub fn verify_tickets(
426 root: &Path,
427 config: &Config,
428 tickets: &[Ticket],
429 merged: &HashSet<String>,
430) -> Vec<String> {
431 let valid_states: HashSet<&str> = config.workflow.states.iter()
432 .map(|s| s.id.as_str())
433 .collect();
434 let terminal = config.terminal_state_ids();
435
436 let in_progress_states: HashSet<&str> =
437 ["in_progress", "implemented"].iter().copied().collect();
438
439 let worktree_states: HashSet<&str> =
440 ["in_design", "in_progress"].iter().copied().collect();
441 let main_root = crate::git_util::main_worktree_root(root)
442 .unwrap_or_else(|| root.to_path_buf());
443 let worktrees_base = main_root.join(&config.worktrees.dir);
444
445 let mut issues: Vec<String> = Vec::new();
446
447 for t in tickets {
448 let fm = &t.frontmatter;
449
450 if terminal.contains(fm.state.as_str()) { continue; }
452
453 let prefix = format!("#{} [{}]", fm.id, fm.state);
454
455 if !valid_states.is_empty() && !valid_states.contains(fm.state.as_str()) {
457 issues.push(format!("{prefix}: unknown state {:?}", fm.state));
458 }
459
460 if let Some(name) = t.path.file_name().and_then(|n| n.to_str()) {
462 let expected_prefix = format!("{:04}", fm.id);
463 if !name.starts_with(&expected_prefix) {
464 issues.push(format!("{prefix}: id {} does not match filename {name}", fm.id));
465 }
466 }
467
468 if in_progress_states.contains(fm.state.as_str()) && fm.branch.is_none() {
470 issues.push(format!("{prefix}: state requires branch but none set"));
471 }
472
473 if let Some(branch) = &fm.branch {
475 if (fm.state == "in_progress" || fm.state == "implemented")
476 && merged.contains(branch.as_str())
477 {
478 issues.push(format!("{prefix}: branch {branch} is merged but ticket not closed"));
479 }
480 }
481
482 if worktree_states.contains(fm.state.as_str()) {
484 if let Some(branch) = &fm.branch {
485 let wt_name = branch.replace('/', "-");
486 let wt_path = worktrees_base.join(&wt_name);
487 if !wt_path.is_dir() {
488 issues.push(format!(
489 "{prefix}: worktree at {} is missing",
490 wt_path.display()
491 ));
492 }
493 }
494 }
495
496 if !t.body.contains("## Spec") {
498 issues.push(format!("{prefix}: missing ## Spec section"));
499 }
500
501 if !t.body.contains("## History") {
503 issues.push(format!("{prefix}: missing ## History section"));
504 }
505
506 if let Ok(doc) = t.document() {
508 for err in doc.validate(&config.ticket.sections) {
509 issues.push(format!("{prefix}: {err}"));
510 }
511 }
512
513 let agents_to_check: Vec<&str> = fm.agent
515 .as_deref()
516 .into_iter()
517 .chain(fm.agent_overrides.values().map(String::as_str))
518 .collect();
519
520 for name in agents_to_check {
521 match wrapper::resolve_wrapper(root, name) {
522 Ok(Some(_)) => {}
523 Ok(None) => issues.push(format!(
524 "ticket {}: agent {:?} is not a known built-in",
525 fm.id, name
526 )),
527 Err(e) => issues.push(format!(
528 "ticket {}: agent {:?}: {e}",
529 fm.id, name
530 )),
531 }
532 }
533 }
534
535 issues
536}
537
538pub fn validate_warnings(config: &crate::config::Config, root: &Path) -> Vec<String> {
539 let mut warnings = validate_warnings_no_agents(config, root);
540 let (_, agent_warnings) = validate_agents(config, root);
541 warnings.extend(agent_warnings);
542 warnings
543}
544
545fn validate_warnings_no_agents(config: &crate::config::Config, _root: &Path) -> Vec<String> {
546 let mut warnings = config.load_warnings.clone();
547 if let Some(container) = &config.workers.container {
548 if !container.is_empty() {
549 let docker_ok = std::process::Command::new("docker")
550 .arg("--version")
551 .output()
552 .map(|o| o.status.success())
553 .unwrap_or(false);
554 if !docker_ok {
555 warnings.push(
556 "workers.container is set but 'docker' is not in PATH".to_string()
557 );
558 }
559 }
560 }
561
562 let state_map: std::collections::HashMap<&str, &crate::config::StateConfig> =
565 config.workflow.states.iter()
566 .map(|s| (s.id.as_str(), s))
567 .collect();
568
569 let agent_startable: Vec<&str> = config.workflow.states.iter()
570 .filter(|s| s.actionable.iter().any(|a| a == "agent" || a == "any"))
571 .map(|s| s.id.as_str())
572 .collect();
573
574 if !agent_startable.is_empty() {
575 let mut visited: std::collections::HashSet<&str> = std::collections::HashSet::new();
576 let mut queue: std::collections::VecDeque<&str> = std::collections::VecDeque::new();
577 let mut found_success = false;
578
579 for &start in &agent_startable {
580 if visited.insert(start) {
581 queue.push_back(start);
582 }
583 }
584
585 'bfs: while let Some(state_id) = queue.pop_front() {
586 let Some(state) = state_map.get(state_id) else { continue };
587 for t in &state.transitions {
588 let Some(&target) = state_map.get(t.to.as_str()) else { continue };
591 if resolve_outcome(t, target) == "success" {
592 found_success = true;
593 break 'bfs;
594 }
595 if !target.terminal && visited.insert(t.to.as_str()) {
596 queue.push_back(t.to.as_str());
597 }
598 }
599 }
600
601 if !found_success {
602 warnings.push(
603 "workflow has no reachable 'success' outcome from any agent-actionable state; \
604 workers may never complete successfully".to_string()
605 );
606 }
607 }
608
609 warnings
610}
611
612pub fn validate_all(config: &Config, root: &Path) -> (Vec<String>, Vec<String>) {
615 let mut errors = validate_config_no_agents(config, root);
616 let mut warnings = validate_warnings_no_agents(config, root);
617 let (agent_errors, agent_warnings) = validate_agents(config, root);
618 errors.extend(agent_errors);
619 warnings.extend(agent_warnings);
620 (errors, warnings)
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626 use crate::config::{Config, CompletionStrategy, LocalConfig};
627 use crate::ticket::Ticket;
628 use crate::git_util;
629 use std::path::Path;
630 use std::collections::HashSet;
631
632 fn git_cmd(dir: &std::path::Path, args: &[&str]) {
633 std::process::Command::new("git")
634 .args(args)
635 .current_dir(dir)
636 .env("GIT_AUTHOR_NAME", "test")
637 .env("GIT_AUTHOR_EMAIL", "test@test.com")
638 .env("GIT_COMMITTER_NAME", "test")
639 .env("GIT_COMMITTER_EMAIL", "test@test.com")
640 .status()
641 .unwrap();
642 }
643
644 fn setup_verify_repo() -> tempfile::TempDir {
645 let dir = tempfile::tempdir().unwrap();
646 let p = dir.path();
647
648 git_cmd(p, &["init", "-q", "-b", "main"]);
649 git_cmd(p, &["config", "user.email", "test@test.com"]);
650 git_cmd(p, &["config", "user.name", "test"]);
651
652 std::fs::create_dir_all(p.join(".apm")).unwrap();
653 std::fs::write(
654 p.join(".apm/config.toml"),
655 r#"[project]
656name = "test"
657
658[tickets]
659dir = "tickets"
660
661[worktrees]
662dir = "worktrees"
663
664[[workflow.states]]
665id = "in_design"
666label = "In Design"
667
668[[workflow.states]]
669id = "in_progress"
670label = "In Progress"
671
672[[workflow.states]]
673id = "specd"
674label = "Specd"
675"#,
676 )
677 .unwrap();
678
679 git_cmd(p, &["add", ".apm/config.toml"]);
680 git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
681
682 dir
683 }
684
685 fn make_verify_ticket(root: &std::path::Path, id: &str, state: &str, branch: Option<&str>) -> Ticket {
686 let branch_line = match branch {
687 Some(b) => format!("branch = \"{b}\"\n"),
688 None => String::new(),
689 };
690 let raw = format!(
691 "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{branch_line}+++\n\n## Spec\n\n## History\n"
692 );
693 let path = root.join("tickets").join(format!("{id}-test.md"));
694 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
695 std::fs::write(&path, &raw).unwrap();
696 Ticket::parse(&path, &raw).unwrap()
697 }
698
699 fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
700 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
701 let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
702 let raw = format!(
703 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
704 );
705 Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
706 }
707
708 fn strategy_config(completion: &str) -> Config {
709 let toml = format!(
710 r#"
711[project]
712name = "test"
713
714[tickets]
715dir = "tickets"
716
717[[workflow.states]]
718id = "in_progress"
719label = "In Progress"
720
721[[workflow.states.transitions]]
722to = "implemented"
723completion = "{completion}"
724
725[[workflow.states]]
726id = "implemented"
727label = "Implemented"
728terminal = true
729"#
730 );
731 toml::from_str(&toml).unwrap()
732 }
733
734 #[test]
735 fn strategy_finds_in_progress_to_implemented() {
736 let config = strategy_config("pr_or_epic_merge");
737 assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
738 }
739
740 #[test]
741 fn strategy_defaults_to_none_when_absent() {
742 let toml = r#"
743[project]
744name = "test"
745
746[tickets]
747dir = "tickets"
748
749[[workflow.states]]
750id = "new"
751label = "New"
752
753[[workflow.states.transitions]]
754to = "closed"
755
756[[workflow.states]]
757id = "closed"
758label = "Closed"
759terminal = true
760"#;
761 let config: Config = toml::from_str(toml).unwrap();
762 assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
763 }
764
765 #[test]
766 fn dep_rules_pr_rejects_dep() {
767 let dep = make_ticket("dep1", None, None);
768 let result = check_depends_on_rules(
769 &CompletionStrategy::Pr,
770 None,
771 None,
772 &["dep1".to_string()],
773 &[dep],
774 "main",
775 );
776 assert!(result.is_err());
777 let msg = result.unwrap_err().to_string();
778 assert!(msg.contains("pr"), "expected strategy name in: {msg}");
779 }
780
781 #[test]
782 fn dep_rules_none_rejects_dep() {
783 let dep = make_ticket("dep1", None, None);
784 let result = check_depends_on_rules(
785 &CompletionStrategy::None,
786 None,
787 None,
788 &["dep1".to_string()],
789 &[dep],
790 "main",
791 );
792 assert!(result.is_err());
793 let msg = result.unwrap_err().to_string();
794 assert!(msg.contains("none"), "expected strategy name in: {msg}");
795 }
796
797 #[test]
798 fn dep_rules_pr_or_epic_merge_same_epic_ok() {
799 let dep = make_ticket("dep1", Some("abc"), None);
800 let result = check_depends_on_rules(
801 &CompletionStrategy::PrOrEpicMerge,
802 Some("abc"),
803 None,
804 &["dep1".to_string()],
805 &[dep],
806 "main",
807 );
808 assert!(result.is_ok(), "expected Ok, got {result:?}");
809 }
810
811 #[test]
812 fn dep_rules_pr_or_epic_merge_different_epic_fails() {
813 let dep = make_ticket("dep1", Some("xyz"), None);
814 let result = check_depends_on_rules(
815 &CompletionStrategy::PrOrEpicMerge,
816 Some("abc"),
817 None,
818 &["dep1".to_string()],
819 &[dep],
820 "main",
821 );
822 assert!(result.is_err());
823 let msg = result.unwrap_err().to_string();
824 assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
825 }
826
827 #[test]
828 fn dep_rules_pr_or_epic_merge_ticket_no_epic_fails() {
829 let dep = make_ticket("dep1", Some("abc"), None);
830 let result = check_depends_on_rules(
831 &CompletionStrategy::PrOrEpicMerge,
832 None,
833 None,
834 &["dep1".to_string()],
835 &[dep],
836 "main",
837 );
838 assert!(result.is_err());
839 let msg = result.unwrap_err().to_string();
840 assert!(msg.contains("epic"), "expected epic mention in: {msg}");
841 }
842
843 #[test]
844 fn dep_rules_merge_both_default_branch_ok() {
845 let dep = make_ticket("dep1", None, None);
846 let result = check_depends_on_rules(
847 &CompletionStrategy::Merge,
848 None,
849 None,
850 &["dep1".to_string()],
851 &[dep],
852 "main",
853 );
854 assert!(result.is_ok(), "expected Ok, got {result:?}");
855 }
856
857 #[test]
858 fn dep_rules_merge_different_target_fails() {
859 let dep = make_ticket("dep1", None, Some("epic/other"));
860 let result = check_depends_on_rules(
861 &CompletionStrategy::Merge,
862 None,
863 None,
864 &["dep1".to_string()],
865 &[dep],
866 "main",
867 );
868 assert!(result.is_err());
869 let msg = result.unwrap_err().to_string();
870 assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
871 }
872
873 fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
874 let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
875 let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
876 let deps_line = if depends_on.is_empty() {
877 String::new()
878 } else {
879 let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
880 format!("depends_on = [{}]\n", quoted.join(", "))
881 };
882 let raw = format!(
883 "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
884 );
885 Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
886 }
887
888 #[test]
889 fn validate_depends_on_no_deps_clean() {
890 let config = strategy_config("pr_or_epic_merge");
891 let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
892 let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
893 let result = validate_depends_on(&config, &[t1, t2]);
894 assert!(result.is_empty(), "expected no violations, got {result:?}");
895 }
896
897 #[test]
898 fn validate_depends_on_closed_ticket_skipped() {
899 let config = strategy_config("pr");
900 let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
901 let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
902 let result = validate_depends_on(&config, &[dep, ticket]);
903 assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
904 }
905
906 #[test]
907 fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
908 let config = strategy_config("pr_or_epic_merge");
909 let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
910 let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
911 let result = validate_depends_on(&config, &[dep, ticket]);
912 assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
913 }
914
915 #[test]
916 fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
917 let config = strategy_config("pr_or_epic_merge");
918 let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
919 let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
920 let result = validate_depends_on(&config, &[dep, ticket]);
921 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
922 assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
923 }
924
925 #[test]
926 fn validate_depends_on_merge_same_target_ok() {
927 let config = strategy_config("merge");
928 let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
929 let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
930 let result = validate_depends_on(&config, &[dep, ticket]);
931 assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
932 }
933
934 #[test]
935 fn validate_depends_on_merge_different_target_fails() {
936 let config = strategy_config("merge");
937 let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
938 let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
939 let result = validate_depends_on(&config, &[dep, ticket]);
940 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
941 assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
942 }
943
944 #[test]
945 fn validate_depends_on_pr_strategy_rejects_any_dep() {
946 let config = strategy_config("pr");
947 let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
948 let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
949 let result = validate_depends_on(&config, &[dep, ticket]);
950 assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
951 assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
952 }
953
954 fn load_config(toml: &str) -> Config {
955 toml::from_str(toml).expect("config parse failed")
956 }
957
958 fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
959 config.workflow.states.iter().map(|s| s.id.as_str()).collect()
960 }
961
962 #[test]
964 fn correct_config_passes() {
965 let toml = r#"
966[project]
967name = "test"
968
969[tickets]
970dir = "tickets"
971
972[[workflow.states]]
973id = "new"
974label = "New"
975
976[[workflow.states.transitions]]
977to = "in_progress"
978
979[[workflow.states]]
980id = "in_progress"
981label = "In Progress"
982terminal = false
983
984[[workflow.states.transitions]]
985to = "closed"
986
987[[workflow.states]]
988id = "closed"
989label = "Closed"
990terminal = true
991"#;
992 let config = load_config(toml);
993 let errors = validate_config(&config, Path::new("/tmp"));
994 assert!(errors.is_empty(), "unexpected errors: {errors:?}");
995 }
996
997 #[test]
999 fn transition_to_nonexistent_state_detected() {
1000 let toml = r#"
1001[project]
1002name = "test"
1003
1004[tickets]
1005dir = "tickets"
1006
1007[[workflow.states]]
1008id = "new"
1009label = "New"
1010
1011[[workflow.states.transitions]]
1012to = "ghost"
1013"#;
1014 let config = load_config(toml);
1015 let errors = validate_config(&config, Path::new("/tmp"));
1016 assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
1017 }
1018
1019 #[test]
1021 fn terminal_state_with_transitions_detected() {
1022 let toml = r#"
1023[project]
1024name = "test"
1025
1026[tickets]
1027dir = "tickets"
1028
1029[[workflow.states]]
1030id = "closed"
1031label = "Closed"
1032terminal = true
1033
1034[[workflow.states.transitions]]
1035to = "new"
1036
1037[[workflow.states]]
1038id = "new"
1039label = "New"
1040
1041[[workflow.states.transitions]]
1042to = "closed"
1043"#;
1044 let config = load_config(toml);
1045 let errors = validate_config(&config, Path::new("/tmp"));
1046 assert!(
1047 errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
1048 "expected terminal error in {errors:?}"
1049 );
1050 }
1051
1052 #[test]
1054 fn ticket_with_unknown_state_detected() {
1055 use crate::ticket::Ticket;
1056
1057 let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
1058 let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1059
1060 let known_states: std::collections::HashSet<&str> =
1061 ["new", "ready", "closed"].iter().copied().collect();
1062
1063 assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
1064 }
1065
1066 #[test]
1068 fn dead_end_non_terminal_detected() {
1069 let toml = r#"
1070[project]
1071name = "test"
1072
1073[tickets]
1074dir = "tickets"
1075
1076[[workflow.states]]
1077id = "stuck"
1078label = "Stuck"
1079
1080[[workflow.states]]
1081id = "closed"
1082label = "Closed"
1083terminal = true
1084"#;
1085 let config = load_config(toml);
1086 let errors = validate_config(&config, Path::new("/tmp"));
1087 assert!(
1088 errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
1089 "expected dead-end error in {errors:?}"
1090 );
1091 }
1092
1093 #[test]
1095 fn context_section_mismatch_detected() {
1096 let toml = r#"
1097[project]
1098name = "test"
1099
1100[tickets]
1101dir = "tickets"
1102
1103[[ticket.sections]]
1104name = "Problem"
1105type = "free"
1106
1107[[workflow.states]]
1108id = "new"
1109label = "New"
1110
1111[[workflow.states.transitions]]
1112to = "ready"
1113context_section = "NonExistent"
1114
1115[[workflow.states]]
1116id = "ready"
1117label = "Ready"
1118
1119[[workflow.states.transitions]]
1120to = "closed"
1121
1122[[workflow.states]]
1123id = "closed"
1124label = "Closed"
1125terminal = true
1126"#;
1127 let config = load_config(toml);
1128 let errors = validate_config(&config, Path::new("/tmp"));
1129 assert!(
1130 errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
1131 "expected context_section error in {errors:?}"
1132 );
1133 }
1134
1135 #[test]
1137 fn focus_section_mismatch_detected() {
1138 let toml = r#"
1139[project]
1140name = "test"
1141
1142[tickets]
1143dir = "tickets"
1144
1145[[ticket.sections]]
1146name = "Problem"
1147type = "free"
1148
1149[[workflow.states]]
1150id = "new"
1151label = "New"
1152
1153[[workflow.states.transitions]]
1154to = "ready"
1155focus_section = "BadSection"
1156
1157[[workflow.states]]
1158id = "ready"
1159label = "Ready"
1160
1161[[workflow.states.transitions]]
1162to = "closed"
1163
1164[[workflow.states]]
1165id = "closed"
1166label = "Closed"
1167terminal = true
1168"#;
1169 let config = load_config(toml);
1170 let errors = validate_config(&config, Path::new("/tmp"));
1171 assert!(
1172 errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
1173 "expected focus_section error in {errors:?}"
1174 );
1175 }
1176
1177 #[test]
1179 fn completion_pr_without_provider_detected() {
1180 let toml = r#"
1181[project]
1182name = "test"
1183
1184[tickets]
1185dir = "tickets"
1186
1187[[workflow.states]]
1188id = "new"
1189label = "New"
1190
1191[[workflow.states.transitions]]
1192to = "closed"
1193completion = "pr"
1194
1195[[workflow.states]]
1196id = "closed"
1197label = "Closed"
1198terminal = true
1199"#;
1200 let config = load_config(toml);
1201 let errors = validate_config(&config, Path::new("/tmp"));
1202 assert!(
1203 errors.iter().any(|e| e.contains("provider")),
1204 "expected provider error in {errors:?}"
1205 );
1206 }
1207
1208 #[test]
1210 fn completion_pr_with_provider_passes() {
1211 let toml = r#"
1212[project]
1213name = "test"
1214
1215[tickets]
1216dir = "tickets"
1217
1218[git_host]
1219provider = "github"
1220
1221[[workflow.states]]
1222id = "new"
1223label = "New"
1224
1225[[workflow.states.transitions]]
1226to = "closed"
1227completion = "pr"
1228
1229[[workflow.states]]
1230id = "closed"
1231label = "Closed"
1232terminal = true
1233"#;
1234 let config = load_config(toml);
1235 let errors = validate_config(&config, Path::new("/tmp"));
1236 assert!(
1237 !errors.iter().any(|e| e.contains("provider")),
1238 "unexpected provider error in {errors:?}"
1239 );
1240 }
1241
1242 #[test]
1244 fn context_section_skipped_when_no_sections_defined() {
1245 let toml = r#"
1246[project]
1247name = "test"
1248
1249[tickets]
1250dir = "tickets"
1251
1252[[workflow.states]]
1253id = "new"
1254label = "New"
1255
1256[[workflow.states.transitions]]
1257to = "closed"
1258context_section = "AnySection"
1259
1260[[workflow.states]]
1261id = "closed"
1262label = "Closed"
1263terminal = true
1264"#;
1265 let config = load_config(toml);
1266 let errors = validate_config(&config, Path::new("/tmp"));
1267 assert!(
1268 !errors.iter().any(|e| e.contains("context_section")),
1269 "unexpected context_section error in {errors:?}"
1270 );
1271 }
1272
1273 #[test]
1275 fn closed_state_not_flagged_as_unknown() {
1276 use crate::ticket::Ticket;
1277
1278 let toml = r#"
1280[project]
1281name = "test"
1282
1283[tickets]
1284dir = "tickets"
1285
1286[[workflow.states]]
1287id = "new"
1288label = "New"
1289
1290[[workflow.states.transitions]]
1291to = "done"
1292
1293[[workflow.states]]
1294id = "done"
1295label = "Done"
1296terminal = true
1297"#;
1298 let config = load_config(toml);
1299 let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
1300 .map(|s| s.id.as_str())
1301 .collect();
1302
1303 let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
1304 let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1305
1306 assert!(!state_ids.contains("closed"));
1308 let fm = &ticket.frontmatter;
1310 let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
1311 assert!(!flagged, "closed state should not be flagged as unknown");
1312 }
1313
1314 #[test]
1316 fn state_ids_helper() {
1317 let toml = r#"
1318[project]
1319name = "test"
1320
1321[tickets]
1322dir = "tickets"
1323
1324[[workflow.states]]
1325id = "new"
1326label = "New"
1327"#;
1328 let config = load_config(toml);
1329 let ids = state_ids(&config);
1330 assert!(ids.contains("new"));
1331 }
1332
1333 #[test]
1334 fn validate_warnings_no_container() {
1335 let toml = r#"
1336[project]
1337name = "test"
1338
1339[tickets]
1340dir = "tickets"
1341"#;
1342 let config = load_config(toml);
1343 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1344 assert!(warnings.is_empty());
1345 }
1346
1347 #[test]
1348 fn valid_collaborator_accepted() {
1349 let toml = r#"
1350[project]
1351name = "test"
1352collaborators = ["alice", "bob"]
1353
1354[tickets]
1355dir = "tickets"
1356"#;
1357 let config = load_config(toml);
1358 assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1359 }
1360
1361 #[test]
1362 fn unknown_user_rejected() {
1363 let toml = r#"
1364[project]
1365name = "test"
1366collaborators = ["alice", "bob"]
1367
1368[tickets]
1369dir = "tickets"
1370"#;
1371 let config = load_config(toml);
1372 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1373 let msg = err.to_string();
1374 assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
1375 assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
1376 }
1377
1378 #[test]
1379 fn empty_collaborators_skips_validation() {
1380 let toml = r#"
1381[project]
1382name = "test"
1383
1384[tickets]
1385dir = "tickets"
1386"#;
1387 let config = load_config(toml);
1388 assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1389 }
1390
1391 #[test]
1392 fn clear_owner_always_allowed() {
1393 let toml = r#"
1394[project]
1395name = "test"
1396collaborators = ["alice"]
1397
1398[tickets]
1399dir = "tickets"
1400"#;
1401 let config = load_config(toml);
1402 assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1403 }
1404
1405 #[test]
1406 fn github_mode_known_user_accepted() {
1407 let toml = r#"
1408[project]
1409name = "test"
1410collaborators = ["alice", "bob"]
1411
1412[tickets]
1413dir = "tickets"
1414
1415[git_host]
1416provider = "github"
1417repo = "org/repo"
1418"#;
1419 let config = load_config(toml);
1420 assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1422 }
1423
1424 #[test]
1425 fn github_mode_unknown_user_rejected() {
1426 let toml = r#"
1427[project]
1428name = "test"
1429collaborators = ["alice", "bob"]
1430
1431[tickets]
1432dir = "tickets"
1433
1434[git_host]
1435provider = "github"
1436repo = "org/repo"
1437"#;
1438 let config = load_config(toml);
1439 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1441 assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1442 }
1443
1444 #[test]
1445 fn github_mode_no_collaborators_skips_check() {
1446 let toml = r#"
1447[project]
1448name = "test"
1449
1450[tickets]
1451dir = "tickets"
1452
1453[git_host]
1454provider = "github"
1455repo = "org/repo"
1456"#;
1457 let config = load_config(toml);
1458 assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1460 }
1461
1462 #[test]
1463 fn github_mode_clear_owner_accepted() {
1464 let toml = r#"
1465[project]
1466name = "test"
1467collaborators = ["alice"]
1468
1469[tickets]
1470dir = "tickets"
1471
1472[git_host]
1473provider = "github"
1474repo = "org/repo"
1475"#;
1476 let config = load_config(toml);
1477 assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1478 }
1479
1480 #[test]
1481 fn non_github_mode_unknown_user_rejected() {
1482 let toml = r#"
1483[project]
1484name = "test"
1485collaborators = ["alice", "bob"]
1486
1487[tickets]
1488dir = "tickets"
1489"#;
1490 let config = load_config(toml);
1491 let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1492 assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1493 }
1494
1495 #[test]
1496 fn validate_warnings_empty_container() {
1497 let toml = r#"
1498[project]
1499name = "test"
1500
1501[tickets]
1502dir = "tickets"
1503
1504[workers]
1505container = ""
1506"#;
1507 let config = load_config(toml);
1508 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1509 assert!(warnings.is_empty(), "empty container string should not warn");
1510 }
1511
1512 #[test]
1513 fn dead_end_workflow_warning_emitted() {
1514 let toml = r#"
1517[project]
1518name = "test"
1519
1520[tickets]
1521dir = "tickets"
1522
1523[[workflow.states]]
1524id = "start"
1525label = "Start"
1526actionable = ["agent"]
1527
1528[[workflow.states.transitions]]
1529to = "middle"
1530
1531[[workflow.states]]
1532id = "middle"
1533label = "Middle"
1534
1535[[workflow.states.transitions]]
1536to = "start"
1537"#;
1538 let config = load_config(toml);
1539 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1540 assert!(
1541 warnings.iter().any(|w| w.contains("success")),
1542 "expected dead-end warning containing 'success'; got: {warnings:?}"
1543 );
1544 }
1545
1546 #[test]
1547 fn default_workflow_no_dead_end_warning() {
1548 let base = r#"
1551[project]
1552name = "test"
1553
1554[tickets]
1555dir = "tickets"
1556"#;
1557 let combined = format!("{}\n{}", base, crate::init::default_workflow_toml());
1558 let config: Config = toml::from_str(&combined).unwrap();
1559 let warnings = super::validate_warnings(&config, Path::new("/tmp"));
1560 assert!(
1561 !warnings.iter().any(|w| w.contains("no reachable") && w.contains("success")),
1562 "unexpected dead-end warning for default workflow; got: {warnings:?}"
1563 );
1564 }
1565
1566 #[test]
1567 fn worktree_missing_in_design() {
1568 let dir = setup_verify_repo();
1569 let root = dir.path();
1570 let config = Config::load(root).unwrap();
1571 let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1572
1573 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1574
1575 let main_root = git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1576 let wt_path = main_root.join("worktrees").join("ticket-abcd1234-test");
1577 let expected = format!(
1578 "#abcd1234 [in_design]: worktree at {} is missing",
1579 wt_path.display()
1580 );
1581 assert!(
1582 issues.iter().any(|i| i == &expected),
1583 "expected worktree missing issue; got: {issues:?}"
1584 );
1585 }
1586
1587 #[test]
1588 fn worktree_present_no_issue() {
1589 let dir = setup_verify_repo();
1590 let root = dir.path();
1591 let config = Config::load(root).unwrap();
1592 let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1593
1594 std::fs::create_dir_all(root.join("worktrees").join("ticket-abcd1234-test")).unwrap();
1595
1596 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1597 assert!(
1598 !issues.iter().any(|i| i.contains("worktree")),
1599 "unexpected worktree issue; got: {issues:?}"
1600 );
1601 }
1602
1603 #[test]
1604 fn worktree_check_skipped_for_other_states() {
1605 let dir = setup_verify_repo();
1606 let root = dir.path();
1607 let config = Config::load(root).unwrap();
1608 let ticket = make_verify_ticket(root, "abcd1234", "specd", Some("ticket/abcd1234-test"));
1609
1610 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1611 assert!(
1612 !issues.iter().any(|i| i.contains("worktree")),
1613 "unexpected worktree issue for specd state; got: {issues:?}"
1614 );
1615 }
1616
1617 fn in_repo_wt_config(dir: &str) -> Config {
1618 let toml = format!(
1619 r#"
1620[project]
1621name = "test"
1622
1623[tickets]
1624dir = "tickets"
1625
1626[worktrees]
1627dir = "{dir}"
1628"#
1629 );
1630 toml::from_str(&toml).expect("config parse failed")
1631 }
1632
1633 #[test]
1634 fn validate_config_gitignore_missing_in_repo_wt() {
1635 let tmp = tempfile::TempDir::new().unwrap();
1636 let config = in_repo_wt_config("worktrees");
1637 let errors = validate_config(&config, tmp.path());
1638 assert!(
1639 errors.iter().any(|e| e.contains("worktrees") && e.contains(".gitignore")),
1640 "expected gitignore missing error; got: {errors:?}"
1641 );
1642 }
1643
1644 #[test]
1645 fn validate_config_gitignore_covered_anchored_slash() {
1646 let tmp = tempfile::TempDir::new().unwrap();
1647 std::fs::write(tmp.path().join(".gitignore"), "/worktrees/\n").unwrap();
1648 let config = in_repo_wt_config("worktrees");
1649 let errors = validate_config(&config, tmp.path());
1650 assert!(
1651 !errors.iter().any(|e| e.contains("gitignore")),
1652 "unexpected gitignore error; got: {errors:?}"
1653 );
1654 }
1655
1656 #[test]
1657 fn validate_config_gitignore_covered_anchored_no_slash() {
1658 let tmp = tempfile::TempDir::new().unwrap();
1659 std::fs::write(tmp.path().join(".gitignore"), "/worktrees\n").unwrap();
1660 let config = in_repo_wt_config("worktrees");
1661 let errors = validate_config(&config, tmp.path());
1662 assert!(
1663 !errors.iter().any(|e| e.contains("gitignore")),
1664 "unexpected gitignore error; got: {errors:?}"
1665 );
1666 }
1667
1668 #[test]
1669 fn validate_config_gitignore_covered_unanchored_slash() {
1670 let tmp = tempfile::TempDir::new().unwrap();
1671 std::fs::write(tmp.path().join(".gitignore"), "worktrees/\n").unwrap();
1672 let config = in_repo_wt_config("worktrees");
1673 let errors = validate_config(&config, tmp.path());
1674 assert!(
1675 !errors.iter().any(|e| e.contains("gitignore")),
1676 "unexpected gitignore error; got: {errors:?}"
1677 );
1678 }
1679
1680 #[test]
1681 fn validate_config_gitignore_covered_bare() {
1682 let tmp = tempfile::TempDir::new().unwrap();
1683 std::fs::write(tmp.path().join(".gitignore"), "worktrees\n").unwrap();
1684 let config = in_repo_wt_config("worktrees");
1685 let errors = validate_config(&config, tmp.path());
1686 assert!(
1687 !errors.iter().any(|e| e.contains("gitignore")),
1688 "unexpected gitignore error; got: {errors:?}"
1689 );
1690 }
1691
1692 #[test]
1693 fn validate_config_gitignore_not_covered() {
1694 let tmp = tempfile::TempDir::new().unwrap();
1695 std::fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
1696 let config = in_repo_wt_config("worktrees");
1697 let errors = validate_config(&config, tmp.path());
1698 assert!(
1699 errors.iter().any(|e| e.contains("worktrees") && e.contains("gitignore")),
1700 "expected gitignore not covered error; got: {errors:?}"
1701 );
1702 }
1703
1704 #[test]
1705 fn validate_config_gitignore_no_false_positive() {
1706 let tmp = tempfile::TempDir::new().unwrap();
1707 std::fs::write(tmp.path().join(".gitignore"), "wt-old/\n").unwrap();
1708 let config = in_repo_wt_config("wt");
1709 let errors = validate_config(&config, tmp.path());
1710 assert!(
1711 errors.iter().any(|e| e.contains("wt") && e.contains("gitignore")),
1712 "wt-old should not match wt; got: {errors:?}"
1713 );
1714 }
1715
1716 #[test]
1717 fn validate_config_external_dotdot_no_check() {
1718 let tmp = tempfile::TempDir::new().unwrap();
1719 let config = in_repo_wt_config("../ext");
1721 let errors = validate_config(&config, tmp.path());
1722 assert!(
1723 !errors.iter().any(|e| e.contains("gitignore")),
1724 "external dotdot path should skip gitignore check; got: {errors:?}"
1725 );
1726 }
1727
1728 #[test]
1729 fn validate_config_external_absolute_no_check() {
1730 let tmp = tempfile::TempDir::new().unwrap();
1731 let config = in_repo_wt_config("/abs/path");
1733 let errors = validate_config(&config, tmp.path());
1734 assert!(
1735 !errors.iter().any(|e| e.contains("gitignore")),
1736 "absolute path should skip gitignore check; got: {errors:?}"
1737 );
1738 }
1739
1740 fn config_with_merge_transition(completion: &str, on_failure: Option<&str>, declare_failure_state: bool) -> Config {
1741 let on_failure_line = on_failure
1742 .map(|v| format!("on_failure = \"{v}\"\n"))
1743 .unwrap_or_default();
1744 let merge_failed_state = if declare_failure_state {
1745 r#"
1746[[workflow.states]]
1747id = "merge_failed"
1748label = "Merge failed"
1749
1750[[workflow.states.transitions]]
1751to = "closed"
1752"#
1753 } else {
1754 ""
1755 };
1756 let toml = format!(
1757 r#"
1758[project]
1759name = "test"
1760
1761[tickets]
1762dir = "tickets"
1763
1764[[workflow.states]]
1765id = "in_progress"
1766label = "In Progress"
1767
1768[[workflow.states.transitions]]
1769to = "implemented"
1770completion = "{completion}"
1771{on_failure_line}
1772[[workflow.states]]
1773id = "implemented"
1774label = "Implemented"
1775terminal = true
1776
1777[[workflow.states]]
1778id = "closed"
1779label = "Closed"
1780terminal = true
1781{merge_failed_state}
1782"#
1783 );
1784 toml::from_str(&toml).expect("config parse failed")
1785 }
1786
1787 #[test]
1788 fn test_on_failure_missing_for_merge() {
1789 let config = config_with_merge_transition("merge", None, false);
1790 let errors = validate_config(&config, std::path::Path::new("/tmp"));
1791 assert!(
1792 errors.iter().any(|e| e.contains("missing `on_failure`")),
1793 "expected missing on_failure error; got: {errors:?}"
1794 );
1795 }
1796
1797 #[test]
1798 fn test_on_failure_missing_for_pr_or_epic_merge() {
1799 let config = config_with_merge_transition("pr_or_epic_merge", None, false);
1801 let errors = validate_config(&config, std::path::Path::new("/tmp"));
1802 assert!(
1803 errors.iter().any(|e| e.contains("missing `on_failure`")),
1804 "expected missing on_failure error for pr_or_epic_merge; got: {errors:?}"
1805 );
1806 }
1807
1808 #[test]
1809 fn test_on_failure_unknown_state() {
1810 let config = config_with_merge_transition("merge", Some("ghost_state"), false);
1811 let errors = validate_config(&config, std::path::Path::new("/tmp"));
1812 assert!(
1813 errors.iter().any(|e| e.contains("ghost_state")),
1814 "expected unknown state error for ghost_state; got: {errors:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_on_failure_valid() {
1820 let config = config_with_merge_transition("merge", Some("merge_failed"), true);
1821 let errors = validate_config(&config, std::path::Path::new("/tmp"));
1822 let on_failure_errors: Vec<&String> = errors.iter()
1823 .filter(|e| e.contains("on_failure") || e.contains("ghost_state") || e.contains("merge_failed"))
1824 .collect();
1825 assert!(
1826 on_failure_errors.is_empty(),
1827 "unexpected on_failure errors: {on_failure_errors:?}"
1828 );
1829 }
1830
1831 fn make_agent_verify_ticket(root: &std::path::Path, id: &str, state: &str, extra_fm: &str) -> Ticket {
1834 let raw = format!(
1835 "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{extra_fm}+++\n\n## Spec\n\n## History\n"
1836 );
1837 let path = root.join("tickets").join(format!("{id}-test.md"));
1838 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1839 std::fs::write(&path, &raw).unwrap();
1840 Ticket::parse(&path, &raw).unwrap()
1841 }
1842
1843 #[test]
1844 fn validate_unknown_frontmatter_agent_is_error() {
1845 let dir = setup_verify_repo();
1846 let root = dir.path();
1847 let config = Config::load(root).unwrap();
1848 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"nonexistent-bot\"\n");
1849
1850 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1851
1852 assert!(
1853 issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
1854 "expected error with ticket id and agent name; got: {issues:?}"
1855 );
1856 }
1857
1858 #[test]
1859 fn validate_unknown_agent_in_overrides_is_error() {
1860 let dir = setup_verify_repo();
1861 let root = dir.path();
1862 let config = Config::load(root).unwrap();
1863 let ticket = make_agent_verify_ticket(
1864 root,
1865 "abcd1234",
1866 "specd",
1867 "[agent_overrides]\nimpl_agent = \"nonexistent-bot\"\n",
1868 );
1869
1870 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1871
1872 assert!(
1873 issues.iter().any(|i| i.contains("abcd1234") && i.contains("nonexistent-bot")),
1874 "expected error with ticket id and agent name; got: {issues:?}"
1875 );
1876 }
1877
1878 #[test]
1879 fn validate_known_frontmatter_agent_passes() {
1880 let dir = setup_verify_repo();
1881 let root = dir.path();
1882 let config = Config::load(root).unwrap();
1883 let ticket = make_agent_verify_ticket(root, "abcd1234", "specd", "agent = \"claude\"\n");
1884
1885 let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1886
1887 assert!(
1888 !issues.iter().any(|i| i.contains("is not a known built-in")),
1889 "expected no agent error for known built-in; got: {issues:?}"
1890 );
1891 }
1892}