1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::path::Path;
4
5use crate::error::{Error, Result};
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9pub struct Config {
10 #[serde(default)]
11 pub scope: ScopeConfig,
12 #[serde(default)]
13 pub kinds: KindsConfig,
14 #[serde(default)]
15 pub statuses: StatusesConfig,
16 #[serde(default)]
17 pub identity: IdentityConfig,
18 #[serde(default)]
19 pub schema: SchemaConfig,
20 #[serde(default)]
21 pub rules: RulesConfig,
22 #[serde(default)]
23 pub parser: ParserConfig,
24 #[serde(default)]
25 pub detection: DetectionConfig,
26 #[serde(default)]
27 pub output: OutputConfig,
28 #[serde(default)]
29 pub report: ReportConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ScopeConfig {
34 #[serde(default)]
35 pub include: Vec<String>,
36 #[serde(default)]
37 pub exclude: Vec<String>,
38 #[serde(default)]
39 pub conditional_exclude: Vec<ConditionalExclude>,
40}
41
42impl Default for ScopeConfig {
43 fn default() -> Self {
44 Self {
45 include: vec!["**/*.md".to_string()],
46 exclude: vec![],
47 conditional_exclude: vec![],
48 }
49 }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ConditionalExclude {
58 pub parent_glob: String,
59 #[serde(default = "default_condition")]
60 pub condition: String,
61}
62
63fn default_condition() -> String {
64 "status_terminal".to_string()
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct KindsConfig {
69 #[serde(default = "default_kinds")]
70 pub allowed: Vec<String>,
71}
72
73impl Default for KindsConfig {
74 fn default() -> Self {
75 Self {
76 allowed: default_kinds(),
77 }
78 }
79}
80
81fn default_kinds() -> Vec<String> {
82 ["generic", "guide", "readme"]
83 .iter()
84 .map(|s| s.to_string())
85 .collect()
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct StatusesConfig {
90 #[serde(default = "default_statuses")]
91 pub allowed: Vec<String>,
92 #[serde(default = "default_terminal")]
93 pub terminal: Vec<String>,
94}
95
96impl Default for StatusesConfig {
97 fn default() -> Self {
98 Self {
99 allowed: default_statuses(),
100 terminal: default_terminal(),
101 }
102 }
103}
104
105fn default_statuses() -> Vec<String> {
106 [
107 "active",
108 "superseded",
109 "archived",
110 "deprecated",
111 "abandoned",
112 ]
113 .iter()
114 .map(|s| s.to_string())
115 .collect()
116}
117
118fn default_terminal() -> Vec<String> {
119 ["superseded", "archived", "deprecated", "abandoned"]
120 .iter()
121 .map(|s| s.to_string())
122 .collect()
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
126pub struct IdentityConfig {
127 #[serde(default)]
128 pub kind_rules: Vec<KindRule>,
129 #[serde(default)]
130 pub id_rules: Vec<IdRule>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct KindRule {
135 pub glob: String,
136 pub kind: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct IdRule {
141 #[serde(default)]
142 pub kind: String,
143 #[serde(default)]
144 pub glob: Option<String>,
145 pub template: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SchemaConfig {
156 #[serde(default = "default_required")]
157 pub required: Vec<String>,
158 #[serde(default)]
159 pub types: BTreeMap<String, FieldType>,
160 #[serde(default)]
161 pub enums: BTreeMap<String, Vec<String>>,
162 #[serde(default)]
163 pub cross_field: Vec<CrossFieldSpec>,
164 #[serde(default)]
165 pub overrides: Vec<SchemaOverride>,
166}
167
168impl Default for SchemaConfig {
169 fn default() -> Self {
170 Self {
171 required: default_required(),
172 types: BTreeMap::new(),
173 enums: BTreeMap::new(),
174 cross_field: vec![],
175 overrides: vec![],
176 }
177 }
178}
179
180fn default_required() -> Vec<String> {
181 ["id", "title", "kind", "status"]
182 .iter()
183 .map(|s| s.to_string())
184 .collect()
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct SchemaOverride {
194 pub kinds: Vec<String>,
195 pub required: Vec<String>,
196 #[serde(default)]
197 pub types: BTreeMap<String, FieldType>,
198 #[serde(default)]
199 pub enums: BTreeMap<String, Vec<String>>,
200 #[serde(default)]
201 pub cross_field: Vec<CrossFieldSpec>,
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum FieldType {
211 String,
212 Integer,
213 Bool,
214 Date,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct CrossFieldSpec {
224 pub when: String,
225 pub require: String,
226}
227
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct RulesConfig {
230 #[serde(default)]
231 pub naming: Vec<NamingRule>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct NamingRule {
236 pub glob: String,
237 pub pattern: String,
238 #[serde(default)]
239 pub sequential: bool,
240 #[serde(default)]
241 pub unique: bool,
242}
243
244#[derive(Debug, Clone, Default, Serialize, Deserialize)]
245pub struct ParserConfig {
246 #[serde(default)]
247 pub link_patterns: Vec<LinkPattern>,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct LinkPattern {
252 pub pattern: String,
253 pub relation: String,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct DetectionConfig {
258 #[serde(default = "default_stale_days")]
259 pub stale_days: u32,
260 #[serde(default = "default_orphan_grace_days")]
261 pub orphan_grace_days: u32,
262 #[serde(default)]
269 pub orphan_ok_kinds: Vec<String>,
270}
271
272impl Default for DetectionConfig {
273 fn default() -> Self {
274 Self {
275 stale_days: default_stale_days(),
276 orphan_grace_days: default_orphan_grace_days(),
277 orphan_ok_kinds: Vec::new(),
278 }
279 }
280}
281
282fn default_stale_days() -> u32 {
283 180
284}
285
286fn default_orphan_grace_days() -> u32 {
287 14
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct OutputConfig {
292 #[serde(default = "default_output_dir")]
293 pub dir: String,
294}
295
296impl Default for OutputConfig {
297 fn default() -> Self {
298 Self {
299 dir: default_output_dir(),
300 }
301 }
302}
303
304fn default_output_dir() -> String {
305 "_index".to_string()
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct ReportConfig {
310 #[serde(default = "default_report_title")]
311 pub title: String,
312 #[serde(default = "default_god_node_display_limit")]
313 pub god_node_display_limit: usize,
314 #[serde(default = "default_display_limit")]
315 pub orphan_display_limit: usize,
316 #[serde(default = "default_display_limit")]
317 pub stale_display_limit: usize,
318}
319
320impl Default for ReportConfig {
321 fn default() -> Self {
322 Self {
323 title: default_report_title(),
324 god_node_display_limit: default_god_node_display_limit(),
325 orphan_display_limit: default_display_limit(),
326 stale_display_limit: default_display_limit(),
327 }
328 }
329}
330
331fn default_report_title() -> String {
332 "Document Graph".to_string()
333}
334
335fn default_god_node_display_limit() -> usize {
336 10
337}
338
339fn default_display_limit() -> usize {
340 20
341}
342
343impl Config {
344 pub fn load(root: &Path) -> Result<Self> {
350 let path = root.join("nodex.toml");
351 if !path.exists() {
352 return Ok(Self::default());
353 }
354 let content = std::fs::read_to_string(&path).map_err(|e| Error::Io {
355 path: path.clone(),
356 source: e,
357 })?;
358 let config: Self =
359 toml::from_str(&content).map_err(|e| Error::Config(format!("{path:?}: {e}")))?;
360 config.validate()?;
361 Ok(config)
362 }
363
364 pub fn validate(&self) -> Result<()> {
379 if self.kinds.allowed.is_empty() {
385 return Err(Error::Config(
386 "kinds.allowed must not be empty; omit the key to accept the defaults, \
387 or list every kind your project uses"
388 .to_string(),
389 ));
390 }
391
392 if self.statuses.allowed.is_empty() {
396 return Err(Error::Config(
397 "statuses.allowed must not be empty; omit the key to accept the defaults, \
398 or list every status your project uses"
399 .to_string(),
400 ));
401 }
402
403 let missing: Vec<&str> = crate::lifecycle::LIFECYCLE_TARGET_STATUSES
410 .iter()
411 .copied()
412 .filter(|s| !self.statuses.allowed.iter().any(|a| a == s))
413 .collect();
414 if !missing.is_empty() {
415 return Err(Error::Config(format!(
416 "statuses.allowed is missing lifecycle target status(es): {missing:?}; \
417 add them to `statuses.allowed` or omit the key to accept the defaults"
418 )));
419 }
420
421 if !self
432 .kinds
433 .allowed
434 .iter()
435 .any(|k| k == crate::parser::identity::FALLBACK_KIND)
436 {
437 return Err(Error::Config(format!(
438 "kinds.allowed is missing the fallback kind {:?}; \
439 either include it, or omit `kinds.allowed` to accept the defaults",
440 crate::parser::identity::FALLBACK_KIND
441 )));
442 }
443
444 for k in &self.detection.orphan_ok_kinds {
451 if !self.kinds.allowed.iter().any(|a| a == k) {
452 return Err(Error::Config(format!(
453 "detection.orphan_ok_kinds contains {k:?} which is not in \
454 kinds.allowed; add it to kinds.allowed or remove the exemption"
455 )));
456 }
457 }
458
459 if !self.output.dir.is_empty() {
466 crate::path_guard::reject_traversal(std::path::Path::new(&self.output.dir)).map_err(
467 |_| {
468 Error::Config(format!(
469 "output.dir {:?} escapes the project root; \
470 use a relative path without `..` or a leading `/`",
471 self.output.dir
472 ))
473 },
474 )?;
475 }
476
477 self.validate_block(
478 "schema",
479 &self.schema.required,
480 &self.schema.types,
481 &self.schema.enums,
482 &self.schema.cross_field,
483 )?;
484
485 for (idx, nr) in self.rules.naming.iter().enumerate() {
489 if globset::Glob::new(&nr.glob).is_err() {
490 return Err(Error::Config(format!(
491 "rules.naming[{idx}].glob {:?} is not a valid glob",
492 nr.glob
493 )));
494 }
495 if regex::Regex::new(&nr.pattern).is_err() {
496 return Err(Error::Config(format!(
497 "rules.naming[{idx}].pattern {:?} is not a valid regex",
498 nr.pattern
499 )));
500 }
501 }
502
503 for (idx, ov) in self.schema.overrides.iter().enumerate() {
504 let ctx = format!("schema.overrides[{idx}] (kinds={:?})", ov.kinds);
505 self.validate_block(&ctx, &ov.required, &ov.types, &ov.enums, &ov.cross_field)?;
506 for cf in &ov.cross_field {
512 if self
513 .schema
514 .cross_field
515 .iter()
516 .any(|g| g.when == cf.when && g.require == cf.require)
517 {
518 return Err(Error::Config(format!(
519 "{ctx}: cross_field {{ when={:?}, require={:?} }} \
520 is already declared in [schema].cross_field — \
521 remove the override copy or change its predicate",
522 cf.when, cf.require
523 )));
524 }
525 }
526 }
527 Ok(())
528 }
529
530 fn validate_block(
533 &self,
534 ctx: &str,
535 required: &[String],
536 types: &BTreeMap<String, FieldType>,
537 enums: &BTreeMap<String, Vec<String>>,
538 cross_field: &[CrossFieldSpec],
539 ) -> Result<()> {
540 for (field, allowed) in enums {
541 if is_collection_builtin(field) {
542 return Err(Error::Config(format!(
543 "{ctx}: enums.{field} — collection-valued built-in \
544 fields cannot have a scalar enum constraint"
545 )));
546 }
547 let global = match field.as_str() {
548 "status" => Some((&self.statuses.allowed, "statuses.allowed")),
549 "kind" => Some((&self.kinds.allowed, "kinds.allowed")),
550 _ => None,
551 };
552 if let Some((global, key)) = global {
553 for value in allowed {
554 if !global.contains(value) {
555 return Err(Error::Config(format!(
556 "{ctx}: enums.{field} contains {value:?} \
557 which is not in {key}"
558 )));
559 }
560 }
561 }
562
563 if field == "status" {
571 let missing: Vec<&str> = crate::lifecycle::LIFECYCLE_TARGET_STATUSES
572 .iter()
573 .copied()
574 .filter(|s| !allowed.iter().any(|a| a == s))
575 .collect();
576 if !missing.is_empty() {
577 return Err(Error::Config(format!(
578 "{ctx}: enums.status narrows below the lifecycle target set; \
579 missing {missing:?}. Either include all four \
580 (superseded, archived, deprecated, abandoned) or drop \
581 the enum constraint on status"
582 )));
583 }
584 }
585
586 if let Some(ty) = types.get(field)
594 && let Some(bad) = allowed.iter().find(|v| !value_matches_field_type(v, *ty))
595 {
596 return Err(Error::Config(format!(
597 "{ctx}: enums.{field} value {bad:?} is not a valid \
598 {ty:?}; either drop the enum or widen types.{field}"
599 )));
600 }
601 }
602
603 for cf in cross_field {
604 let predicate = parse_when(&cf.when).map_err(|e| {
605 Error::Config(format!("{ctx}: cross_field.when {:?}: {e}", cf.when))
606 })?;
607 let WhenPredicate::Equals { field, .. } = &predicate;
608 ensure_field_known(field, required, types, enums, ctx, "cross_field.when")?;
609 ensure_field_known(
610 &cf.require,
611 required,
612 types,
613 enums,
614 ctx,
615 "cross_field.require",
616 )?;
617 }
618 Ok(())
619 }
620
621 pub fn types_for(&self, kind: &str) -> BTreeMap<String, FieldType> {
625 let mut out = self.schema.types.clone();
626 if let Some(ov) = self.schema_override_for(kind) {
627 for (k, v) in &ov.types {
628 out.insert(k.clone(), *v);
629 }
630 }
631 out
632 }
633
634 pub fn enums_for(&self, kind: &str) -> BTreeMap<String, Vec<String>> {
636 let mut out = self.schema.enums.clone();
637 if let Some(ov) = self.schema_override_for(kind) {
638 for (k, v) in &ov.enums {
639 out.insert(k.clone(), v.clone());
640 }
641 }
642 out
643 }
644
645 pub fn cross_field_for(&self, kind: &str) -> Vec<CrossFieldSpec> {
649 let mut out = self.schema.cross_field.clone();
650 if let Some(ov) = self.schema_override_for(kind) {
651 out.extend_from_slice(&ov.cross_field);
652 }
653 out
654 }
655
656 pub fn is_terminal(&self, status: &str) -> bool {
658 self.statuses.terminal.iter().any(|t| t == status)
659 }
660
661 pub fn is_orphan_ok_kind(&self, kind: &str) -> bool {
669 self.detection.orphan_ok_kinds.iter().any(|k| k == kind)
670 }
671
672 pub fn required_for(&self, kind: &str) -> &[String] {
675 for ov in &self.schema.overrides {
676 if ov.kinds.iter().any(|k| k == kind) {
677 return &ov.required;
678 }
679 }
680 &self.schema.required
681 }
682
683 pub fn schema_override_for(&self, kind: &str) -> Option<&SchemaOverride> {
685 self.schema
686 .overrides
687 .iter()
688 .find(|ov| ov.kinds.iter().any(|k| k == kind))
689 }
690
691 pub fn initial_status_for(&self, kind: &str) -> &str {
702 if let Some(ov) = self.schema_override_for(kind)
703 && let Some(allowed) = ov.enums.get("status")
704 && let Some(first) = allowed.first()
705 {
706 return first.as_str();
707 }
708 if let Some(allowed) = self.schema.enums.get("status")
709 && let Some(first) = allowed.first()
710 {
711 return first.as_str();
712 }
713 self.statuses
714 .allowed
715 .first()
716 .map(String::as_str)
717 .expect("statuses.allowed non-empty — enforced by Config::validate")
718 }
719}
720
721#[derive(Debug, Clone, PartialEq, Eq)]
723pub enum WhenPredicate {
724 Equals { field: String, value: String },
726}
727
728pub const BUILTIN_SCALAR_FIELDS: &[&str] = &[
733 "id",
734 "title",
735 "kind",
736 "status",
737 "created",
738 "updated",
739 "reviewed",
740 "owner",
741 "superseded_by",
742];
743
744pub const BUILTIN_COLLECTION_FIELDS: &[&str] = &["tags", "supersedes", "implements", "related"];
747
748pub fn is_builtin_node_field(field: &str) -> bool {
750 BUILTIN_SCALAR_FIELDS.contains(&field) || BUILTIN_COLLECTION_FIELDS.contains(&field)
751}
752
753pub fn is_collection_builtin(field: &str) -> bool {
755 BUILTIN_COLLECTION_FIELDS.contains(&field)
756}
757
758fn value_matches_field_type(value: &str, ty: FieldType) -> bool {
763 match ty {
764 FieldType::String => true,
765 FieldType::Integer => value.parse::<i64>().is_ok(),
766 FieldType::Bool => matches!(value, "true" | "false"),
767 FieldType::Date => chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d").is_ok(),
768 }
769}
770
771fn ensure_field_known(
775 field: &str,
776 required: &[String],
777 types: &BTreeMap<String, FieldType>,
778 enums: &BTreeMap<String, Vec<String>>,
779 ctx: &str,
780 slot: &str,
781) -> Result<()> {
782 if is_builtin_node_field(field)
783 || required.iter().any(|r| r == field)
784 || types.contains_key(field)
785 || enums.contains_key(field)
786 {
787 return Ok(());
788 }
789 Err(Error::Config(format!(
790 "{ctx}: {slot} references unknown field {field:?}; declare it \
791 in required / types / enums or use a built-in name"
792 )))
793}
794
795pub fn parse_when(raw: &str) -> std::result::Result<WhenPredicate, String> {
801 let trimmed = raw.trim();
802 let parts: Vec<&str> = trimmed.splitn(3, '=').collect();
803 if parts.len() != 2 {
804 return Err(format!(
805 "expected exactly one '=' in <field>=<value>; values with \
806 embedded '=' are not supported in v1 (got {raw:?})"
807 ));
808 }
809 let field = parts[0].trim();
810 let value = parts[1].trim();
811 if field.is_empty() || value.is_empty() {
812 return Err("expected non-empty <field>=<value>".to_string());
813 }
814 if value.starts_with('=') {
815 return Err("value must not start with '=' (use a single '=' separator)".to_string());
816 }
817 Ok(WhenPredicate::Equals {
818 field: field.to_string(),
819 value: value.to_string(),
820 })
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826
827 #[test]
828 fn parse_when_accepts_simple_equality() {
829 let p = parse_when("status=superseded").unwrap();
830 assert_eq!(
831 p,
832 WhenPredicate::Equals {
833 field: "status".into(),
834 value: "superseded".into()
835 }
836 );
837 }
838
839 #[test]
840 fn parse_when_trims_whitespace() {
841 let p = parse_when(" status = superseded ").unwrap();
842 let WhenPredicate::Equals { field, value } = p;
843 assert_eq!(field, "status");
844 assert_eq!(value, "superseded");
845 }
846
847 #[test]
848 fn parse_when_rejects_double_equals() {
849 assert!(parse_when("status==foo").is_err());
850 }
851
852 #[test]
853 fn parse_when_rejects_empty_sides() {
854 assert!(parse_when("=foo").is_err());
855 assert!(parse_when("field=").is_err());
856 assert!(parse_when("").is_err());
857 }
858
859 #[test]
860 fn parse_when_rejects_triple_equals() {
861 assert!(parse_when("a=b=c").is_err());
862 }
863
864 fn override_with(kind: &str, mut ov: SchemaOverride) -> Config {
865 ov.kinds = vec![kind.into()];
866 Config {
867 schema: SchemaConfig {
868 overrides: vec![ov],
869 ..Default::default()
870 },
871 ..Config::default()
872 }
873 }
874
875 #[test]
876 fn validate_rejects_enum_on_collection_field() {
877 let config = override_with(
878 "adr",
879 SchemaOverride {
880 kinds: vec![],
881 required: vec![],
882 types: BTreeMap::new(),
883 enums: [("tags".to_string(), vec!["foo".into()])]
884 .into_iter()
885 .collect(),
886 cross_field: vec![],
887 },
888 );
889 let err = config.validate().unwrap_err();
890 match err {
891 Error::Config(msg) => assert!(msg.contains("collection-valued"), "{msg}"),
892 _ => panic!("expected Config error"),
893 }
894 }
895
896 #[test]
897 fn validate_rejects_enum_value_outside_global_allowed() {
898 let config = Config {
904 statuses: StatusesConfig {
905 allowed: vec![
906 "active".into(),
907 "superseded".into(),
908 "archived".into(),
909 "deprecated".into(),
910 "abandoned".into(),
911 ],
912 terminal: vec![],
913 },
914 schema: SchemaConfig {
915 overrides: vec![SchemaOverride {
916 kinds: vec!["adr".into()],
917 required: vec![],
918 types: BTreeMap::new(),
919 enums: [("status".to_string(), vec!["active".into(), "bogus".into()])]
920 .into_iter()
921 .collect(),
922 cross_field: vec![],
923 }],
924 ..Default::default()
925 },
926 ..Config::default()
927 };
928 let err = config.validate().unwrap_err();
929 match err {
930 Error::Config(msg) => {
931 assert!(msg.contains("bogus"));
932 assert!(msg.contains("statuses.allowed"));
933 }
934 _ => panic!("expected Config error"),
935 }
936 }
937
938 #[test]
939 fn validate_rejects_cross_field_unknown_field() {
940 let config = override_with(
941 "adr",
942 SchemaOverride {
943 kinds: vec![],
944 required: vec![],
945 types: BTreeMap::new(),
946 enums: BTreeMap::new(),
947 cross_field: vec![CrossFieldSpec {
948 when: "statuz=superseded".into(),
949 require: "superseded_by".into(),
950 }],
951 },
952 );
953 let err = config.validate().unwrap_err();
954 match err {
955 Error::Config(msg) => assert!(msg.contains("unknown field"), "{msg}"),
956 _ => panic!("expected Config error"),
957 }
958 }
959
960 #[test]
961 fn validate_error_includes_override_context() {
962 let config = Config {
963 schema: SchemaConfig {
964 overrides: vec![SchemaOverride {
965 kinds: vec!["adr".into(), "guide".into()],
966 required: vec![],
967 types: BTreeMap::new(),
968 enums: [("tags".to_string(), vec!["x".into()])]
969 .into_iter()
970 .collect(),
971 cross_field: vec![],
972 }],
973 ..Default::default()
974 },
975 ..Config::default()
976 };
977 let err = config.validate().unwrap_err();
978 match err {
979 Error::Config(msg) => {
980 assert!(msg.contains("overrides[0]"));
981 assert!(msg.contains("\"adr\""));
982 }
983 _ => panic!("expected Config error"),
984 }
985 }
986
987 #[test]
988 fn validate_accepts_empty_schema() {
989 Config::default().validate().unwrap();
990 }
991
992 #[test]
993 fn validate_rejects_statuses_allowed_missing_lifecycle_target() {
994 let config = Config {
998 statuses: StatusesConfig {
999 allowed: vec![
1000 "active".into(),
1001 "superseded".into(),
1002 "deprecated".into(),
1003 "abandoned".into(),
1004 ],
1005 terminal: vec!["superseded".into()],
1006 },
1007 ..Config::default()
1008 };
1009 let err = config.validate().unwrap_err();
1010 match err {
1011 Error::Config(msg) => {
1012 assert!(msg.contains("archived"), "message was: {msg}");
1013 assert!(msg.contains("lifecycle"), "message was: {msg}");
1014 }
1015 _ => panic!("expected Config error"),
1016 }
1017 }
1018
1019 #[test]
1020 fn validate_rejects_override_status_enum_missing_lifecycle_target() {
1021 let config = Config {
1027 schema: SchemaConfig {
1028 overrides: vec![SchemaOverride {
1029 kinds: vec!["adr".into()],
1030 required: vec![],
1031 types: BTreeMap::new(),
1032 enums: [(
1033 "status".to_string(),
1034 vec!["active".into(), "superseded".into()],
1035 )]
1036 .into_iter()
1037 .collect(),
1038 cross_field: vec![],
1039 }],
1040 ..Default::default()
1041 },
1042 ..Config::default()
1043 };
1044 let err = config.validate().unwrap_err();
1045 match err {
1046 Error::Config(msg) => {
1047 assert!(msg.contains("archived"), "message was: {msg}");
1048 assert!(msg.contains("lifecycle"), "message was: {msg}");
1049 }
1050 _ => panic!("expected Config error"),
1051 }
1052 }
1053
1054 #[test]
1055 fn validate_rejects_output_dir_escaping_root() {
1056 for bad in ["../escape", "/etc/nodex", "docs/../../out"] {
1062 let config = Config {
1063 output: OutputConfig {
1064 dir: bad.to_string(),
1065 },
1066 ..Config::default()
1067 };
1068 match config.validate() {
1069 Err(Error::Config(msg)) => assert!(
1070 msg.contains("output.dir") && msg.contains("escapes"),
1071 "for {bad:?} got unexpected message: {msg}"
1072 ),
1073 other => panic!("value {bad:?} should have been rejected, got {other:?}"),
1074 }
1075 }
1076 }
1077
1078 #[test]
1079 fn validate_rejects_kinds_allowed_missing_fallback_kind() {
1080 let config = Config {
1087 kinds: KindsConfig {
1088 allowed: vec!["adr".into()],
1089 },
1090 ..Config::default()
1091 };
1092 let err = config.validate().unwrap_err();
1093 match err {
1094 Error::Config(msg) => {
1095 assert!(msg.contains("generic"), "message was: {msg}");
1096 assert!(msg.contains("fallback"), "message was: {msg}");
1097 }
1098 _ => panic!("expected Config error"),
1099 }
1100 }
1101
1102 #[test]
1103 fn validate_rejects_enum_value_failing_its_declared_type() {
1104 let config = Config {
1111 schema: SchemaConfig {
1112 overrides: vec![SchemaOverride {
1113 kinds: vec!["adr".into()],
1114 required: vec![],
1115 types: [("priority".to_string(), FieldType::Integer)]
1116 .into_iter()
1117 .collect(),
1118 enums: [(
1119 "priority".to_string(),
1120 vec!["low".into(), "medium".into(), "high".into()],
1121 )]
1122 .into_iter()
1123 .collect(),
1124 cross_field: vec![],
1125 }],
1126 ..Default::default()
1127 },
1128 ..Config::default()
1129 };
1130 let err = config.validate().unwrap_err();
1131 match err {
1132 Error::Config(msg) => {
1133 assert!(msg.contains("priority"), "message was: {msg}");
1134 assert!(msg.contains("\"low\""), "message was: {msg}");
1135 }
1136 _ => panic!("expected Config error"),
1137 }
1138 }
1139
1140 #[test]
1141 fn global_cross_field_applies_without_override() {
1142 let config = Config {
1143 schema: SchemaConfig {
1144 cross_field: vec![CrossFieldSpec {
1145 when: "status=superseded".into(),
1146 require: "superseded_by".into(),
1147 }],
1148 ..Default::default()
1149 },
1150 ..Config::default()
1151 };
1152 config.validate().unwrap();
1153 let collected = config.cross_field_for("adr");
1154 assert_eq!(collected.len(), 1);
1155 assert_eq!(collected[0].require, "superseded_by");
1156 }
1157
1158 #[test]
1159 fn validate_rejects_cross_field_duplicate_across_global_and_override() {
1160 let config = Config {
1161 schema: SchemaConfig {
1162 cross_field: vec![CrossFieldSpec {
1163 when: "status=superseded".into(),
1164 require: "superseded_by".into(),
1165 }],
1166 overrides: vec![SchemaOverride {
1167 kinds: vec!["adr".into()],
1168 required: vec![],
1169 types: BTreeMap::new(),
1170 enums: BTreeMap::new(),
1171 cross_field: vec![CrossFieldSpec {
1172 when: "status=superseded".into(),
1173 require: "superseded_by".into(),
1174 }],
1175 }],
1176 ..Default::default()
1177 },
1178 ..Config::default()
1179 };
1180 let err = config.validate().unwrap_err();
1181 match err {
1182 Error::Config(msg) => {
1183 assert!(msg.contains("already declared in [schema].cross_field"));
1184 }
1185 _ => panic!("expected Config error"),
1186 }
1187 }
1188
1189 #[test]
1190 fn validate_rejects_orphan_ok_kind_outside_kinds_allowed() {
1191 let config = Config {
1196 kinds: KindsConfig {
1197 allowed: vec!["generic".into(), "guide".into(), "readme".into()],
1198 },
1199 detection: DetectionConfig {
1200 orphan_ok_kinds: vec!["skll".into()],
1201 ..DetectionConfig::default()
1202 },
1203 ..Config::default()
1204 };
1205 let err = config.validate().unwrap_err();
1206 match err {
1207 Error::Config(msg) => {
1208 assert!(msg.contains("orphan_ok_kinds"), "message was: {msg}");
1209 assert!(msg.contains("\"skll\""), "message was: {msg}");
1210 assert!(msg.contains("kinds.allowed"), "message was: {msg}");
1211 }
1212 _ => panic!("expected Config error"),
1213 }
1214 }
1215
1216 #[test]
1217 fn is_orphan_ok_kind_matches_configured_entries() {
1218 let config = Config {
1219 kinds: KindsConfig {
1220 allowed: vec!["generic".into(), "skill".into()],
1221 },
1222 detection: DetectionConfig {
1223 orphan_ok_kinds: vec!["skill".into()],
1224 ..DetectionConfig::default()
1225 },
1226 ..Config::default()
1227 };
1228 config.validate().unwrap();
1229 assert!(config.is_orphan_ok_kind("skill"));
1230 assert!(!config.is_orphan_ok_kind("generic"));
1231 }
1232
1233 #[test]
1234 fn parse_when_error_mentions_quoting_unsupported() {
1235 let err = parse_when("status==foo").unwrap_err();
1236 assert!(err.contains("embedded '='") || err.contains("exactly one"));
1237 }
1238}