1#![forbid(unsafe_code)]
6#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
7
8use marque_ism::UtcOffset;
20use marque_rules::Severity;
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::path::PathBuf;
24use thiserror::Error;
25
26#[cfg(feature = "corpus-override")]
27pub mod corpus_override;
28
29pub const EX_DATAERR: i32 = 65;
31
32#[derive(Debug, Error)]
33pub enum ConfigError {
34 #[error("failed to read config file {path}: {source}")]
35 ReadError {
36 path: PathBuf,
37 source: std::io::Error,
38 },
39
40 #[error("failed to parse config: {0}")]
41 ParseError(#[from] toml::de::Error),
42
43 #[error(
45 "committed config file {path} contains a [user] section — classifier identity \
46 must live only in .marque.local.toml or env vars (FR-010)"
47 )]
48 UserSectionInCommitted { path: PathBuf },
49
50 #[error(
52 "schema version mismatch: config says {config_version:?} but marque was compiled \
53 against {compiled_version:?} (FR-011). Update [capco] version in .marque.toml."
54 )]
55 SchemaVersionMismatch {
56 config_version: String,
57 compiled_version: &'static str,
58 },
59
60 #[error("confidence_threshold {value} is outside [0.0, 1.0]")]
62 ThresholdOutOfRange { value: f32 },
63
64 #[error("environment variable {var} has invalid value {raw:?}: {reason}")]
66 InvalidEnvVar {
67 var: &'static str,
68 raw: String,
69 reason: &'static str,
70 },
71
72 #[error(
74 "rule {rule:?} has unrecognized severity {value:?} — expected one of \
75 \"off\", \"suggest\", \"info\", \"warn\", \"error\", \"fix\""
76 )]
77 UnknownSeverity { rule: String, value: String },
78
79 #[error("invalid timezone offset {value:?} — expected \"Z\", \"+HH:MM\", or \"-HH:MM\"")]
81 InvalidTimezone { value: String },
82
83 #[error("failed to parse corpus override {path}: {reason}")]
86 CorpusOverrideParse { path: PathBuf, reason: String },
87
88 #[error(
91 "corpus override {path} has schema_version {file_version:?} but this build of marque \
92 supports {expected:?}"
93 )]
94 CorpusOverrideSchemaMismatch {
95 path: PathBuf,
96 file_version: String,
97 expected: &'static str,
98 },
99
100 #[error("corpus override {path}: invalid {section}.{key}: {reason}")]
105 CorpusOverrideInvalidValue {
106 path: PathBuf,
107 section: &'static str,
108 key: String,
109 reason: &'static str,
110 },
111}
112
113impl ConfigError {
114 pub fn exit_code(&self) -> i32 {
116 match self {
117 Self::ReadError { .. } => 74, Self::ParseError(_) => EX_DATAERR,
119 Self::UserSectionInCommitted { .. } => EX_DATAERR,
120 Self::SchemaVersionMismatch { .. } => EX_DATAERR,
121 Self::ThresholdOutOfRange { .. } => EX_DATAERR,
122 Self::InvalidEnvVar { .. } => EX_DATAERR,
123 Self::UnknownSeverity { .. } => EX_DATAERR,
124 Self::InvalidTimezone { .. } => EX_DATAERR,
125 Self::CorpusOverrideParse { .. } => EX_DATAERR,
126 Self::CorpusOverrideSchemaMismatch { .. } => EX_DATAERR,
127 Self::CorpusOverrideInvalidValue { .. } => EX_DATAERR,
128 }
129 }
130}
131
132#[derive(Debug, Clone)]
134pub struct Config {
135 pub user: UserConfig,
136 pub rules: RuleConfig,
137 pub corrections: HashMap<String, String>,
143 pub capco: CapcoConfig,
144 confidence_threshold: f32,
147}
148
149impl Default for Config {
150 fn default() -> Self {
151 Self {
152 user: UserConfig::default(),
153 rules: RuleConfig::default(),
154 corrections: HashMap::new(),
155 capco: CapcoConfig::default(),
156 confidence_threshold: 0.95,
157 }
158 }
159}
160
161impl Config {
162 pub fn confidence_threshold(&self) -> f32 {
164 self.confidence_threshold
165 }
166
167 pub fn set_confidence_threshold(&mut self, value: f32) -> Result<(), ConfigError> {
169 if !(0.0..=1.0).contains(&value) || value.is_nan() {
170 return Err(ConfigError::ThresholdOutOfRange { value });
171 }
172 self.confidence_threshold = value;
173 Ok(())
174 }
175}
176
177#[derive(Debug, Clone, Default)]
179pub struct UserConfig {
180 pub classifier_id: Option<String>,
181 pub classification_authority: Option<String>,
182 pub default_reason: Option<String>,
183 pub derived_from_default: Option<String>,
184}
185
186#[derive(Debug, Clone, Default)]
188pub struct RuleConfig {
189 pub overrides: HashMap<String, String>,
191}
192
193#[derive(Debug, Clone)]
195pub struct CapcoConfig {
196 pub version: String,
198
199 pub default_timezone: UtcOffset,
211}
212
213impl Default for CapcoConfig {
214 fn default() -> Self {
215 Self {
216 version: marque_ism::generated::values::SCHEMA_VERSION.to_owned(),
217 default_timezone: UtcOffset::UTC,
218 }
219 }
220}
221
222#[derive(Debug, Deserialize, Serialize, Default)]
227struct ConfigFile {
228 #[serde(default)]
229 user: Option<UserConfigFile>,
230 #[serde(default)]
231 rules: HashMap<String, String>,
232 #[serde(default)]
233 corrections: HashMap<String, String>,
234 #[serde(default)]
235 capco: CapcoConfigFile,
236 #[serde(default)]
237 confidence_threshold: Option<f32>,
238}
239
240#[derive(Debug, Deserialize, Serialize, Default)]
241struct UserConfigFile {
242 classifier_id: Option<String>,
243 classification_authority: Option<String>,
244 default_reason: Option<String>,
245 derived_from_default: Option<String>,
246}
247
248#[derive(Debug, Deserialize, Serialize, Default)]
249struct CapcoConfigFile {
250 version: Option<String>,
251 default_timezone: Option<String>,
253}
254
255pub fn load(start: &std::path::Path) -> Result<Config, ConfigError> {
281 let mut config = Config::default();
282
283 if let Some(project_dir) = discover_project_dir(start) {
285 let project_config = project_dir.join(".marque.toml");
287 let raw = std::fs::read_to_string(&project_config).map_err(|e| ConfigError::ReadError {
288 path: project_config.clone(),
289 source: e,
290 })?;
291 let file: ConfigFile = toml::from_str(&raw)?;
292
293 if file.user.is_some() {
295 return Err(ConfigError::UserSectionInCommitted {
296 path: project_config,
297 });
298 }
299
300 merge_project_into(&mut config, file)?;
301
302 let local_config = project_dir.join(".marque.local.toml");
304 if local_config.exists() {
305 let raw =
306 std::fs::read_to_string(&local_config).map_err(|e| ConfigError::ReadError {
307 path: local_config.clone(),
308 source: e,
309 })?;
310 let file: ConfigFile = toml::from_str(&raw)?;
311 merge_user_into(&mut config, file);
312 }
313 }
314
315 apply_env(&mut config)?;
317
318 validate_schema_version(&config)?;
320
321 Ok(config)
322}
323
324pub fn load_with_explicit_config(project_config: &std::path::Path) -> Result<Config, ConfigError> {
330 let mut config = Config::default();
331
332 let raw = std::fs::read_to_string(project_config).map_err(|e| ConfigError::ReadError {
334 path: project_config.to_path_buf(),
335 source: e,
336 })?;
337 let file: ConfigFile = toml::from_str(&raw)?;
338
339 if file.user.is_some() {
340 return Err(ConfigError::UserSectionInCommitted {
341 path: project_config.to_path_buf(),
342 });
343 }
344
345 merge_project_into(&mut config, file)?;
346
347 if let Some(parent) = project_config.parent() {
349 let local_config = parent.join(".marque.local.toml");
350 if local_config.exists() {
351 let raw =
352 std::fs::read_to_string(&local_config).map_err(|e| ConfigError::ReadError {
353 path: local_config.clone(),
354 source: e,
355 })?;
356 let file: ConfigFile = toml::from_str(&raw)?;
357 merge_user_into(&mut config, file);
358 }
359 }
360
361 apply_env(&mut config)?;
362 validate_schema_version(&config)?;
363 Ok(config)
364}
365
366fn discover_project_dir(start: &std::path::Path) -> Option<std::path::PathBuf> {
377 let mut current = start.to_path_buf();
378 loop {
379 if current.join(".marque.toml").is_file() {
380 return Some(current);
381 }
382 if current.join(".git").exists() {
386 return None;
387 }
388 if !current.pop() {
389 return None;
391 }
392 }
393}
394
395fn merge_project_into(config: &mut Config, file: ConfigFile) -> Result<(), ConfigError> {
396 for (rule, value) in &file.rules {
400 if Severity::parse_config(value).is_none() {
401 return Err(ConfigError::UnknownSeverity {
402 rule: rule.clone(),
403 value: value.clone(),
404 });
405 }
406 }
407 config.rules.overrides.extend(file.rules);
408 config.corrections.extend(file.corrections);
409 if let Some(v) = file.capco.version {
410 config.capco.version = v;
411 }
412 if let Some(ref tz) = file.capco.default_timezone {
413 config.capco.default_timezone = tz
414 .parse::<UtcOffset>()
415 .map_err(|_| ConfigError::InvalidTimezone { value: tz.clone() })?;
416 }
417 if let Some(threshold) = file.confidence_threshold {
418 config.set_confidence_threshold(threshold)?;
419 }
420 Ok(())
421}
422
423fn merge_user_into(config: &mut Config, file: ConfigFile) {
424 fn non_empty(s: Option<String>) -> Option<String> {
430 s.filter(|v| !v.trim().is_empty())
431 }
432
433 if let Some(user) = file.user {
434 if let Some(v) = non_empty(user.classifier_id) {
435 config.user.classifier_id = Some(v);
436 }
437 if let Some(v) = non_empty(user.classification_authority) {
438 config.user.classification_authority = Some(v);
439 }
440 if let Some(v) = non_empty(user.default_reason) {
441 config.user.default_reason = Some(v);
442 }
443 if let Some(v) = non_empty(user.derived_from_default) {
444 config.user.derived_from_default = Some(v);
445 }
446 }
447}
448
449fn apply_env(config: &mut Config) -> Result<(), ConfigError> {
450 if let Ok(id) = std::env::var("MARQUE_CLASSIFIER_ID") {
454 if !id.trim().is_empty() {
455 config.user.classifier_id = Some(id);
456 }
457 }
458 if let Ok(raw) = std::env::var("MARQUE_CONFIDENCE_THRESHOLD") {
461 let threshold = raw.parse::<f32>().map_err(|_| ConfigError::InvalidEnvVar {
462 var: "MARQUE_CONFIDENCE_THRESHOLD",
463 raw: raw.clone(),
464 reason: "expected a floating-point number in [0.0, 1.0]",
465 })?;
466 config.set_confidence_threshold(threshold)?;
467 }
468 if let Ok(raw) = std::env::var("MARQUE_DEFAULT_TIMEZONE") {
471 if !raw.trim().is_empty() {
472 config.capco.default_timezone =
473 raw.parse::<UtcOffset>()
474 .map_err(|_| ConfigError::InvalidEnvVar {
475 var: "MARQUE_DEFAULT_TIMEZONE",
476 raw: raw.clone(),
477 reason: "expected \"Z\", \"+HH:MM\", or \"-HH:MM\"",
478 })?;
479 }
480 }
481 Ok(())
482}
483
484fn validate_schema_version(config: &Config) -> Result<(), ConfigError> {
488 let compiled = marque_ism::generated::values::SCHEMA_VERSION;
489 let config_ver = &config.capco.version;
490
491 if config_ver != compiled {
492 return Err(ConfigError::SchemaVersionMismatch {
493 config_version: config_ver.clone(),
494 compiled_version: compiled,
495 });
496 }
497 Ok(())
498}
499
500#[cfg(test)]
505#[cfg_attr(coverage_nightly, coverage(off))]
506mod tests {
507 use super::*;
508
509 fn config_file_with_rules(rules: &[(&str, &str)]) -> ConfigFile {
510 let mut file = ConfigFile::default();
511 for (k, v) in rules {
512 file.rules.insert((*k).to_owned(), (*v).to_owned());
513 }
514 file
515 }
516
517 #[test]
518 fn set_confidence_threshold_accepts_boundaries() {
519 let mut c = Config::default();
520 assert!(c.set_confidence_threshold(0.0).is_ok());
521 assert!(c.set_confidence_threshold(1.0).is_ok());
522 assert!(c.set_confidence_threshold(0.5).is_ok());
523 }
524
525 #[test]
526 fn set_confidence_threshold_rejects_out_of_range() {
527 let mut c = Config::default();
528 assert!(matches!(
529 c.set_confidence_threshold(-0.1),
530 Err(ConfigError::ThresholdOutOfRange { .. })
531 ));
532 assert!(matches!(
533 c.set_confidence_threshold(1.1),
534 Err(ConfigError::ThresholdOutOfRange { .. })
535 ));
536 }
537
538 #[test]
539 fn set_confidence_threshold_rejects_nan() {
540 let mut c = Config::default();
541 assert!(matches!(
542 c.set_confidence_threshold(f32::NAN),
543 Err(ConfigError::ThresholdOutOfRange { .. })
544 ));
545 }
546
547 #[test]
548 fn merge_project_accepts_valid_severity_strings() {
549 let mut c = Config::default();
550 let file = config_file_with_rules(&[
551 ("E001", "fix"),
552 ("E002", "warn"),
553 ("E003", "error"),
554 ("E004", "off"),
555 ("E005", "info"),
556 ("S004", "suggest"),
557 ]);
558 assert!(merge_project_into(&mut c, file).is_ok());
559 assert_eq!(c.rules.overrides.len(), 6);
560 }
561
562 #[test]
563 fn merge_project_accepts_suggest_severity() {
564 let mut c = Config::default();
568 let file = config_file_with_rules(&[("S004", "suggest")]);
569 assert!(merge_project_into(&mut c, file).is_ok());
570 assert_eq!(
571 c.rules.overrides.get("S004").map(String::as_str),
572 Some("suggest")
573 );
574 }
575
576 #[test]
577 fn merge_project_rejects_unknown_severity() {
578 let mut c = Config::default();
579 let file = config_file_with_rules(&[("E001", "err")]);
580 let err = merge_project_into(&mut c, file).unwrap_err();
581 match err {
582 ConfigError::UnknownSeverity { rule, value } => {
583 assert_eq!(rule, "E001");
584 assert_eq!(value, "err");
585 }
586 other => panic!("expected UnknownSeverity, got {other:?}"),
587 }
588 }
589
590 #[test]
591 fn merge_project_rejects_severity_is_case_sensitive() {
592 let mut c = Config::default();
594 let file = config_file_with_rules(&[("E001", "FIX")]);
595 assert!(matches!(
596 merge_project_into(&mut c, file),
597 Err(ConfigError::UnknownSeverity { .. })
598 ));
599 }
600
601 #[test]
602 fn merge_project_rejects_empty_severity() {
603 let mut c = Config::default();
604 let file = config_file_with_rules(&[("E001", "")]);
605 assert!(matches!(
606 merge_project_into(&mut c, file),
607 Err(ConfigError::UnknownSeverity { .. })
608 ));
609 }
610
611 #[test]
612 fn exit_code_matches_contract() {
613 assert_eq!(
614 ConfigError::ThresholdOutOfRange { value: 2.0 }.exit_code(),
615 EX_DATAERR
616 );
617 assert_eq!(
618 ConfigError::UnknownSeverity {
619 rule: "E001".into(),
620 value: "err".into(),
621 }
622 .exit_code(),
623 EX_DATAERR
624 );
625 assert_eq!(
626 ConfigError::InvalidEnvVar {
627 var: "MARQUE_CONFIDENCE_THRESHOLD",
628 raw: "bananas".into(),
629 reason: "not a float",
630 }
631 .exit_code(),
632 EX_DATAERR
633 );
634 }
635
636 use std::fs;
641 use std::path::PathBuf;
642
643 fn make_tmpdir(name: &str) -> PathBuf {
644 let dir =
645 std::env::temp_dir().join(format!("marque-config-test-{name}-{}", std::process::id()));
646 let _ = fs::remove_dir_all(&dir);
647 fs::create_dir_all(&dir).expect("create tmpdir");
648 dir
649 }
650
651 #[test]
652 fn discover_finds_marque_toml_in_start_dir() {
653 let dir = make_tmpdir("discover-here");
654 fs::write(dir.join(".marque.toml"), b"").unwrap();
655 assert_eq!(super::discover_project_dir(&dir), Some(dir.clone()));
656 let _ = fs::remove_dir_all(&dir);
657 }
658
659 #[test]
660 fn discover_walks_upward_for_marque_toml() {
661 let root = make_tmpdir("discover-walk");
663 fs::write(root.join(".marque.toml"), b"").unwrap();
664 let sub = root.join("sub").join("deeper");
665 fs::create_dir_all(&sub).unwrap();
666 assert_eq!(super::discover_project_dir(&sub), Some(root.clone()));
667 let _ = fs::remove_dir_all(&root);
668 }
669
670 #[test]
671 fn discover_stops_at_git_root_without_marque_toml() {
672 let root = make_tmpdir("discover-git-stop");
675 fs::create_dir_all(root.join(".git")).unwrap();
676 let sub = root.join("sub");
677 fs::create_dir_all(&sub).unwrap();
678 assert_eq!(super::discover_project_dir(&sub), None);
679 let _ = fs::remove_dir_all(&root);
680 }
681
682 #[test]
683 fn discover_returns_marque_toml_at_git_root_when_both_present() {
684 let root = make_tmpdir("discover-both");
687 fs::create_dir_all(root.join(".git")).unwrap();
688 fs::write(root.join(".marque.toml"), b"").unwrap();
689 let sub = root.join("crates").join("foo");
690 fs::create_dir_all(&sub).unwrap();
691 assert_eq!(super::discover_project_dir(&sub), Some(root.clone()));
692 let _ = fs::remove_dir_all(&root);
693 }
694
695 #[test]
696 fn load_walks_upward_to_find_project_config() {
697 let root = make_tmpdir("load-walk");
699 fs::write(
700 root.join(".marque.toml"),
701 br#"
702[rules]
703E001 = "warn"
704"#,
705 )
706 .unwrap();
707 let sub = root.join("sub");
708 fs::create_dir_all(&sub).unwrap();
709 let config = super::load(&sub).expect("load should succeed");
710 assert_eq!(config.rules.overrides.get("E001"), Some(&"warn".to_owned()));
711 let _ = fs::remove_dir_all(&root);
712 }
713
714 #[test]
715 fn load_returns_defaults_when_walk_finds_no_marque_toml() {
716 let root = make_tmpdir("load-defaults");
718 fs::create_dir_all(root.join(".git")).unwrap();
719 let sub = root.join("sub");
720 fs::create_dir_all(&sub).unwrap();
721 let config = super::load(&sub).expect("load should succeed with defaults");
722 assert!(config.rules.overrides.is_empty());
723 let _ = fs::remove_dir_all(&root);
724 }
725
726 #[test]
727 fn load_local_config_only_in_same_dir_as_marque_toml() {
728 let root = make_tmpdir("load-local-same-dir");
731 fs::write(
732 root.join(".marque.toml"),
733 br#"
734[capco]
735"#,
736 )
737 .unwrap();
738 fs::write(
739 root.join(".marque.local.toml"),
740 br#"
741[user]
742classifier_id = "from-root"
743"#,
744 )
745 .unwrap();
746 let sub = root.join("sub");
747 fs::create_dir_all(&sub).unwrap();
748 fs::write(
751 sub.join(".marque.local.toml"),
752 br#"
753[user]
754classifier_id = "from-sub"
755"#,
756 )
757 .unwrap();
758 let config = super::load(&sub).expect("load should succeed");
759 assert_eq!(
760 config.user.classifier_id.as_deref(),
761 Some("from-root"),
762 "local config must be the one alongside .marque.toml, not in sub"
763 );
764 let _ = fs::remove_dir_all(&root);
765 }
766
767 #[test]
768 #[cfg(unix)]
769 fn load_returns_read_error_for_unreadable_project_config() {
770 use std::os::unix::fs::PermissionsExt;
771 let root = make_tmpdir("load-err-proj");
772 let project_config = root.join(".marque.toml");
773 fs::write(&project_config, b"").unwrap();
774
775 let mut perms = fs::metadata(&project_config).unwrap().permissions();
776 perms.set_mode(0o000); fs::set_permissions(&project_config, perms).unwrap();
778
779 let err = super::load(&root).unwrap_err();
780 assert!(matches!(err, ConfigError::ReadError { .. }));
781
782 let _ = fs::remove_dir_all(&root);
783 }
784
785 #[test]
786 #[cfg(unix)]
787 fn load_returns_read_error_for_unreadable_local_config() {
788 use std::os::unix::fs::PermissionsExt;
789 let root = make_tmpdir("load-err-local");
790 fs::write(root.join(".marque.toml"), b"").unwrap();
791
792 let local_config = root.join(".marque.local.toml");
793 fs::write(&local_config, b"").unwrap();
794
795 let mut perms = fs::metadata(&local_config).unwrap().permissions();
796 perms.set_mode(0o000); fs::set_permissions(&local_config, perms).unwrap();
798
799 let err = super::load(&root).unwrap_err();
800 assert!(matches!(err, ConfigError::ReadError { .. }));
801
802 let _ = fs::remove_dir_all(&root);
803 }
804
805 #[test]
810 fn capco_default_timezone_defaults_to_utc() {
811 let c = Config::default();
812 assert_eq!(c.capco.default_timezone, UtcOffset::UTC);
813 }
814
815 #[test]
816 fn merge_project_accepts_valid_timezone_offsets() {
817 for tz in ["Z", "+05:30", "-05:00", "+00:00", "+23:59"] {
818 let mut c = Config::default();
819 let mut file = ConfigFile::default();
820 file.capco.default_timezone = Some(tz.to_owned());
821 assert!(
822 merge_project_into(&mut c, file).is_ok(),
823 "should accept timezone {tz:?}"
824 );
825 }
826 }
827
828 #[test]
829 fn merge_project_timezone_sets_correct_offset() {
830 let mut c = Config::default();
831 let mut file = ConfigFile::default();
832 file.capco.default_timezone = Some("+05:30".to_owned());
833 merge_project_into(&mut c, file).unwrap();
834 assert_eq!(
835 c.capco.default_timezone,
836 UtcOffset::from_hhmm(1, 5, 30).unwrap()
837 );
838 }
839
840 #[test]
841 fn merge_project_rejects_invalid_timezone() {
842 for bad in ["EST", "UTC", "utc", "+0530", "+05-30", "05:30"] {
843 let mut c = Config::default();
844 let mut file = ConfigFile::default();
845 file.capco.default_timezone = Some(bad.to_owned());
846 assert!(
847 matches!(
848 merge_project_into(&mut c, file),
849 Err(ConfigError::InvalidTimezone { .. })
850 ),
851 "should reject timezone {bad:?}"
852 );
853 }
854 }
855
856 #[test]
857 fn utc_offset_from_str_z_is_utc() {
858 assert_eq!("Z".parse::<UtcOffset>().unwrap(), UtcOffset::UTC);
860 }
861
862 #[test]
863 fn utc_offset_from_str_wrong_separator_is_err() {
864 assert!("+05-30".parse::<UtcOffset>().is_err());
866 }
867
868 #[test]
869 fn utc_offset_from_str_out_of_range_is_err() {
870 assert!("+24:00".parse::<UtcOffset>().is_err());
872 }
873}