Skip to main content

apm_core/
validate.rs

1use crate::config::{CompletionStrategy, Config, LocalConfig};
2use anyhow::{bail, Result};
3use std::collections::HashSet;
4use std::path::Path;
5
6pub fn validate_owner(config: &Config, local: &LocalConfig, username: &str) -> Result<()> {
7    if username == "-" {
8        return Ok(());
9    }
10    let (collaborators, warnings) = crate::config::resolve_collaborators(config, local);
11    for w in &warnings {
12        #[allow(clippy::print_stderr)]
13        { eprintln!("{w}"); }
14    }
15    if collaborators.is_empty() {
16        return Ok(());
17    }
18    if collaborators.iter().any(|c| c == username) {
19        return Ok(());
20    }
21    let list = collaborators.join(", ");
22    bail!("unknown user '{username}'; valid collaborators: {list}");
23}
24
25pub fn validate_config(config: &Config, root: &Path) -> Vec<String> {
26    let mut errors: Vec<String> = Vec::new();
27
28    let state_ids: HashSet<&str> = config.workflow.states.iter()
29        .map(|s| s.id.as_str())
30        .collect();
31
32    let section_names: HashSet<&str> = config.ticket.sections.iter()
33        .map(|s| s.name.as_str())
34        .collect();
35    let has_sections = !section_names.is_empty();
36
37    // Check whether any transition requires a provider.
38    let needs_provider = config.workflow.states.iter()
39        .flat_map(|s| s.transitions.iter())
40        .any(|t| matches!(t.completion, CompletionStrategy::Pr | CompletionStrategy::Merge));
41
42    let provider_ok = config.git_host.provider.as_ref()
43        .map(|p| !p.is_empty())
44        .unwrap_or(false);
45
46    if needs_provider && !provider_ok {
47        errors.push(
48            "config: workflow — completion 'pr' or 'merge' requires [git_host] with a provider".into()
49        );
50    }
51
52    // At least one non-terminal state.
53    let has_non_terminal = config.workflow.states.iter().any(|s| !s.terminal);
54    if !has_non_terminal {
55        errors.push("config: workflow — no non-terminal state exists".into());
56    }
57
58    for state in &config.workflow.states {
59        // Terminal state with outgoing transitions.
60        if state.terminal && !state.transitions.is_empty() {
61            errors.push(format!(
62                "config: state.{} — terminal but has {} outgoing transition(s)",
63                state.id,
64                state.transitions.len()
65            ));
66        }
67
68        // Non-terminal state with no outgoing transitions (tickets will be stranded).
69        if !state.terminal && state.transitions.is_empty() {
70            errors.push(format!(
71                "config: state.{} — no outgoing transitions (tickets will be stranded)",
72                state.id
73            ));
74        }
75
76        // Instructions path exists on disk.
77        if let Some(instructions) = &state.instructions {
78            if !root.join(instructions).exists() {
79                errors.push(format!(
80                    "config: state.{}.instructions — file not found: {}",
81                    state.id, instructions
82                ));
83            }
84        }
85
86        for transition in &state.transitions {
87            // Transition target must exist.  "closed" is a built-in terminal state
88            // that is always valid even when absent from [[workflow.states]].
89            if transition.to != "closed" && !state_ids.contains(transition.to.as_str()) {
90                errors.push(format!(
91                    "config: state.{}.transition({}) — target state '{}' does not exist",
92                    state.id, transition.to, transition.to
93                ));
94            }
95
96            // context_section must match a known ticket section.
97            if let Some(section) = &transition.context_section {
98                if has_sections && !section_names.contains(section.as_str()) {
99                    errors.push(format!(
100                        "config: state.{}.transition({}).context_section — unknown section '{}'",
101                        state.id, transition.to, section
102                    ));
103                }
104            }
105
106            // focus_section must match a known ticket section.
107            if let Some(section) = &transition.focus_section {
108                if has_sections && !section_names.contains(section.as_str()) {
109                    errors.push(format!(
110                        "config: state.{}.transition({}).focus_section — unknown section '{}'",
111                        state.id, transition.to, section
112                    ));
113                }
114            }
115        }
116    }
117
118    errors
119}
120
121pub fn validate_warnings(config: &crate::config::Config) -> Vec<String> {
122    let mut warnings = config.load_warnings.clone();
123    if let Some(container) = &config.workers.container {
124        if !container.is_empty() {
125            let docker_ok = std::process::Command::new("docker")
126                .arg("--version")
127                .output()
128                .map(|o| o.status.success())
129                .unwrap_or(false);
130            if !docker_ok {
131                warnings.push(
132                    "workers.container is set but 'docker' is not in PATH".to_string()
133                );
134            }
135        }
136    }
137    warnings
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::config::{Config, LocalConfig};
144    use std::path::Path;
145
146    fn load_config(toml: &str) -> Config {
147        toml::from_str(toml).expect("config parse failed")
148    }
149
150    fn state_ids(config: &Config) -> std::collections::HashSet<&str> {
151        config.workflow.states.iter().map(|s| s.id.as_str()).collect()
152    }
153
154    // Test 1: correct config passes all checks
155    #[test]
156    fn correct_config_passes() {
157        let toml = r#"
158[project]
159name = "test"
160
161[tickets]
162dir = "tickets"
163
164[[workflow.states]]
165id    = "new"
166label = "New"
167
168[[workflow.states.transitions]]
169to = "in_progress"
170
171[[workflow.states]]
172id       = "in_progress"
173label    = "In Progress"
174terminal = false
175
176[[workflow.states.transitions]]
177to = "closed"
178
179[[workflow.states]]
180id       = "closed"
181label    = "Closed"
182terminal = true
183"#;
184        let config = load_config(toml);
185        let errors = validate_config(&config, Path::new("/tmp"));
186        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
187    }
188
189    // Test 2: transition to non-existent state is detected
190    #[test]
191    fn transition_to_nonexistent_state_detected() {
192        let toml = r#"
193[project]
194name = "test"
195
196[tickets]
197dir = "tickets"
198
199[[workflow.states]]
200id    = "new"
201label = "New"
202
203[[workflow.states.transitions]]
204to = "ghost"
205"#;
206        let config = load_config(toml);
207        let errors = validate_config(&config, Path::new("/tmp"));
208        assert!(errors.iter().any(|e| e.contains("ghost")), "expected ghost error in {errors:?}");
209    }
210
211    // Test 3: terminal state with outgoing transitions is detected
212    #[test]
213    fn terminal_state_with_transitions_detected() {
214        let toml = r#"
215[project]
216name = "test"
217
218[tickets]
219dir = "tickets"
220
221[[workflow.states]]
222id       = "closed"
223label    = "Closed"
224terminal = true
225
226[[workflow.states.transitions]]
227to = "new"
228
229[[workflow.states]]
230id    = "new"
231label = "New"
232
233[[workflow.states.transitions]]
234to = "closed"
235"#;
236        let config = load_config(toml);
237        let errors = validate_config(&config, Path::new("/tmp"));
238        assert!(
239            errors.iter().any(|e| e.contains("state.closed") && e.contains("terminal")),
240            "expected terminal error in {errors:?}"
241        );
242    }
243
244    // Test 5: ticket with unknown state is detected
245    #[test]
246    fn ticket_with_unknown_state_detected() {
247        use crate::ticket::Ticket;
248
249        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"phantom\"\n+++\n\n## Spec\n";
250        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
251
252        let known_states: std::collections::HashSet<&str> =
253            ["new", "ready", "closed"].iter().copied().collect();
254
255        assert!(!known_states.contains(ticket.frontmatter.state.as_str()));
256    }
257
258    // Test 6: dead-end non-terminal state is detected
259    #[test]
260    fn dead_end_non_terminal_detected() {
261        let toml = r#"
262[project]
263name = "test"
264
265[tickets]
266dir = "tickets"
267
268[[workflow.states]]
269id    = "stuck"
270label = "Stuck"
271
272[[workflow.states]]
273id       = "closed"
274label    = "Closed"
275terminal = true
276"#;
277        let config = load_config(toml);
278        let errors = validate_config(&config, Path::new("/tmp"));
279        assert!(
280            errors.iter().any(|e| e.contains("state.stuck") && e.contains("no outgoing transitions")),
281            "expected dead-end error in {errors:?}"
282        );
283    }
284
285    // Test 7: context_section mismatch is detected
286    #[test]
287    fn context_section_mismatch_detected() {
288        let toml = r#"
289[project]
290name = "test"
291
292[tickets]
293dir = "tickets"
294
295[[ticket.sections]]
296name = "Problem"
297type = "free"
298
299[[workflow.states]]
300id    = "new"
301label = "New"
302
303[[workflow.states.transitions]]
304to              = "ready"
305context_section = "NonExistent"
306
307[[workflow.states]]
308id    = "ready"
309label = "Ready"
310
311[[workflow.states.transitions]]
312to = "closed"
313
314[[workflow.states]]
315id       = "closed"
316label    = "Closed"
317terminal = true
318"#;
319        let config = load_config(toml);
320        let errors = validate_config(&config, Path::new("/tmp"));
321        assert!(
322            errors.iter().any(|e| e.contains("context_section") && e.contains("NonExistent")),
323            "expected context_section error in {errors:?}"
324        );
325    }
326
327    // Test 8: focus_section mismatch is detected
328    #[test]
329    fn focus_section_mismatch_detected() {
330        let toml = r#"
331[project]
332name = "test"
333
334[tickets]
335dir = "tickets"
336
337[[ticket.sections]]
338name = "Problem"
339type = "free"
340
341[[workflow.states]]
342id    = "new"
343label = "New"
344
345[[workflow.states.transitions]]
346to             = "ready"
347focus_section  = "BadSection"
348
349[[workflow.states]]
350id    = "ready"
351label = "Ready"
352
353[[workflow.states.transitions]]
354to = "closed"
355
356[[workflow.states]]
357id       = "closed"
358label    = "Closed"
359terminal = true
360"#;
361        let config = load_config(toml);
362        let errors = validate_config(&config, Path::new("/tmp"));
363        assert!(
364            errors.iter().any(|e| e.contains("focus_section") && e.contains("BadSection")),
365            "expected focus_section error in {errors:?}"
366        );
367    }
368
369    // Test 9: completion=pr without provider is detected
370    #[test]
371    fn completion_pr_without_provider_detected() {
372        let toml = r#"
373[project]
374name = "test"
375
376[tickets]
377dir = "tickets"
378
379[[workflow.states]]
380id    = "new"
381label = "New"
382
383[[workflow.states.transitions]]
384to         = "closed"
385completion = "pr"
386
387[[workflow.states]]
388id       = "closed"
389label    = "Closed"
390terminal = true
391"#;
392        let config = load_config(toml);
393        let errors = validate_config(&config, Path::new("/tmp"));
394        assert!(
395            errors.iter().any(|e| e.contains("provider")),
396            "expected provider error in {errors:?}"
397        );
398    }
399
400    // Test 10: completion=pr with provider configured passes
401    #[test]
402    fn completion_pr_with_provider_passes() {
403        let toml = r#"
404[project]
405name = "test"
406
407[tickets]
408dir = "tickets"
409
410[git_host]
411provider = "github"
412
413[[workflow.states]]
414id    = "new"
415label = "New"
416
417[[workflow.states.transitions]]
418to         = "closed"
419completion = "pr"
420
421[[workflow.states]]
422id       = "closed"
423label    = "Closed"
424terminal = true
425"#;
426        let config = load_config(toml);
427        let errors = validate_config(&config, Path::new("/tmp"));
428        assert!(
429            !errors.iter().any(|e| e.contains("provider")),
430            "unexpected provider error in {errors:?}"
431        );
432    }
433
434    // Test 11: context_section with empty ticket.sections is skipped
435    #[test]
436    fn context_section_skipped_when_no_sections_defined() {
437        let toml = r#"
438[project]
439name = "test"
440
441[tickets]
442dir = "tickets"
443
444[[workflow.states]]
445id    = "new"
446label = "New"
447
448[[workflow.states.transitions]]
449to              = "closed"
450context_section = "AnySection"
451
452[[workflow.states]]
453id       = "closed"
454label    = "Closed"
455terminal = true
456"#;
457        let config = load_config(toml);
458        let errors = validate_config(&config, Path::new("/tmp"));
459        assert!(
460            !errors.iter().any(|e| e.contains("context_section")),
461            "unexpected context_section error in {errors:?}"
462        );
463    }
464
465    // Test: closed state is not flagged as unknown even when absent from config
466    #[test]
467    fn closed_state_not_flagged_as_unknown() {
468        use crate::ticket::Ticket;
469
470        // Config with no "closed" state
471        let toml = r#"
472[project]
473name = "test"
474
475[tickets]
476dir = "tickets"
477
478[[workflow.states]]
479id    = "new"
480label = "New"
481
482[[workflow.states.transitions]]
483to = "done"
484
485[[workflow.states]]
486id       = "done"
487label    = "Done"
488terminal = true
489"#;
490        let config = load_config(toml);
491        let state_ids: std::collections::HashSet<&str> = config.workflow.states.iter()
492            .map(|s| s.id.as_str())
493            .collect();
494
495        let raw = "+++\nid = 1\ntitle = \"Test\"\nstate = \"closed\"\n+++\n\n## Spec\n";
496        let ticket = Ticket::parse(Path::new("tickets/0001-test.md"), raw).unwrap();
497
498        // "closed" is not in state_ids, but the validate logic skips it.
499        assert!(!state_ids.contains("closed"));
500        // Simulate the validate check: closed should be exempt.
501        let fm = &ticket.frontmatter;
502        let flagged = !state_ids.is_empty() && fm.state != "closed" && !state_ids.contains(fm.state.as_str());
503        assert!(!flagged, "closed state should not be flagged as unknown");
504    }
505
506    // Test for state_ids helper (kept for compatibility)
507    #[test]
508    fn state_ids_helper() {
509        let toml = r#"
510[project]
511name = "test"
512
513[tickets]
514dir = "tickets"
515
516[[workflow.states]]
517id    = "new"
518label = "New"
519"#;
520        let config = load_config(toml);
521        let ids = state_ids(&config);
522        assert!(ids.contains("new"));
523    }
524
525    #[test]
526    fn validate_warnings_no_container() {
527        let toml = r#"
528[project]
529name = "test"
530
531[tickets]
532dir = "tickets"
533"#;
534        let config = load_config(toml);
535        let warnings = super::validate_warnings(&config);
536        assert!(warnings.is_empty());
537    }
538
539    #[test]
540    fn valid_collaborator_accepted() {
541        let toml = r#"
542[project]
543name = "test"
544collaborators = ["alice", "bob"]
545
546[tickets]
547dir = "tickets"
548"#;
549        let config = load_config(toml);
550        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
551    }
552
553    #[test]
554    fn unknown_user_rejected() {
555        let toml = r#"
556[project]
557name = "test"
558collaborators = ["alice", "bob"]
559
560[tickets]
561dir = "tickets"
562"#;
563        let config = load_config(toml);
564        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
565        let msg = err.to_string();
566        assert!(msg.contains("unknown user 'charlie'"), "unexpected message: {msg}");
567        assert!(msg.contains("alice, bob"), "unexpected message: {msg}");
568    }
569
570    #[test]
571    fn empty_collaborators_skips_validation() {
572        let toml = r#"
573[project]
574name = "test"
575
576[tickets]
577dir = "tickets"
578"#;
579        let config = load_config(toml);
580        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
581    }
582
583    #[test]
584    fn clear_owner_always_allowed() {
585        let toml = r#"
586[project]
587name = "test"
588collaborators = ["alice"]
589
590[tickets]
591dir = "tickets"
592"#;
593        let config = load_config(toml);
594        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
595    }
596
597    #[test]
598    fn github_mode_known_user_accepted() {
599        let toml = r#"
600[project]
601name = "test"
602collaborators = ["alice", "bob"]
603
604[tickets]
605dir = "tickets"
606
607[git_host]
608provider = "github"
609repo = "org/repo"
610"#;
611        let config = load_config(toml);
612        // No token in LocalConfig::default() — falls back to project.collaborators
613        assert!(super::validate_owner(&config, &LocalConfig::default(), "alice").is_ok());
614    }
615
616    #[test]
617    fn github_mode_unknown_user_rejected() {
618        let toml = r#"
619[project]
620name = "test"
621collaborators = ["alice", "bob"]
622
623[tickets]
624dir = "tickets"
625
626[git_host]
627provider = "github"
628repo = "org/repo"
629"#;
630        let config = load_config(toml);
631        // No token — falls back to project.collaborators; charlie is not in the list
632        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
633        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
634    }
635
636    #[test]
637    fn github_mode_no_collaborators_skips_check() {
638        let toml = r#"
639[project]
640name = "test"
641
642[tickets]
643dir = "tickets"
644
645[git_host]
646provider = "github"
647repo = "org/repo"
648"#;
649        let config = load_config(toml);
650        // Empty collaborators list — no validation
651        assert!(super::validate_owner(&config, &LocalConfig::default(), "anyone").is_ok());
652    }
653
654    #[test]
655    fn github_mode_clear_owner_accepted() {
656        let toml = r#"
657[project]
658name = "test"
659collaborators = ["alice"]
660
661[tickets]
662dir = "tickets"
663
664[git_host]
665provider = "github"
666repo = "org/repo"
667"#;
668        let config = load_config(toml);
669        assert!(super::validate_owner(&config, &LocalConfig::default(), "-").is_ok());
670    }
671
672    #[test]
673    fn non_github_mode_unknown_user_rejected() {
674        let toml = r#"
675[project]
676name = "test"
677collaborators = ["alice", "bob"]
678
679[tickets]
680dir = "tickets"
681"#;
682        let config = load_config(toml);
683        let err = super::validate_owner(&config, &LocalConfig::default(), "charlie").unwrap_err();
684        assert!(err.to_string().contains("charlie"), "expected charlie in: {err}");
685    }
686
687    #[test]
688    fn validate_warnings_empty_container() {
689        let toml = r#"
690[project]
691name = "test"
692
693[tickets]
694dir = "tickets"
695
696[workers]
697container = ""
698"#;
699        let config = load_config(toml);
700        let warnings = super::validate_warnings(&config);
701        assert!(warnings.is_empty(), "empty container string should not warn");
702    }
703}