1mod fragments;
2
3use std::collections::{HashMap, HashSet};
4use std::path::{Path, PathBuf};
5
6use regex::Regex;
7use serde::Deserialize;
8use thiserror::Error;
9
10use code_moniker_core::core::shape::Shape;
11use code_moniker_core::lang::Lang;
12
13const DEFAULT_PRESET: &str = include_str!("presets/default.toml");
14
15pub(crate) use code_moniker_core::lang::kinds::INTERNAL_KINDS;
16
17const RESERVED_LANG_KEYS: &[&str] = &["refs"];
20
21#[derive(Debug, Default, Deserialize, Clone)]
22#[serde(deny_unknown_fields)]
23pub struct Config {
24 #[serde(default)]
25 pub default_rules: Option<bool>,
26 #[serde(default)]
27 pub aliases: HashMap<String, String>,
28 #[serde(default)]
29 pub exclude: ExcludeRules,
30 #[serde(default)]
31 pub refs: RefsRules,
32 #[serde(default)]
33 pub shape: HashMap<String, KindRules>,
34 #[serde(default)]
35 pub default: LangRules,
36 #[serde(default)]
37 pub ts: LangRules,
38 #[serde(default)]
39 pub rust: LangRules,
40 #[serde(default)]
41 pub java: LangRules,
42 #[serde(default)]
43 pub python: LangRules,
44 #[serde(default)]
45 pub go: LangRules,
46 #[serde(default)]
47 pub cs: LangRules,
48 #[serde(default)]
49 pub sql: LangRules,
50 #[serde(default)]
51 pub profiles: HashMap<String, Profile>,
52 #[serde(default)]
53 pub views: Vec<toml::Value>,
54 #[serde(skip)]
55 pub fragments: Vec<FragmentInfo>,
56}
57
58#[derive(Debug, Default, Deserialize, Clone)]
59#[serde(deny_unknown_fields)]
60pub struct ExcludeRules {
61 #[serde(default)]
62 pub uris: Vec<String>,
63}
64
65#[derive(Debug, Clone)]
66pub struct FragmentInfo {
67 pub id: String,
68 pub path: PathBuf,
69 pub enabled: bool,
70 pub declared_rules: usize,
71 pub active_rules: usize,
72 pub(crate) rule_keys: Vec<String>,
73}
74
75#[derive(Debug, Default, Deserialize, Clone)]
76#[serde(deny_unknown_fields)]
77pub struct Profile {
78 #[serde(default)]
79 pub enable: Vec<String>,
80 #[serde(default)]
81 pub disable: Vec<String>,
82}
83
84#[derive(Debug, Default, Deserialize, Clone)]
85#[serde(deny_unknown_fields)]
86pub struct RefsRules {
87 #[serde(default, rename = "where")]
88 pub rules: Vec<RuleEntry>,
89}
90
91#[derive(Debug, Default, Deserialize, Clone)]
92pub struct LangRules {
93 #[serde(default)]
94 pub shape: HashMap<String, KindRules>,
95 #[serde(flatten)]
96 pub kinds: HashMap<String, KindRules>,
97}
98
99#[derive(Debug, Default, Deserialize, Clone)]
100#[serde(deny_unknown_fields)]
101pub struct KindRules {
102 #[serde(default, rename = "where")]
103 pub rules: Vec<RuleEntry>,
104 pub require_doc_comment: Option<String>,
105}
106
107#[derive(Debug, Deserialize, Clone)]
108#[serde(deny_unknown_fields)]
109pub struct RuleEntry {
110 #[serde(default)]
111 pub id: Option<String>,
112 pub expr: String,
113 #[serde(default)]
114 pub severity: RuleSeverity,
115 #[serde(default)]
116 pub message: Option<String>,
117 #[serde(default)]
118 pub rationale: Option<String>,
119}
120
121#[derive(
122 Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, serde::Serialize,
123)]
124#[serde(rename_all = "lowercase")]
125pub enum RuleSeverity {
126 Warn,
127 #[default]
128 Error,
129}
130
131impl RuleSeverity {
132 #[allow(dead_code)]
133 pub fn as_str(self) -> &'static str {
134 match self {
135 Self::Warn => "warn",
136 Self::Error => "error",
137 }
138 }
139
140 pub fn is_error(self) -> bool {
141 matches!(self, Self::Error)
142 }
143
144 pub fn is_warn(self) -> bool {
145 matches!(self, Self::Warn)
146 }
147}
148
149#[derive(Debug, Error)]
150pub enum ConfigError {
151 #[error("default preset embedded in the binary is invalid: {0}")]
152 DefaultPresetInvalid(toml::de::Error),
153 #[error("user config `{path}`: {error}")]
154 UserConfig {
155 path: String,
156 error: toml::de::Error,
157 },
158 #[error("fragment config `{path}`: {error}")]
159 FragmentConfig {
160 path: String,
161 error: toml::de::Error,
162 },
163 #[error("cannot read `{path}`: {error}")]
164 Io { path: String, error: std::io::Error },
165 #[error("invalid expression at `{at}`: {error}")]
166 InvalidExpr {
167 at: String,
168 error: super::expr::ParseError,
169 },
170 #[error("unknown kind `{kind}` under `[{section}.{kind}]` (allowed: {allowed})")]
171 UnknownKind {
172 section: String,
173 kind: String,
174 allowed: String,
175 },
176 #[error("unknown shape `{shape}` under `[{section}]` (allowed: {allowed})")]
177 UnknownShape {
178 section: String,
179 shape: String,
180 allowed: String,
181 },
182 #[error(
183 "shape rules under `[default.shape]` are not supported; use top-level `[shape]` for cross-language shape rules"
184 )]
185 DefaultShapeUnsupported,
186 #[error(
187 "require_doc_comment = `{value}` under `[{section}.{kind}]` is not a recognised visibility for that language (allowed: {allowed})"
188 )]
189 UnknownDocVisibility {
190 section: String,
191 kind: String,
192 value: String,
193 allowed: String,
194 },
195 #[error("alias cycle through `{chain}`")]
196 AliasCycle { chain: String },
197 #[error("unknown alias `${name}` referenced under `{at}`")]
198 UnknownAlias { name: String, at: String },
199 #[error("unknown profile `{name}` (known: {known})")]
200 UnknownProfile { name: String, known: String },
201 #[error("invalid regex `{pattern}` in profile `{profile}` ({field}): {error}")]
202 BadProfileRegex {
203 profile: String,
204 field: &'static str,
205 pattern: String,
206 error: regex::Error,
207 },
208 #[error("invalid fragment id `{id}` in `{path}`; use ASCII letters, digits, `_`, or `-`")]
209 InvalidFragmentId { path: String, id: String },
210 #[error(
211 "invalid alias id `{alias}` in fragment `{fragment}` at `{path}`; use ASCII letters, digits, or `_`"
212 )]
213 InvalidFragmentAliasId {
214 path: String,
215 fragment: String,
216 alias: String,
217 },
218 #[error("duplicate fragment id `{id}` in `{first}` and `{second}`")]
219 DuplicateFragment {
220 id: String,
221 first: String,
222 second: String,
223 },
224 #[error("alias `{alias}` from fragment `{fragment}` in `{path}` shadows an existing alias")]
225 FragmentAliasShadowsExisting {
226 path: String,
227 fragment: String,
228 alias: String,
229 },
230 #[error("alias `{alias}` from `{path}` collides with alias from `{existing}`")]
231 FragmentAliasCollision {
232 alias: String,
233 path: String,
234 existing: String,
235 },
236 #[error("fragment `{fragment}` in `{path}` has a rule without an explicit id under `{at}`")]
237 FragmentRuleMissingId {
238 path: String,
239 fragment: String,
240 at: String,
241 },
242 #[error(
243 "invalid rule id `{id}` in fragment `{fragment}` at `{path}`; use ASCII letters, digits, `_`, or `-`"
244 )]
245 InvalidFragmentRuleId {
246 path: String,
247 fragment: String,
248 id: String,
249 },
250 #[error(
251 "fragment `{fragment}` in `{path}` uses unsupported `require_doc_comment` under `{at}`"
252 )]
253 FragmentRequireDocUnsupported {
254 path: String,
255 fragment: String,
256 at: String,
257 },
258 #[error("rule `{rule_id}` from `{path}` collides with rule from `{existing}`")]
259 FragmentRuleCollision {
260 rule_id: String,
261 path: String,
262 existing: String,
263 },
264}
265
266pub(crate) fn load_default() -> Result<Config, ConfigError> {
267 let cfg: Config = toml::from_str(DEFAULT_PRESET).map_err(ConfigError::DefaultPresetInvalid)?;
268 validate(&cfg, "<embedded preset>")?;
269 Ok(cfg)
270}
271
272pub fn load_with_overrides(user_path: Option<&Path>) -> Result<Config, ConfigError> {
275 load_with_options(user_path, true)
276}
277
278pub fn load_with_cli_default_rules(
282 user_path: Option<&Path>,
283 default_rules: Option<bool>,
284) -> Result<Config, ConfigError> {
285 load_with_cli_sources(user_path, &[], default_rules)
286}
287
288pub fn load_with_cli_sources(
292 user_path: Option<&Path>,
293 inline_sources: &[String],
294 default_rules: Option<bool>,
295) -> Result<Config, ConfigError> {
296 let project = read_project_config(user_path)?;
297 let inline = parse_inline_configs(inline_sources)?;
298 let include_defaults = default_rules.unwrap_or_else(|| {
299 inline
300 .iter()
301 .rev()
302 .find_map(|cfg| cfg.default_rules)
303 .or_else(|| project.root.as_ref().and_then(|cfg| cfg.default_rules))
304 .unwrap_or(true)
305 });
306 load_with_project(project, include_defaults, inline)
307}
308
309fn parse_inline_configs(inline_sources: &[String]) -> Result<Vec<Config>, ConfigError> {
310 inline_sources
311 .iter()
312 .enumerate()
313 .map(|(index, raw)| parse_inline_config(raw, index))
314 .collect()
315}
316
317fn parse_inline_config(raw: &str, index: usize) -> Result<Config, ConfigError> {
318 let path = inline_rules_label(index);
319 let user: Config = toml::from_str(raw).map_err(|error| ConfigError::UserConfig {
320 path: path.clone(),
321 error,
322 })?;
323 validate(&user, &path)?;
324 Ok(user)
325}
326
327fn inline_rules_label(index: usize) -> String {
328 format!("<inline rules #{}>", index + 1)
329}
330
331fn include_defaults_from_project(project: &ProjectConfig, include_defaults: bool) -> bool {
332 include_defaults
333 && project
334 .root
335 .as_ref()
336 .and_then(|cfg| cfg.default_rules)
337 .unwrap_or(true)
338}
339
340pub fn load_from_str(
341 raw: &str,
342 path: &str,
343 default_rules: Option<bool>,
344) -> Result<Config, ConfigError> {
345 let user: Config = toml::from_str(raw).map_err(|error| ConfigError::UserConfig {
346 path: path.to_string(),
347 error,
348 })?;
349 validate(&user, path)?;
350 let include_defaults = default_rules.unwrap_or_else(|| user.default_rules.unwrap_or(true));
351 load_with_project(
352 ProjectConfig {
353 root: Some(user),
354 fragments: Vec::new(),
355 },
356 include_defaults,
357 Vec::new(),
358 )
359}
360
361pub(crate) fn load_with_options(
365 user_path: Option<&Path>,
366 include_defaults: bool,
367) -> Result<Config, ConfigError> {
368 let project = read_project_config(user_path)?;
369 let include_defaults = include_defaults_from_project(&project, include_defaults);
370 load_with_project(project, include_defaults, Vec::new())
371}
372
373struct ProjectConfig {
374 root: Option<Config>,
375 fragments: Vec<fragments::FragmentFile>,
376}
377
378fn load_with_project(
379 project: ProjectConfig,
380 include_defaults: bool,
381 inline: Vec<Config>,
382) -> Result<Config, ConfigError> {
383 let mut cfg = if include_defaults {
384 load_default()?
385 } else {
386 Config::default()
387 };
388 cfg.default_rules = Some(include_defaults);
389 if let Some(user) = project.root {
390 merge_into(&mut cfg, user);
391 }
392 fragments::merge_into(&mut cfg, project.fragments)?;
393 for inline in inline {
394 merge_into(&mut cfg, inline);
395 }
396 Ok(cfg)
397}
398
399fn read_project_config(user_path: Option<&Path>) -> Result<ProjectConfig, ConfigError> {
400 let root = read_user_config(user_path)?;
401 let fragments = if root.is_some() {
402 fragments::read(user_path)?
403 } else {
404 Vec::new()
405 };
406 Ok(ProjectConfig { root, fragments })
407}
408
409fn read_user_config(user_path: Option<&Path>) -> Result<Option<Config>, ConfigError> {
410 let Some(p) = user_path else {
411 return Ok(None);
412 };
413 if !p.exists() {
414 return Ok(None);
415 }
416 let raw = std::fs::read_to_string(p).map_err(|error| ConfigError::Io {
417 path: p.display().to_string(),
418 error,
419 })?;
420 let user: Config = toml::from_str(&raw).map_err(|error| ConfigError::UserConfig {
421 path: p.display().to_string(),
422 error,
423 })?;
424 validate(&user, &p.display().to_string())?;
425 Ok(Some(user))
426}
427
428fn merge_into(base: &mut Config, ov: Config) {
429 for (k, v) in ov.aliases {
430 base.aliases.insert(k, v);
431 }
432 base.exclude.uris.extend(ov.exclude.uris);
433 for (k, v) in ov.profiles {
434 base.profiles.insert(k, v);
435 }
436 base.views.extend(ov.views);
437 merge_refs(&mut base.refs, ov.refs);
438 merge_shape_map(&mut base.shape, ov.shape);
439 merge_lang(&mut base.default, ov.default);
440 merge_lang(&mut base.ts, ov.ts);
441 merge_lang(&mut base.rust, ov.rust);
442 merge_lang(&mut base.java, ov.java);
443 merge_lang(&mut base.python, ov.python);
444 merge_lang(&mut base.go, ov.go);
445 merge_lang(&mut base.cs, ov.cs);
446 merge_lang(&mut base.sql, ov.sql);
447}
448
449fn merge_refs(base: &mut RefsRules, ov: RefsRules) {
450 for ov_rule in ov.rules {
451 match ov_rule
452 .id
453 .as_deref()
454 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
455 {
456 Some(idx) => base.rules[idx] = ov_rule,
457 None => base.rules.push(ov_rule),
458 }
459 }
460}
461
462fn merge_lang(base: &mut LangRules, ov: LangRules) {
463 merge_shape_map(&mut base.shape, ov.shape);
464 for (kind, ov_rules) in ov.kinds {
465 match base.kinds.get_mut(&kind) {
466 Some(base_rules) => merge_kind(base_rules, ov_rules),
467 None => {
468 base.kinds.insert(kind, ov_rules);
469 }
470 }
471 }
472}
473
474fn merge_shape_map(base: &mut HashMap<String, KindRules>, ov: HashMap<String, KindRules>) {
475 for (shape, ov_rules) in ov {
476 match base.get_mut(&shape) {
477 Some(base_rules) => merge_kind(base_rules, ov_rules),
478 None => {
479 base.insert(shape, ov_rules);
480 }
481 }
482 }
483}
484
485fn merge_kind(base: &mut KindRules, ov: KindRules) {
489 for ov_rule in ov.rules {
490 match ov_rule
491 .id
492 .as_deref()
493 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
494 {
495 Some(idx) => base.rules[idx] = ov_rule,
496 None => base.rules.push(ov_rule),
497 }
498 }
499 if ov.require_doc_comment.is_some() {
500 base.require_doc_comment = ov.require_doc_comment;
501 }
502}
503
504pub(crate) fn resolve_aliases(
509 aliases: &HashMap<String, String>,
510) -> Result<HashMap<String, String>, ConfigError> {
511 let mut resolved: HashMap<String, String> = HashMap::new();
512 for name in aliases.keys() {
513 let mut stack: Vec<String> = Vec::new();
514 resolve_one(name, aliases, &mut resolved, &mut stack)?;
515 }
516 Ok(resolved)
517}
518
519fn resolve_one(
520 name: &str,
521 src: &HashMap<String, String>,
522 resolved: &mut HashMap<String, String>,
523 stack: &mut Vec<String>,
524) -> Result<String, ConfigError> {
525 if let Some(v) = resolved.get(name) {
526 return Ok(v.clone());
527 }
528 if stack.iter().any(|s| s == name) {
529 stack.push(name.to_string());
530 return Err(ConfigError::AliasCycle {
531 chain: stack.join(" → "),
532 });
533 }
534 let Some(body) = src.get(name) else {
535 return Err(ConfigError::UnknownAlias {
536 name: name.to_string(),
537 at: format!("alias `{}`", stack.last().unwrap_or(&"<root>".to_string())),
538 });
539 };
540 stack.push(name.to_string());
541 let expanded = expand_refs(body, src, resolved, stack)?;
542 stack.pop();
543 resolved.insert(name.to_string(), expanded.clone());
544 Ok(expanded)
545}
546
547fn expand_refs(
548 body: &str,
549 src: &HashMap<String, String>,
550 resolved: &mut HashMap<String, String>,
551 stack: &mut Vec<String>,
552) -> Result<String, ConfigError> {
553 let mut out = String::with_capacity(body.len());
554 let bytes = body.as_bytes();
555 let mut i = 0;
556 while i < bytes.len() {
557 if bytes[i] == b'$' {
558 let start = i + 1;
559 let mut j = start;
560 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
561 j += 1;
562 }
563 if j > start {
564 let name = &body[start..j];
565 let expanded = resolve_one(name, src, resolved, stack)?;
566 out.push('(');
567 out.push_str(&expanded);
568 out.push(')');
569 i = j;
570 continue;
571 }
572 }
573 out.push(bytes[i] as char);
574 i += 1;
575 }
576 Ok(out)
577}
578
579pub(crate) fn substitute_aliases(
582 expr: &str,
583 resolved: &HashMap<String, String>,
584 at: &str,
585) -> Result<String, ConfigError> {
586 let mut out = String::with_capacity(expr.len());
587 let bytes = expr.as_bytes();
588 let mut i = 0;
589 while i < bytes.len() {
590 if bytes[i] == b'$' {
591 let start = i + 1;
592 let mut j = start;
593 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
594 j += 1;
595 }
596 if j > start {
597 let name = &expr[start..j];
598 let Some(expanded) = resolved.get(name) else {
599 return Err(ConfigError::UnknownAlias {
600 name: name.to_string(),
601 at: at.to_string(),
602 });
603 };
604 out.push('(');
605 out.push_str(expanded);
606 out.push(')');
607 i = j;
608 continue;
609 }
610 }
611 out.push(bytes[i] as char);
612 i += 1;
613 }
614 Ok(out)
615}
616
617fn validate(cfg: &Config, path: &str) -> Result<(), ConfigError> {
619 resolve_aliases(&cfg.aliases)?;
620 validate_structure(cfg, path)
621}
622
623fn validate_structure(cfg: &Config, path: &str) -> Result<(), ConfigError> {
624 validate_shape_section(&cfg.shape, "shape", None)?;
625 if !cfg.default.shape.is_empty() {
626 return Err(ConfigError::DefaultShapeUnsupported);
627 }
628 validate_lang_section(
629 &cfg.default,
630 "default",
631 &allowed_kinds_set(None),
632 None,
633 path,
634 )?;
635 for lang in Lang::ALL {
636 let allowed = allowed_kinds_set(Some(*lang));
637 validate_lang_section(
638 cfg.for_lang(*lang),
639 config_section(*lang),
640 &allowed,
641 Some(*lang),
642 path,
643 )?;
644 }
645 Ok(())
646}
647
648fn validate_shape_section(
649 rules: &HashMap<String, KindRules>,
650 section: &str,
651 lang: Option<Lang>,
652) -> Result<(), ConfigError> {
653 for (shape, kr) in rules {
654 if !allowed_def_shape_names().contains(&shape.as_str()) {
655 return Err(ConfigError::UnknownShape {
656 section: section.to_string(),
657 shape: shape.clone(),
658 allowed: allowed_def_shape_names().join(", "),
659 });
660 }
661 if let Some(value) = &kr.require_doc_comment {
662 let allowed_vis = lang.map_or_else(allowed_doc_vis_any_lang, allowed_doc_vis_for);
663 if !allowed_vis.contains(&value.as_str()) {
664 return Err(ConfigError::UnknownDocVisibility {
665 section: section.to_string(),
666 kind: shape.clone(),
667 value: value.clone(),
668 allowed: allowed_vis.join(", "),
669 });
670 }
671 }
672 }
673 Ok(())
674}
675
676fn allowed_kinds_set(lang: Option<Lang>) -> Vec<&'static str> {
677 let mut out: Vec<&'static str> = INTERNAL_KINDS.to_vec();
678 if let Some(l) = lang {
679 out.extend(l.allowed_kinds().iter().copied());
680 } else {
681 for l in Lang::ALL {
682 out.extend(l.allowed_kinds().iter().copied());
683 }
684 }
685 out.sort();
686 out.dedup();
687 out
688}
689
690fn allowed_def_shape_names() -> Vec<&'static str> {
691 Shape::ALL
692 .iter()
693 .copied()
694 .filter(|shape| *shape != Shape::Ref)
695 .map(Shape::as_str)
696 .collect()
697}
698
699pub(crate) fn allowed_kinds_for(lang: Lang) -> Vec<&'static str> {
703 allowed_kinds_set(Some(lang))
704}
705
706fn allowed_doc_vis_for(lang: Lang) -> Vec<&'static str> {
709 let mut out: Vec<&'static str> = vec!["any"];
710 out.extend(lang.allowed_visibilities().iter().copied());
711 out
712}
713
714fn allowed_doc_vis_any_lang() -> Vec<&'static str> {
715 let mut out: Vec<&'static str> = vec!["any"];
716 for lang in Lang::ALL {
717 out.extend(lang.allowed_visibilities().iter().copied());
718 }
719 out.sort();
720 out.dedup();
721 out
722}
723
724pub(crate) fn config_section(lang: Lang) -> &'static str {
727 match lang {
728 Lang::Rs => "rust",
729 other => other.tag(),
730 }
731}
732
733fn validate_lang_section(
734 lr: &LangRules,
735 section: &str,
736 allowed: &[&str],
737 lang: Option<Lang>,
738 _path: &str,
739) -> Result<(), ConfigError> {
740 validate_shape_section(&lr.shape, &format!("{section}.shape"), lang)?;
741 for (kind, kr) in lr.kinds.iter() {
742 if RESERVED_LANG_KEYS.contains(&kind.as_str()) {
743 continue;
744 }
745 if !allowed.contains(&kind.as_str()) {
746 return Err(ConfigError::UnknownKind {
747 section: section.to_string(),
748 kind: kind.clone(),
749 allowed: allowed.join(", "),
750 });
751 }
752 if let (Some(value), Some(l)) = (&kr.require_doc_comment, lang) {
753 let allowed_vis = allowed_doc_vis_for(l);
754 if !allowed_vis.contains(&value.as_str()) {
755 return Err(ConfigError::UnknownDocVisibility {
756 section: section.to_string(),
757 kind: kind.clone(),
758 value: value.clone(),
759 allowed: allowed_vis.join(", "),
760 });
761 }
762 }
763 }
764 Ok(())
765}
766
767impl Config {
768 pub fn for_lang(&self, lang: Lang) -> &LangRules {
769 match lang {
770 Lang::Ts => &self.ts,
771 Lang::Rs => &self.rust,
772 Lang::Java => &self.java,
773 Lang::Python => &self.python,
774 Lang::Go => &self.go,
775 Lang::Cs => &self.cs,
776 Lang::Sql => &self.sql,
777 }
778 }
779
780 pub fn for_lang_mut(&mut self, lang: Lang) -> &mut LangRules {
781 match lang {
782 Lang::Ts => &mut self.ts,
783 Lang::Rs => &mut self.rust,
784 Lang::Java => &mut self.java,
785 Lang::Python => &mut self.python,
786 Lang::Go => &mut self.go,
787 Lang::Cs => &mut self.cs,
788 Lang::Sql => &mut self.sql,
789 }
790 }
791
792 #[cfg(test)]
793 pub fn rules_for(&self, lang: Lang, kind: &str) -> Option<&KindRules> {
794 self.for_lang(lang)
795 .kinds
796 .get(kind)
797 .or_else(|| self.default.kinds.get(kind))
798 }
799
800 pub fn apply_profile(&mut self, name: &str) -> Result<(), ConfigError> {
801 let profile = self
802 .profiles
803 .get(name)
804 .ok_or_else(|| ConfigError::UnknownProfile {
805 name: name.to_string(),
806 known: self.known_profiles(),
807 })?
808 .clone();
809 let enable = compile_patterns(&profile.enable, name, "enable")?;
810 let disable = compile_patterns(&profile.disable, name, "disable")?;
811 filter_rules(&mut self.refs.rules, "refs", &enable, &disable);
812 filter_shape_map(&mut self.shape, "shape", &enable, &disable);
813 filter_lang(&mut self.default, "default", &enable, &disable);
814 for lang in Lang::ALL {
815 filter_lang(
816 self.for_lang_mut(*lang),
817 config_section(*lang),
818 &enable,
819 &disable,
820 );
821 }
822 self.refresh_fragment_active_rules();
823 Ok(())
824 }
825
826 fn known_profiles(&self) -> String {
827 let mut names: Vec<&str> = self.profiles.keys().map(|s| s.as_str()).collect();
828 names.sort();
829 names.join(", ")
830 }
831
832 fn refresh_fragment_active_rules(&mut self) {
833 if self.fragments.is_empty() {
834 return;
835 }
836 let active_keys = collect_rule_keys(self);
837 for fragment in &mut self.fragments {
838 fragment.active_rules = if fragment.enabled {
839 fragment
840 .rule_keys
841 .iter()
842 .filter(|key| active_keys.contains(key.as_str()))
843 .count()
844 } else {
845 0
846 };
847 }
848 }
849}
850
851impl RuleEntry {
852 pub(crate) fn fallback_id(&self, idx: usize) -> String {
853 self.id.clone().unwrap_or_else(|| format!("where_{idx}"))
854 }
855}
856
857fn compile_patterns(
858 patterns: &[String],
859 profile: &str,
860 field: &'static str,
861) -> Result<Vec<Regex>, ConfigError> {
862 patterns
863 .iter()
864 .map(|p| {
865 Regex::new(p).map_err(|error| ConfigError::BadProfileRegex {
866 profile: profile.to_string(),
867 field,
868 pattern: p.clone(),
869 error,
870 })
871 })
872 .collect()
873}
874
875fn filter_lang(lr: &mut LangRules, section: &str, enable: &[Regex], disable: &[Regex]) {
876 filter_shape_map(&mut lr.shape, &format!("{section}.shape"), enable, disable);
877 for (kind, kr) in lr.kinds.iter_mut() {
878 let prefix = format!("{section}.{kind}");
879 filter_rules(&mut kr.rules, &prefix, enable, disable);
880 }
881}
882
883fn filter_shape_map(
884 rules: &mut HashMap<String, KindRules>,
885 section: &str,
886 enable: &[Regex],
887 disable: &[Regex],
888) {
889 for (shape, kr) in rules.iter_mut() {
890 let prefix = format!("{section}.{shape}");
891 filter_rules(&mut kr.rules, &prefix, enable, disable);
892 }
893}
894
895fn filter_rules(rules: &mut Vec<RuleEntry>, prefix: &str, enable: &[Regex], disable: &[Regex]) {
896 if rules.is_empty() || (enable.is_empty() && disable.is_empty()) {
897 return;
898 }
899 let mut idx = 0;
900 rules.retain(|r| {
901 let full = format!("{prefix}.{}", r.fallback_id(idx));
902 idx += 1;
903 (enable.is_empty() || enable.iter().any(|re| re.is_match(&full)))
904 && !disable.iter().any(|re| re.is_match(&full))
905 });
906}
907
908fn collect_rule_keys(cfg: &Config) -> HashSet<String> {
909 let mut out = HashSet::new();
910 collect_rule_list_keys("refs", &cfg.refs.rules, &mut out);
911 for (shape, rules) in &cfg.shape {
912 collect_rule_list_keys(&format!("shape.{shape}"), &rules.rules, &mut out);
913 }
914 collect_lang_rule_keys("default", &cfg.default, &mut out);
915 for lang in Lang::ALL {
916 collect_lang_rule_keys(config_section(*lang), cfg.for_lang(*lang), &mut out);
917 }
918 out
919}
920
921fn collect_lang_rule_keys(section: &str, rules: &LangRules, out: &mut HashSet<String>) {
922 for (shape, kind_rules) in &rules.shape {
923 collect_rule_list_keys(&format!("{section}.shape.{shape}"), &kind_rules.rules, out);
924 }
925 for (kind, kind_rules) in &rules.kinds {
926 collect_rule_list_keys(&format!("{section}.{kind}"), &kind_rules.rules, out);
927 }
928}
929
930fn collect_rule_list_keys(prefix: &str, rules: &[RuleEntry], out: &mut HashSet<String>) {
931 for rule in rules {
932 if let Some(id) = &rule.id {
933 out.insert(format!("{prefix}.{id}"));
934 }
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 fn parse(s: &str) -> Result<Config, ConfigError> {
943 let cfg: Config = toml::from_str(s).map_err(|e| ConfigError::UserConfig {
944 path: "<test>".to_string(),
945 error: e,
946 })?;
947 validate(&cfg, "<test>")?;
948 Ok(cfg)
949 }
950
951 #[test]
952 fn embedded_default_parses() {
953 let cfg = load_default().expect("default preset must parse");
954 assert!(cfg.ts.kinds.contains_key("class"));
955 assert!(cfg.ts.kinds.contains_key("function"));
956 }
957
958 #[test]
959 fn ts_class_ships_at_least_one_rule_in_default() {
960 let cfg = load_default().unwrap();
961 let r = cfg.rules_for(Lang::Ts, "class").expect("ts.class present");
962 assert!(!r.rules.is_empty(), "preset must ship rules for ts.class");
963 }
964
965 #[test]
966 fn rules_for_falls_back_to_default_section() {
967 let cfg = parse(
968 r#"
969 [[default.module.where]]
970 id = "stub"
971 expr = "lines <= 99"
972
973 [[ts.class.where]]
974 expr = "name =~ ^X"
975 "#,
976 )
977 .unwrap();
978 let r = cfg
979 .rules_for(Lang::Ts, "module")
980 .expect("falls back to default.module");
981 assert_eq!(r.rules.len(), 1);
982 assert_eq!(r.rules[0].id.as_deref(), Some("stub"));
983 }
984
985 #[test]
986 fn parses_top_level_and_lang_shape_scopes() {
987 let cfg = parse(
988 r#"
989 [[shape.callable.where]]
990 id = "max-lines"
991 expr = "lines <= 60"
992
993 [[rust.shape.callable.where]]
994 id = "max-lines"
995 expr = "lines <= 120"
996 "#,
997 )
998 .unwrap();
999 assert_eq!(cfg.shape["callable"].rules.len(), 1);
1000 assert_eq!(cfg.rust.shape["callable"].rules.len(), 1);
1001 }
1002
1003 #[test]
1004 fn unknown_shape_scope_is_rejected() {
1005 let r = parse(
1006 r#"
1007 [[shape.ref.where]]
1008 id = "nope"
1009 expr = "lines <= 1"
1010 "#,
1011 );
1012 match r {
1013 Err(ConfigError::UnknownShape { shape, .. }) => assert_eq!(shape, "ref"),
1014 other => panic!("expected UnknownShape, got {other:?}"),
1015 }
1016 }
1017
1018 #[test]
1019 fn default_shape_scope_is_rejected() {
1020 let r = parse(
1021 r#"
1022 [[default.shape.callable.where]]
1023 id = "nope"
1024 expr = "lines <= 1"
1025 "#,
1026 );
1027 assert!(matches!(r, Err(ConfigError::DefaultShapeUnsupported)));
1028 }
1029
1030 #[test]
1031 fn override_with_same_id_replaces_preset_rule() {
1032 let user = parse(
1033 r#"
1034 [[ts.function.where]]
1035 id = "max-lines"
1036 expr = "lines <= 999"
1037 "#,
1038 )
1039 .unwrap();
1040 let mut base = parse(
1041 r#"
1042 [[ts.function.where]]
1043 id = "name-camel"
1044 expr = "name =~ ^[a-z]"
1045
1046 [[ts.function.where]]
1047 id = "max-lines"
1048 expr = "lines <= 60"
1049 "#,
1050 )
1051 .unwrap();
1052 merge_into(&mut base, user);
1053 let f = base.rules_for(Lang::Ts, "function").unwrap();
1054 assert_eq!(f.rules.len(), 2, "id-matched override replaces in place");
1055 let max_lines = f
1056 .rules
1057 .iter()
1058 .find(|r| r.id.as_deref() == Some("max-lines"))
1059 .unwrap();
1060 assert!(max_lines.expr.contains("999"), "user override applied");
1061 assert!(
1062 f.rules
1063 .iter()
1064 .any(|r| r.id.as_deref() == Some("name-camel")),
1065 "sibling rule preserved"
1066 );
1067 }
1068
1069 #[test]
1070 fn override_with_new_id_appends_to_preset() {
1071 let user = parse(
1072 r#"
1073 [[ts.class.where]]
1074 id = "extra"
1075 expr = "name !~ ^Internal"
1076 "#,
1077 )
1078 .unwrap();
1079 let mut base = parse(
1080 r#"
1081 [[ts.class.where]]
1082 id = "name-pascal"
1083 expr = "name =~ ^[A-Z]"
1084 "#,
1085 )
1086 .unwrap();
1087 merge_into(&mut base, user);
1088 let r = base.rules_for(Lang::Ts, "class").unwrap();
1089 assert_eq!(r.rules.len(), 2);
1090 }
1091
1092 #[test]
1093 fn unknown_field_in_kind_rules_is_rejected() {
1094 let r = toml::from_str::<Config>(
1095 r#"
1096 [ts.function]
1097 max_lines = 10
1098 "#,
1099 );
1100 assert!(r.is_err(), "deny_unknown_fields rejects legacy fields");
1101 }
1102
1103 #[test]
1104 fn alias_section_parses() {
1105 let cfg = parse(
1106 r#"
1107 [aliases]
1108 domain = "moniker ~ '**/module:domain/**'"
1109 "#,
1110 )
1111 .unwrap();
1112 assert_eq!(
1113 cfg.aliases.get("domain").map(|s| s.as_str()),
1114 Some("moniker ~ '**/module:domain/**'"),
1115 );
1116 }
1117
1118 #[test]
1119 fn alias_cycle_is_rejected() {
1120 let r = parse(
1121 r#"
1122 [aliases]
1123 a = "$b"
1124 b = "$a"
1125 "#,
1126 );
1127 match r {
1128 Err(ConfigError::AliasCycle { chain }) => {
1129 assert!(chain.contains("a") && chain.contains("b"), "{chain}");
1130 }
1131 other => panic!("expected AliasCycle, got {other:?}"),
1132 }
1133 }
1134
1135 #[test]
1136 fn alias_chain_resolves() {
1137 let cfg = parse(
1138 r#"
1139 [aliases]
1140 a = "name = 'X'"
1141 b = "$a OR name = 'Y'"
1142 c = "$b AND lines <= 10"
1143 "#,
1144 )
1145 .unwrap();
1146 let resolved = resolve_aliases(&cfg.aliases).unwrap();
1147 let final_c = resolved.get("c").unwrap();
1148 assert!(final_c.contains("name = 'X'"), "{final_c}");
1149 assert!(final_c.contains("name = 'Y'"), "{final_c}");
1150 assert!(final_c.contains("lines <= 10"), "{final_c}");
1151 }
1152
1153 #[test]
1154 fn alias_substitution_wraps_in_parens() {
1155 let mut src = HashMap::new();
1157 src.insert("x".to_string(), "A AND B".to_string());
1158 let resolved = resolve_aliases(&src).unwrap();
1159 let out = substitute_aliases("$x OR C", &resolved, "test").unwrap();
1160 assert_eq!(out, "(A AND B) OR C");
1161 }
1162
1163 #[test]
1164 fn unknown_alias_is_rejected_at_substitution() {
1165 let resolved = HashMap::new();
1166 match substitute_aliases("$bogus AND name = 'X'", &resolved, "ts.class.r1") {
1167 Err(ConfigError::UnknownAlias { name, at }) => {
1168 assert_eq!(name, "bogus");
1169 assert_eq!(at, "ts.class.r1");
1170 }
1171 other => panic!("expected UnknownAlias, got {other:?}"),
1172 }
1173 }
1174
1175 #[test]
1176 fn unknown_top_level_lang_section_is_rejected() {
1177 let r = toml::from_str::<Config>(
1178 r#"
1179 [[typescript.class.where]]
1180 expr = "name =~ ^[A-Z]"
1181 "#,
1182 );
1183 assert!(
1184 r.is_err(),
1185 "deny_unknown_fields must reject unknown lang sections"
1186 );
1187 }
1188
1189 #[test]
1190 fn unknown_require_doc_visibility_is_rejected() {
1191 let r = parse(
1192 r#"
1193 [ts.class]
1194 require_doc_comment = "publc"
1195 "#,
1196 );
1197 match r {
1198 Err(ConfigError::UnknownDocVisibility { value, .. }) => assert_eq!(value, "publc"),
1199 other => panic!("expected UnknownDocVisibility, got {other:?}"),
1200 }
1201 }
1202
1203 #[test]
1204 fn doc_visibility_any_is_accepted() {
1205 let r = parse(
1206 r#"
1207 [ts.class]
1208 require_doc_comment = "any"
1209 "#,
1210 );
1211 assert!(r.is_ok(), "any is always valid");
1212 }
1213
1214 #[test]
1215 fn unknown_kind_section_is_rejected() {
1216 let r = parse(
1217 r#"
1218 [[ts.classs.where]]
1219 expr = "name =~ ^X"
1220 "#,
1221 );
1222 match r {
1223 Err(ConfigError::UnknownKind { kind, .. }) => assert_eq!(kind, "classs"),
1224 other => panic!("expected UnknownKind, got {other:?}"),
1225 }
1226 }
1227
1228 #[test]
1229 fn missing_user_file_is_not_an_error() {
1230 let cfg = load_with_overrides(Some(Path::new("/no/such/file.toml")))
1231 .expect("missing file falls back to defaults");
1232 assert!(cfg.ts.kinds.contains_key("class"));
1233 }
1234
1235 #[test]
1236 fn missing_user_file_without_defaults_is_empty() {
1237 let cfg = load_with_options(Some(Path::new("/no/such/file.toml")), false)
1238 .expect("missing file is still accepted without defaults");
1239 assert!(cfg.refs.rules.is_empty());
1240 assert!(cfg.ts.kinds.is_empty());
1241 }
1242
1243 #[test]
1244 fn missing_user_file_does_not_discover_fragments() {
1245 let dir = tempfile::tempdir().unwrap();
1246 let missing_root = dir.path().join(".code-moniker.toml");
1247 write_fragment(
1248 dir.path(),
1249 "src",
1250 r#"
1251 fragment = "local"
1252
1253 [[rust.fn.where]]
1254 id = "parked"
1255 expr = "lines <= 10"
1256 "#,
1257 );
1258
1259 let cfg = load_with_options(Some(&missing_root), false).expect("missing root loads empty");
1260
1261 assert!(cfg.refs.rules.is_empty());
1262 assert!(cfg.rust.kinds.is_empty());
1263 assert!(cfg.fragments.is_empty());
1264 }
1265
1266 #[test]
1267 fn user_config_can_disable_embedded_default_rules() {
1268 let dir = tempfile::tempdir().unwrap();
1269 let p = dir.path().join(".code-moniker.toml");
1270 std::fs::write(&p, "default_rules = false\n").unwrap();
1271
1272 let cfg = load_with_overrides(Some(&p)).expect("config loads");
1273
1274 assert!(cfg.refs.rules.is_empty());
1275 assert!(cfg.ts.kinds.is_empty());
1276 assert_eq!(cfg.default_rules, Some(false));
1277 }
1278
1279 #[test]
1280 fn command_line_default_rules_off_wins_over_config_flag() {
1281 let dir = tempfile::tempdir().unwrap();
1282 let p = dir.path().join(".code-moniker.toml");
1283 std::fs::write(&p, "default_rules = true\n").unwrap();
1284
1285 let cfg = load_with_options(Some(&p), false).expect("config loads");
1286
1287 assert!(cfg.refs.rules.is_empty());
1288 assert!(cfg.ts.kinds.is_empty());
1289 assert_eq!(cfg.default_rules, Some(false));
1290 }
1291
1292 #[test]
1293 fn inline_default_rules_flag_can_disable_embedded_default_rules() {
1294 let inline = vec!["default_rules = false\n".to_string()];
1295
1296 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, None)
1297 .expect("inline config loads");
1298
1299 assert!(cfg.refs.rules.is_empty());
1300 assert!(cfg.ts.kinds.is_empty());
1301 assert_eq!(cfg.default_rules, Some(false));
1302 }
1303
1304 #[test]
1305 fn command_line_default_rules_on_wins_over_inline_flag() {
1306 let inline = vec!["default_rules = false\n".to_string()];
1307
1308 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, Some(true))
1309 .expect("inline config loads");
1310
1311 assert!(cfg.ts.kinds.contains_key("class"));
1312 assert_eq!(cfg.default_rules, Some(true));
1313 }
1314
1315 #[test]
1316 fn inline_rules_override_project_rule_by_same_id() {
1317 let dir = tempfile::tempdir().unwrap();
1318 let p = dir.path().join(".code-moniker.toml");
1319 std::fs::write(
1320 &p,
1321 r#"
1322 default_rules = false
1323
1324 [[ts.class.where]]
1325 id = "name-policy"
1326 expr = "name =~ ^Good"
1327 "#,
1328 )
1329 .unwrap();
1330 let inline = vec![
1331 r#"
1332 [[ts.class.where]]
1333 id = "name-policy"
1334 expr = "name =~ ^Inline"
1335 "#
1336 .to_string(),
1337 ];
1338
1339 let cfg = load_with_cli_sources(Some(&p), &inline, None).expect("inline config loads");
1340 let rule = cfg
1341 .rules_for(Lang::Ts, "class")
1342 .unwrap()
1343 .rules
1344 .iter()
1345 .find(|rule| rule.id.as_deref() == Some("name-policy"))
1346 .unwrap();
1347
1348 assert_eq!(rule.expr, "name =~ ^Inline");
1349 }
1350
1351 #[test]
1352 fn repeated_inline_rules_merge_in_order() {
1353 let inline = vec![
1354 r#"
1355 default_rules = false
1356
1357 [[ts.class.where]]
1358 id = "inline-name"
1359 expr = "name =~ ^First"
1360 "#
1361 .to_string(),
1362 r#"
1363 [[ts.class.where]]
1364 id = "inline-name"
1365 expr = "name =~ ^Second"
1366 "#
1367 .to_string(),
1368 ];
1369
1370 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, None)
1371 .expect("inline config loads");
1372 let rule = cfg
1373 .rules_for(Lang::Ts, "class")
1374 .unwrap()
1375 .rules
1376 .iter()
1377 .find(|rule| rule.id.as_deref() == Some("inline-name"))
1378 .unwrap();
1379
1380 assert_eq!(rule.expr, "name =~ ^Second");
1381 }
1382
1383 #[test]
1384 fn malformed_user_file_returns_user_config_error() {
1385 let dir = tempfile::tempdir().unwrap();
1386 let p = dir.path().join("bad.toml");
1387 std::fs::write(&p, "this is not toml = = =").unwrap();
1388 match load_with_overrides(Some(&p)) {
1389 Err(ConfigError::UserConfig { .. }) => {}
1390 other => panic!("expected UserConfig error, got {other:?}"),
1391 }
1392 }
1393
1394 fn write_fragment(root: &Path, rel_dir: &str, body: &str) -> std::path::PathBuf {
1395 let dir = root.join(rel_dir);
1396 std::fs::create_dir_all(&dir).unwrap();
1397 let path = dir.join("code-moniker.fragment.toml");
1398 std::fs::write(&path, body).unwrap();
1399 path
1400 }
1401
1402 #[test]
1403 fn fragment_rules_are_loaded_with_fragment_namespace() {
1404 let dir = tempfile::tempdir().unwrap();
1405 let root = dir.path().join(".code-moniker.toml");
1406 std::fs::write(
1407 &root,
1408 r#"
1409 default_rules = false
1410
1411 [aliases]
1412 local_name = "name =~ ^[a-z_]"
1413 "#,
1414 )
1415 .unwrap();
1416 let fragment_path = write_fragment(
1417 dir.path(),
1418 "crates/check/src/check",
1419 r#"
1420 fragment = "check"
1421
1422 [[rust.fn.where]]
1423 id = "parser-only"
1424 expr = "$local_name"
1425 "#,
1426 );
1427
1428 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1429
1430 assert_eq!(cfg.fragments.len(), 1);
1431 assert_eq!(cfg.fragments[0].id, "check");
1432 assert_eq!(cfg.fragments[0].path, fragment_path);
1433 assert!(cfg.fragments[0].enabled);
1434 assert_eq!(cfg.fragments[0].declared_rules, 1);
1435 assert_eq!(cfg.fragments[0].active_rules, 1);
1436 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1437 let ids: Vec<_> = compiled
1438 .specs(Lang::Rs)
1439 .into_iter()
1440 .map(|rule| rule.rule_id)
1441 .collect();
1442 assert!(
1443 ids.iter().any(|id| id == "rust.fn.check.parser-only"),
1444 "{ids:?}"
1445 );
1446 }
1447
1448 #[test]
1449 fn disabled_fragment_is_reported_but_not_merged() {
1450 let dir = tempfile::tempdir().unwrap();
1451 let root = dir.path().join(".code-moniker.toml");
1452 std::fs::write(&root, "default_rules = false\n").unwrap();
1453 write_fragment(
1454 dir.path(),
1455 "src",
1456 r#"
1457 fragment = "local"
1458 enabled = false
1459
1460 [[rust.fn.where]]
1461 id = "parked"
1462 expr = "$missing_while_disabled"
1463 "#,
1464 );
1465
1466 let cfg = load_with_overrides(Some(&root)).expect("disabled fragment loads");
1467
1468 assert_eq!(cfg.fragments.len(), 1);
1469 assert_eq!(cfg.fragments[0].id, "local");
1470 assert!(!cfg.fragments[0].enabled);
1471 assert_eq!(cfg.fragments[0].declared_rules, 1);
1472 assert_eq!(cfg.fragments[0].active_rules, 0);
1473 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1474 let ids: Vec<_> = compiled
1475 .specs(Lang::Rs)
1476 .into_iter()
1477 .map(|rule| rule.rule_id)
1478 .collect();
1479 assert!(
1480 !ids.iter().any(|id| id == "rust.fn.local.parked"),
1481 "{ids:?}"
1482 );
1483 }
1484
1485 #[test]
1486 fn profile_recomputes_fragment_active_rules() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let root = dir.path().join(".code-moniker.toml");
1489 std::fs::write(
1490 &root,
1491 r#"
1492 default_rules = false
1493
1494 [profiles.none]
1495 disable = ["^rust\\.fn\\.local\\.parked$"]
1496 "#,
1497 )
1498 .unwrap();
1499 write_fragment(
1500 dir.path(),
1501 "src",
1502 r#"
1503 fragment = "local"
1504
1505 [[rust.fn.where]]
1506 id = "parked"
1507 expr = "lines <= 10"
1508 "#,
1509 );
1510 let mut cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1511
1512 cfg.apply_profile("none").expect("profile applies");
1513
1514 assert_eq!(cfg.fragments[0].declared_rules, 1);
1515 assert_eq!(cfg.fragments[0].active_rules, 0);
1516 }
1517
1518 #[test]
1519 fn disabled_fragment_still_rejects_missing_rule_ids() {
1520 let dir = tempfile::tempdir().unwrap();
1521 let root = dir.path().join(".code-moniker.toml");
1522 std::fs::write(&root, "default_rules = false\n").unwrap();
1523 write_fragment(
1524 dir.path(),
1525 "src",
1526 r#"
1527 fragment = "local"
1528 enabled = false
1529
1530 [[rust.fn.where]]
1531 expr = "lines <= 10"
1532 "#,
1533 );
1534
1535 match load_with_overrides(Some(&root)) {
1536 Err(ConfigError::FragmentRuleMissingId { fragment, at, .. }) => {
1537 assert_eq!(fragment, "local");
1538 assert_eq!(at, "rust.fn");
1539 }
1540 other => panic!("expected FragmentRuleMissingId error, got {other:?}"),
1541 }
1542 }
1543
1544 #[test]
1545 fn fragment_local_aliases_are_namespaced_and_usable() {
1546 let dir = tempfile::tempdir().unwrap();
1547 let root = dir.path().join(".code-moniker.toml");
1548 std::fs::write(&root, "default_rules = false\n").unwrap();
1549 write_fragment(
1550 dir.path(),
1551 "src",
1552 r#"
1553 fragment = "local"
1554
1555 [aliases]
1556 local_name = "name = 'Ok'"
1557
1558 [[rust.fn.where]]
1559 id = "uses-local"
1560 expr = "$local_name"
1561 "#,
1562 );
1563
1564 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1565
1566 assert_eq!(
1567 cfg.aliases.get("local_local_name").map(|s| s.as_str()),
1568 Some("name = 'Ok'")
1569 );
1570 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1571 let specs = compiled.specs(Lang::Rs);
1572 let rule = specs
1573 .iter()
1574 .find(|rule| rule.rule_id == "rust.fn.local.uses-local")
1575 .expect("fragment rule is compiled");
1576 assert!(
1577 rule.expanded_expr.contains("name = 'Ok'"),
1578 "{}",
1579 rule.expanded_expr
1580 );
1581 }
1582
1583 #[test]
1584 fn fragment_local_alias_can_reference_global_alias() {
1585 let dir = tempfile::tempdir().unwrap();
1586 let root = dir.path().join(".code-moniker.toml");
1587 std::fs::write(
1588 &root,
1589 r#"
1590 default_rules = false
1591
1592 [aliases]
1593 global_name = "name = 'Ok'"
1594 "#,
1595 )
1596 .unwrap();
1597 write_fragment(
1598 dir.path(),
1599 "src",
1600 r#"
1601 fragment = "local"
1602
1603 [aliases]
1604 local_name = "$global_name"
1605
1606 [[rust.fn.where]]
1607 id = "uses-local"
1608 expr = "$local_name"
1609 "#,
1610 );
1611
1612 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1613 let resolved = resolve_aliases(&cfg.aliases).expect("aliases resolve");
1614 assert_eq!(
1615 resolved.get("local_local_name").map(|s| s.as_str()),
1616 Some("(name = 'Ok')")
1617 );
1618 }
1619
1620 #[test]
1621 fn fragment_local_alias_can_reference_another_local_alias() {
1622 let dir = tempfile::tempdir().unwrap();
1623 let root = dir.path().join(".code-moniker.toml");
1624 std::fs::write(&root, "default_rules = false\n").unwrap();
1625 write_fragment(
1626 dir.path(),
1627 "src",
1628 r#"
1629 fragment = "local"
1630
1631 [aliases]
1632 leaf = "name = 'Ok'"
1633 composed = "$leaf AND lines <= 10"
1634
1635 [[rust.fn.where]]
1636 id = "uses-composed"
1637 expr = "$composed"
1638 "#,
1639 );
1640
1641 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
1642
1643 assert_eq!(
1644 cfg.aliases.get("local_composed").map(|s| s.as_str()),
1645 Some("$local_leaf AND lines <= 10")
1646 );
1647 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
1648 let specs = compiled.specs(Lang::Rs);
1649 let rule = specs
1650 .iter()
1651 .find(|rule| rule.rule_id == "rust.fn.local.uses-composed")
1652 .expect("fragment rule is compiled");
1653 assert!(
1654 rule.expanded_expr.contains("name = 'Ok'"),
1655 "{}",
1656 rule.expanded_expr
1657 );
1658 assert!(
1659 rule.expanded_expr.contains("lines <= 10"),
1660 "{}",
1661 rule.expanded_expr
1662 );
1663 }
1664
1665 #[test]
1666 fn fragment_aliases_cannot_reference_other_fragments() {
1667 let dir = tempfile::tempdir().unwrap();
1668 let root = dir.path().join(".code-moniker.toml");
1669 std::fs::write(&root, "default_rules = false\n").unwrap();
1670 write_fragment(
1671 dir.path(),
1672 "a",
1673 r#"
1674 fragment = "first"
1675
1676 [aliases]
1677 shared = "name = 'Shared'"
1678 "#,
1679 );
1680 write_fragment(
1681 dir.path(),
1682 "b",
1683 r#"
1684 fragment = "second"
1685
1686 [aliases]
1687 local = "$first_shared"
1688
1689 [[rust.fn.where]]
1690 id = "uses-local"
1691 expr = "$local"
1692 "#,
1693 );
1694
1695 match load_with_overrides(Some(&root)) {
1696 Err(ConfigError::UnknownAlias { name, at }) => {
1697 assert_eq!(name, "first_shared");
1698 assert_eq!(at, "alias `second_local`");
1699 }
1700 other => panic!("expected UnknownAlias error, got {other:?}"),
1701 }
1702 }
1703
1704 #[test]
1705 fn fragment_alias_local_name_must_not_shadow_existing_alias() {
1706 let dir = tempfile::tempdir().unwrap();
1707 let root = dir.path().join(".code-moniker.toml");
1708 std::fs::write(
1709 &root,
1710 r#"
1711 default_rules = false
1712
1713 [aliases]
1714 shared = "name = 'Global'"
1715 "#,
1716 )
1717 .unwrap();
1718 write_fragment(
1719 dir.path(),
1720 "src",
1721 r#"
1722 fragment = "local"
1723
1724 [aliases]
1725 shared = "name = 'Local'"
1726 "#,
1727 );
1728
1729 match load_with_overrides(Some(&root)) {
1730 Err(ConfigError::FragmentAliasShadowsExisting {
1731 fragment, alias, ..
1732 }) => {
1733 assert_eq!(fragment, "local");
1734 assert_eq!(alias, "shared");
1735 }
1736 other => panic!("expected FragmentAliasShadowsExisting error, got {other:?}"),
1737 }
1738 }
1739
1740 #[test]
1741 fn fragment_alias_effective_key_collision_is_rejected() {
1742 let dir = tempfile::tempdir().unwrap();
1743 let root = dir.path().join(".code-moniker.toml");
1744 std::fs::write(
1745 &root,
1746 r#"
1747 default_rules = false
1748
1749 [aliases]
1750 local_shared = "name = 'Global'"
1751 "#,
1752 )
1753 .unwrap();
1754 write_fragment(
1755 dir.path(),
1756 "src",
1757 r#"
1758 fragment = "local"
1759
1760 [aliases]
1761 shared = "name = 'Local'"
1762 "#,
1763 );
1764
1765 match load_with_overrides(Some(&root)) {
1766 Err(ConfigError::FragmentAliasCollision {
1767 alias, existing, ..
1768 }) => {
1769 assert_eq!(alias, "local_shared");
1770 assert_eq!(existing, "<effective config>");
1771 }
1772 other => panic!("expected FragmentAliasCollision error, got {other:?}"),
1773 }
1774 }
1775
1776 #[test]
1777 fn fragment_alias_ids_must_match_reference_grammar() {
1778 let dir = tempfile::tempdir().unwrap();
1779 let root = dir.path().join(".code-moniker.toml");
1780 std::fs::write(&root, "default_rules = false\n").unwrap();
1781 write_fragment(
1782 dir.path(),
1783 "src",
1784 r#"
1785 fragment = "local"
1786
1787 [aliases]
1788 "bad-name" = "name = 'X'"
1789 "#,
1790 );
1791
1792 match load_with_overrides(Some(&root)) {
1793 Err(ConfigError::InvalidFragmentAliasId {
1794 fragment, alias, ..
1795 }) => {
1796 assert_eq!(fragment, "local");
1797 assert_eq!(alias, "bad-name");
1798 }
1799 other => panic!("expected InvalidFragmentAliasId error, got {other:?}"),
1800 }
1801 }
1802
1803 #[test]
1804 fn duplicate_fragment_ids_are_rejected() {
1805 let dir = tempfile::tempdir().unwrap();
1806 let root = dir.path().join(".code-moniker.toml");
1807 std::fs::write(&root, "default_rules = false\n").unwrap();
1808 write_fragment(dir.path(), "a", "fragment = \"local\"\n");
1809 write_fragment(dir.path(), "b", "fragment = \"local\"\n");
1810
1811 match load_with_overrides(Some(&root)) {
1812 Err(ConfigError::DuplicateFragment { id, first, second }) => {
1813 assert_eq!(id, "local");
1814 assert!(first.ends_with("a/code-moniker.fragment.toml"), "{first}");
1815 assert!(second.ends_with("b/code-moniker.fragment.toml"), "{second}");
1816 }
1817 other => panic!("expected DuplicateFragment error, got {other:?}"),
1818 }
1819 }
1820
1821 #[test]
1822 fn fragment_rules_must_have_explicit_ids() {
1823 let dir = tempfile::tempdir().unwrap();
1824 let root = dir.path().join(".code-moniker.toml");
1825 std::fs::write(&root, "default_rules = false\n").unwrap();
1826 write_fragment(
1827 dir.path(),
1828 "src",
1829 r#"
1830 fragment = "local"
1831
1832 [[rust.fn.where]]
1833 expr = "lines <= 10"
1834 "#,
1835 );
1836
1837 match load_with_overrides(Some(&root)) {
1838 Err(ConfigError::FragmentRuleMissingId { fragment, at, .. }) => {
1839 assert_eq!(fragment, "local");
1840 assert_eq!(at, "rust.fn");
1841 }
1842 other => panic!("expected FragmentRuleMissingId error, got {other:?}"),
1843 }
1844 }
1845
1846 #[test]
1847 fn fragment_rule_collisions_are_rejected() {
1848 let dir = tempfile::tempdir().unwrap();
1849 let root = dir.path().join(".code-moniker.toml");
1850 std::fs::write(
1851 &root,
1852 r#"
1853 default_rules = false
1854
1855 [[rust.fn.where]]
1856 id = "local.small"
1857 expr = "lines <= 10"
1858 "#,
1859 )
1860 .unwrap();
1861 write_fragment(
1862 dir.path(),
1863 "src",
1864 r#"
1865 fragment = "local"
1866
1867 [[rust.fn.where]]
1868 id = "small"
1869 expr = "lines <= 20"
1870 "#,
1871 );
1872
1873 match load_with_overrides(Some(&root)) {
1874 Err(ConfigError::FragmentRuleCollision {
1875 rule_id, existing, ..
1876 }) => {
1877 assert_eq!(rule_id, "rust.fn.local.small");
1878 assert_eq!(existing, "<effective config>");
1879 }
1880 other => panic!("expected FragmentRuleCollision error, got {other:?}"),
1881 }
1882 }
1883
1884 #[test]
1885 fn fragment_unknown_alias_is_reported_at_fragment_rule() {
1886 let dir = tempfile::tempdir().unwrap();
1887 let root = dir.path().join(".code-moniker.toml");
1888 std::fs::write(&root, "default_rules = false\n").unwrap();
1889 write_fragment(
1890 dir.path(),
1891 "src",
1892 r#"
1893 fragment = "local"
1894
1895 [[rust.fn.where]]
1896 id = "uses-alias"
1897 expr = "$missing_alias"
1898 "#,
1899 );
1900
1901 match load_with_overrides(Some(&root)) {
1902 Err(ConfigError::UnknownAlias { name, at }) => {
1903 assert_eq!(name, "missing_alias");
1904 assert!(at.contains("code-moniker.fragment.toml:rust.fn.local.uses-alias"));
1905 }
1906 other => panic!("expected UnknownAlias error, got {other:?}"),
1907 }
1908 }
1909
1910 #[test]
1911 fn profile_enable_filters_in() {
1912 let mut cfg = parse(
1913 r#"
1914 [[ts.class.where]]
1915 id = "keep"
1916 expr = "lines <= 99"
1917
1918 [[ts.class.where]]
1919 id = "drop"
1920 expr = "lines <= 99"
1921
1922 [profiles.only_keep]
1923 enable = ["\\.keep$"]
1924 "#,
1925 )
1926 .unwrap();
1927 cfg.apply_profile("only_keep").unwrap();
1928 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
1929 assert_eq!(r.rules.len(), 1);
1930 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
1931 }
1932
1933 #[test]
1934 fn profile_disable_filters_out() {
1935 let mut cfg = parse(
1936 r#"
1937 [[ts.class.where]]
1938 id = "keep"
1939 expr = "lines <= 99"
1940
1941 [[ts.class.where]]
1942 id = "drop"
1943 expr = "lines <= 99"
1944
1945 [profiles.drop_one]
1946 disable = ["\\.drop$"]
1947 "#,
1948 )
1949 .unwrap();
1950 cfg.apply_profile("drop_one").unwrap();
1951 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
1952 assert_eq!(r.rules.len(), 1);
1953 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
1954 }
1955
1956 #[test]
1957 fn profile_enable_then_disable() {
1958 let mut cfg = parse(
1959 r#"
1960 [[ts.class.where]]
1961 id = "a"
1962 expr = "lines <= 99"
1963
1964 [[ts.class.where]]
1965 id = "b"
1966 expr = "lines <= 99"
1967
1968 [[ts.class.where]]
1969 id = "c"
1970 expr = "lines <= 99"
1971
1972 [profiles.p]
1973 enable = ["ts\\.class\\.(a|b)$"]
1974 disable = ["ts\\.class\\.b$"]
1975 "#,
1976 )
1977 .unwrap();
1978 cfg.apply_profile("p").unwrap();
1979 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
1980 assert_eq!(r.rules.len(), 1);
1981 assert_eq!(r.rules[0].id.as_deref(), Some("a"));
1982 }
1983
1984 #[test]
1985 fn profile_filters_refs_top_level() {
1986 let mut cfg = parse(
1987 r#"
1988 [[refs.where]]
1989 id = "stay"
1990 expr = "kind = 'call'"
1991
1992 [[refs.where]]
1993 id = "go"
1994 expr = "kind = 'call'"
1995
1996 [profiles.p]
1997 disable = ["^refs\\.go$"]
1998 "#,
1999 )
2000 .unwrap();
2001 cfg.apply_profile("p").unwrap();
2002 assert_eq!(cfg.refs.rules.len(), 1);
2003 assert_eq!(cfg.refs.rules[0].id.as_deref(), Some("stay"));
2004 }
2005
2006 #[test]
2007 fn profile_filters_per_lang_refs() {
2008 let mut cfg = parse(
2009 r#"
2010 [[ts.refs.where]]
2011 id = "stay"
2012 expr = "kind = 'call'"
2013
2014 [[ts.refs.where]]
2015 id = "go"
2016 expr = "kind = 'call'"
2017
2018 [profiles.p]
2019 disable = ["^ts\\.refs\\.go$"]
2020 "#,
2021 )
2022 .unwrap();
2023 cfg.apply_profile("p").unwrap();
2024 let r = cfg.ts.kinds.get("refs").unwrap();
2025 assert_eq!(r.rules.len(), 1);
2026 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
2027 }
2028
2029 #[test]
2030 fn profile_filters_shape_scopes() {
2031 let mut cfg = parse(
2032 r#"
2033 [[shape.callable.where]]
2034 id = "stay"
2035 expr = "lines <= 99"
2036
2037 [[shape.callable.where]]
2038 id = "go"
2039 expr = "lines <= 99"
2040
2041 [[ts.shape.type.where]]
2042 id = "go"
2043 expr = "lines <= 99"
2044
2045 [profiles.p]
2046 disable = ["^shape\\.callable\\.go$", "^ts\\.shape\\.type\\.go$"]
2047 "#,
2048 )
2049 .unwrap();
2050 cfg.apply_profile("p").unwrap();
2051 assert_eq!(cfg.shape["callable"].rules.len(), 1);
2052 assert_eq!(cfg.shape["callable"].rules[0].id.as_deref(), Some("stay"));
2053 assert!(cfg.ts.shape["type"].rules.is_empty());
2054 }
2055
2056 #[test]
2057 fn profile_filters_default_section() {
2058 let mut cfg = parse(
2059 r#"
2060 [[default.module.where]]
2061 id = "stay"
2062 expr = "lines <= 99"
2063
2064 [[default.module.where]]
2065 id = "go"
2066 expr = "lines <= 99"
2067
2068 [profiles.p]
2069 disable = ["^default\\.module\\.go$"]
2070 "#,
2071 )
2072 .unwrap();
2073 cfg.apply_profile("p").unwrap();
2074 let r = cfg.default.kinds.get("module").unwrap();
2075 assert_eq!(r.rules.len(), 1);
2076 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
2077 }
2078
2079 #[test]
2080 fn unknown_profile_returns_error() {
2081 let mut cfg = parse(
2082 r#"
2083 [profiles.known]
2084 disable = []
2085 "#,
2086 )
2087 .unwrap();
2088 match cfg.apply_profile("nope") {
2089 Err(ConfigError::UnknownProfile { name, known }) => {
2090 assert_eq!(name, "nope");
2091 assert!(known.contains("known"), "{known}");
2092 }
2093 other => panic!("expected UnknownProfile, got {other:?}"),
2094 }
2095 }
2096
2097 #[test]
2098 fn bad_regex_returns_error() {
2099 let mut cfg = parse(
2100 r#"
2101 [profiles.p]
2102 enable = ["(unclosed"]
2103 "#,
2104 )
2105 .unwrap();
2106 match cfg.apply_profile("p") {
2107 Err(ConfigError::BadProfileRegex {
2108 profile,
2109 field,
2110 pattern,
2111 ..
2112 }) => {
2113 assert_eq!(profile, "p");
2114 assert_eq!(field, "enable");
2115 assert_eq!(pattern, "(unclosed");
2116 }
2117 other => panic!("expected BadProfileRegex, got {other:?}"),
2118 }
2119 }
2120
2121 #[test]
2122 fn fallback_where_n_id_matches() {
2123 let mut cfg = parse(
2124 r#"
2125 [[ts.class.where]]
2126 expr = "lines <= 99"
2127
2128 [[ts.class.where]]
2129 expr = "lines <= 99"
2130
2131 [profiles.p]
2132 disable = ["^ts\\.class\\.where_0$"]
2133 "#,
2134 )
2135 .unwrap();
2136 cfg.apply_profile("p").unwrap();
2137 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
2138 assert_eq!(r.rules.len(), 1);
2139 }
2140
2141 #[test]
2142 fn user_profile_overrides_preset_by_name() {
2143 let user = parse(
2144 r#"
2145 [profiles.bugfix]
2146 enable = ["^user$"]
2147 disable = []
2148 "#,
2149 )
2150 .unwrap();
2151 let mut base = parse(
2152 r#"
2153 [profiles.bugfix]
2154 enable = ["^base$"]
2155 disable = []
2156 "#,
2157 )
2158 .unwrap();
2159 merge_into(&mut base, user);
2160 let p = base.profiles.get("bugfix").unwrap();
2161 assert_eq!(p.enable, vec!["^user$".to_string()]);
2162 }
2163
2164 #[test]
2165 fn default_preset_ships_at_least_one_rule_per_language() {
2166 let cfg = load_default().unwrap();
2167 for lang in Lang::ALL {
2168 let lr = cfg.for_lang(*lang);
2169 assert!(
2170 !lr.kinds.is_empty(),
2171 "{} should ship at least one default rule",
2172 lang.tag()
2173 );
2174 }
2175 }
2176}