Skip to main content

apm_core/
validate.rs

1use crate::config::{CompletionStrategy, Config, LocalConfig};
2use crate::ticket_fmt::Ticket;
3use anyhow::{bail, Result};
4use std::collections::HashSet;
5use std::path::Path;
6
7/// Return the completion strategy configured for the `in_progress → implemented`
8/// transition.  Falls back to `None` when the transition is absent.
9pub fn active_completion_strategy(config: &Config) -> CompletionStrategy {
10    config.workflow.states.iter()
11        .find(|s| s.id == "in_progress")
12        .and_then(|s| s.transitions.iter().find(|t| t.to == "implemented"))
13        .map(|t| t.completion.clone())
14        .unwrap_or(CompletionStrategy::None)
15}
16
17fn strategy_name(strategy: &CompletionStrategy) -> &'static str {
18    match strategy {
19        CompletionStrategy::Pr => "pr",
20        CompletionStrategy::Merge => "merge",
21        CompletionStrategy::Pull => "pull",
22        CompletionStrategy::PrOrEpicMerge => "pr_or_epic_merge",
23        CompletionStrategy::None => "none",
24    }
25}
26
27/// Validate that `dep_ids` satisfy the dependency rules for `strategy`.
28///
29/// - `ticket_epic`: epic ID of the ticket being written (None if no epic)
30/// - `ticket_target_branch`: target_branch of the ticket (None = default branch)
31/// - `dep_ids`: the proposed dependency list (empty slice → always Ok)
32/// - `all_tickets`: all known tickets (used to look up dep metadata)
33/// - `default_branch`: project default branch name
34pub fn check_depends_on_rules(
35    strategy: &CompletionStrategy,
36    ticket_epic: Option<&str>,
37    ticket_target_branch: Option<&str>,
38    dep_ids: &[String],
39    all_tickets: &[crate::ticket_fmt::Ticket],
40    default_branch: &str,
41) -> Result<()> {
42    if dep_ids.is_empty() {
43        return Ok(());
44    }
45    match strategy {
46        CompletionStrategy::Pr | CompletionStrategy::None | CompletionStrategy::Pull => {
47            bail!(
48                "depends_on is not allowed under the {} completion strategy",
49                strategy_name(strategy)
50            );
51        }
52        CompletionStrategy::PrOrEpicMerge => {
53            let Some(epic) = ticket_epic else {
54                bail!(
55                    "pr_or_epic_merge requires the ticket to belong to an epic before depends_on can be set"
56                );
57            };
58            let mut offending: Vec<&str> = Vec::new();
59            for dep_id in dep_ids {
60                let dep = all_tickets.iter().find(|t| t.frontmatter.id == *dep_id)
61                    .ok_or_else(|| anyhow::anyhow!("dep {dep_id} not found"))?;
62                if dep.frontmatter.epic.as_deref() != Some(epic) {
63                    offending.push(dep_id.as_str());
64                }
65            }
66            if !offending.is_empty() {
67                bail!(
68                    "pr_or_epic_merge requires all deps to share epic {epic}; offending deps: {}",
69                    offending.join(", ")
70                );
71            }
72        }
73        CompletionStrategy::Merge => {
74            let ticket_target = ticket_target_branch.unwrap_or(default_branch);
75            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                let dep_target = dep.frontmatter.target_branch.as_deref().unwrap_or(default_branch);
80                if dep_target != ticket_target {
81                    offending.push(dep_id.as_str());
82                }
83            }
84            if !offending.is_empty() {
85                bail!(
86                    "merge requires all deps to share target_branch {ticket_target}; offending deps: {}",
87                    offending.join(", ")
88                );
89            }
90        }
91    }
92    Ok(())
93}
94
95/// Walk every non-closed ticket and return a vec of `(subject, message)` pairs
96/// for each ticket whose `depends_on` violates the active completion strategy rule.
97pub fn validate_depends_on(config: &Config, tickets: &[Ticket]) -> Vec<(String, String)> {
98    let strategy = active_completion_strategy(config);
99    let mut violations: Vec<(String, String)> = Vec::new();
100    for ticket in tickets {
101        let fm = &ticket.frontmatter;
102        if fm.state == "closed" {
103            continue;
104        }
105        let dep_ids = match &fm.depends_on {
106            Some(deps) if !deps.is_empty() => deps,
107            _ => continue,
108        };
109        if let Err(e) = check_depends_on_rules(
110            &strategy,
111            fm.epic.as_deref(),
112            fm.target_branch.as_deref(),
113            dep_ids,
114            tickets,
115            &config.project.default_branch,
116        ) {
117            violations.push((format!("#{}", fm.id), e.to_string()));
118        }
119    }
120    violations
121}
122
123pub fn validate_owner(config: &Config, local: &LocalConfig, username: &str) -> Result<()> {
124    if username == "-" {
125        return Ok(());
126    }
127    let (collaborators, warnings) = crate::config::resolve_collaborators(config, local);
128    for w in &warnings {
129        #[allow(clippy::print_stderr)]
130        { eprintln!("{w}"); }
131    }
132    if collaborators.is_empty() {
133        return Ok(());
134    }
135    if collaborators.iter().any(|c| c == username) {
136        return Ok(());
137    }
138    let list = collaborators.join(", ");
139    bail!("unknown user '{username}'; valid collaborators: {list}");
140}
141
142fn is_external_worktree(dir: &Path) -> bool {
143    let s = dir.to_string_lossy();
144    s.starts_with('/') || s.starts_with("..")
145}
146
147fn gitignore_covers_dir(content: &str, dir: &str) -> bool {
148    let normalized_dir = dir.trim_matches('/');
149    content
150        .lines()
151        .map(|line| line.trim())
152        .filter(|line| !line.is_empty() && !line.starts_with('#'))
153        .any(|line| line.trim_matches('/') == normalized_dir)
154}
155
156pub fn validate_config(config: &Config, root: &Path) -> Vec<String> {
157    let mut errors: Vec<String> = Vec::new();
158
159    let state_ids: HashSet<&str> = config.workflow.states.iter()
160        .map(|s| s.id.as_str())
161        .collect();
162
163    let section_names: HashSet<&str> = config.ticket.sections.iter()
164        .map(|s| s.name.as_str())
165        .collect();
166    let has_sections = !section_names.is_empty();
167
168    // Check whether any transition requires a provider.
169    let needs_provider = config.workflow.states.iter()
170        .flat_map(|s| s.transitions.iter())
171        .any(|t| matches!(t.completion, CompletionStrategy::Pr | CompletionStrategy::Merge));
172
173    let provider_ok = config.git_host.provider.as_ref()
174        .map(|p| !p.is_empty())
175        .unwrap_or(false);
176
177    if needs_provider && !provider_ok {
178        errors.push(
179            "config: workflow — completion 'pr' or 'merge' requires [git_host] with a provider".into()
180        );
181    }
182
183    // At least one non-terminal state.
184    let has_non_terminal = config.workflow.states.iter().any(|s| !s.terminal);
185    if !has_non_terminal {
186        errors.push("config: workflow — no non-terminal state exists".into());
187    }
188
189    for state in &config.workflow.states {
190        // Terminal state with outgoing transitions.
191        if state.terminal && !state.transitions.is_empty() {
192            errors.push(format!(
193                "config: state.{} — terminal but has {} outgoing transition(s)",
194                state.id,
195                state.transitions.len()
196            ));
197        }
198
199        // Non-terminal state with no outgoing transitions (tickets will be stranded).
200        if !state.terminal && state.transitions.is_empty() {
201            errors.push(format!(
202                "config: state.{} — no outgoing transitions (tickets will be stranded)",
203                state.id
204            ));
205        }
206
207        // Instructions path exists on disk.
208        if let Some(instructions) = &state.instructions {
209            if !root.join(instructions).exists() {
210                errors.push(format!(
211                    "config: state.{}.instructions — file not found: {}",
212                    state.id, instructions
213                ));
214            }
215        }
216
217        for transition in &state.transitions {
218            // Transition target must exist.  "closed" is a built-in terminal state
219            // that is always valid even when absent from [[workflow.states]].
220            if transition.to != "closed" && !state_ids.contains(transition.to.as_str()) {
221                errors.push(format!(
222                    "config: state.{}.transition({}) — target state '{}' does not exist",
223                    state.id, transition.to, transition.to
224                ));
225            }
226
227            // context_section must match a known ticket section.
228            if let Some(section) = &transition.context_section {
229                if has_sections && !section_names.contains(section.as_str()) {
230                    errors.push(format!(
231                        "config: state.{}.transition({}).context_section — unknown section '{}'",
232                        state.id, transition.to, section
233                    ));
234                }
235            }
236
237            // focus_section must match a known ticket section.
238            if let Some(section) = &transition.focus_section {
239                if has_sections && !section_names.contains(section.as_str()) {
240                    errors.push(format!(
241                        "config: state.{}.transition({}).focus_section — unknown section '{}'",
242                        state.id, transition.to, section
243                    ));
244                }
245            }
246
247            // Merge/PrOrEpicMerge transitions require on_failure.
248            if matches!(
249                transition.completion,
250                CompletionStrategy::Merge | CompletionStrategy::PrOrEpicMerge
251            ) {
252                if transition.on_failure.is_none() {
253                    errors.push(format!(
254                        "config: transition '{}' → '{}' uses completion '{}' but is missing \
255                         `on_failure`; run `apm validate --fix` to add it",
256                        state.id,
257                        transition.to,
258                        strategy_name(&transition.completion)
259                    ));
260                } else if let Some(ref name) = transition.on_failure {
261                    if name != "closed" && !state_ids.contains(name.as_str()) {
262                        errors.push(format!(
263                            "config: transition '{}' → '{}' has `on_failure = \"{}\"` but \
264                             state \"{}\" is not declared in workflow.toml",
265                            state.id, transition.to, name, name
266                        ));
267                    }
268                }
269            }
270        }
271    }
272
273    if !is_external_worktree(&config.worktrees.dir) {
274        let dir_str = config.worktrees.dir.to_string_lossy();
275        let gitignore = root.join(".gitignore");
276        match std::fs::read_to_string(&gitignore) {
277            Err(_) => errors.push(format!(
278                "config: worktrees.dir '{dir_str}' is in-repo but .gitignore is missing; \
279                 run 'apm init' or add '/{dir_str}/' manually"
280            )),
281            Ok(content) if !gitignore_covers_dir(&content, &dir_str) => errors.push(format!(
282                "config: worktrees.dir '{dir_str}' is in-repo but .gitignore does not cover it; \
283                 add '/{dir_str}/' or run 'apm init'"
284            )),
285            Ok(_) => {}
286        }
287    }
288
289    errors
290}
291
292pub fn verify_tickets(
293    root: &Path,
294    config: &Config,
295    tickets: &[Ticket],
296    merged: &HashSet<String>,
297) -> Vec<String> {
298    let valid_states: HashSet<&str> = config.workflow.states.iter()
299        .map(|s| s.id.as_str())
300        .collect();
301    let terminal = config.terminal_state_ids();
302
303    let in_progress_states: HashSet<&str> =
304        ["in_progress", "implemented"].iter().copied().collect();
305
306    let worktree_states: HashSet<&str> =
307        ["in_design", "in_progress"].iter().copied().collect();
308    let main_root = crate::git_util::main_worktree_root(root)
309        .unwrap_or_else(|| root.to_path_buf());
310    let worktrees_base = main_root.join(&config.worktrees.dir);
311
312    let mut issues: Vec<String> = Vec::new();
313
314    for t in tickets {
315        let fm = &t.frontmatter;
316
317        // Skip terminal-state tickets.
318        if terminal.contains(fm.state.as_str()) { continue; }
319
320        let prefix = format!("#{} [{}]", fm.id, fm.state);
321
322        // State value not in config.
323        if !valid_states.is_empty() && !valid_states.contains(fm.state.as_str()) {
324            issues.push(format!("{prefix}: unknown state {:?}", fm.state));
325        }
326
327        // Frontmatter id doesn't match filename numeric prefix.
328        if let Some(name) = t.path.file_name().and_then(|n| n.to_str()) {
329            let expected_prefix = format!("{:04}", fm.id);
330            if !name.starts_with(&expected_prefix) {
331                issues.push(format!("{prefix}: id {} does not match filename {name}", fm.id));
332            }
333        }
334
335        // in_progress/implemented with no branch.
336        if in_progress_states.contains(fm.state.as_str()) && fm.branch.is_none() {
337            issues.push(format!("{prefix}: state requires branch but none set"));
338        }
339
340        // Branch merged but ticket not yet closed.
341        if let Some(branch) = &fm.branch {
342            if (fm.state == "in_progress" || fm.state == "implemented")
343                && merged.contains(branch.as_str())
344            {
345                issues.push(format!("{prefix}: branch {branch} is merged but ticket not closed"));
346            }
347        }
348
349        // in_design/in_progress with missing worktree directory.
350        if worktree_states.contains(fm.state.as_str()) {
351            if let Some(branch) = &fm.branch {
352                let wt_name = branch.replace('/', "-");
353                let wt_path = worktrees_base.join(&wt_name);
354                if !wt_path.is_dir() {
355                    issues.push(format!(
356                        "{prefix}: worktree at {} is missing",
357                        wt_path.display()
358                    ));
359                }
360            }
361        }
362
363        // Missing ## Spec section.
364        if !t.body.contains("## Spec") {
365            issues.push(format!("{prefix}: missing ## Spec section"));
366        }
367
368        // Missing ## History section.
369        if !t.body.contains("## History") {
370            issues.push(format!("{prefix}: missing ## History section"));
371        }
372
373        // Validate document structure (required sections non-empty, AC items present).
374        if let Ok(doc) = t.document() {
375            for err in doc.validate(&config.ticket.sections) {
376                issues.push(format!("{prefix}: {err}"));
377            }
378        }
379    }
380
381    issues
382}
383
384pub fn validate_warnings(config: &crate::config::Config) -> Vec<String> {
385    let mut warnings = config.load_warnings.clone();
386    if let Some(container) = &config.workers.container {
387        if !container.is_empty() {
388            let docker_ok = std::process::Command::new("docker")
389                .arg("--version")
390                .output()
391                .map(|o| o.status.success())
392                .unwrap_or(false);
393            if !docker_ok {
394                warnings.push(
395                    "workers.container is set but 'docker' is not in PATH".to_string()
396                );
397            }
398        }
399    }
400    warnings
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406    use crate::config::{Config, CompletionStrategy, LocalConfig};
407    use crate::ticket::Ticket;
408    use crate::git_util;
409    use std::path::Path;
410    use std::collections::HashSet;
411
412    fn git_cmd(dir: &std::path::Path, args: &[&str]) {
413        std::process::Command::new("git")
414            .args(args)
415            .current_dir(dir)
416            .env("GIT_AUTHOR_NAME", "test")
417            .env("GIT_AUTHOR_EMAIL", "test@test.com")
418            .env("GIT_COMMITTER_NAME", "test")
419            .env("GIT_COMMITTER_EMAIL", "test@test.com")
420            .status()
421            .unwrap();
422    }
423
424    fn setup_verify_repo() -> tempfile::TempDir {
425        let dir = tempfile::tempdir().unwrap();
426        let p = dir.path();
427
428        git_cmd(p, &["init", "-q", "-b", "main"]);
429        git_cmd(p, &["config", "user.email", "test@test.com"]);
430        git_cmd(p, &["config", "user.name", "test"]);
431
432        std::fs::write(
433            p.join("apm.toml"),
434            r#"[project]
435name = "test"
436
437[tickets]
438dir = "tickets"
439
440[worktrees]
441dir = "worktrees"
442
443[[workflow.states]]
444id = "in_design"
445label = "In Design"
446
447[[workflow.states]]
448id = "in_progress"
449label = "In Progress"
450
451[[workflow.states]]
452id = "specd"
453label = "Specd"
454"#,
455        )
456        .unwrap();
457
458        git_cmd(p, &["add", "apm.toml"]);
459        git_cmd(p, &["-c", "commit.gpgsign=false", "commit", "-m", "init"]);
460
461        dir
462    }
463
464    fn make_verify_ticket(root: &std::path::Path, id: &str, state: &str, branch: Option<&str>) -> Ticket {
465        let branch_line = match branch {
466            Some(b) => format!("branch = \"{b}\"\n"),
467            None => String::new(),
468        };
469        let raw = format!(
470            "+++\nid = \"{id}\"\ntitle = \"Test ticket\"\nstate = \"{state}\"\n{branch_line}+++\n\n## Spec\n\n## History\n"
471        );
472        let path = root.join("tickets").join(format!("{id}-test.md"));
473        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
474        std::fs::write(&path, &raw).unwrap();
475        Ticket::parse(&path, &raw).unwrap()
476    }
477
478    fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
479        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
480        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
481        let raw = format!(
482            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
483        );
484        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
485    }
486
487    fn strategy_config(completion: &str) -> Config {
488        let toml = format!(
489            r#"
490[project]
491name = "test"
492
493[tickets]
494dir = "tickets"
495
496[[workflow.states]]
497id    = "in_progress"
498label = "In Progress"
499
500[[workflow.states.transitions]]
501to         = "implemented"
502completion = "{completion}"
503
504[[workflow.states]]
505id       = "implemented"
506label    = "Implemented"
507terminal = true
508"#
509        );
510        toml::from_str(&toml).unwrap()
511    }
512
513    #[test]
514    fn strategy_finds_in_progress_to_implemented() {
515        let config = strategy_config("pr_or_epic_merge");
516        assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
517    }
518
519    #[test]
520    fn strategy_defaults_to_none_when_absent() {
521        let toml = r#"
522[project]
523name = "test"
524
525[tickets]
526dir = "tickets"
527
528[[workflow.states]]
529id    = "new"
530label = "New"
531
532[[workflow.states.transitions]]
533to = "closed"
534
535[[workflow.states]]
536id       = "closed"
537label    = "Closed"
538terminal = true
539"#;
540        let config: Config = toml::from_str(toml).unwrap();
541        assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
542    }
543
544    #[test]
545    fn dep_rules_pr_rejects_dep() {
546        let dep = make_ticket("dep1", None, None);
547        let result = check_depends_on_rules(
548            &CompletionStrategy::Pr,
549            None,
550            None,
551            &["dep1".to_string()],
552            &[dep],
553            "main",
554        );
555        assert!(result.is_err());
556        let msg = result.unwrap_err().to_string();
557        assert!(msg.contains("pr"), "expected strategy name in: {msg}");
558    }
559
560    #[test]
561    fn dep_rules_none_rejects_dep() {
562        let dep = make_ticket("dep1", None, None);
563        let result = check_depends_on_rules(
564            &CompletionStrategy::None,
565            None,
566            None,
567            &["dep1".to_string()],
568            &[dep],
569            "main",
570        );
571        assert!(result.is_err());
572        let msg = result.unwrap_err().to_string();
573        assert!(msg.contains("none"), "expected strategy name in: {msg}");
574    }
575
576    #[test]
577    fn dep_rules_pr_or_epic_merge_same_epic_ok() {
578        let dep = make_ticket("dep1", Some("abc"), None);
579        let result = check_depends_on_rules(
580            &CompletionStrategy::PrOrEpicMerge,
581            Some("abc"),
582            None,
583            &["dep1".to_string()],
584            &[dep],
585            "main",
586        );
587        assert!(result.is_ok(), "expected Ok, got {result:?}");
588    }
589
590    #[test]
591    fn dep_rules_pr_or_epic_merge_different_epic_fails() {
592        let dep = make_ticket("dep1", Some("xyz"), None);
593        let result = check_depends_on_rules(
594            &CompletionStrategy::PrOrEpicMerge,
595            Some("abc"),
596            None,
597            &["dep1".to_string()],
598            &[dep],
599            "main",
600        );
601        assert!(result.is_err());
602        let msg = result.unwrap_err().to_string();
603        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
604    }
605
606    #[test]
607    fn dep_rules_pr_or_epic_merge_ticket_no_epic_fails() {
608        let dep = make_ticket("dep1", Some("abc"), None);
609        let result = check_depends_on_rules(
610            &CompletionStrategy::PrOrEpicMerge,
611            None,
612            None,
613            &["dep1".to_string()],
614            &[dep],
615            "main",
616        );
617        assert!(result.is_err());
618        let msg = result.unwrap_err().to_string();
619        assert!(msg.contains("epic"), "expected epic mention in: {msg}");
620    }
621
622    #[test]
623    fn dep_rules_merge_both_default_branch_ok() {
624        let dep = make_ticket("dep1", None, None);
625        let result = check_depends_on_rules(
626            &CompletionStrategy::Merge,
627            None,
628            None,
629            &["dep1".to_string()],
630            &[dep],
631            "main",
632        );
633        assert!(result.is_ok(), "expected Ok, got {result:?}");
634    }
635
636    #[test]
637    fn dep_rules_merge_different_target_fails() {
638        let dep = make_ticket("dep1", None, Some("epic/other"));
639        let result = check_depends_on_rules(
640            &CompletionStrategy::Merge,
641            None,
642            None,
643            &["dep1".to_string()],
644            &[dep],
645            "main",
646        );
647        assert!(result.is_err());
648        let msg = result.unwrap_err().to_string();
649        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
650    }
651
652    fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
653        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
654        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
655        let deps_line = if depends_on.is_empty() {
656            String::new()
657        } else {
658            let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
659            format!("depends_on = [{}]\n", quoted.join(", "))
660        };
661        let raw = format!(
662            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
663        );
664        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
665    }
666
667    #[test]
668    fn validate_depends_on_no_deps_clean() {
669        let config = strategy_config("pr_or_epic_merge");
670        let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
671        let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
672        let result = validate_depends_on(&config, &[t1, t2]);
673        assert!(result.is_empty(), "expected no violations, got {result:?}");
674    }
675
676    #[test]
677    fn validate_depends_on_closed_ticket_skipped() {
678        let config = strategy_config("pr");
679        let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
680        let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
681        let result = validate_depends_on(&config, &[dep, ticket]);
682        assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
683    }
684
685    #[test]
686    fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
687        let config = strategy_config("pr_or_epic_merge");
688        let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
689        let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
690        let result = validate_depends_on(&config, &[dep, ticket]);
691        assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
692    }
693
694    #[test]
695    fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
696        let config = strategy_config("pr_or_epic_merge");
697        let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
698        let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
699        let result = validate_depends_on(&config, &[dep, ticket]);
700        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
701        assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
702    }
703
704    #[test]
705    fn validate_depends_on_merge_same_target_ok() {
706        let config = strategy_config("merge");
707        let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
708        let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
709        let result = validate_depends_on(&config, &[dep, ticket]);
710        assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
711    }
712
713    #[test]
714    fn validate_depends_on_merge_different_target_fails() {
715        let config = strategy_config("merge");
716        let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
717        let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
718        let result = validate_depends_on(&config, &[dep, ticket]);
719        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
720        assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
721    }
722
723    #[test]
724    fn validate_depends_on_pr_strategy_rejects_any_dep() {
725        let config = strategy_config("pr");
726        let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
727        let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
728        let result = validate_depends_on(&config, &[dep, ticket]);
729        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
730        assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
731    }
732
733    fn load_config(toml: &str) -> Config {
734        toml::from_str(toml).expect("config parse failed")
735    }
736
737    fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
738        config.workflow.states.iter().map(|s| s.id.as_str()).collect()
739    }
740
741    // Test 1: correct config passes all checks
742    #[test]
743    fn correct_config_passes() {
744        let toml = r#"
745[project]
746name = "test"
747
748[tickets]
749dir = "tickets"
750
751[[workflow.states]]
752id    = "new"
753label = "New"
754
755[[workflow.states.transitions]]
756to = "in_progress"
757
758[[workflow.states]]
759id       = "in_progress"
760label    = "In Progress"
761terminal = false
762
763[[workflow.states.transitions]]
764to = "closed"
765
766[[workflow.states]]
767id       = "closed"
768label    = "Closed"
769terminal = true
770"#;
771        let config = load_config(toml);
772        let errors = validate_config(&config, Path::new("/tmp"));
773        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
774    }
775
776    // Test 2: transition to non-existent state is detected
777    #[test]
778    fn transition_to_nonexistent_state_detected() {
779        let toml = r#"
780[project]
781name = "test"
782
783[tickets]
784dir = "tickets"
785
786[[workflow.states]]
787id    = "new"
788label = "New"
789
790[[workflow.states.transitions]]
791to = "ghost"
792"#;
793        let config = load_config(toml);
794        let errors = validate_config(&config, Path::new("/tmp"));
795        assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
796    }
797
798    // Test 3: terminal state with outgoing transitions is detected
799    #[test]
800    fn terminal_state_with_transitions_detected() {
801        let toml = r#"
802[project]
803name = "test"
804
805[tickets]
806dir = "tickets"
807
808[[workflow.states]]
809id       = "closed"
810label    = "Closed"
811terminal = true
812
813[[workflow.states.transitions]]
814to = "new"
815
816[[workflow.states]]
817id    = "new"
818label = "New"
819
820[[workflow.states.transitions]]
821to = "closed"
822"#;
823        let config = load_config(toml);
824        let errors = validate_config(&config, Path::new("/tmp"));
825        assert!(
826            errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
827            "expected terminal error in {errors:?}"
828        );
829    }
830
831    // Test 5: ticket with unknown state is detected
832    #[test]
833    fn ticket_with_unknown_state_detected() {
834        use crate::ticket::Ticket;
835
836        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
837        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
838
839        let known_states: std::collections::HashSet<&str> =
840            ["new", "ready", "closed"].iter().copied().collect();
841
842        assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
843    }
844
845    // Test 6: dead-end non-terminal state is detected
846    #[test]
847    fn dead_end_non_terminal_detected() {
848        let toml = r#"
849[project]
850name = "test"
851
852[tickets]
853dir = "tickets"
854
855[[workflow.states]]
856id    = "stuck"
857label = "Stuck"
858
859[[workflow.states]]
860id       = "closed"
861label    = "Closed"
862terminal = true
863"#;
864        let config = load_config(toml);
865        let errors = validate_config(&config, Path::new("/tmp"));
866        assert!(
867            errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
868            "expected dead-end error in {errors:?}"
869        );
870    }
871
872    // Test 7: context_section mismatch is detected
873    #[test]
874    fn context_section_mismatch_detected() {
875        let toml = r#"
876[project]
877name = "test"
878
879[tickets]
880dir = "tickets"
881
882[[ticket.sections]]
883name = "Problem"
884type = "free"
885
886[[workflow.states]]
887id    = "new"
888label = "New"
889
890[[workflow.states.transitions]]
891to              = "ready"
892context_section = "NonExistent"
893
894[[workflow.states]]
895id    = "ready"
896label = "Ready"
897
898[[workflow.states.transitions]]
899to = "closed"
900
901[[workflow.states]]
902id       = "closed"
903label    = "Closed"
904terminal = true
905"#;
906        let config = load_config(toml);
907        let errors = validate_config(&config, Path::new("/tmp"));
908        assert!(
909            errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
910            "expected context_section error in {errors:?}"
911        );
912    }
913
914    // Test 8: focus_section mismatch is detected
915    #[test]
916    fn focus_section_mismatch_detected() {
917        let toml = r#"
918[project]
919name = "test"
920
921[tickets]
922dir = "tickets"
923
924[[ticket.sections]]
925name = "Problem"
926type = "free"
927
928[[workflow.states]]
929id    = "new"
930label = "New"
931
932[[workflow.states.transitions]]
933to             = "ready"
934focus_section  = "BadSection"
935
936[[workflow.states]]
937id    = "ready"
938label = "Ready"
939
940[[workflow.states.transitions]]
941to = "closed"
942
943[[workflow.states]]
944id       = "closed"
945label    = "Closed"
946terminal = true
947"#;
948        let config = load_config(toml);
949        let errors = validate_config(&config, Path::new("/tmp"));
950        assert!(
951            errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
952            "expected focus_section error in {errors:?}"
953        );
954    }
955
956    // Test 9: completion=pr without provider is detected
957    #[test]
958    fn completion_pr_without_provider_detected() {
959        let toml = r#"
960[project]
961name = "test"
962
963[tickets]
964dir = "tickets"
965
966[[workflow.states]]
967id    = "new"
968label = "New"
969
970[[workflow.states.transitions]]
971to         = "closed"
972completion = "pr"
973
974[[workflow.states]]
975id       = "closed"
976label    = "Closed"
977terminal = true
978"#;
979        let config = load_config(toml);
980        let errors = validate_config(&config, Path::new("/tmp"));
981        assert!(
982            errors.iter().any(|e| e.contains("provider")),
983            "expected provider error in {errors:?}"
984        );
985    }
986
987    // Test 10: completion=pr with provider configured passes
988    #[test]
989    fn completion_pr_with_provider_passes() {
990        let toml = r#"
991[project]
992name = "test"
993
994[tickets]
995dir = "tickets"
996
997[git_host]
998provider = "github"
999
1000[[workflow.states]]
1001id    = "new"
1002label = "New"
1003
1004[[workflow.states.transitions]]
1005to         = "closed"
1006completion = "pr"
1007
1008[[workflow.states]]
1009id       = "closed"
1010label    = "Closed"
1011terminal = true
1012"#;
1013        let config = load_config(toml);
1014        let errors = validate_config(&config, Path::new("/tmp"));
1015        assert!(
1016            !errors.iter().any(|e| e.contains("provider")),
1017            "unexpected provider error in {errors:?}"
1018        );
1019    }
1020
1021    // Test 11: context_section with empty ticket.sections is skipped
1022    #[test]
1023    fn context_section_skipped_when_no_sections_defined() {
1024        let toml = r#"
1025[project]
1026name = "test"
1027
1028[tickets]
1029dir = "tickets"
1030
1031[[workflow.states]]
1032id    = "new"
1033label = "New"
1034
1035[[workflow.states.transitions]]
1036to              = "closed"
1037context_section = "AnySection"
1038
1039[[workflow.states]]
1040id       = "closed"
1041label    = "Closed"
1042terminal = true
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("context_section")),
1048            "unexpected context_section error in {errors:?}"
1049        );
1050    }
1051
1052    // Test: closed state is not flagged as unknown even when absent from config
1053    #[test]
1054    fn closed_state_not_flagged_as_unknown() {
1055        use crate::ticket::Ticket;
1056
1057        // Config with no "closed" state
1058        let toml = r#"
1059[project]
1060name = "test"
1061
1062[tickets]
1063dir = "tickets"
1064
1065[[workflow.states]]
1066id    = "new"
1067label = "New"
1068
1069[[workflow.states.transitions]]
1070to = "done"
1071
1072[[workflow.states]]
1073id       = "done"
1074label    = "Done"
1075terminal = true
1076"#;
1077        let config = load_config(toml);
1078        let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
1079            .map(|s| s.id.as_str())
1080            .collect();
1081
1082        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
1083        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
1084
1085        // "closed" is not in state_ids, but the validate logic skips it.
1086        assert!(!state_ids.contains("closed"));
1087        // Simulate the validate check: closed should be exempt.
1088        let fm = &ticket.frontmatter;
1089        let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
1090        assert!(!flagged, "closed state should not be flagged as unknown");
1091    }
1092
1093    // Test for state_ids helper (kept for compatibility)
1094    #[test]
1095    fn state_ids_helper() {
1096        let toml = r#"
1097[project]
1098name = "test"
1099
1100[tickets]
1101dir = "tickets"
1102
1103[[workflow.states]]
1104id    = "new"
1105label = "New"
1106"#;
1107        let config = load_config(toml);
1108        let ids = state_ids(&config);
1109        assert!(ids.contains("new"));
1110    }
1111
1112    #[test]
1113    fn validate_warnings_no_container() {
1114        let toml = r#"
1115[project]
1116name = "test"
1117
1118[tickets]
1119dir = "tickets"
1120"#;
1121        let config = load_config(toml);
1122        let warnings = super::validate_warnings(&config);
1123        assert!(warnings.is_empty());
1124    }
1125
1126    #[test]
1127    fn valid_collaborator_accepted() {
1128        let toml = r#"
1129[project]
1130name = "test"
1131collaborators = ["alice", "bob"]
1132
1133[tickets]
1134dir = "tickets"
1135"#;
1136        let config = load_config(toml);
1137        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1138    }
1139
1140    #[test]
1141    fn unknown_user_rejected() {
1142        let toml = r#"
1143[project]
1144name = "test"
1145collaborators = ["alice", "bob"]
1146
1147[tickets]
1148dir = "tickets"
1149"#;
1150        let config = load_config(toml);
1151        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1152        let msg = err.to_string();
1153        assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
1154        assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
1155    }
1156
1157    #[test]
1158    fn empty_collaborators_skips_validation() {
1159        let toml = r#"
1160[project]
1161name = "test"
1162
1163[tickets]
1164dir = "tickets"
1165"#;
1166        let config = load_config(toml);
1167        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1168    }
1169
1170    #[test]
1171    fn clear_owner_always_allowed() {
1172        let toml = r#"
1173[project]
1174name = "test"
1175collaborators = ["alice"]
1176
1177[tickets]
1178dir = "tickets"
1179"#;
1180        let config = load_config(toml);
1181        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1182    }
1183
1184    #[test]
1185    fn github_mode_known_user_accepted() {
1186        let toml = r#"
1187[project]
1188name = "test"
1189collaborators = ["alice", "bob"]
1190
1191[tickets]
1192dir = "tickets"
1193
1194[git_host]
1195provider = "github"
1196repo = "org/repo"
1197"#;
1198        let config = load_config(toml);
1199        // No token in LocalConfig::default() — falls back to project.collaborators
1200        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
1201    }
1202
1203    #[test]
1204    fn github_mode_unknown_user_rejected() {
1205        let toml = r#"
1206[project]
1207name = "test"
1208collaborators = ["alice", "bob"]
1209
1210[tickets]
1211dir = "tickets"
1212
1213[git_host]
1214provider = "github"
1215repo = "org/repo"
1216"#;
1217        let config = load_config(toml);
1218        // No token — falls back to project.collaborators; charlie is not in the list
1219        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1220        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1221    }
1222
1223    #[test]
1224    fn github_mode_no_collaborators_skips_check() {
1225        let toml = r#"
1226[project]
1227name = "test"
1228
1229[tickets]
1230dir = "tickets"
1231
1232[git_host]
1233provider = "github"
1234repo = "org/repo"
1235"#;
1236        let config = load_config(toml);
1237        // Empty collaborators list — no validation
1238        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1239    }
1240
1241    #[test]
1242    fn github_mode_clear_owner_accepted() {
1243        let toml = r#"
1244[project]
1245name = "test"
1246collaborators = ["alice"]
1247
1248[tickets]
1249dir = "tickets"
1250
1251[git_host]
1252provider = "github"
1253repo = "org/repo"
1254"#;
1255        let config = load_config(toml);
1256        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1257    }
1258
1259    #[test]
1260    fn non_github_mode_unknown_user_rejected() {
1261        let toml = r#"
1262[project]
1263name = "test"
1264collaborators = ["alice", "bob"]
1265
1266[tickets]
1267dir = "tickets"
1268"#;
1269        let config = load_config(toml);
1270        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1271        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1272    }
1273
1274    #[test]
1275    fn validate_warnings_empty_container() {
1276        let toml = r#"
1277[project]
1278name = "test"
1279
1280[tickets]
1281dir = "tickets"
1282
1283[workers]
1284container = ""
1285"#;
1286        let config = load_config(toml);
1287        let warnings = super::validate_warnings(&config);
1288        assert!(warnings.is_empty(), "empty container string should not warn");
1289    }
1290
1291    #[test]
1292    fn worktree_missing_in_design() {
1293        let dir = setup_verify_repo();
1294        let root = dir.path();
1295        let config = Config::load(root).unwrap();
1296        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1297
1298        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1299
1300        let main_root = git_util::main_worktree_root(root).unwrap_or_else(|| root.to_path_buf());
1301        let wt_path = main_root.join("worktrees").join("ticket-abcd1234-test");
1302        let expected = format!(
1303            "#abcd1234 [in_design]: worktree at {} is missing",
1304            wt_path.display()
1305        );
1306        assert!(
1307            issues.iter().any(|i| i == &expected),
1308            "expected worktree missing issue; got: {issues:?}"
1309        );
1310    }
1311
1312    #[test]
1313    fn worktree_present_no_issue() {
1314        let dir = setup_verify_repo();
1315        let root = dir.path();
1316        let config = Config::load(root).unwrap();
1317        let ticket = make_verify_ticket(root, "abcd1234", "in_design", Some("ticket/abcd1234-test"));
1318
1319        std::fs::create_dir_all(root.join("worktrees").join("ticket-abcd1234-test")).unwrap();
1320
1321        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1322        assert!(
1323            !issues.iter().any(|i| i.contains("worktree")),
1324            "unexpected worktree issue; got: {issues:?}"
1325        );
1326    }
1327
1328    #[test]
1329    fn worktree_check_skipped_for_other_states() {
1330        let dir = setup_verify_repo();
1331        let root = dir.path();
1332        let config = Config::load(root).unwrap();
1333        let ticket = make_verify_ticket(root, "abcd1234", "specd", Some("ticket/abcd1234-test"));
1334
1335        let issues = verify_tickets(root, &config, &[ticket], &HashSet::new());
1336        assert!(
1337            !issues.iter().any(|i| i.contains("worktree")),
1338            "unexpected worktree issue for specd state; got: {issues:?}"
1339        );
1340    }
1341
1342    fn in_repo_wt_config(dir: &str) -> Config {
1343        let toml = format!(
1344            r#"
1345[project]
1346name = "test"
1347
1348[tickets]
1349dir = "tickets"
1350
1351[worktrees]
1352dir = "{dir}"
1353"#
1354        );
1355        toml::from_str(&toml).expect("config parse failed")
1356    }
1357
1358    #[test]
1359    fn validate_config_gitignore_missing_in_repo_wt() {
1360        let tmp = tempfile::TempDir::new().unwrap();
1361        let config = in_repo_wt_config("worktrees");
1362        let errors = validate_config(&config, tmp.path());
1363        assert!(
1364            errors.iter().any(|e| e.contains("worktrees") && e.contains(".gitignore")),
1365            "expected gitignore missing error; got: {errors:?}"
1366        );
1367    }
1368
1369    #[test]
1370    fn validate_config_gitignore_covered_anchored_slash() {
1371        let tmp = tempfile::TempDir::new().unwrap();
1372        std::fs::write(tmp.path().join(".gitignore"), "/worktrees/\n").unwrap();
1373        let config = in_repo_wt_config("worktrees");
1374        let errors = validate_config(&config, tmp.path());
1375        assert!(
1376            !errors.iter().any(|e| e.contains("gitignore")),
1377            "unexpected gitignore error; got: {errors:?}"
1378        );
1379    }
1380
1381    #[test]
1382    fn validate_config_gitignore_covered_anchored_no_slash() {
1383        let tmp = tempfile::TempDir::new().unwrap();
1384        std::fs::write(tmp.path().join(".gitignore"), "/worktrees\n").unwrap();
1385        let config = in_repo_wt_config("worktrees");
1386        let errors = validate_config(&config, tmp.path());
1387        assert!(
1388            !errors.iter().any(|e| e.contains("gitignore")),
1389            "unexpected gitignore error; got: {errors:?}"
1390        );
1391    }
1392
1393    #[test]
1394    fn validate_config_gitignore_covered_unanchored_slash() {
1395        let tmp = tempfile::TempDir::new().unwrap();
1396        std::fs::write(tmp.path().join(".gitignore"), "worktrees/\n").unwrap();
1397        let config = in_repo_wt_config("worktrees");
1398        let errors = validate_config(&config, tmp.path());
1399        assert!(
1400            !errors.iter().any(|e| e.contains("gitignore")),
1401            "unexpected gitignore error; got: {errors:?}"
1402        );
1403    }
1404
1405    #[test]
1406    fn validate_config_gitignore_covered_bare() {
1407        let tmp = tempfile::TempDir::new().unwrap();
1408        std::fs::write(tmp.path().join(".gitignore"), "worktrees\n").unwrap();
1409        let config = in_repo_wt_config("worktrees");
1410        let errors = validate_config(&config, tmp.path());
1411        assert!(
1412            !errors.iter().any(|e| e.contains("gitignore")),
1413            "unexpected gitignore error; got: {errors:?}"
1414        );
1415    }
1416
1417    #[test]
1418    fn validate_config_gitignore_not_covered() {
1419        let tmp = tempfile::TempDir::new().unwrap();
1420        std::fs::write(tmp.path().join(".gitignore"), "node_modules\n").unwrap();
1421        let config = in_repo_wt_config("worktrees");
1422        let errors = validate_config(&config, tmp.path());
1423        assert!(
1424            errors.iter().any(|e| e.contains("worktrees") && e.contains("gitignore")),
1425            "expected gitignore not covered error; got: {errors:?}"
1426        );
1427    }
1428
1429    #[test]
1430    fn validate_config_gitignore_no_false_positive() {
1431        let tmp = tempfile::TempDir::new().unwrap();
1432        std::fs::write(tmp.path().join(".gitignore"), "wt-old/\n").unwrap();
1433        let config = in_repo_wt_config("wt");
1434        let errors = validate_config(&config, tmp.path());
1435        assert!(
1436            errors.iter().any(|e| e.contains("wt") && e.contains("gitignore")),
1437            "wt-old should not match wt; got: {errors:?}"
1438        );
1439    }
1440
1441    #[test]
1442    fn validate_config_external_dotdot_no_check() {
1443        let tmp = tempfile::TempDir::new().unwrap();
1444        // No .gitignore at all
1445        let config = in_repo_wt_config("../ext");
1446        let errors = validate_config(&config, tmp.path());
1447        assert!(
1448            !errors.iter().any(|e| e.contains("gitignore")),
1449            "external dotdot path should skip gitignore check; got: {errors:?}"
1450        );
1451    }
1452
1453    #[test]
1454    fn validate_config_external_absolute_no_check() {
1455        let tmp = tempfile::TempDir::new().unwrap();
1456        // No .gitignore at all
1457        let config = in_repo_wt_config("/abs/path");
1458        let errors = validate_config(&config, tmp.path());
1459        assert!(
1460            !errors.iter().any(|e| e.contains("gitignore")),
1461            "absolute path should skip gitignore check; got: {errors:?}"
1462        );
1463    }
1464
1465    fn config_with_merge_transition(completion: &str, on_failure: Option<&str>, declare_failure_state: bool) -> Config {
1466        let on_failure_line = on_failure
1467            .map(|v| format!("on_failure = \"{v}\"\n"))
1468            .unwrap_or_default();
1469        let merge_failed_state = if declare_failure_state {
1470            r#"
1471[[workflow.states]]
1472id       = "merge_failed"
1473label    = "Merge failed"
1474
1475[[workflow.states.transitions]]
1476to = "closed"
1477"#
1478        } else {
1479            ""
1480        };
1481        let toml = format!(
1482            r#"
1483[project]
1484name = "test"
1485
1486[tickets]
1487dir = "tickets"
1488
1489[[workflow.states]]
1490id    = "in_progress"
1491label = "In Progress"
1492
1493[[workflow.states.transitions]]
1494to         = "implemented"
1495completion = "{completion}"
1496{on_failure_line}
1497[[workflow.states]]
1498id       = "implemented"
1499label    = "Implemented"
1500terminal = true
1501
1502[[workflow.states]]
1503id       = "closed"
1504label    = "Closed"
1505terminal = true
1506{merge_failed_state}
1507"#
1508        );
1509        toml::from_str(&toml).expect("config parse failed")
1510    }
1511
1512    #[test]
1513    fn test_on_failure_missing_for_merge() {
1514        let config = config_with_merge_transition("merge", None, false);
1515        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1516        assert!(
1517            errors.iter().any(|e| e.contains("missing `on_failure`")),
1518            "expected missing on_failure error; got: {errors:?}"
1519        );
1520    }
1521
1522    #[test]
1523    fn test_on_failure_missing_for_pr_or_epic_merge() {
1524        // No ticket with target_branch — rule fires on transition definition alone.
1525        let config = config_with_merge_transition("pr_or_epic_merge", None, false);
1526        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1527        assert!(
1528            errors.iter().any(|e| e.contains("missing `on_failure`")),
1529            "expected missing on_failure error for pr_or_epic_merge; got: {errors:?}"
1530        );
1531    }
1532
1533    #[test]
1534    fn test_on_failure_unknown_state() {
1535        let config = config_with_merge_transition("merge", Some("ghost_state"), false);
1536        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1537        assert!(
1538            errors.iter().any(|e| e.contains("ghost_state")),
1539            "expected unknown state error for ghost_state; got: {errors:?}"
1540        );
1541    }
1542
1543    #[test]
1544    fn test_on_failure_valid() {
1545        let config = config_with_merge_transition("merge", Some("merge_failed"), true);
1546        let errors = validate_config(&config, std::path::Path::new("/tmp"));
1547        let on_failure_errors: Vec<&String> = errors.iter()
1548            .filter(|e| e.contains("on_failure") || e.contains("ghost_state") || e.contains("merge_failed"))
1549            .collect();
1550        assert!(
1551            on_failure_errors.is_empty(),
1552            "unexpected on_failure errors: {on_failure_errors:?}"
1553        );
1554    }
1555}