Skip to main content

apm_core/
validate.rs

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
9/// Return the completion strategy configured for the `in_progress → implemented`
10/// transition.  Falls back to `None` when the transition is absent.
11pub 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
29/// Validate that `dep_ids` satisfy the dependency rules for `strategy`.
30///
31/// - `ticket_epic`: epic ID of the ticket being written (None if no epic)
32/// - `ticket_target_branch`: target_branch of the ticket (None = default branch)
33/// - `dep_ids`: the proposed dependency list (empty slice → always Ok)
34/// - `all_tickets`: all known tickets (used to look up dep metadata)
35/// - `default_branch`: project default branch name
36pub 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
97/// Walk every non-closed ticket and return a vec of `(subject, message)` pairs
98/// for each ticket whose `depends_on` violates the active completion strategy rule.
99pub 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
158/// Layer 1 of the two-layer manifest validation design.
159/// Validates all configured agent names and scans `.apm/agents/` for issues.
160/// Returns (errors, warnings) so callers can route them — keeps this single
161/// directory scan from running twice when both validate_config and
162/// validate_warnings are invoked.
163pub 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    // Collect configured agent names
172    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    // Validate each configured agent name (Layer 1 error check)
183    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    // Scan .apm/agents/ for per-directory warnings and errors
206    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        // Check for non-executable wrapper.* files (Unix only)
220        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        // Check manifest.toml
246        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    // Check whether any transition requires a provider.
294    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    // At least one non-terminal state.
309    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        // Terminal state with outgoing transitions.
316        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        // Non-terminal state with no outgoing transitions (tickets will be stranded).
325        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        // Instructions path exists on disk.
333        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            // Transition target must exist.  "closed" is a built-in terminal state
344            // that is always valid even when absent from [[workflow.states]].
345            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            // context_section must match a known ticket section.
353            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            // focus_section must match a known ticket section.
363            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            // Merge/PrOrEpicMerge transitions require on_failure.
373            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        // Skip terminal-state tickets.
451        if terminal.contains(fm.state.as_str()) { continue; }
452
453        let prefix = format!("#{} [{}]", fm.id, fm.state);
454
455        // State value not in config.
456        if !valid_states.is_empty() && !valid_states.contains(fm.state.as_str()) {
457            issues.push(format!("{prefix}: unknown state {:?}", fm.state));
458        }
459
460        // Frontmatter id doesn't match filename numeric prefix.
461        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        // in_progress/implemented with no branch.
469        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        // Branch merged but ticket not yet closed.
474        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        // in_design/in_progress with missing worktree directory.
483        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        // Missing ## Spec section.
497        if !t.body.contains("## Spec") {
498            issues.push(format!("{prefix}: missing ## Spec section"));
499        }
500
501        // Missing ## History section.
502        if !t.body.contains("## History") {
503            issues.push(format!("{prefix}: missing ## History section"));
504        }
505
506        // Validate document structure (required sections non-empty, AC items present).
507        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        // Validate frontmatter agent names against known built-ins.
514        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    // Dead-end reachability check: warn when no agent-actionable state can reach a
563    // transition whose outcome resolves to "success".
564    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                // Unknown targets are reported as errors by validate_config; skip
589                // here so reachability isn't computed against malformed transitions.
590                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
612/// Single-pass equivalent of calling `validate_config` followed by
613/// `validate_warnings` — runs the agent-directory scan once and dedupes.
614pub 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 1: correct config passes all checks
963    #[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 2: transition to non-existent state is detected
998    #[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 3: terminal state with outgoing transitions is detected
1020    #[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 5: ticket with unknown state is detected
1053    #[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 6: dead-end non-terminal state is detected
1067    #[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 7: context_section mismatch is detected
1094    #[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 8: focus_section mismatch is detected
1136    #[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 9: completion=pr without provider is detected
1178    #[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 10: completion=pr with provider configured passes
1209    #[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 11: context_section with empty ticket.sections is skipped
1243    #[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: closed state is not flagged as unknown even when absent from config
1274    #[test]
1275    fn closed_state_not_flagged_as_unknown() {
1276        use crate::ticket::Ticket;
1277
1278        // Config with no "closed" state
1279        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        // "closed" is not in state_ids, but the validate logic skips it.
1307        assert!(!state_ids.contains("closed"));
1308        // Simulate the validate check: closed should be exempt.
1309        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 for state_ids helper (kept for compatibility)
1315    #[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        // No token in LocalConfig::default() — falls back to project.collaborators
1421        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        // No token — falls back to project.collaborators; charlie is not in the list
1440        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        // Empty collaborators list — no validation
1459        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        // A workflow where the only agent-actionable state cycles back to itself
1515        // with no completion strategy — no "success" outcome is reachable.
1516        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        // The default workflow has in_progress → implemented with completion = pr_or_epic_merge,
1549        // reachable from the agent-actionable "ready" state. No dead-end warning should fire.
1550        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        // No .gitignore at all
1720        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        // No .gitignore at all
1732        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        // No ticket with target_branch — rule fires on transition definition alone.
1800        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    // --- frontmatter agent validation ---
1832
1833    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}