1use std::collections::{BTreeMap, HashSet};
11use std::path::{Path, PathBuf};
12
13use regex::Regex;
14use serde::Deserialize;
15
16use crate::config::{FieldSpec, FieldType};
17use crate::error::{usage, OpysError, Result};
18use crate::palette::{parse_color, PaletteEntry};
19use crate::refs;
20
21fn default_pad() -> usize {
22 4
23}
24fn default_base() -> String {
25 DEFAULT_BASE.to_string()
26}
27fn default_roots() -> Vec<String> {
28 vec![".".to_string()]
29}
30fn default_min() -> usize {
31 1
32}
33fn default_true() -> bool {
34 true
35}
36fn default_layout_path() -> String {
37 DEFAULT_LAYOUT_PATH.to_string()
38}
39
40fn unknown_placeholders(template: &str) -> Vec<&str> {
42 let known = ["type", "status", "id"];
43 placeholder_names(template)
44 .into_iter()
45 .filter(|name| !known.contains(name))
46 .collect()
47}
48
49fn placeholder_names(template: &str) -> Vec<&str> {
52 let mut out = Vec::new();
53 let mut rest = template;
54 while let Some(open) = rest.find('{') {
55 rest = &rest[open + 1..];
56 if let Some(close) = rest.find('}') {
57 let name = &rest[..close];
58 if !out.contains(&name) {
59 out.push(name);
60 }
61 rest = &rest[close + 1..];
62 } else {
63 break;
64 }
65 }
66 out
67}
68
69pub const DEFAULT_BASE: &str = "opys";
72
73pub const DEFAULT_DOC_DIR: &str = "";
76
77pub const DEFAULT_LAYOUT_PATH: &str = "{type}/{status}/{id}.md";
81
82#[derive(Debug, Clone, Deserialize)]
87pub struct Layout {
88 #[serde(default = "default_layout_path")]
89 pub path: String,
90}
91
92impl Default for Layout {
93 fn default() -> Self {
94 Layout {
95 path: default_layout_path(),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
107#[serde(rename_all = "kebab-case")]
108pub enum SectionKind {
109 Prose,
110 Log,
111 Checklist,
112 Structured,
113}
114
115impl SectionKind {
116 pub fn is_checkable(self) -> bool {
118 matches!(self, SectionKind::Checklist)
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum CheckScope {
126 #[default]
128 All,
129 Checked,
132}
133
134#[derive(Debug, Clone, Deserialize)]
139pub struct SectionCheck {
140 pub pattern: String,
143 #[serde(default)]
146 pub file: Option<String>,
147 #[serde(default = "default_roots")]
150 pub roots: Vec<String>,
151 #[serde(default)]
155 pub must_match: Option<String>,
156 #[serde(default)]
158 pub scope: CheckScope,
159 #[serde(default)]
162 pub message: Option<String>,
163}
164
165#[derive(Debug, Clone, Deserialize)]
166pub struct SectionSpec {
167 pub heading: String,
168 pub kind: SectionKind,
169 #[serde(default)]
171 pub required: bool,
172 #[serde(default)]
174 pub checks: Vec<SectionCheck>,
175 #[serde(default)]
178 pub structure: Option<String>,
179}
180
181#[derive(Debug, Clone, Deserialize)]
184pub struct LinkReq {
185 pub to: String,
186 #[serde(default = "default_min")]
187 pub min: usize,
188}
189
190#[derive(Debug, Clone, Deserialize)]
191pub struct DocType {
192 pub prefix: String,
193 #[serde(default)]
196 pub dir: Option<String>,
197 #[serde(default)]
198 pub statuses: Vec<String>,
199 #[serde(default)]
202 pub status_dirs: BTreeMap<String, String>,
203 #[serde(default)]
204 pub default_status: String,
205 #[serde(default)]
206 pub terminal_statuses: Vec<String>,
207 #[serde(default)]
208 pub tags_required: bool,
209 #[serde(default)]
210 pub requires_link: Option<LinkReq>,
211 #[serde(default)]
212 pub fields: BTreeMap<String, FieldSpec>,
213 #[serde(default)]
214 pub sections: Vec<SectionSpec>,
215}
216
217impl DocType {
218 pub fn resolved_dir(&self) -> &str {
221 self.dir.as_deref().unwrap_or(DEFAULT_DOC_DIR)
222 }
223
224 pub fn status_dir(&self, status: &str) -> &str {
226 self.status_dirs.get(status).map_or("", String::as_str)
227 }
228}
229
230#[derive(Debug, Clone, Default, Deserialize)]
232pub struct When {
233 #[serde(default, rename = "type")]
234 pub doc_type: Option<String>,
235 #[serde(default)]
236 pub status: Option<String>,
237 #[serde(default)]
240 pub tag: Option<String>,
241}
242
243#[derive(Debug, Clone, Deserialize)]
245pub struct AnyTerm {
246 #[serde(default)]
247 pub field: Option<String>,
248 #[serde(default)]
249 pub link: Option<String>,
250 #[serde(default)]
251 pub section: Option<String>,
252 #[serde(default)]
254 pub tag: Option<String>,
255}
256
257#[derive(Debug, Clone, Deserialize)]
258pub struct FieldMatch {
259 pub field: String,
260 pub pattern: String,
261}
262
263#[derive(Debug, Clone, Default, Deserialize)]
266pub struct Rule {
267 #[serde(default)]
268 pub when: When,
269 #[serde(default)]
270 pub require_field: Option<String>,
271 #[serde(default)]
272 pub require_section: Option<String>,
273 #[serde(default)]
274 pub require_checked_section: Option<String>,
275 #[serde(default)]
276 pub require_link: Option<LinkReq>,
277 #[serde(default)]
278 pub require_any: Option<Vec<AnyTerm>>,
279 #[serde(default)]
280 pub field_matches: Option<FieldMatch>,
281}
282
283impl Rule {
284 fn assertion_count(&self) -> usize {
286 [
287 self.require_field.is_some(),
288 self.require_section.is_some(),
289 self.require_checked_section.is_some(),
290 self.require_link.is_some(),
291 self.require_any.is_some(),
292 self.field_matches.is_some(),
293 ]
294 .iter()
295 .filter(|b| **b)
296 .count()
297 }
298}
299
300pub const BUILTIN_COLUMNS: [&str; 7] = [
302 "id", "type", "title", "status", "tags", "created", "updated",
303];
304
305fn default_columns() -> Vec<String> {
306 ["id", "title", "status", "tags"]
307 .iter()
308 .map(|s| s.to_string())
309 .collect()
310}
311
312#[derive(Debug, Clone, Deserialize)]
315pub struct TuiConfig {
316 #[serde(default = "default_columns")]
319 pub columns: Vec<String>,
320}
321
322impl Default for TuiConfig {
323 fn default() -> Self {
324 TuiConfig {
325 columns: default_columns(),
326 }
327 }
328}
329
330#[derive(Debug, Clone, Deserialize)]
335pub struct FileRefs {
336 #[serde(default = "default_roots")]
340 pub roots: Vec<String>,
341 #[serde(default)]
344 pub formats: Vec<RefFormat>,
345}
346
347impl Default for FileRefs {
348 fn default() -> Self {
349 FileRefs {
350 roots: default_roots(),
351 formats: Vec::new(),
352 }
353 }
354}
355
356impl FileRefs {
357 pub fn effective_formats(&self) -> Vec<RefFormat> {
360 if self.formats.is_empty() {
361 vec![RefFormat {
362 template: "{id}".to_string(),
363 word: true,
364 }]
365 } else {
366 self.formats.clone()
367 }
368 }
369}
370
371#[derive(Debug, Clone, Deserialize)]
377pub struct RefFormat {
378 pub template: String,
379 #[serde(default = "default_true")]
380 pub word: bool,
381}
382
383pub const REF_PLACEHOLDERS: [&str; 5] = ["id", "prefix", "prefix_lower", "num", "padded"];
386
387#[derive(Debug, Clone, Deserialize)]
394pub struct StatSpec {
395 pub name: String,
396 pub sql: String,
397 #[serde(default)]
398 pub template: Option<String>,
399}
400
401#[derive(Debug, Clone, Deserialize)]
402pub struct ProjectConfig {
403 #[serde(default = "default_base")]
406 pub base: String,
407 #[serde(default = "default_pad")]
408 pub pad: usize,
409 #[serde(default)]
410 pub layout: Layout,
411 #[serde(default)]
412 pub types: BTreeMap<String, DocType>,
413 #[serde(default)]
414 pub rules: Vec<Rule>,
415 #[serde(default)]
419 pub stats: Vec<StatSpec>,
420 #[serde(default)]
424 pub palette: BTreeMap<String, PaletteEntry>,
425 #[serde(default)]
427 pub tui: TuiConfig,
428 #[serde(default)]
431 pub file_refs: FileRefs,
432}
433
434impl ProjectConfig {
435 pub fn load(path: &Path) -> Result<ProjectConfig> {
437 if !path.exists() {
438 return Err(usage(format!(
439 "no opys.toml at {} — run `opys config init`",
440 path.display()
441 )));
442 }
443 let text = std::fs::read_to_string(path)?;
444 toml::from_str(&text).map_err(|source| OpysError::Toml {
445 path: path.to_path_buf(),
446 source,
447 })
448 }
449
450 pub fn doc_dirs(&self) -> Vec<&str> {
454 let mut dirs: Vec<&str> = self.types.values().map(DocType::resolved_dir).collect();
455 dirs.sort_unstable();
456 dirs.dedup();
457 dirs
458 }
459
460 pub fn dir_for_id(&self, id: &str) -> &str {
463 self.type_name_for_id(id)
464 .and_then(|n| self.types.get(n))
465 .map(DocType::resolved_dir)
466 .unwrap_or(DEFAULT_DOC_DIR)
467 }
468
469 pub fn doc_relpath(&self, id: &str, status: &str) -> PathBuf {
474 let t = self.type_name_for_id(id).and_then(|n| self.types.get(n));
475 let type_seg = t.map_or(DEFAULT_DOC_DIR, DocType::resolved_dir);
476 let status_seg = t.map_or("", |t| t.status_dir(status));
477 let rendered = self
478 .layout
479 .path
480 .replace("{type}", type_seg)
481 .replace("{status}", status_seg)
482 .replace("{id}", id);
483 rendered.split('/').filter(|seg| !seg.is_empty()).collect()
485 }
486
487 pub fn type_name_for_id(&self, id: &str) -> Option<&str> {
490 let prefix = id.split_once('-')?.0;
491 self.types
492 .iter()
493 .find(|(_, t)| t.prefix == prefix)
494 .map(|(name, _)| name.as_str())
495 }
496
497 pub fn validate(&self) -> Vec<String> {
500 let mut errs = Vec::new();
501 if self.types.is_empty() {
502 errs.push("no document types defined ([types.<name>])".into());
503 }
504
505 if !self.layout.path.contains("{id}") {
508 errs.push("layout.path must contain the {id} placeholder".into());
509 }
510 for unknown in unknown_placeholders(&self.layout.path) {
511 errs.push(format!(
512 "layout.path: unknown placeholder '{{{unknown}}}' (use type/status/id)"
513 ));
514 }
515
516 let prefix_re = Regex::new(r"^[A-Z][A-Z0-9]*$").unwrap();
517 let type_names: HashSet<&str> = self.types.keys().map(String::as_str).collect();
518 let mut seen_prefix: BTreeMap<&str, &str> = BTreeMap::new();
519
520 for (name, t) in &self.types {
521 if !prefix_re.is_match(&t.prefix) {
522 errs.push(format!(
523 "type '{name}': prefix '{}' must match ^[A-Z][A-Z0-9]*$",
524 t.prefix
525 ));
526 }
527 if let Some(prev) = seen_prefix.insert(&t.prefix, name) {
528 errs.push(format!(
529 "type '{name}': prefix '{}' already used by type '{prev}'",
530 t.prefix
531 ));
532 }
533 if t.statuses.is_empty() {
534 errs.push(format!("type '{name}': statuses must be non-empty"));
535 }
536 if t.default_status.is_empty() {
537 errs.push(format!("type '{name}': default_status is required"));
538 } else if !t.statuses.contains(&t.default_status) {
539 errs.push(format!(
540 "type '{name}': default_status '{}' not in statuses",
541 t.default_status
542 ));
543 }
544 for s in &t.terminal_statuses {
545 if !t.statuses.contains(s) {
546 errs.push(format!(
547 "type '{name}': terminal_status '{s}' not in statuses"
548 ));
549 }
550 }
551 for s in t.status_dirs.keys() {
552 if !t.statuses.contains(s) {
553 errs.push(format!(
554 "type '{name}': status_dirs key '{s}' is not a status"
555 ));
556 }
557 }
558 if let Some(lr) = &t.requires_link {
559 if !type_names.contains(lr.to.as_str()) {
560 errs.push(format!(
561 "type '{name}': requires_link.to '{}' is not a defined type",
562 lr.to
563 ));
564 }
565 }
566 for (fname, spec) in &t.fields {
567 if spec.field_type == FieldType::Enum && spec.values.is_empty() {
568 errs.push(format!(
569 "type '{name}' field '{fname}': enum declares no values"
570 ));
571 }
572 if let Some(p) = &spec.pattern {
573 if Regex::new(p).is_err() {
574 errs.push(format!(
575 "type '{name}' field '{fname}': pattern is not a valid regex"
576 ));
577 }
578 }
579 }
580 let mut seen_section: HashSet<&str> = HashSet::new();
581 for sec in &t.sections {
582 if !seen_section.insert(sec.heading.as_str()) {
583 errs.push(format!(
584 "type '{name}': duplicate section heading '{}'",
585 sec.heading
586 ));
587 }
588 for (ci, chk) in sec.checks.iter().enumerate() {
589 validate_check(name, &sec.heading, sec.kind, ci + 1, chk, &mut errs);
590 }
591 validate_structure(name, sec, &mut errs);
592 }
593 }
594
595 for (i, rule) in self.rules.iter().enumerate() {
596 self.validate_rule(i + 1, rule, &type_names, &mut errs);
597 }
598
599 self.validate_palette(&type_names, &mut errs);
600 self.validate_tui(&mut errs);
601 self.validate_file_refs(&mut errs);
602 self.validate_stats(&mut errs);
603 errs
604 }
605
606 fn validate_stats(&self, errs: &mut Vec<String>) {
610 let empty = serde_json::json!([]);
611 for s in &self.stats {
612 if let Err(e) = crate::commands::stats::render_stat(s, &empty) {
613 errs.push(e);
614 }
615 }
616 }
617
618 fn validate_file_refs(&self, errs: &mut Vec<String>) {
622 for (i, f) in self.file_refs.formats.iter().enumerate() {
623 for ph in placeholder_names(&f.template) {
624 if !REF_PLACEHOLDERS.contains(&ph) {
625 errs.push(format!(
626 "file_refs.formats[{i}]: unknown placeholder '{{{ph}}}' (use {})",
627 REF_PLACEHOLDERS.join("/")
628 ));
629 }
630 }
631 let numbered = ["{id}", "{num}", "{padded}"]
632 .iter()
633 .any(|p| f.template.contains(p));
634 if !numbered {
635 errs.push(format!(
636 "file_refs.formats[{i}]: template '{}' must include a number placeholder ({{id}}, {{num}}, or {{padded}})",
637 f.template
638 ));
639 }
640 }
641 }
642
643 fn validate_tui(&self, errs: &mut Vec<String>) {
646 let known_field = |key: &str| self.types.values().any(|t| t.fields.contains_key(key));
647 for col in &self.tui.columns {
648 if !BUILTIN_COLUMNS.contains(&col.as_str()) && !known_field(col) {
649 errs.push(format!(
650 "tui.columns: '{col}' is not a built-in column or a declared field"
651 ));
652 }
653 }
654 }
655
656 fn validate_palette(&self, types: &HashSet<&str>, errs: &mut Vec<String>) {
660 for (name, entry) in &self.palette {
661 if entry.matchers.is_empty() {
662 errs.push(format!("palette '{name}': needs at least one matcher"));
663 }
664 for m in &entry.matchers {
665 if let Some(t) = &m.doc_type {
666 if !types.contains(t.as_str()) {
667 errs.push(format!(
668 "palette '{name}': matcher type '{t}' is not a defined type"
669 ));
670 }
671 }
672 if let Some(s) = &m.status {
673 let ok = match &m.doc_type {
674 Some(t) => self.types.get(t).is_some_and(|dt| dt.statuses.contains(s)),
678 None => self.types.values().any(|dt| dt.statuses.contains(s)),
679 };
680 if !ok {
681 let scope = m
682 .doc_type
683 .as_ref()
684 .map(|t| format!(" of type '{t}'"))
685 .unwrap_or_default();
686 errs.push(format!(
687 "palette '{name}': matcher status '{s}' is not a status{scope}"
688 ));
689 }
690 }
691 }
692 for (label, color) in [
693 ("fg_color", &entry.style.fg_color),
694 ("bg_color", &entry.style.bg_color),
695 ] {
696 if let Some(c) = color {
697 if parse_color(c).is_none() {
698 errs.push(format!(
699 "palette '{name}': {label} '{c}' is not a valid color (a name, #rrggbb, or 0-255)"
700 ));
701 }
702 }
703 }
704 }
705 }
706
707 fn validate_rule(&self, n: usize, r: &Rule, types: &HashSet<&str>, errs: &mut Vec<String>) {
708 let tag = format!("rule #{n}");
709 match r.assertion_count() {
710 1 => {}
711 0 => errs.push(format!("{tag}: has no assertion")),
712 _ => errs.push(format!("{tag}: has more than one assertion")),
713 }
714
715 if let Some(t) = &r.when.doc_type {
717 if !types.contains(t.as_str()) {
718 errs.push(format!("{tag}: when.type '{t}' is not a defined type"));
719 } else if let Some(s) = &r.when.status {
720 if !self.types[t].statuses.contains(s) {
721 errs.push(format!(
722 "{tag}: when.status '{s}' is not a status of type '{t}'"
723 ));
724 }
725 }
726 }
727
728 if let Some(lr) = &r.require_link {
730 if !types.contains(lr.to.as_str()) {
731 errs.push(format!(
732 "{tag}: require_link.to '{}' is not a defined type",
733 lr.to
734 ));
735 }
736 }
737
738 if let Some(terms) = &r.require_any {
740 if terms.is_empty() {
741 errs.push(format!("{tag}: require_any is empty"));
742 }
743 for term in terms {
744 let count = [
745 term.field.is_some(),
746 term.link.is_some(),
747 term.section.is_some(),
748 term.tag.is_some(),
749 ]
750 .iter()
751 .filter(|b| **b)
752 .count();
753 if count != 1 {
754 errs.push(format!(
755 "{tag}: each require_any term needs exactly one of field/link/section/tag"
756 ));
757 }
758 if let Some(l) = &term.link {
759 if !refs::RELATION_FIELDS.contains(&l.as_str()) {
760 errs.push(format!(
761 "{tag}: require_any link '{l}' is not a relation field (references/blocked_by/blocks)"
762 ));
763 }
764 }
765 }
766 }
767
768 if let Some(t) = &r.when.doc_type {
770 if let Some(dt) = self.types.get(t) {
771 if let Some(f) = &r.require_field {
772 if !dt.fields.contains_key(f) {
773 errs.push(format!(
774 "{tag}: require_field '{f}' is not a field of type '{t}'"
775 ));
776 }
777 }
778 if let Some(sec) = &r.require_section {
779 if !dt.sections.iter().any(|s| &s.heading == sec) {
780 errs.push(format!(
781 "{tag}: require_section '{sec}' is not a section of type '{t}'"
782 ));
783 }
784 }
785 if let Some(sec) = &r.require_checked_section {
786 match dt.sections.iter().find(|s| &s.heading == sec) {
787 None => errs.push(format!(
788 "{tag}: require_checked_section '{sec}' is not a section of type '{t}'"
789 )),
790 Some(s) if !s.kind.is_checkable() => errs.push(format!(
791 "{tag}: require_checked_section '{sec}' targets a non-checklist section"
792 )),
793 _ => {}
794 }
795 }
796 }
797 }
798
799 if let Some(fm) = &r.field_matches {
801 if Regex::new(&fm.pattern).is_err() {
802 errs.push(format!("{tag}: field_matches.pattern is not a valid regex"));
803 }
804 if let Some(t) = &r.when.doc_type {
805 if let Some(dt) = self.types.get(t) {
806 if !dt.fields.contains_key(&fm.field) {
807 errs.push(format!(
808 "{tag}: field_matches.field '{}' is not a field of type '{t}'",
809 fm.field
810 ));
811 }
812 }
813 }
814 }
815 }
816}
817
818fn validate_structure(type_name: &str, sec: &SectionSpec, errs: &mut Vec<String>) {
821 let tag = format!("type '{type_name}' section '{}'", sec.heading);
822 if sec.kind != SectionKind::Structured {
823 if sec.structure.is_some() {
824 errs.push(format!(
825 "{tag}: 'structure' is only allowed on a 'structured' section"
826 ));
827 }
828 return;
829 }
830 match &sec.structure {
831 None => errs.push(format!("{tag}: a 'structured' section needs a 'structure'")),
832 Some(src) => {
833 if let Err(e) = crate::mdprism::parse_schema(src) {
834 errs.push(format!("{tag}: invalid structure ({e})"));
835 }
836 }
837 }
838}
839
840fn validate_check(
845 type_name: &str,
846 heading: &str,
847 kind: SectionKind,
848 n: usize,
849 chk: &SectionCheck,
850 errs: &mut Vec<String>,
851) {
852 let tag = format!("type '{type_name}' section '{heading}' check #{n}");
853 let names: HashSet<String> = match Regex::new(&chk.pattern) {
854 Ok(re) => re
855 .capture_names()
856 .flatten()
857 .map(|s| s.to_string())
858 .collect(),
859 Err(_) => {
860 errs.push(format!("{tag}: pattern is not a valid regex"));
861 return;
862 }
863 };
864
865 if let Some(f) = &chk.file {
866 if !names.contains(f) {
867 errs.push(format!(
868 "{tag}: file '{f}' is not a named capture group of pattern"
869 ));
870 }
871 }
872 if chk.file.is_none() && chk.must_match.is_none() {
873 errs.push(format!("{tag}: needs at least one of file / must_match"));
874 }
875
876 let group_re = Regex::new(r"\$\{(\w+)\}").unwrap();
877 for tmpl in [chk.must_match.as_deref(), chk.message.as_deref()]
878 .into_iter()
879 .flatten()
880 {
881 for c in group_re.captures_iter(tmpl) {
882 let g = &c[1];
883 if !names.contains(g) {
884 errs.push(format!(
885 "{tag}: ${{{g}}} is not a named capture group of pattern"
886 ));
887 }
888 }
889 }
890 if let Some(mm) = &chk.must_match {
891 let probe = group_re.replace_all(mm, "x");
892 if Regex::new(&probe).is_err() {
893 errs.push(format!("{tag}: must_match is not a valid regex"));
894 }
895 }
896 if chk.scope == CheckScope::Checked && !kind.is_checkable() {
897 errs.push(format!(
898 "{tag}: scope = \"checked\" requires a checklist section"
899 ));
900 }
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use crate::templates::DEFAULT_OPYS_CONFIG;
907
908 #[test]
909 fn default_config_validates_clean() {
910 let cfg: ProjectConfig = toml::from_str(DEFAULT_OPYS_CONFIG).unwrap();
911 let problems = cfg.validate();
912 assert!(
913 problems.is_empty(),
914 "default config has problems: {problems:?}"
915 );
916 assert_eq!(cfg.types.len(), 4);
917 }
918
919 #[test]
920 fn flags_structured_structure_problems() {
921 let cfg: ProjectConfig = toml::from_str(
925 r#"
926[types.feature]
927prefix = "FEAT"
928statuses = ["planned"]
929default_status = "planned"
930
931[[types.feature.sections]]
932heading = "Empty"
933kind = "structured"
934
935[[types.feature.sections]]
936heading = "Bad"
937kind = "structured"
938structure = "this is not a marker"
939
940[[types.feature.sections]]
941heading = "Notes"
942kind = "prose"
943structure = "- @x"
944"#,
945 )
946 .unwrap();
947 let joined = cfg.validate().join("\n");
948 assert!(
949 joined.contains("section 'Empty': a 'structured' section needs a 'structure'"),
950 "{joined}"
951 );
952 assert!(
953 joined.contains("section 'Bad': invalid structure"),
954 "{joined}"
955 );
956 assert!(
957 joined
958 .contains("section 'Notes': 'structure' is only allowed on a 'structured' section"),
959 "{joined}"
960 );
961 }
962
963 #[test]
964 fn flags_unknown_tui_column() {
965 let cfg: ProjectConfig = toml::from_str(
966 r#"
967[types.feature]
968prefix = "FEAT"
969statuses = ["planned"]
970default_status = "planned"
971[types.feature.fields.priority]
972type = "string"
973
974[tui]
975columns = ["id", "title", "priority", "bogus"]
976"#,
977 )
978 .unwrap();
979 let joined = cfg.validate().join("\n");
980 assert!(joined.contains("tui.columns: 'bogus'"), "{joined}");
981 assert!(!joined.contains("'priority'"), "{joined}");
983 assert!(!joined.contains("'id'"), "{joined}");
984 }
985
986 #[test]
987 fn flags_palette_unknown_type_status_and_bad_color() {
988 let cfg: ProjectConfig = toml::from_str(
989 r#"
990[types.feature]
991prefix = "FEAT"
992statuses = ["planned", "done"]
993default_status = "planned"
994
995[palette.ghost]
996matchers = [ { type = "ghost" } ]
997
998[palette.badstatus]
999matchers = [ { status = "nope" } ]
1000
1001[palette.typedstatus]
1002matchers = [ { type = "feature", status = "nope" } ]
1003
1004[palette.badcolor]
1005matchers = [ { status = "done" } ]
1006[palette.badcolor.style]
1007fg_color = "rainbow"
1008
1009[palette.empty]
1010matchers = []
1011"#,
1012 )
1013 .unwrap();
1014 let joined = cfg.validate().join("\n");
1015 assert!(
1016 joined.contains("matcher type 'ghost' is not a defined type"),
1017 "{joined}"
1018 );
1019 assert!(
1020 joined.contains("matcher status 'nope' is not a status\n")
1021 || joined.contains("matcher status 'nope' is not a status of"),
1022 "{joined}"
1023 );
1024 assert!(
1025 joined.contains("matcher status 'nope' is not a status of type 'feature'"),
1026 "{joined}"
1027 );
1028 assert!(
1029 joined.contains("fg_color 'rainbow' is not a valid color"),
1030 "{joined}"
1031 );
1032 assert!(
1033 joined.contains("palette 'empty': needs at least one matcher"),
1034 "{joined}"
1035 );
1036 }
1037
1038 #[test]
1039 fn flags_bad_default_status_and_duplicate_prefix_and_unknown_rule_type() {
1040 let cfg: ProjectConfig = toml::from_str(
1041 r#"
1042[types.feature]
1043prefix = "FEAT"
1044statuses = ["planned"]
1045default_status = "nope"
1046
1047[types.bug]
1048prefix = "FEAT"
1049statuses = ["todo"]
1050default_status = "todo"
1051
1052[[rules]]
1053when = { type = "ghost" }
1054require_field = "x"
1055"#,
1056 )
1057 .unwrap();
1058 let problems = cfg.validate();
1059 let joined = problems.join("\n");
1060 assert!(
1061 joined.contains("default_status 'nope' not in statuses"),
1062 "{joined}"
1063 );
1064 assert!(joined.contains("already used by type"), "{joined}");
1065 assert!(
1066 joined.contains("when.type 'ghost' is not a defined type"),
1067 "{joined}"
1068 );
1069 }
1070}