1use crate::config::{CompletionStrategy, Config, LocalConfig};
2use crate::ticket_fmt::Ticket;
3use anyhow::{bail, Result};
4use std::collections::HashSet;
5use std::path::Path;
6
7pub 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
27pub 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
95pub 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 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 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 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 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 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 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 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 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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
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]
840 fn closed_state_not_flagged_as_unknown() {
841 use crate::ticket::Ticket;
842
843 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 assert!(!state_ids.contains("closed"));
873 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]
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 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 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 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}