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 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 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 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 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 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 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 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 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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
467 fn closed_state_not_flagged_as_unknown() {
468 use crate::ticket::Ticket;
469
470 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 assert!(!state_ids.contains("closed"));
500 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]
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 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 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 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}