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
142pub fn validate_config(config: &Config, root: &Path) -> Vec<String> {
143    let mut errors: Vec<String> = Vec::new();
144
145    let state_ids: HashSet<&str> = config.workflow.states.iter()
146        .map(|s| s.id.as_str())
147        .collect();
148
149    let section_names: HashSet<&str> = config.ticket.sections.iter()
150        .map(|s| s.name.as_str())
151        .collect();
152    let has_sections = !section_names.is_empty();
153
154    // Check whether any transition requires a provider.
155    let needs_provider = config.workflow.states.iter()
156        .flat_map(|s| s.transitions.iter())
157        .any(|t| matches!(t.completion, CompletionStrategy::Pr | CompletionStrategy::Merge));
158
159    let provider_ok = config.git_host.provider.as_ref()
160        .map(|p| !p.is_empty())
161        .unwrap_or(false);
162
163    if needs_provider && !provider_ok {
164        errors.push(
165            "config: workflow — completion 'pr' or 'merge' requires [git_host] with a provider".into()
166        );
167    }
168
169    // At least one non-terminal state.
170    let has_non_terminal = config.workflow.states.iter().any(|s| !s.terminal);
171    if !has_non_terminal {
172        errors.push("config: workflow — no non-terminal state exists".into());
173    }
174
175    for state in &config.workflow.states {
176        // Terminal state with outgoing transitions.
177        if state.terminal && !state.transitions.is_empty() {
178            errors.push(format!(
179                "config: state.{} — terminal but has {} outgoing transition(s)",
180                state.id,
181                state.transitions.len()
182            ));
183        }
184
185        // Non-terminal state with no outgoing transitions (tickets will be stranded).
186        if !state.terminal && state.transitions.is_empty() {
187            errors.push(format!(
188                "config: state.{} — no outgoing transitions (tickets will be stranded)",
189                state.id
190            ));
191        }
192
193        // Instructions path exists on disk.
194        if let Some(instructions) = &state.instructions {
195            if !root.join(instructions).exists() {
196                errors.push(format!(
197                    "config: state.{}.instructions — file not found: {}",
198                    state.id, instructions
199                ));
200            }
201        }
202
203        for transition in &state.transitions {
204            // Transition target must exist.  "closed" is a built-in terminal state
205            // that is always valid even when absent from [[workflow.states]].
206            if transition.to != "closed" && !state_ids.contains(transition.to.as_str()) {
207                errors.push(format!(
208                    "config: state.{}.transition({}) — target state '{}' does not exist",
209                    state.id, transition.to, transition.to
210                ));
211            }
212
213            // context_section must match a known ticket section.
214            if let Some(section) = &transition.context_section {
215                if has_sections && !section_names.contains(section.as_str()) {
216                    errors.push(format!(
217                        "config: state.{}.transition({}).context_section — unknown section '{}'",
218                        state.id, transition.to, section
219                    ));
220                }
221            }
222
223            // focus_section must match a known ticket section.
224            if let Some(section) = &transition.focus_section {
225                if has_sections && !section_names.contains(section.as_str()) {
226                    errors.push(format!(
227                        "config: state.{}.transition({}).focus_section — unknown section '{}'",
228                        state.id, transition.to, section
229                    ));
230                }
231            }
232        }
233    }
234
235    errors
236}
237
238pub fn validate_warnings(config: &crate::config::Config) -> Vec<String> {
239    let mut warnings = config.load_warnings.clone();
240    if let Some(container) = &config.workers.container {
241        if !container.is_empty() {
242            let docker_ok = std::process::Command::new("docker")
243                .arg("--version")
244                .output()
245                .map(|o| o.status.success())
246                .unwrap_or(false);
247            if !docker_ok {
248                warnings.push(
249                    "workers.container is set but 'docker' is not in PATH".to_string()
250                );
251            }
252        }
253    }
254    warnings
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::config::{Config, CompletionStrategy, LocalConfig};
261    use crate::ticket::Ticket;
262    use std::path::Path;
263
264    fn make_ticket(id: &str, epic: Option<&str>, target_branch: Option<&str>) -> Ticket {
265        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
266        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
267        let raw = format!(
268            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"ready\"\n{epic_line}{target_line}+++\n\n"
269        );
270        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
271    }
272
273    fn strategy_config(completion: &str) -> Config {
274        let toml = format!(
275            r#"
276[project]
277name = "test"
278
279[tickets]
280dir = "tickets"
281
282[[workflow.states]]
283id    = "in_progress"
284label = "In Progress"
285
286[[workflow.states.transitions]]
287to         = "implemented"
288completion = "{completion}"
289
290[[workflow.states]]
291id       = "implemented"
292label    = "Implemented"
293terminal = true
294"#
295        );
296        toml::from_str(&toml).unwrap()
297    }
298
299    #[test]
300    fn strategy_finds_in_progress_to_implemented() {
301        let config = strategy_config("pr_or_epic_merge");
302        assert_eq!(active_completion_strategy(&config), CompletionStrategy::PrOrEpicMerge);
303    }
304
305    #[test]
306    fn strategy_defaults_to_none_when_absent() {
307        let toml = r#"
308[project]
309name = "test"
310
311[tickets]
312dir = "tickets"
313
314[[workflow.states]]
315id    = "new"
316label = "New"
317
318[[workflow.states.transitions]]
319to = "closed"
320
321[[workflow.states]]
322id       = "closed"
323label    = "Closed"
324terminal = true
325"#;
326        let config: Config = toml::from_str(toml).unwrap();
327        assert_eq!(active_completion_strategy(&config), CompletionStrategy::None);
328    }
329
330    #[test]
331    fn dep_rules_pr_rejects_dep() {
332        let dep = make_ticket("dep1", None, None);
333        let result = check_depends_on_rules(
334            &CompletionStrategy::Pr,
335            None,
336            None,
337            &["dep1".to_string()],
338            &[dep],
339            "main",
340        );
341        assert!(result.is_err());
342        let msg = result.unwrap_err().to_string();
343        assert!(msg.contains("pr"), "expected strategy name in: {msg}");
344    }
345
346    #[test]
347    fn dep_rules_none_rejects_dep() {
348        let dep = make_ticket("dep1", None, None);
349        let result = check_depends_on_rules(
350            &CompletionStrategy::None,
351            None,
352            None,
353            &["dep1".to_string()],
354            &[dep],
355            "main",
356        );
357        assert!(result.is_err());
358        let msg = result.unwrap_err().to_string();
359        assert!(msg.contains("none"), "expected strategy name in: {msg}");
360    }
361
362    #[test]
363    fn dep_rules_pr_or_epic_merge_same_epic_ok() {
364        let dep = make_ticket("dep1", Some("abc"), None);
365        let result = check_depends_on_rules(
366            &CompletionStrategy::PrOrEpicMerge,
367            Some("abc"),
368            None,
369            &["dep1".to_string()],
370            &[dep],
371            "main",
372        );
373        assert!(result.is_ok(), "expected Ok, got {result:?}");
374    }
375
376    #[test]
377    fn dep_rules_pr_or_epic_merge_different_epic_fails() {
378        let dep = make_ticket("dep1", Some("xyz"), None);
379        let result = check_depends_on_rules(
380            &CompletionStrategy::PrOrEpicMerge,
381            Some("abc"),
382            None,
383            &["dep1".to_string()],
384            &[dep],
385            "main",
386        );
387        assert!(result.is_err());
388        let msg = result.unwrap_err().to_string();
389        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
390    }
391
392    #[test]
393    fn dep_rules_pr_or_epic_merge_ticket_no_epic_fails() {
394        let dep = make_ticket("dep1", Some("abc"), None);
395        let result = check_depends_on_rules(
396            &CompletionStrategy::PrOrEpicMerge,
397            None,
398            None,
399            &["dep1".to_string()],
400            &[dep],
401            "main",
402        );
403        assert!(result.is_err());
404        let msg = result.unwrap_err().to_string();
405        assert!(msg.contains("epic"), "expected epic mention in: {msg}");
406    }
407
408    #[test]
409    fn dep_rules_merge_both_default_branch_ok() {
410        let dep = make_ticket("dep1", None, None);
411        let result = check_depends_on_rules(
412            &CompletionStrategy::Merge,
413            None,
414            None,
415            &["dep1".to_string()],
416            &[dep],
417            "main",
418        );
419        assert!(result.is_ok(), "expected Ok, got {result:?}");
420    }
421
422    #[test]
423    fn dep_rules_merge_different_target_fails() {
424        let dep = make_ticket("dep1", None, Some("epic/other"));
425        let result = check_depends_on_rules(
426            &CompletionStrategy::Merge,
427            None,
428            None,
429            &["dep1".to_string()],
430            &[dep],
431            "main",
432        );
433        assert!(result.is_err());
434        let msg = result.unwrap_err().to_string();
435        assert!(msg.contains("dep1"), "expected dep ID in: {msg}");
436    }
437
438    fn make_full_ticket(id: &str, state: &str, epic: Option<&str>, target_branch: Option<&str>, depends_on: &[&str]) -> Ticket {
439        let epic_line = epic.map(|e| format!("epic = \"{e}\"\n")).unwrap_or_default();
440        let target_line = target_branch.map(|b| format!("target_branch = \"{b}\"\n")).unwrap_or_default();
441        let deps_line = if depends_on.is_empty() {
442            String::new()
443        } else {
444            let quoted: Vec<String> = depends_on.iter().map(|d| format!("\"{d}\"")).collect();
445            format!("depends_on = [{}]\n", quoted.join(", "))
446        };
447        let raw = format!(
448            "+++\nid = \"{id}\"\ntitle = \"T\"\nstate = \"{state}\"\n{epic_line}{target_line}{deps_line}+++\n\n"
449        );
450        Ticket::parse(Path::new(&format!("tickets/{id}-t.md")), &raw).unwrap()
451    }
452
453    #[test]
454    fn validate_depends_on_no_deps_clean() {
455        let config = strategy_config("pr_or_epic_merge");
456        let t1 = make_full_ticket("aa000001", "ready", Some("epic1"), None, &[]);
457        let t2 = make_full_ticket("aa000002", "in_progress", Some("epic1"), None, &[]);
458        let result = validate_depends_on(&config, &[t1, t2]);
459        assert!(result.is_empty(), "expected no violations, got {result:?}");
460    }
461
462    #[test]
463    fn validate_depends_on_closed_ticket_skipped() {
464        let config = strategy_config("pr");
465        let dep = make_full_ticket("bb000001", "closed", None, None, &[]);
466        let ticket = make_full_ticket("bb000002", "closed", None, None, &["bb000001"]);
467        let result = validate_depends_on(&config, &[dep, ticket]);
468        assert!(result.is_empty(), "closed ticket should be skipped, got {result:?}");
469    }
470
471    #[test]
472    fn validate_depends_on_pr_or_epic_merge_same_epic_ok() {
473        let config = strategy_config("pr_or_epic_merge");
474        let dep = make_full_ticket("cc000001", "ready", Some("abc"), None, &[]);
475        let ticket = make_full_ticket("cc000002", "ready", Some("abc"), None, &["cc000001"]);
476        let result = validate_depends_on(&config, &[dep, ticket]);
477        assert!(result.is_empty(), "same-epic deps should pass, got {result:?}");
478    }
479
480    #[test]
481    fn validate_depends_on_pr_or_epic_merge_cross_epic_fails() {
482        let config = strategy_config("pr_or_epic_merge");
483        let dep = make_full_ticket("dd000001", "ready", Some("xyz"), None, &[]);
484        let ticket = make_full_ticket("dd000002", "ready", Some("abc"), None, &["dd000001"]);
485        let result = validate_depends_on(&config, &[dep, ticket]);
486        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
487        assert!(result[0].1.contains("dd000001"), "message should mention dep ID: {}", result[0].1);
488    }
489
490    #[test]
491    fn validate_depends_on_merge_same_target_ok() {
492        let config = strategy_config("merge");
493        let dep = make_full_ticket("ee000001", "ready", None, Some("feat"), &[]);
494        let ticket = make_full_ticket("ee000002", "ready", None, Some("feat"), &["ee000001"]);
495        let result = validate_depends_on(&config, &[dep, ticket]);
496        assert!(result.is_empty(), "same-target deps should pass, got {result:?}");
497    }
498
499    #[test]
500    fn validate_depends_on_merge_different_target_fails() {
501        let config = strategy_config("merge");
502        let dep = make_full_ticket("ff000001", "ready", None, Some("other"), &[]);
503        let ticket = make_full_ticket("ff000002", "ready", None, Some("feat"), &["ff000001"]);
504        let result = validate_depends_on(&config, &[dep, ticket]);
505        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
506        assert!(result[0].1.contains("ff000001"), "message should mention dep ID: {}", result[0].1);
507    }
508
509    #[test]
510    fn validate_depends_on_pr_strategy_rejects_any_dep() {
511        let config = strategy_config("pr");
512        let dep = make_full_ticket("gg000001", "ready", None, None, &[]);
513        let ticket = make_full_ticket("gg000002", "ready", None, None, &["gg000001"]);
514        let result = validate_depends_on(&config, &[dep, ticket]);
515        assert_eq!(result.len(), 1, "expected one violation, got {result:?}");
516        assert!(result[0].1.contains("pr"), "message should mention strategy: {}", result[0].1);
517    }
518
519    fn load_config(toml: &str) -> Config {
520        toml::from_str(toml).expect("config parse failed")
521    }
522
523    fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
524        config.workflow.states.iter().map(|s| s.id.as_str()).collect()
525    }
526
527    // Test 1: correct config passes all checks
528    #[test]
529    fn correct_config_passes() {
530        let toml = r#"
531[project]
532name = "test"
533
534[tickets]
535dir = "tickets"
536
537[[workflow.states]]
538id    = "new"
539label = "New"
540
541[[workflow.states.transitions]]
542to = "in_progress"
543
544[[workflow.states]]
545id       = "in_progress"
546label    = "In Progress"
547terminal = false
548
549[[workflow.states.transitions]]
550to = "closed"
551
552[[workflow.states]]
553id       = "closed"
554label    = "Closed"
555terminal = true
556"#;
557        let config = load_config(toml);
558        let errors = validate_config(&config, Path::new("/tmp"));
559        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
560    }
561
562    // Test 2: transition to non-existent state is detected
563    #[test]
564    fn transition_to_nonexistent_state_detected() {
565        let toml = r#"
566[project]
567name = "test"
568
569[tickets]
570dir = "tickets"
571
572[[workflow.states]]
573id    = "new"
574label = "New"
575
576[[workflow.states.transitions]]
577to = "ghost"
578"#;
579        let config = load_config(toml);
580        let errors = validate_config(&config, Path::new("/tmp"));
581        assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
582    }
583
584    // Test 3: terminal state with outgoing transitions is detected
585    #[test]
586    fn terminal_state_with_transitions_detected() {
587        let toml = r#"
588[project]
589name = "test"
590
591[tickets]
592dir = "tickets"
593
594[[workflow.states]]
595id       = "closed"
596label    = "Closed"
597terminal = true
598
599[[workflow.states.transitions]]
600to = "new"
601
602[[workflow.states]]
603id    = "new"
604label = "New"
605
606[[workflow.states.transitions]]
607to = "closed"
608"#;
609        let config = load_config(toml);
610        let errors = validate_config(&config, Path::new("/tmp"));
611        assert!(
612            errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
613            "expected terminal error in {errors:?}"
614        );
615    }
616
617    // Test 5: ticket with unknown state is detected
618    #[test]
619    fn ticket_with_unknown_state_detected() {
620        use crate::ticket::Ticket;
621
622        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
623        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
624
625        let known_states: std::collections::HashSet<&str> =
626            ["new", "ready", "closed"].iter().copied().collect();
627
628        assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
629    }
630
631    // Test 6: dead-end non-terminal state is detected
632    #[test]
633    fn dead_end_non_terminal_detected() {
634        let toml = r#"
635[project]
636name = "test"
637
638[tickets]
639dir = "tickets"
640
641[[workflow.states]]
642id    = "stuck"
643label = "Stuck"
644
645[[workflow.states]]
646id       = "closed"
647label    = "Closed"
648terminal = true
649"#;
650        let config = load_config(toml);
651        let errors = validate_config(&config, Path::new("/tmp"));
652        assert!(
653            errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
654            "expected dead-end error in {errors:?}"
655        );
656    }
657
658    // Test 7: context_section mismatch is detected
659    #[test]
660    fn context_section_mismatch_detected() {
661        let toml = r#"
662[project]
663name = "test"
664
665[tickets]
666dir = "tickets"
667
668[[ticket.sections]]
669name = "Problem"
670type = "free"
671
672[[workflow.states]]
673id    = "new"
674label = "New"
675
676[[workflow.states.transitions]]
677to              = "ready"
678context_section = "NonExistent"
679
680[[workflow.states]]
681id    = "ready"
682label = "Ready"
683
684[[workflow.states.transitions]]
685to = "closed"
686
687[[workflow.states]]
688id       = "closed"
689label    = "Closed"
690terminal = true
691"#;
692        let config = load_config(toml);
693        let errors = validate_config(&config, Path::new("/tmp"));
694        assert!(
695            errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
696            "expected context_section error in {errors:?}"
697        );
698    }
699
700    // Test 8: focus_section mismatch is detected
701    #[test]
702    fn focus_section_mismatch_detected() {
703        let toml = r#"
704[project]
705name = "test"
706
707[tickets]
708dir = "tickets"
709
710[[ticket.sections]]
711name = "Problem"
712type = "free"
713
714[[workflow.states]]
715id    = "new"
716label = "New"
717
718[[workflow.states.transitions]]
719to             = "ready"
720focus_section  = "BadSection"
721
722[[workflow.states]]
723id    = "ready"
724label = "Ready"
725
726[[workflow.states.transitions]]
727to = "closed"
728
729[[workflow.states]]
730id       = "closed"
731label    = "Closed"
732terminal = true
733"#;
734        let config = load_config(toml);
735        let errors = validate_config(&config, Path::new("/tmp"));
736        assert!(
737            errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
738            "expected focus_section error in {errors:?}"
739        );
740    }
741
742    // Test 9: completion=pr without provider is detected
743    #[test]
744    fn completion_pr_without_provider_detected() {
745        let toml = r#"
746[project]
747name = "test"
748
749[tickets]
750dir = "tickets"
751
752[[workflow.states]]
753id    = "new"
754label = "New"
755
756[[workflow.states.transitions]]
757to         = "closed"
758completion = "pr"
759
760[[workflow.states]]
761id       = "closed"
762label    = "Closed"
763terminal = true
764"#;
765        let config = load_config(toml);
766        let errors = validate_config(&config, Path::new("/tmp"));
767        assert!(
768            errors.iter().any(|e| e.contains("provider")),
769            "expected provider error in {errors:?}"
770        );
771    }
772
773    // Test 10: completion=pr with provider configured passes
774    #[test]
775    fn completion_pr_with_provider_passes() {
776        let toml = r#"
777[project]
778name = "test"
779
780[tickets]
781dir = "tickets"
782
783[git_host]
784provider = "github"
785
786[[workflow.states]]
787id    = "new"
788label = "New"
789
790[[workflow.states.transitions]]
791to         = "closed"
792completion = "pr"
793
794[[workflow.states]]
795id       = "closed"
796label    = "Closed"
797terminal = true
798"#;
799        let config = load_config(toml);
800        let errors = validate_config(&config, Path::new("/tmp"));
801        assert!(
802            !errors.iter().any(|e| e.contains("provider")),
803            "unexpected provider error in {errors:?}"
804        );
805    }
806
807    // Test 11: context_section with empty ticket.sections is skipped
808    #[test]
809    fn context_section_skipped_when_no_sections_defined() {
810        let toml = r#"
811[project]
812name = "test"
813
814[tickets]
815dir = "tickets"
816
817[[workflow.states]]
818id    = "new"
819label = "New"
820
821[[workflow.states.transitions]]
822to              = "closed"
823context_section = "AnySection"
824
825[[workflow.states]]
826id       = "closed"
827label    = "Closed"
828terminal = true
829"#;
830        let config = load_config(toml);
831        let errors = validate_config(&config, Path::new("/tmp"));
832        assert!(
833            !errors.iter().any(|e| e.contains("context_section")),
834            "unexpected context_section error in {errors:?}"
835        );
836    }
837
838    // Test: closed state is not flagged as unknown even when absent from config
839    #[test]
840    fn closed_state_not_flagged_as_unknown() {
841        use crate::ticket::Ticket;
842
843        // Config with no "closed" state
844        let toml = r#"
845[project]
846name = "test"
847
848[tickets]
849dir = "tickets"
850
851[[workflow.states]]
852id    = "new"
853label = "New"
854
855[[workflow.states.transitions]]
856to = "done"
857
858[[workflow.states]]
859id       = "done"
860label    = "Done"
861terminal = true
862"#;
863        let config = load_config(toml);
864        let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
865            .map(|s| s.id.as_str())
866            .collect();
867
868        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
869        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
870
871        // "closed" is not in state_ids, but the validate logic skips it.
872        assert!(!state_ids.contains("closed"));
873        // Simulate the validate check: closed should be exempt.
874        let fm = &ticket.frontmatter;
875        let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
876        assert!(!flagged, "closed state should not be flagged as unknown");
877    }
878
879    // Test for state_ids helper (kept for compatibility)
880    #[test]
881    fn state_ids_helper() {
882        let toml = r#"
883[project]
884name = "test"
885
886[tickets]
887dir = "tickets"
888
889[[workflow.states]]
890id    = "new"
891label = "New"
892"#;
893        let config = load_config(toml);
894        let ids = state_ids(&config);
895        assert!(ids.contains("new"));
896    }
897
898    #[test]
899    fn validate_warnings_no_container() {
900        let toml = r#"
901[project]
902name = "test"
903
904[tickets]
905dir = "tickets"
906"#;
907        let config = load_config(toml);
908        let warnings = super::validate_warnings(&config);
909        assert!(warnings.is_empty());
910    }
911
912    #[test]
913    fn valid_collaborator_accepted() {
914        let toml = r#"
915[project]
916name = "test"
917collaborators = ["alice", "bob"]
918
919[tickets]
920dir = "tickets"
921"#;
922        let config = load_config(toml);
923        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
924    }
925
926    #[test]
927    fn unknown_user_rejected() {
928        let toml = r#"
929[project]
930name = "test"
931collaborators = ["alice", "bob"]
932
933[tickets]
934dir = "tickets"
935"#;
936        let config = load_config(toml);
937        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
938        let msg = err.to_string();
939        assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
940        assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
941    }
942
943    #[test]
944    fn empty_collaborators_skips_validation() {
945        let toml = r#"
946[project]
947name = "test"
948
949[tickets]
950dir = "tickets"
951"#;
952        let config = load_config(toml);
953        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
954    }
955
956    #[test]
957    fn clear_owner_always_allowed() {
958        let toml = r#"
959[project]
960name = "test"
961collaborators = ["alice"]
962
963[tickets]
964dir = "tickets"
965"#;
966        let config = load_config(toml);
967        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
968    }
969
970    #[test]
971    fn github_mode_known_user_accepted() {
972        let toml = r#"
973[project]
974name = "test"
975collaborators = ["alice", "bob"]
976
977[tickets]
978dir = "tickets"
979
980[git_host]
981provider = "github"
982repo = "org/repo"
983"#;
984        let config = load_config(toml);
985        // No token in LocalConfig::default() — falls back to project.collaborators
986        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
987    }
988
989    #[test]
990    fn github_mode_unknown_user_rejected() {
991        let toml = r#"
992[project]
993name = "test"
994collaborators = ["alice", "bob"]
995
996[tickets]
997dir = "tickets"
998
999[git_host]
1000provider = "github"
1001repo = "org/repo"
1002"#;
1003        let config = load_config(toml);
1004        // No token — falls back to project.collaborators; charlie is not in the list
1005        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1006        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1007    }
1008
1009    #[test]
1010    fn github_mode_no_collaborators_skips_check() {
1011        let toml = r#"
1012[project]
1013name = "test"
1014
1015[tickets]
1016dir = "tickets"
1017
1018[git_host]
1019provider = "github"
1020repo = "org/repo"
1021"#;
1022        let config = load_config(toml);
1023        // Empty collaborators list — no validation
1024        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
1025    }
1026
1027    #[test]
1028    fn github_mode_clear_owner_accepted() {
1029        let toml = r#"
1030[project]
1031name = "test"
1032collaborators = ["alice"]
1033
1034[tickets]
1035dir = "tickets"
1036
1037[git_host]
1038provider = "github"
1039repo = "org/repo"
1040"#;
1041        let config = load_config(toml);
1042        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
1043    }
1044
1045    #[test]
1046    fn non_github_mode_unknown_user_rejected() {
1047        let toml = r#"
1048[project]
1049name = "test"
1050collaborators = ["alice", "bob"]
1051
1052[tickets]
1053dir = "tickets"
1054"#;
1055        let config = load_config(toml);
1056        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
1057        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
1058    }
1059
1060    #[test]
1061    fn validate_warnings_empty_container() {
1062        let toml = r#"
1063[project]
1064name = "test"
1065
1066[tickets]
1067dir = "tickets"
1068
1069[workers]
1070container = ""
1071"#;
1072        let config = load_config(toml);
1073        let warnings = super::validate_warnings(&config);
1074        assert!(warnings.is_empty(), "empty container string should not warn");
1075    }
1076}