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 telemetry: Option<code_moniker_workspace::environment::TelemetryConfig>,
28 #[serde(default)]
29 pub aliases: HashMap<String, String>,
30 #[serde(default)]
31 pub exclude: ExcludeRules,
32 #[serde(default)]
33 pub refs: RefsRules,
34 #[serde(default)]
35 pub workspace: WorkspaceRules,
36 #[serde(default)]
37 pub shape: HashMap<String, KindRules>,
38 #[serde(default)]
39 pub default: LangRules,
40 #[serde(default)]
41 pub ts: LangRules,
42 #[serde(default)]
43 pub rust: LangRules,
44 #[serde(default)]
45 pub java: LangRules,
46 #[serde(default)]
47 pub python: LangRules,
48 #[serde(default)]
49 pub go: LangRules,
50 #[serde(default)]
51 pub c: LangRules,
52 #[serde(default)]
53 pub cs: LangRules,
54 #[serde(default)]
55 pub sql: LangRules,
56 #[serde(default)]
57 pub profiles: HashMap<String, Profile>,
58 #[serde(default)]
59 pub views: Vec<toml::Value>,
60 #[serde(skip)]
61 pub fragments: Vec<FragmentInfo>,
62}
63
64#[derive(Debug, Default, Deserialize, Clone)]
65#[serde(deny_unknown_fields)]
66pub struct ExcludeRules {
67 #[serde(default)]
68 pub uris: Vec<String>,
69}
70
71#[derive(Debug, Clone)]
72pub struct FragmentInfo {
73 pub id: String,
74 pub path: PathBuf,
75 pub enabled: bool,
76 pub declared_rules: usize,
77 pub active_rules: usize,
78 pub(crate) rule_keys: Vec<String>,
79}
80
81#[derive(Debug, Default, Deserialize, Clone)]
82#[serde(deny_unknown_fields)]
83pub struct Profile {
84 #[serde(default)]
85 pub enable: Vec<String>,
86 #[serde(default)]
87 pub disable: Vec<String>,
88}
89
90#[derive(Debug, Default, Deserialize, Clone)]
91#[serde(deny_unknown_fields)]
92pub struct RefsRules {
93 #[serde(default, rename = "where")]
94 pub rules: Vec<RuleEntry>,
95}
96
97#[derive(Debug, Default, Deserialize, Clone)]
98#[serde(deny_unknown_fields)]
99pub struct WorkspaceRules {
100 #[serde(default)]
101 pub min_linkage_coverage: Option<usize>,
102 #[serde(default)]
103 pub symbol: KindRules,
104 #[serde(default)]
105 pub group: WorkspaceGroupRules,
106 #[serde(default)]
107 pub path: Vec<WorkspacePathRuleEntry>,
108 #[serde(default, rename = "source_group")]
109 pub source_groups: Vec<code_moniker_workspace::source_group::SourceGroupConfig>,
110}
111
112#[derive(Debug, Default, Deserialize, Clone)]
113#[serde(deny_unknown_fields)]
114pub struct WorkspaceGroupRules {
115 #[serde(default, rename = "where")]
116 pub rules: Vec<WorkspaceGroupRuleEntry>,
117}
118
119#[derive(Debug, Deserialize, Clone)]
120#[serde(deny_unknown_fields)]
121pub struct WorkspaceGroupRuleEntry {
122 #[serde(default)]
123 pub id: Option<String>,
124 pub members: String,
125 pub group_by: Vec<String>,
126 pub expr: String,
127 #[serde(default)]
128 pub severity: RuleSeverity,
129 #[serde(default)]
130 pub message: Option<String>,
131 #[serde(default)]
132 pub rationale: Option<String>,
133 #[serde(default)]
134 pub suppress: Vec<WorkspaceGroupSuppression>,
135}
136
137#[derive(Debug, Deserialize, Clone)]
138#[serde(deny_unknown_fields)]
139pub struct WorkspaceGroupSuppression {
140 pub values: Vec<String>,
141}
142
143#[derive(Debug, Deserialize, Clone)]
144#[serde(deny_unknown_fields)]
145pub struct WorkspacePathRuleEntry {
146 #[serde(default)]
147 pub id: Option<String>,
148 pub from: String,
149 pub to: String,
150 pub expect: WorkspacePathExpectation,
151 #[serde(default)]
152 pub via: Option<String>,
153 #[serde(default)]
154 pub require_non_empty: bool,
155 #[serde(default = "default_path_relations")]
156 pub relation: Vec<String>,
157 #[serde(default = "default_path_max_depth")]
158 pub max_depth: usize,
159 #[serde(default = "default_path_max_symbols")]
160 pub max_symbols: usize,
161 #[serde(default = "default_path_max_edges")]
162 pub max_edges: usize,
163 #[serde(default = "default_path_max_pairs")]
164 pub max_pairs: usize,
165 #[serde(default)]
166 pub min_coverage: Option<usize>,
167 #[serde(default)]
168 pub severity: RuleSeverity,
169 #[serde(default)]
170 pub message: Option<String>,
171 #[serde(default)]
172 pub rationale: Option<String>,
173}
174
175#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
176#[serde(rename_all = "snake_case")]
177pub enum WorkspacePathExpectation {
178 Reachable,
179 NoPath,
180 AllPathsVia,
181}
182
183fn default_path_relations() -> Vec<String> {
184 vec!["calls".to_string(), "method_call".to_string()]
185}
186
187const fn default_path_max_depth() -> usize {
188 12
189}
190
191const fn default_path_max_symbols() -> usize {
192 10_000
193}
194
195const fn default_path_max_edges() -> usize {
196 50_000
197}
198
199const fn default_path_max_pairs() -> usize {
200 10_000
201}
202
203#[derive(Debug, Default, Deserialize, Clone)]
204pub struct LangRules {
205 #[serde(default)]
206 pub shape: HashMap<String, KindRules>,
207 #[serde(flatten)]
208 pub kinds: HashMap<String, KindRules>,
209}
210
211#[derive(Debug, Default, Deserialize, Clone)]
212#[serde(deny_unknown_fields)]
213pub struct KindRules {
214 #[serde(default, rename = "where")]
215 pub rules: Vec<RuleEntry>,
216 pub require_doc_comment: Option<String>,
217}
218
219#[derive(Debug, Deserialize, Clone)]
220#[serde(deny_unknown_fields)]
221pub struct RuleEntry {
222 #[serde(default)]
223 pub id: Option<String>,
224 pub expr: String,
225 #[serde(default)]
226 pub severity: RuleSeverity,
227 #[serde(default)]
228 pub message: Option<String>,
229 #[serde(default)]
230 pub rationale: Option<String>,
231}
232
233#[derive(
234 Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Deserialize, serde::Serialize,
235)]
236#[serde(rename_all = "lowercase")]
237pub enum RuleSeverity {
238 Warn,
239 #[default]
240 Error,
241}
242
243impl RuleSeverity {
244 #[allow(dead_code)]
245 pub fn as_str(self) -> &'static str {
246 match self {
247 Self::Warn => "warn",
248 Self::Error => "error",
249 }
250 }
251
252 pub fn is_error(self) -> bool {
253 matches!(self, Self::Error)
254 }
255
256 pub fn is_warn(self) -> bool {
257 matches!(self, Self::Warn)
258 }
259}
260
261#[derive(Debug, Error)]
262pub enum ConfigError {
263 #[error("default preset embedded in the binary is invalid: {0}")]
264 DefaultPresetInvalid(toml::de::Error),
265 #[error("user config `{path}`: {error}")]
266 UserConfig {
267 path: String,
268 error: toml::de::Error,
269 },
270 #[error("fragment config `{path}`: {error}")]
271 FragmentConfig {
272 path: String,
273 error: toml::de::Error,
274 },
275 #[error("cannot read `{path}`: {error}")]
276 Io { path: String, error: std::io::Error },
277 #[error("invalid expression at `{at}`: {error}")]
278 InvalidExpr {
279 at: String,
280 error: super::expr::ParseError,
281 },
282 #[error("workspace rule `{at}` requires unsupported capability `{capability}`")]
283 UnsupportedWorkspaceExpr { at: String, capability: String },
284 #[error("invalid workspace group rule `{at}`: {message}")]
285 InvalidWorkspaceGroup { at: String, message: String },
286 #[error("invalid workspace path rule `{at}`: {message}")]
287 InvalidWorkspacePath { at: String, message: String },
288 #[error("workspace.min_linkage_coverage must be between 0 and 100, got {value}")]
289 InvalidWorkspaceCoverage { value: usize },
290 #[error("unknown kind `{kind}` under `[{section}.{kind}]` (allowed: {allowed})")]
291 UnknownKind {
292 section: String,
293 kind: String,
294 allowed: String,
295 },
296 #[error("unknown shape `{shape}` under `[{section}]` (allowed: {allowed})")]
297 UnknownShape {
298 section: String,
299 shape: String,
300 allowed: String,
301 },
302 #[error(
303 "shape rules under `[default.shape]` are not supported; use top-level `[shape]` for cross-language shape rules"
304 )]
305 DefaultShapeUnsupported,
306 #[error(
307 "require_doc_comment under `[workspace.symbol]` is not supported; use a workspace expression"
308 )]
309 WorkspaceRequireDocUnsupported,
310 #[error(
311 "`workspace.source_group` in `{path}` is structural workspace configuration and may be declared only in the canonical `.code-moniker.toml` project file"
312 )]
313 SourceGroupOutsideProjectRoot { path: String },
314 #[error(
315 "require_doc_comment = `{value}` under `[{section}.{kind}]` is not a recognised visibility for that language (allowed: {allowed})"
316 )]
317 UnknownDocVisibility {
318 section: String,
319 kind: String,
320 value: String,
321 allowed: String,
322 },
323 #[error("alias cycle through `{chain}`")]
324 AliasCycle { chain: String },
325 #[error("unknown alias `${name}` referenced under `{at}`")]
326 UnknownAlias { name: String, at: String },
327 #[error("unknown profile `{name}` (known: {known})")]
328 UnknownProfile { name: String, known: String },
329 #[error("invalid regex `{pattern}` in profile `{profile}` ({field}): {error}")]
330 BadProfileRegex {
331 profile: String,
332 field: &'static str,
333 pattern: String,
334 error: regex::Error,
335 },
336 #[error("invalid fragment id `{id}` in `{path}`; use ASCII letters, digits, `_`, or `-`")]
337 InvalidFragmentId { path: String, id: String },
338 #[error(
339 "invalid alias id `{alias}` in fragment `{fragment}` at `{path}`; use ASCII letters, digits, or `_`"
340 )]
341 InvalidFragmentAliasId {
342 path: String,
343 fragment: String,
344 alias: String,
345 },
346 #[error("duplicate fragment id `{id}` in `{first}` and `{second}`")]
347 DuplicateFragment {
348 id: String,
349 first: String,
350 second: String,
351 },
352 #[error("alias `{alias}` from fragment `{fragment}` in `{path}` shadows an existing alias")]
353 FragmentAliasShadowsExisting {
354 path: String,
355 fragment: String,
356 alias: String,
357 },
358 #[error("alias `{alias}` from `{path}` collides with alias from `{existing}`")]
359 FragmentAliasCollision {
360 alias: String,
361 path: String,
362 existing: String,
363 },
364 #[error("fragment `{fragment}` in `{path}` has a rule without an explicit id under `{at}`")]
365 FragmentRuleMissingId {
366 path: String,
367 fragment: String,
368 at: String,
369 },
370 #[error(
371 "invalid rule id `{id}` in fragment `{fragment}` at `{path}`; use ASCII letters, digits, `_`, or `-`"
372 )]
373 InvalidFragmentRuleId {
374 path: String,
375 fragment: String,
376 id: String,
377 },
378 #[error(
379 "fragment `{fragment}` in `{path}` uses unsupported `require_doc_comment` under `{at}`"
380 )]
381 FragmentRequireDocUnsupported {
382 path: String,
383 fragment: String,
384 at: String,
385 },
386 #[error("rule `{rule_id}` from `{path}` collides with rule from `{existing}`")]
387 FragmentRuleCollision {
388 rule_id: String,
389 path: String,
390 existing: String,
391 },
392}
393
394pub(crate) fn load_default() -> Result<Config, ConfigError> {
395 let cfg: Config = toml::from_str(DEFAULT_PRESET).map_err(ConfigError::DefaultPresetInvalid)?;
396 validate(&cfg, "<embedded preset>")?;
397 Ok(cfg)
398}
399
400pub fn load_with_overrides(user_path: Option<&Path>) -> Result<Config, ConfigError> {
403 load_with_options(user_path, true)
404}
405
406pub fn load_with_cli_default_rules(
410 user_path: Option<&Path>,
411 default_rules: Option<bool>,
412) -> Result<Config, ConfigError> {
413 load_with_cli_sources(user_path, &[], default_rules)
414}
415
416pub fn load_with_cli_sources(
420 user_path: Option<&Path>,
421 inline_sources: &[String],
422 default_rules: Option<bool>,
423) -> Result<Config, ConfigError> {
424 load_with_cli_sources_for_project(None, user_path, inline_sources, default_rules)
425}
426
427pub fn load_project_with_cli_sources(
431 project_root: &Path,
432 user_path: Option<&Path>,
433 inline_sources: &[String],
434 default_rules: Option<bool>,
435) -> Result<Config, ConfigError> {
436 load_with_cli_sources_for_project(Some(project_root), user_path, inline_sources, default_rules)
437}
438
439fn load_with_cli_sources_for_project(
440 project_root: Option<&Path>,
441 user_path: Option<&Path>,
442 inline_sources: &[String],
443 default_rules: Option<bool>,
444) -> Result<Config, ConfigError> {
445 let project = read_project_config(user_path, project_root)?;
446 let inline = parse_inline_configs(inline_sources)?;
447 let include_defaults = default_rules.unwrap_or_else(|| {
448 inline
449 .iter()
450 .rev()
451 .find_map(|cfg| cfg.default_rules)
452 .or_else(|| project.root.as_ref().and_then(|cfg| cfg.default_rules))
453 .unwrap_or(true)
454 });
455 load_with_project(project, include_defaults, inline)
456}
457
458fn parse_inline_configs(inline_sources: &[String]) -> Result<Vec<Config>, ConfigError> {
459 inline_sources
460 .iter()
461 .enumerate()
462 .map(|(index, raw)| parse_inline_config(raw, index))
463 .collect()
464}
465
466fn parse_inline_config(raw: &str, index: usize) -> Result<Config, ConfigError> {
467 let path = inline_rules_label(index);
468 let user: Config = toml::from_str(raw).map_err(|error| ConfigError::UserConfig {
469 path: path.clone(),
470 error,
471 })?;
472 ensure_source_groups_owned_by_project_root(&user, &path, false)?;
473 validate(&user, &path)?;
474 Ok(user)
475}
476
477fn inline_rules_label(index: usize) -> String {
478 format!("<inline rules #{}>", index + 1)
479}
480
481fn include_defaults_from_project(project: &ProjectConfig, include_defaults: bool) -> bool {
482 include_defaults
483 && project
484 .root
485 .as_ref()
486 .and_then(|cfg| cfg.default_rules)
487 .unwrap_or(true)
488}
489
490pub fn load_from_str(
491 raw: &str,
492 path: &str,
493 default_rules: Option<bool>,
494) -> Result<Config, ConfigError> {
495 let user: Config = toml::from_str(raw).map_err(|error| ConfigError::UserConfig {
496 path: path.to_string(),
497 error,
498 })?;
499 ensure_source_groups_owned_by_project_root(&user, path, false)?;
500 validate(&user, path)?;
501 let include_defaults = default_rules.unwrap_or_else(|| user.default_rules.unwrap_or(true));
502 load_with_project(
503 ProjectConfig {
504 root: Some(user),
505 fragments: Vec::new(),
506 },
507 include_defaults,
508 Vec::new(),
509 )
510}
511
512pub(crate) fn load_with_options(
516 user_path: Option<&Path>,
517 include_defaults: bool,
518) -> Result<Config, ConfigError> {
519 let project_root = user_path.map(project_root_from_config_path);
520 let project = read_project_config(user_path, project_root)?;
521 let include_defaults = include_defaults_from_project(&project, include_defaults);
522 load_with_project(project, include_defaults, Vec::new())
523}
524
525struct ProjectConfig {
526 root: Option<Config>,
527 fragments: Vec<fragments::FragmentFile>,
528}
529
530fn load_with_project(
531 project: ProjectConfig,
532 include_defaults: bool,
533 inline: Vec<Config>,
534) -> Result<Config, ConfigError> {
535 let mut cfg = if include_defaults {
536 load_default()?
537 } else {
538 Config::default()
539 };
540 cfg.default_rules = Some(include_defaults);
541 if let Some(user) = project.root {
542 merge_into(&mut cfg, user);
543 }
544 fragments::merge_into(&mut cfg, project.fragments)?;
545 for inline in inline {
546 merge_into(&mut cfg, inline);
547 }
548 Ok(cfg)
549}
550
551fn read_project_config(
552 user_path: Option<&Path>,
553 project_root: Option<&Path>,
554) -> Result<ProjectConfig, ConfigError> {
555 let root = read_user_config(user_path, project_root)?;
556 let fragments = if root.is_some() {
557 fragments::read(user_path)?
558 } else {
559 Vec::new()
560 };
561 Ok(ProjectConfig { root, fragments })
562}
563
564fn read_user_config(
565 user_path: Option<&Path>,
566 project_root: Option<&Path>,
567) -> Result<Option<Config>, ConfigError> {
568 let Some(p) = user_path else {
569 return Ok(None);
570 };
571 if !p.exists() {
572 return Ok(None);
573 }
574 let raw = std::fs::read_to_string(p).map_err(|error| ConfigError::Io {
575 path: p.display().to_string(),
576 error,
577 })?;
578 let user: Config = toml::from_str(&raw).map_err(|error| ConfigError::UserConfig {
579 path: p.display().to_string(),
580 error,
581 })?;
582 ensure_source_groups_owned_by_project_root(
583 &user,
584 &p.display().to_string(),
585 is_project_root_config(p, project_root),
586 )?;
587 validate(&user, &p.display().to_string())?;
588 Ok(Some(user))
589}
590
591fn project_root_from_config_path(path: &Path) -> &Path {
592 path.parent()
593 .filter(|parent| !parent.as_os_str().is_empty())
594 .unwrap_or_else(|| Path::new("."))
595}
596
597fn is_project_root_config(path: &Path, project_root: Option<&Path>) -> bool {
598 let Some(project_root) = project_root else {
599 return false;
600 };
601 let expected = project_root.join(".code-moniker.toml");
602 match (path.canonicalize(), expected.canonicalize()) {
603 (Ok(actual), Ok(expected)) => actual == expected,
604 _ => false,
605 }
606}
607
608fn ensure_source_groups_owned_by_project_root(
609 config: &Config,
610 path: &str,
611 project_root: bool,
612) -> Result<(), ConfigError> {
613 if project_root || config.workspace.source_groups.is_empty() {
614 return Ok(());
615 }
616 Err(ConfigError::SourceGroupOutsideProjectRoot {
617 path: path.to_string(),
618 })
619}
620
621fn merge_into(base: &mut Config, ov: Config) {
622 if ov.telemetry.is_some() {
623 base.telemetry = ov.telemetry;
624 }
625 for (k, v) in ov.aliases {
626 base.aliases.insert(k, v);
627 }
628 base.exclude.uris.extend(ov.exclude.uris);
629 for (k, v) in ov.profiles {
630 base.profiles.insert(k, v);
631 }
632 base.views.extend(ov.views);
633 merge_refs(&mut base.refs, ov.refs);
634 if ov.workspace.min_linkage_coverage.is_some() {
635 base.workspace.min_linkage_coverage = ov.workspace.min_linkage_coverage;
636 }
637 merge_kind(&mut base.workspace.symbol, ov.workspace.symbol);
638 merge_group(&mut base.workspace.group, ov.workspace.group);
639 merge_path(&mut base.workspace.path, ov.workspace.path);
640 base.workspace
641 .source_groups
642 .extend(ov.workspace.source_groups);
643 merge_shape_map(&mut base.shape, ov.shape);
644 merge_lang(&mut base.default, ov.default);
645 merge_lang(&mut base.ts, ov.ts);
646 merge_lang(&mut base.rust, ov.rust);
647 merge_lang(&mut base.java, ov.java);
648 merge_lang(&mut base.python, ov.python);
649 merge_lang(&mut base.go, ov.go);
650 merge_lang(&mut base.c, ov.c);
651 merge_lang(&mut base.cs, ov.cs);
652 merge_lang(&mut base.sql, ov.sql);
653}
654
655fn merge_group(base: &mut WorkspaceGroupRules, ov: WorkspaceGroupRules) {
656 for ov_rule in ov.rules {
657 match ov_rule.id.as_deref().and_then(|id| {
658 base.rules
659 .iter()
660 .position(|rule| rule.id.as_deref() == Some(id))
661 }) {
662 Some(index) => base.rules[index] = ov_rule,
663 None => base.rules.push(ov_rule),
664 }
665 }
666}
667
668fn merge_path(base: &mut Vec<WorkspacePathRuleEntry>, ov: Vec<WorkspacePathRuleEntry>) {
669 for ov_rule in ov {
670 match ov_rule
671 .id
672 .as_deref()
673 .and_then(|id| base.iter().position(|rule| rule.id.as_deref() == Some(id)))
674 {
675 Some(index) => base[index] = ov_rule,
676 None => base.push(ov_rule),
677 }
678 }
679}
680
681fn merge_refs(base: &mut RefsRules, ov: RefsRules) {
682 for ov_rule in ov.rules {
683 match ov_rule
684 .id
685 .as_deref()
686 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
687 {
688 Some(idx) => base.rules[idx] = ov_rule,
689 None => base.rules.push(ov_rule),
690 }
691 }
692}
693
694fn merge_lang(base: &mut LangRules, ov: LangRules) {
695 merge_shape_map(&mut base.shape, ov.shape);
696 for (kind, ov_rules) in ov.kinds {
697 match base.kinds.get_mut(&kind) {
698 Some(base_rules) => merge_kind(base_rules, ov_rules),
699 None => {
700 base.kinds.insert(kind, ov_rules);
701 }
702 }
703 }
704}
705
706fn merge_shape_map(base: &mut HashMap<String, KindRules>, ov: HashMap<String, KindRules>) {
707 for (shape, ov_rules) in ov {
708 match base.get_mut(&shape) {
709 Some(base_rules) => merge_kind(base_rules, ov_rules),
710 None => {
711 base.insert(shape, ov_rules);
712 }
713 }
714 }
715}
716
717fn merge_kind(base: &mut KindRules, ov: KindRules) {
721 for ov_rule in ov.rules {
722 match ov_rule
723 .id
724 .as_deref()
725 .and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
726 {
727 Some(idx) => base.rules[idx] = ov_rule,
728 None => base.rules.push(ov_rule),
729 }
730 }
731 if ov.require_doc_comment.is_some() {
732 base.require_doc_comment = ov.require_doc_comment;
733 }
734}
735
736pub(crate) fn resolve_aliases(
741 aliases: &HashMap<String, String>,
742) -> Result<HashMap<String, String>, ConfigError> {
743 let mut resolved: HashMap<String, String> = HashMap::new();
744 for name in aliases.keys() {
745 let mut stack: Vec<String> = Vec::new();
746 resolve_one(name, aliases, &mut resolved, &mut stack)?;
747 }
748 Ok(resolved)
749}
750
751fn resolve_one(
752 name: &str,
753 src: &HashMap<String, String>,
754 resolved: &mut HashMap<String, String>,
755 stack: &mut Vec<String>,
756) -> Result<String, ConfigError> {
757 if let Some(v) = resolved.get(name) {
758 return Ok(v.clone());
759 }
760 if stack.iter().any(|s| s == name) {
761 stack.push(name.to_string());
762 return Err(ConfigError::AliasCycle {
763 chain: stack.join(" → "),
764 });
765 }
766 let Some(body) = src.get(name) else {
767 return Err(ConfigError::UnknownAlias {
768 name: name.to_string(),
769 at: format!("alias `{}`", stack.last().unwrap_or(&"<root>".to_string())),
770 });
771 };
772 stack.push(name.to_string());
773 let expanded = expand_refs(body, src, resolved, stack)?;
774 stack.pop();
775 resolved.insert(name.to_string(), expanded.clone());
776 Ok(expanded)
777}
778
779fn expand_refs(
780 body: &str,
781 src: &HashMap<String, String>,
782 resolved: &mut HashMap<String, String>,
783 stack: &mut Vec<String>,
784) -> Result<String, ConfigError> {
785 let mut out = String::with_capacity(body.len());
786 let bytes = body.as_bytes();
787 let mut i = 0;
788 while i < bytes.len() {
789 if bytes[i] == b'$' {
790 let start = i + 1;
791 let mut j = start;
792 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
793 j += 1;
794 }
795 if j > start {
796 let name = &body[start..j];
797 let expanded = resolve_one(name, src, resolved, stack)?;
798 out.push('(');
799 out.push_str(&expanded);
800 out.push(')');
801 i = j;
802 continue;
803 }
804 }
805 out.push(bytes[i] as char);
806 i += 1;
807 }
808 Ok(out)
809}
810
811pub(crate) fn substitute_aliases(
814 expr: &str,
815 resolved: &HashMap<String, String>,
816 at: &str,
817) -> Result<String, ConfigError> {
818 let mut out = String::with_capacity(expr.len());
819 let bytes = expr.as_bytes();
820 let mut i = 0;
821 while i < bytes.len() {
822 if bytes[i] == b'$' {
823 let start = i + 1;
824 let mut j = start;
825 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
826 j += 1;
827 }
828 if j > start {
829 let name = &expr[start..j];
830 let Some(expanded) = resolved.get(name) else {
831 return Err(ConfigError::UnknownAlias {
832 name: name.to_string(),
833 at: at.to_string(),
834 });
835 };
836 out.push('(');
837 out.push_str(expanded);
838 out.push(')');
839 i = j;
840 continue;
841 }
842 }
843 out.push(bytes[i] as char);
844 i += 1;
845 }
846 Ok(out)
847}
848
849fn validate(cfg: &Config, path: &str) -> Result<(), ConfigError> {
851 resolve_aliases(&cfg.aliases)?;
852 validate_structure(cfg, path)
853}
854
855fn validate_structure(cfg: &Config, path: &str) -> Result<(), ConfigError> {
856 if let Some(value) = cfg.workspace.min_linkage_coverage
857 && value > 100
858 {
859 return Err(ConfigError::InvalidWorkspaceCoverage { value });
860 }
861 if cfg.workspace.symbol.require_doc_comment.is_some() {
862 return Err(ConfigError::WorkspaceRequireDocUnsupported);
863 }
864 for (index, rule) in cfg.workspace.group.rules.iter().enumerate() {
865 let Some(id) = rule.id.as_deref() else {
866 return Err(ConfigError::InvalidWorkspaceGroup {
867 at: format!("workspace.group.where_{index}"),
868 message: "`id` is required because it is part of the stable ScopeKey".to_string(),
869 });
870 };
871 if !is_stable_group_rule_id(id) {
872 return Err(ConfigError::InvalidWorkspaceGroup {
873 at: format!("workspace.group.{id}"),
874 message: "`id` may contain only ASCII letters, digits, `_` and `-`".to_string(),
875 });
876 }
877 }
878 for (index, rule) in cfg.workspace.path.iter().enumerate() {
879 validate_workspace_path(rule, index)?;
880 }
881 validate_shape_section(&cfg.shape, "shape", None)?;
882 if !cfg.default.shape.is_empty() {
883 return Err(ConfigError::DefaultShapeUnsupported);
884 }
885 validate_lang_section(
886 &cfg.default,
887 "default",
888 &allowed_kinds_set(None),
889 None,
890 path,
891 )?;
892 for lang in Lang::ALL {
893 let allowed = allowed_kinds_set(Some(*lang));
894 validate_lang_section(
895 cfg.for_lang(*lang),
896 config_section(*lang),
897 &allowed,
898 Some(*lang),
899 path,
900 )?;
901 }
902 Ok(())
903}
904
905fn is_stable_group_rule_id(id: &str) -> bool {
906 !id.is_empty()
907 && id
908 .bytes()
909 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
910}
911
912fn validate_workspace_path(rule: &WorkspacePathRuleEntry, index: usize) -> Result<(), ConfigError> {
913 let at = rule.id.as_deref().map_or_else(
914 || format!("workspace.path.where_{index}"),
915 |id| format!("workspace.path.{id}"),
916 );
917 let Some(id) = rule.id.as_deref() else {
918 return Err(ConfigError::InvalidWorkspacePath {
919 at,
920 message: "`id` is required for stable diagnostics".to_string(),
921 });
922 };
923 if !is_stable_group_rule_id(id) {
924 return Err(ConfigError::InvalidWorkspacePath {
925 at,
926 message: "`id` may contain only ASCII letters, digits, `_` and `-`".to_string(),
927 });
928 }
929 let invalid = if rule.from.trim().is_empty() {
930 Some("`from` must not be empty".to_string())
931 } else if rule.to.trim().is_empty() {
932 Some("`to` must not be empty".to_string())
933 } else if rule.expect == WorkspacePathExpectation::AllPathsVia
934 && rule.via.as_deref().is_none_or(|via| via.trim().is_empty())
935 {
936 Some("`via` is required and must not be empty for `all_paths_via`".to_string())
937 } else if rule.expect != WorkspacePathExpectation::AllPathsVia && rule.via.is_some() {
938 Some("`via` is only valid for `all_paths_via`".to_string())
939 } else if rule.max_depth > 64 {
940 Some("`max_depth` must be at most 64".to_string())
941 } else if !(1..=100_000).contains(&rule.max_symbols) {
942 Some("`max_symbols` must be between 1 and 100000".to_string())
943 } else if !(1..=500_000).contains(&rule.max_edges) {
944 Some("`max_edges` must be between 1 and 500000".to_string())
945 } else if !(1..=100_000).contains(&rule.max_pairs) {
946 Some("`max_pairs` must be between 1 and 100000".to_string())
947 } else if rule.min_coverage.is_some_and(|coverage| coverage > 100) {
948 Some("`min_coverage` must be between 0 and 100".to_string())
949 } else {
950 None
951 };
952 if let Some(message) = invalid {
953 return Err(ConfigError::InvalidWorkspacePath { at, message });
954 }
955 Ok(())
956}
957
958fn validate_shape_section(
959 rules: &HashMap<String, KindRules>,
960 section: &str,
961 lang: Option<Lang>,
962) -> Result<(), ConfigError> {
963 for (shape, kr) in rules {
964 if !allowed_def_shape_names().contains(&shape.as_str()) {
965 return Err(ConfigError::UnknownShape {
966 section: section.to_string(),
967 shape: shape.clone(),
968 allowed: allowed_def_shape_names().join(", "),
969 });
970 }
971 if let Some(value) = &kr.require_doc_comment {
972 let allowed_vis = lang.map_or_else(allowed_doc_vis_any_lang, allowed_doc_vis_for);
973 if !allowed_vis.contains(&value.as_str()) {
974 return Err(ConfigError::UnknownDocVisibility {
975 section: section.to_string(),
976 kind: shape.clone(),
977 value: value.clone(),
978 allowed: allowed_vis.join(", "),
979 });
980 }
981 }
982 }
983 Ok(())
984}
985
986fn allowed_kinds_set(lang: Option<Lang>) -> Vec<&'static str> {
987 let mut out: Vec<&'static str> = INTERNAL_KINDS.to_vec();
988 if let Some(l) = lang {
989 out.extend(l.allowed_kinds().iter().copied());
990 } else {
991 for l in Lang::ALL {
992 out.extend(l.allowed_kinds().iter().copied());
993 }
994 }
995 out.sort();
996 out.dedup();
997 out
998}
999
1000fn allowed_def_shape_names() -> Vec<&'static str> {
1001 Shape::ALL
1002 .iter()
1003 .copied()
1004 .filter(|shape| *shape != Shape::Ref)
1005 .map(Shape::as_str)
1006 .collect()
1007}
1008
1009pub(crate) fn allowed_kinds_for(lang: Lang) -> Vec<&'static str> {
1013 allowed_kinds_set(Some(lang))
1014}
1015
1016pub(crate) fn allowed_workspace_kinds() -> Vec<&'static str> {
1017 allowed_kinds_set(None)
1018}
1019
1020fn allowed_doc_vis_for(lang: Lang) -> Vec<&'static str> {
1023 let mut out: Vec<&'static str> = vec!["any"];
1024 out.extend(lang.allowed_visibilities().iter().copied());
1025 out
1026}
1027
1028fn allowed_doc_vis_any_lang() -> Vec<&'static str> {
1029 let mut out: Vec<&'static str> = vec!["any"];
1030 for lang in Lang::ALL {
1031 out.extend(lang.allowed_visibilities().iter().copied());
1032 }
1033 out.sort();
1034 out.dedup();
1035 out
1036}
1037
1038pub(crate) fn config_section(lang: Lang) -> &'static str {
1041 match lang {
1042 Lang::Rs => "rust",
1043 other => other.tag(),
1044 }
1045}
1046
1047fn validate_lang_section(
1048 lr: &LangRules,
1049 section: &str,
1050 allowed: &[&str],
1051 lang: Option<Lang>,
1052 _path: &str,
1053) -> Result<(), ConfigError> {
1054 validate_shape_section(&lr.shape, &format!("{section}.shape"), lang)?;
1055 for (kind, kr) in lr.kinds.iter() {
1056 if RESERVED_LANG_KEYS.contains(&kind.as_str()) {
1057 continue;
1058 }
1059 if !allowed.contains(&kind.as_str()) {
1060 return Err(ConfigError::UnknownKind {
1061 section: section.to_string(),
1062 kind: kind.clone(),
1063 allowed: allowed.join(", "),
1064 });
1065 }
1066 if let (Some(value), Some(l)) = (&kr.require_doc_comment, lang) {
1067 let allowed_vis = allowed_doc_vis_for(l);
1068 if !allowed_vis.contains(&value.as_str()) {
1069 return Err(ConfigError::UnknownDocVisibility {
1070 section: section.to_string(),
1071 kind: kind.clone(),
1072 value: value.clone(),
1073 allowed: allowed_vis.join(", "),
1074 });
1075 }
1076 }
1077 }
1078 Ok(())
1079}
1080
1081impl Config {
1082 pub fn for_lang(&self, lang: Lang) -> &LangRules {
1083 match lang {
1084 Lang::Ts => &self.ts,
1085 Lang::Rs => &self.rust,
1086 Lang::Java => &self.java,
1087 Lang::Python => &self.python,
1088 Lang::Go => &self.go,
1089 Lang::C => &self.c,
1090 Lang::Cs => &self.cs,
1091 Lang::Sql => &self.sql,
1092 }
1093 }
1094
1095 pub fn for_lang_mut(&mut self, lang: Lang) -> &mut LangRules {
1096 match lang {
1097 Lang::Ts => &mut self.ts,
1098 Lang::Rs => &mut self.rust,
1099 Lang::Java => &mut self.java,
1100 Lang::Python => &mut self.python,
1101 Lang::Go => &mut self.go,
1102 Lang::C => &mut self.c,
1103 Lang::Cs => &mut self.cs,
1104 Lang::Sql => &mut self.sql,
1105 }
1106 }
1107
1108 #[cfg(test)]
1109 pub fn rules_for(&self, lang: Lang, kind: &str) -> Option<&KindRules> {
1110 self.for_lang(lang)
1111 .kinds
1112 .get(kind)
1113 .or_else(|| self.default.kinds.get(kind))
1114 }
1115
1116 pub fn apply_profile(&mut self, name: &str) -> Result<(), ConfigError> {
1117 let profile = self
1118 .profiles
1119 .get(name)
1120 .ok_or_else(|| ConfigError::UnknownProfile {
1121 name: name.to_string(),
1122 known: self.known_profiles(),
1123 })?
1124 .clone();
1125 let enable = compile_patterns(&profile.enable, name, "enable")?;
1126 let disable = compile_patterns(&profile.disable, name, "disable")?;
1127 filter_rules(&mut self.refs.rules, "refs", &enable, &disable);
1128 filter_workspace_rules(&mut self.workspace, &enable, &disable);
1129 filter_shape_map(&mut self.shape, "shape", &enable, &disable);
1130 filter_lang(&mut self.default, "default", &enable, &disable);
1131 for lang in Lang::ALL {
1132 filter_lang(
1133 self.for_lang_mut(*lang),
1134 config_section(*lang),
1135 &enable,
1136 &disable,
1137 );
1138 }
1139 self.refresh_fragment_active_rules();
1140 Ok(())
1141 }
1142
1143 fn known_profiles(&self) -> String {
1144 let mut names: Vec<&str> = self.profiles.keys().map(|s| s.as_str()).collect();
1145 names.sort();
1146 names.join(", ")
1147 }
1148
1149 fn refresh_fragment_active_rules(&mut self) {
1150 if self.fragments.is_empty() {
1151 return;
1152 }
1153 let active_keys = collect_rule_keys(self);
1154 for fragment in &mut self.fragments {
1155 fragment.active_rules = if fragment.enabled {
1156 fragment
1157 .rule_keys
1158 .iter()
1159 .filter(|key| active_keys.contains(key.as_str()))
1160 .count()
1161 } else {
1162 0
1163 };
1164 }
1165 }
1166}
1167
1168impl RuleEntry {
1169 pub(crate) fn fallback_id(&self, idx: usize) -> String {
1170 self.id.clone().unwrap_or_else(|| format!("where_{idx}"))
1171 }
1172}
1173
1174impl WorkspaceGroupRuleEntry {
1175 pub(crate) fn fallback_id(&self, idx: usize) -> String {
1176 self.id.clone().unwrap_or_else(|| format!("where_{idx}"))
1177 }
1178}
1179
1180impl WorkspacePathRuleEntry {
1181 pub(crate) fn fallback_id(&self, idx: usize) -> String {
1182 self.id.clone().unwrap_or_else(|| format!("where_{idx}"))
1183 }
1184}
1185
1186fn compile_patterns(
1187 patterns: &[String],
1188 profile: &str,
1189 field: &'static str,
1190) -> Result<Vec<Regex>, ConfigError> {
1191 patterns
1192 .iter()
1193 .map(|p| {
1194 Regex::new(p).map_err(|error| ConfigError::BadProfileRegex {
1195 profile: profile.to_string(),
1196 field,
1197 pattern: p.clone(),
1198 error,
1199 })
1200 })
1201 .collect()
1202}
1203
1204fn filter_lang(lr: &mut LangRules, section: &str, enable: &[Regex], disable: &[Regex]) {
1205 filter_shape_map(&mut lr.shape, &format!("{section}.shape"), enable, disable);
1206 for (kind, kr) in lr.kinds.iter_mut() {
1207 let prefix = format!("{section}.{kind}");
1208 filter_rules(&mut kr.rules, &prefix, enable, disable);
1209 }
1210}
1211
1212fn filter_shape_map(
1213 rules: &mut HashMap<String, KindRules>,
1214 section: &str,
1215 enable: &[Regex],
1216 disable: &[Regex],
1217) {
1218 for (shape, kr) in rules.iter_mut() {
1219 let prefix = format!("{section}.{shape}");
1220 filter_rules(&mut kr.rules, &prefix, enable, disable);
1221 }
1222}
1223
1224fn filter_rules(rules: &mut Vec<RuleEntry>, prefix: &str, enable: &[Regex], disable: &[Regex]) {
1225 if rules.is_empty() || (enable.is_empty() && disable.is_empty()) {
1226 return;
1227 }
1228 let mut idx = 0;
1229 rules.retain(|r| {
1230 let full = format!("{prefix}.{}", r.fallback_id(idx));
1231 idx += 1;
1232 (enable.is_empty() || enable.iter().any(|re| re.is_match(&full)))
1233 && !disable.iter().any(|re| re.is_match(&full))
1234 });
1235}
1236
1237fn filter_workspace_rules(workspace: &mut WorkspaceRules, enable: &[Regex], disable: &[Regex]) {
1238 filter_rules(
1239 &mut workspace.symbol.rules,
1240 "workspace.symbol",
1241 enable,
1242 disable,
1243 );
1244 filter_group_rules(
1245 &mut workspace.group.rules,
1246 "workspace.group",
1247 enable,
1248 disable,
1249 );
1250 filter_path_rules(&mut workspace.path, "workspace.path", enable, disable);
1251}
1252
1253fn filter_group_rules(
1254 rules: &mut Vec<WorkspaceGroupRuleEntry>,
1255 prefix: &str,
1256 enable: &[Regex],
1257 disable: &[Regex],
1258) {
1259 if rules.is_empty() || (enable.is_empty() && disable.is_empty()) {
1260 return;
1261 }
1262 let mut index = 0;
1263 rules.retain(|rule| {
1264 let full = format!("{prefix}.{}", rule.fallback_id(index));
1265 index += 1;
1266 (enable.is_empty() || enable.iter().any(|regex| regex.is_match(&full)))
1267 && !disable.iter().any(|regex| regex.is_match(&full))
1268 });
1269}
1270
1271fn filter_path_rules(
1272 rules: &mut Vec<WorkspacePathRuleEntry>,
1273 prefix: &str,
1274 enable: &[Regex],
1275 disable: &[Regex],
1276) {
1277 if rules.is_empty() || (enable.is_empty() && disable.is_empty()) {
1278 return;
1279 }
1280 let mut index = 0;
1281 rules.retain(|rule| {
1282 let full = format!("{prefix}.{}", rule.fallback_id(index));
1283 index += 1;
1284 (enable.is_empty() || enable.iter().any(|regex| regex.is_match(&full)))
1285 && !disable.iter().any(|regex| regex.is_match(&full))
1286 });
1287}
1288
1289fn collect_rule_keys(cfg: &Config) -> HashSet<String> {
1290 let mut out = HashSet::new();
1291 collect_rule_list_keys("refs", &cfg.refs.rules, &mut out);
1292 collect_rule_list_keys("workspace.symbol", &cfg.workspace.symbol.rules, &mut out);
1293 for rule in &cfg.workspace.group.rules {
1294 if let Some(id) = &rule.id {
1295 out.insert(format!("workspace.group.{id}"));
1296 }
1297 }
1298 for rule in &cfg.workspace.path {
1299 if let Some(id) = &rule.id {
1300 out.insert(format!("workspace.path.{id}"));
1301 }
1302 }
1303 for (shape, rules) in &cfg.shape {
1304 collect_rule_list_keys(&format!("shape.{shape}"), &rules.rules, &mut out);
1305 }
1306 collect_lang_rule_keys("default", &cfg.default, &mut out);
1307 for lang in Lang::ALL {
1308 collect_lang_rule_keys(config_section(*lang), cfg.for_lang(*lang), &mut out);
1309 }
1310 out
1311}
1312
1313fn collect_lang_rule_keys(section: &str, rules: &LangRules, out: &mut HashSet<String>) {
1314 for (shape, kind_rules) in &rules.shape {
1315 collect_rule_list_keys(&format!("{section}.shape.{shape}"), &kind_rules.rules, out);
1316 }
1317 for (kind, kind_rules) in &rules.kinds {
1318 collect_rule_list_keys(&format!("{section}.{kind}"), &kind_rules.rules, out);
1319 }
1320}
1321
1322fn collect_rule_list_keys(prefix: &str, rules: &[RuleEntry], out: &mut HashSet<String>) {
1323 for rule in rules {
1324 if let Some(id) = &rule.id {
1325 out.insert(format!("{prefix}.{id}"));
1326 }
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::*;
1333
1334 fn parse(s: &str) -> Result<Config, ConfigError> {
1335 let cfg: Config = toml::from_str(s).map_err(|e| ConfigError::UserConfig {
1336 path: "<test>".to_string(),
1337 error: e,
1338 })?;
1339 validate(&cfg, "<test>")?;
1340 Ok(cfg)
1341 }
1342
1343 #[test]
1344 fn embedded_default_parses() {
1345 let cfg = load_default().expect("default preset must parse");
1346 assert!(cfg.ts.kinds.contains_key("class"));
1347 assert!(cfg.ts.kinds.contains_key("function"));
1348 }
1349
1350 #[test]
1351 fn ts_class_ships_at_least_one_rule_in_default() {
1352 let cfg = load_default().unwrap();
1353 let r = cfg.rules_for(Lang::Ts, "class").expect("ts.class present");
1354 assert!(!r.rules.is_empty(), "preset must ship rules for ts.class");
1355 }
1356
1357 #[test]
1358 fn rules_for_falls_back_to_default_section() {
1359 let cfg = parse(
1360 r#"
1361 [[default.module.where]]
1362 id = "stub"
1363 expr = "lines <= 99"
1364
1365 [[ts.class.where]]
1366 expr = "name =~ ^X"
1367 "#,
1368 )
1369 .unwrap();
1370 let r = cfg
1371 .rules_for(Lang::Ts, "module")
1372 .expect("falls back to default.module");
1373 assert_eq!(r.rules.len(), 1);
1374 assert_eq!(r.rules[0].id.as_deref(), Some("stub"));
1375 }
1376
1377 #[test]
1378 fn parses_top_level_and_lang_shape_scopes() {
1379 let cfg = parse(
1380 r#"
1381 [[shape.callable.where]]
1382 id = "max-lines"
1383 expr = "lines <= 60"
1384
1385 [[rust.shape.callable.where]]
1386 id = "max-lines"
1387 expr = "lines <= 120"
1388 "#,
1389 )
1390 .unwrap();
1391 assert_eq!(cfg.shape["callable"].rules.len(), 1);
1392 assert_eq!(cfg.rust.shape["callable"].rules.len(), 1);
1393 }
1394
1395 #[test]
1396 fn parses_workspace_symbol_rules() {
1397 let cfg = parse(
1398 r#"
1399 [[workspace.symbol.where]]
1400 id = "repositories-under-infra"
1401 expr = "name =~ Repository$ => uri ~ '**/dir:infra/**'"
1402 "#,
1403 )
1404 .expect("workspace symbol rule parses");
1405 assert_eq!(cfg.workspace.symbol.rules.len(), 1);
1406 assert_eq!(
1407 cfg.workspace.symbol.rules[0].id.as_deref(),
1408 Some("repositories-under-infra")
1409 );
1410 }
1411
1412 #[test]
1413 fn parses_typed_workspace_source_group_mappings() {
1414 let project = tempfile::tempdir().expect("project");
1415 let root_config = project.path().join(".code-moniker.toml");
1416 std::fs::write(
1417 &root_config,
1418 r#"
1419 [[workspace.source_group]]
1420 roots = [
1421 { path = "src/java", srcset = "main" },
1422 { path = "test", srcset = "test" },
1423 ]
1424 "#,
1425 )
1426 .expect("write root config");
1427 let cfg =
1428 load_with_options(Some(&root_config), false).expect("source group mapping parses");
1429
1430 assert_eq!(cfg.workspace.source_groups.len(), 1);
1431 assert_eq!(cfg.workspace.source_groups[0].roots.len(), 2);
1432 assert!(matches!(
1433 &cfg.workspace.source_groups[0].roots[1],
1434 code_moniker_workspace::source_group::SourceGroupRootConfig::Mapped(root)
1435 if root.path == "test" && root.srcset == "test"
1436 ));
1437 }
1438
1439 #[test]
1440 fn source_groups_are_rejected_outside_the_canonical_project_file() {
1441 let source_group = r#"
1442[[workspace.source_group]]
1443roots = ["src"]
1444"#;
1445 let standalone = load_from_str(source_group, "rules.toml", Some(false))
1446 .expect_err("standalone rules must not redefine source groups");
1447 assert!(matches!(
1448 standalone,
1449 ConfigError::SourceGroupOutsideProjectRoot { .. }
1450 ));
1451
1452 let inline = load_with_cli_sources(None, &[source_group.to_string()], Some(false))
1453 .expect_err("inline rules must not redefine source groups");
1454 assert!(matches!(
1455 inline,
1456 ConfigError::SourceGroupOutsideProjectRoot { .. }
1457 ));
1458 }
1459
1460 #[test]
1461 fn source_groups_are_rejected_when_rules_belong_to_another_project() {
1462 let analyzed = tempfile::tempdir().expect("analyzed project");
1463 let external = tempfile::tempdir().expect("external rules project");
1464 let external_config = external.path().join(".code-moniker.toml");
1465 std::fs::write(
1466 &external_config,
1467 r#"
1468[[workspace.source_group]]
1469roots = ["src"]
1470"#,
1471 )
1472 .expect("write external project config");
1473
1474 let error = load_project_with_cli_sources(
1475 analyzed.path(),
1476 Some(&external_config),
1477 &[],
1478 Some(false),
1479 )
1480 .expect_err("another project's structural mapping must not act as rules");
1481 assert!(matches!(
1482 error,
1483 ConfigError::SourceGroupOutsideProjectRoot { .. }
1484 ));
1485 }
1486
1487 #[test]
1488 fn source_groups_are_rejected_in_rule_fragments() {
1489 let temp = tempfile::tempdir().expect("tempdir");
1490 let root_config = temp.path().join(".code-moniker.toml");
1491 std::fs::write(&root_config, "default_rules = false\n").expect("root config");
1492 let fragment_dir = temp.path().join("nested");
1493 std::fs::create_dir_all(&fragment_dir).expect("fragment dir");
1494 std::fs::write(
1495 fragment_dir.join("code-moniker.fragment.toml"),
1496 r#"
1497fragment = "nested"
1498
1499[[workspace.source_group]]
1500roots = ["src"]
1501"#,
1502 )
1503 .expect("fragment config");
1504
1505 let error = load_with_overrides(Some(&root_config))
1506 .expect_err("fragments must not redefine source groups");
1507 assert!(matches!(
1508 error,
1509 ConfigError::SourceGroupOutsideProjectRoot { .. }
1510 ));
1511 }
1512
1513 #[test]
1514 fn rejects_unknown_workspace_source_group_mapping_fields() {
1515 let result = parse(
1516 r#"
1517 [[workspace.source_group]]
1518 roots = [{ path = "test", source_set = "test" }]
1519 "#,
1520 );
1521
1522 assert!(matches!(result, Err(ConfigError::UserConfig { .. })));
1523 }
1524
1525 #[test]
1526 fn workspace_linkage_coverage_is_a_bounded_percent() {
1527 let error = load_from_str(
1528 r#"
1529 [workspace]
1530 min_linkage_coverage = 101
1531
1532 [[workspace.symbol.where]]
1533 id = "used"
1534 expr = "count(in_refs) >= 1"
1535 "#,
1536 "<test>",
1537 Some(false),
1538 )
1539 .expect_err("coverage over 100 must fail");
1540 assert!(matches!(
1541 error,
1542 ConfigError::InvalidWorkspaceCoverage { value: 101 }
1543 ));
1544 }
1545
1546 #[test]
1547 fn workspace_path_rules_parse_bounded_graph_contract() {
1548 let cfg = load_from_str(
1549 r#"
1550 [[workspace.path]]
1551 id = "service-reaches-repository"
1552 from = "name = 'service'"
1553 to = "name = 'repository'"
1554 expect = "reachable"
1555 require_non_empty = true
1556 relation = ["calls", "method_call"]
1557 max_depth = 8
1558 max_symbols = 2000
1559 max_edges = 4000
1560 max_pairs = 500
1561 min_coverage = 95
1562 "#,
1563 "<test>",
1564 Some(false),
1565 )
1566 .expect("workspace path config");
1567 assert_eq!(cfg.workspace.path.len(), 1);
1568 let rule = &cfg.workspace.path[0];
1569 assert_eq!(rule.id.as_deref(), Some("service-reaches-repository"));
1570 assert_eq!(rule.expect, WorkspacePathExpectation::Reachable);
1571 assert!(rule.require_non_empty);
1572 assert_eq!(rule.relation, ["calls", "method_call"]);
1573 assert_eq!(rule.max_depth, 8);
1574 assert_eq!(rule.max_symbols, 2000);
1575 assert_eq!(rule.max_edges, 4000);
1576 assert_eq!(rule.max_pairs, 500);
1577 assert_eq!(rule.min_coverage, Some(95));
1578 }
1579
1580 #[test]
1581 fn all_paths_via_requires_an_exclusive_via_selector() {
1582 let missing = load_from_str(
1583 r#"
1584 [[workspace.path]]
1585 id = "boundary"
1586 from = "name = 'entry'"
1587 to = "name = 'sink'"
1588 expect = "all_paths_via"
1589 "#,
1590 "<test>",
1591 Some(false),
1592 )
1593 .expect_err("all_paths_via without via must fail");
1594 assert!(
1595 missing.to_string().contains("`via` is required"),
1596 "{missing}"
1597 );
1598
1599 let unexpected = load_from_str(
1600 r#"
1601 [[workspace.path]]
1602 id = "boundary"
1603 from = "name = 'entry'"
1604 to = "name = 'sink'"
1605 via = "name = 'boundary'"
1606 expect = "reachable"
1607 "#,
1608 "<test>",
1609 Some(false),
1610 )
1611 .expect_err("via on reachable must fail");
1612 assert!(
1613 unexpected.to_string().contains("`via` is only valid"),
1614 "{unexpected}"
1615 );
1616 }
1617
1618 #[test]
1619 fn parses_and_profiles_workspace_group_rules() {
1620 let mut cfg = parse(
1621 r#"
1622 [profiles.groups]
1623 enable = ["^workspace\\.group\\."]
1624
1625 [[workspace.symbol.where]]
1626 id = "symbol-rule"
1627 expr = "name = 'Invoice'"
1628
1629 [[workspace.group.where]]
1630 id = "unique-type"
1631 members = "shape = 'type'"
1632 group_by = ["lang", "segment('package')", "name"]
1633 expr = "count(member) <= 1"
1634 suppress = [{ values = ["java", "com.acme.legacy", "Legacy"] }]
1635 "#,
1636 )
1637 .expect("workspace group rule parses");
1638 assert_eq!(cfg.workspace.group.rules.len(), 1);
1639 assert_eq!(cfg.workspace.group.rules[0].group_by.len(), 3);
1640 cfg.apply_profile("groups").expect("group profile");
1641 assert!(cfg.workspace.symbol.rules.is_empty());
1642 assert_eq!(
1643 cfg.workspace.group.rules[0].id.as_deref(),
1644 Some("unique-type")
1645 );
1646 }
1647
1648 #[test]
1649 fn workspace_group_requires_an_explicit_stable_id() {
1650 let result = parse(
1651 r#"
1652 [[workspace.group.where]]
1653 members = "shape = 'type'"
1654 group_by = ["lang", "name"]
1655 expr = "count(member) <= 1"
1656 "#,
1657 );
1658 assert!(matches!(
1659 result,
1660 Err(ConfigError::InvalidWorkspaceGroup { .. })
1661 ));
1662 }
1663
1664 #[test]
1665 fn workspace_symbol_rejects_local_doc_comment_directive() {
1666 let result = parse(
1667 r#"
1668 [workspace.symbol]
1669 require_doc_comment = "public"
1670 "#,
1671 );
1672 assert!(matches!(
1673 result,
1674 Err(ConfigError::WorkspaceRequireDocUnsupported)
1675 ));
1676 }
1677
1678 #[test]
1679 fn unknown_shape_scope_is_rejected() {
1680 let r = parse(
1681 r#"
1682 [[shape.ref.where]]
1683 id = "nope"
1684 expr = "lines <= 1"
1685 "#,
1686 );
1687 match r {
1688 Err(ConfigError::UnknownShape { shape, .. }) => assert_eq!(shape, "ref"),
1689 other => panic!("expected UnknownShape, got {other:?}"),
1690 }
1691 }
1692
1693 #[test]
1694 fn default_shape_scope_is_rejected() {
1695 let r = parse(
1696 r#"
1697 [[default.shape.callable.where]]
1698 id = "nope"
1699 expr = "lines <= 1"
1700 "#,
1701 );
1702 assert!(matches!(r, Err(ConfigError::DefaultShapeUnsupported)));
1703 }
1704
1705 #[test]
1706 fn override_with_same_id_replaces_preset_rule() {
1707 let user = parse(
1708 r#"
1709 [[ts.function.where]]
1710 id = "max-lines"
1711 expr = "lines <= 999"
1712 "#,
1713 )
1714 .unwrap();
1715 let mut base = parse(
1716 r#"
1717 [[ts.function.where]]
1718 id = "name-camel"
1719 expr = "name =~ ^[a-z]"
1720
1721 [[ts.function.where]]
1722 id = "max-lines"
1723 expr = "lines <= 60"
1724 "#,
1725 )
1726 .unwrap();
1727 merge_into(&mut base, user);
1728 let f = base.rules_for(Lang::Ts, "function").unwrap();
1729 assert_eq!(f.rules.len(), 2, "id-matched override replaces in place");
1730 let max_lines = f
1731 .rules
1732 .iter()
1733 .find(|r| r.id.as_deref() == Some("max-lines"))
1734 .unwrap();
1735 assert!(max_lines.expr.contains("999"), "user override applied");
1736 assert!(
1737 f.rules
1738 .iter()
1739 .any(|r| r.id.as_deref() == Some("name-camel")),
1740 "sibling rule preserved"
1741 );
1742 }
1743
1744 #[test]
1745 fn override_with_new_id_appends_to_preset() {
1746 let user = parse(
1747 r#"
1748 [[ts.class.where]]
1749 id = "extra"
1750 expr = "name !~ ^Internal"
1751 "#,
1752 )
1753 .unwrap();
1754 let mut base = parse(
1755 r#"
1756 [[ts.class.where]]
1757 id = "name-pascal"
1758 expr = "name =~ ^[A-Z]"
1759 "#,
1760 )
1761 .unwrap();
1762 merge_into(&mut base, user);
1763 let r = base.rules_for(Lang::Ts, "class").unwrap();
1764 assert_eq!(r.rules.len(), 2);
1765 }
1766
1767 #[test]
1768 fn unknown_field_in_kind_rules_is_rejected() {
1769 let r = toml::from_str::<Config>(
1770 r#"
1771 [ts.function]
1772 max_lines = 10
1773 "#,
1774 );
1775 assert!(r.is_err(), "deny_unknown_fields rejects legacy fields");
1776 }
1777
1778 #[test]
1779 fn alias_section_parses() {
1780 let cfg = parse(
1781 r#"
1782 [aliases]
1783 domain = "moniker ~ '**/module:domain/**'"
1784 "#,
1785 )
1786 .unwrap();
1787 assert_eq!(
1788 cfg.aliases.get("domain").map(|s| s.as_str()),
1789 Some("moniker ~ '**/module:domain/**'"),
1790 );
1791 }
1792
1793 #[test]
1794 fn alias_cycle_is_rejected() {
1795 let r = parse(
1796 r#"
1797 [aliases]
1798 a = "$b"
1799 b = "$a"
1800 "#,
1801 );
1802 match r {
1803 Err(ConfigError::AliasCycle { chain }) => {
1804 assert!(chain.contains("a") && chain.contains("b"), "{chain}");
1805 }
1806 other => panic!("expected AliasCycle, got {other:?}"),
1807 }
1808 }
1809
1810 #[test]
1811 fn alias_chain_resolves() {
1812 let cfg = parse(
1813 r#"
1814 [aliases]
1815 a = "name = 'X'"
1816 b = "$a OR name = 'Y'"
1817 c = "$b AND lines <= 10"
1818 "#,
1819 )
1820 .unwrap();
1821 let resolved = resolve_aliases(&cfg.aliases).unwrap();
1822 let final_c = resolved.get("c").unwrap();
1823 assert!(final_c.contains("name = 'X'"), "{final_c}");
1824 assert!(final_c.contains("name = 'Y'"), "{final_c}");
1825 assert!(final_c.contains("lines <= 10"), "{final_c}");
1826 }
1827
1828 #[test]
1829 fn alias_substitution_wraps_in_parens() {
1830 let mut src = HashMap::new();
1832 src.insert("x".to_string(), "A AND B".to_string());
1833 let resolved = resolve_aliases(&src).unwrap();
1834 let out = substitute_aliases("$x OR C", &resolved, "test").unwrap();
1835 assert_eq!(out, "(A AND B) OR C");
1836 }
1837
1838 #[test]
1839 fn unknown_alias_is_rejected_at_substitution() {
1840 let resolved = HashMap::new();
1841 match substitute_aliases("$bogus AND name = 'X'", &resolved, "ts.class.r1") {
1842 Err(ConfigError::UnknownAlias { name, at }) => {
1843 assert_eq!(name, "bogus");
1844 assert_eq!(at, "ts.class.r1");
1845 }
1846 other => panic!("expected UnknownAlias, got {other:?}"),
1847 }
1848 }
1849
1850 #[test]
1851 fn unknown_top_level_lang_section_is_rejected() {
1852 let r = toml::from_str::<Config>(
1853 r#"
1854 [[typescript.class.where]]
1855 expr = "name =~ ^[A-Z]"
1856 "#,
1857 );
1858 assert!(
1859 r.is_err(),
1860 "deny_unknown_fields must reject unknown lang sections"
1861 );
1862 }
1863
1864 #[test]
1865 fn unknown_require_doc_visibility_is_rejected() {
1866 let r = parse(
1867 r#"
1868 [ts.class]
1869 require_doc_comment = "publc"
1870 "#,
1871 );
1872 match r {
1873 Err(ConfigError::UnknownDocVisibility { value, .. }) => assert_eq!(value, "publc"),
1874 other => panic!("expected UnknownDocVisibility, got {other:?}"),
1875 }
1876 }
1877
1878 #[test]
1879 fn doc_visibility_any_is_accepted() {
1880 let r = parse(
1881 r#"
1882 [ts.class]
1883 require_doc_comment = "any"
1884 "#,
1885 );
1886 assert!(r.is_ok(), "any is always valid");
1887 }
1888
1889 #[test]
1890 fn unknown_kind_section_is_rejected() {
1891 let r = parse(
1892 r#"
1893 [[ts.classs.where]]
1894 expr = "name =~ ^X"
1895 "#,
1896 );
1897 match r {
1898 Err(ConfigError::UnknownKind { kind, .. }) => assert_eq!(kind, "classs"),
1899 other => panic!("expected UnknownKind, got {other:?}"),
1900 }
1901 }
1902
1903 #[test]
1904 fn missing_user_file_is_not_an_error() {
1905 let cfg = load_with_overrides(Some(Path::new("/no/such/file.toml")))
1906 .expect("missing file falls back to defaults");
1907 assert!(cfg.ts.kinds.contains_key("class"));
1908 }
1909
1910 #[test]
1911 fn missing_user_file_without_defaults_is_empty() {
1912 let cfg = load_with_options(Some(Path::new("/no/such/file.toml")), false)
1913 .expect("missing file is still accepted without defaults");
1914 assert!(cfg.refs.rules.is_empty());
1915 assert!(cfg.ts.kinds.is_empty());
1916 }
1917
1918 #[test]
1919 fn missing_user_file_does_not_discover_fragments() {
1920 let dir = tempfile::tempdir().unwrap();
1921 let missing_root = dir.path().join(".code-moniker.toml");
1922 write_fragment(
1923 dir.path(),
1924 "src",
1925 r#"
1926 fragment = "local"
1927
1928 [[rust.fn.where]]
1929 id = "parked"
1930 expr = "lines <= 10"
1931 "#,
1932 );
1933
1934 let cfg = load_with_options(Some(&missing_root), false).expect("missing root loads empty");
1935
1936 assert!(cfg.refs.rules.is_empty());
1937 assert!(cfg.rust.kinds.is_empty());
1938 assert!(cfg.fragments.is_empty());
1939 }
1940
1941 #[test]
1942 fn scratch_rules_file_does_not_discover_sibling_fragments() {
1943 let dir = tempfile::tempdir().unwrap();
1944 let scratch_rules = dir.path().join("workspace-path-dogfood.toml");
1945 std::fs::write(&scratch_rules, "default_rules = false\n").unwrap();
1946 for relative in ["first", "second"] {
1947 write_fragment(
1948 dir.path(),
1949 relative,
1950 r#"
1951 fragment = "duplicate"
1952
1953 [[rust.fn.where]]
1954 id = "parked"
1955 expr = "lines <= 10"
1956 "#,
1957 );
1958 }
1959
1960 let cfg = load_with_options(Some(&scratch_rules), false)
1961 .expect("an explicit scratch rules file is standalone");
1962
1963 assert!(cfg.fragments.is_empty());
1964 assert!(cfg.rust.kinds.is_empty());
1965 }
1966
1967 #[test]
1968 fn user_config_can_disable_embedded_default_rules() {
1969 let dir = tempfile::tempdir().unwrap();
1970 let p = dir.path().join(".code-moniker.toml");
1971 std::fs::write(&p, "default_rules = false\n").unwrap();
1972
1973 let cfg = load_with_overrides(Some(&p)).expect("config loads");
1974
1975 assert!(cfg.refs.rules.is_empty());
1976 assert!(cfg.ts.kinds.is_empty());
1977 assert_eq!(cfg.default_rules, Some(false));
1978 }
1979
1980 #[test]
1981 fn command_line_default_rules_off_wins_over_config_flag() {
1982 let dir = tempfile::tempdir().unwrap();
1983 let p = dir.path().join(".code-moniker.toml");
1984 std::fs::write(&p, "default_rules = true\n").unwrap();
1985
1986 let cfg = load_with_options(Some(&p), false).expect("config loads");
1987
1988 assert!(cfg.refs.rules.is_empty());
1989 assert!(cfg.ts.kinds.is_empty());
1990 assert_eq!(cfg.default_rules, Some(false));
1991 }
1992
1993 #[test]
1994 fn inline_default_rules_flag_can_disable_embedded_default_rules() {
1995 let inline = vec!["default_rules = false\n".to_string()];
1996
1997 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, None)
1998 .expect("inline config loads");
1999
2000 assert!(cfg.refs.rules.is_empty());
2001 assert!(cfg.ts.kinds.is_empty());
2002 assert_eq!(cfg.default_rules, Some(false));
2003 }
2004
2005 #[test]
2006 fn command_line_default_rules_on_wins_over_inline_flag() {
2007 let inline = vec!["default_rules = false\n".to_string()];
2008
2009 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, Some(true))
2010 .expect("inline config loads");
2011
2012 assert!(cfg.ts.kinds.contains_key("class"));
2013 assert_eq!(cfg.default_rules, Some(true));
2014 }
2015
2016 #[test]
2017 fn inline_rules_override_project_rule_by_same_id() {
2018 let dir = tempfile::tempdir().unwrap();
2019 let p = dir.path().join(".code-moniker.toml");
2020 std::fs::write(
2021 &p,
2022 r#"
2023 default_rules = false
2024
2025 [[ts.class.where]]
2026 id = "name-policy"
2027 expr = "name =~ ^Good"
2028 "#,
2029 )
2030 .unwrap();
2031 let inline = vec![
2032 r#"
2033 [[ts.class.where]]
2034 id = "name-policy"
2035 expr = "name =~ ^Inline"
2036 "#
2037 .to_string(),
2038 ];
2039
2040 let cfg = load_with_cli_sources(Some(&p), &inline, None).expect("inline config loads");
2041 let rule = cfg
2042 .rules_for(Lang::Ts, "class")
2043 .unwrap()
2044 .rules
2045 .iter()
2046 .find(|rule| rule.id.as_deref() == Some("name-policy"))
2047 .unwrap();
2048
2049 assert_eq!(rule.expr, "name =~ ^Inline");
2050 }
2051
2052 #[test]
2053 fn repeated_inline_rules_merge_in_order() {
2054 let inline = vec![
2055 r#"
2056 default_rules = false
2057
2058 [[ts.class.where]]
2059 id = "inline-name"
2060 expr = "name =~ ^First"
2061 "#
2062 .to_string(),
2063 r#"
2064 [[ts.class.where]]
2065 id = "inline-name"
2066 expr = "name =~ ^Second"
2067 "#
2068 .to_string(),
2069 ];
2070
2071 let cfg = load_with_cli_sources(Some(Path::new("/no/such/file.toml")), &inline, None)
2072 .expect("inline config loads");
2073 let rule = cfg
2074 .rules_for(Lang::Ts, "class")
2075 .unwrap()
2076 .rules
2077 .iter()
2078 .find(|rule| rule.id.as_deref() == Some("inline-name"))
2079 .unwrap();
2080
2081 assert_eq!(rule.expr, "name =~ ^Second");
2082 }
2083
2084 #[test]
2085 fn malformed_user_file_returns_user_config_error() {
2086 let dir = tempfile::tempdir().unwrap();
2087 let p = dir.path().join("bad.toml");
2088 std::fs::write(&p, "this is not toml = = =").unwrap();
2089 match load_with_overrides(Some(&p)) {
2090 Err(ConfigError::UserConfig { .. }) => {}
2091 other => panic!("expected UserConfig error, got {other:?}"),
2092 }
2093 }
2094
2095 fn write_fragment(root: &Path, rel_dir: &str, body: &str) -> std::path::PathBuf {
2096 let dir = root.join(rel_dir);
2097 std::fs::create_dir_all(&dir).unwrap();
2098 let path = dir.join("code-moniker.fragment.toml");
2099 std::fs::write(&path, body).unwrap();
2100 path
2101 }
2102
2103 #[test]
2104 fn fragment_rules_are_loaded_with_fragment_namespace() {
2105 let dir = tempfile::tempdir().unwrap();
2106 let root = dir.path().join(".code-moniker.toml");
2107 std::fs::write(
2108 &root,
2109 r#"
2110 default_rules = false
2111
2112 [aliases]
2113 local_name = "name =~ ^[a-z_]"
2114 "#,
2115 )
2116 .unwrap();
2117 let fragment_path = write_fragment(
2118 dir.path(),
2119 "crates/check/src/check",
2120 r#"
2121 fragment = "check"
2122
2123 [[rust.fn.where]]
2124 id = "parser-only"
2125 expr = "$local_name"
2126 "#,
2127 );
2128
2129 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
2130
2131 assert_eq!(cfg.fragments.len(), 1);
2132 assert_eq!(cfg.fragments[0].id, "check");
2133 assert_eq!(cfg.fragments[0].path, fragment_path);
2134 assert!(cfg.fragments[0].enabled);
2135 assert_eq!(cfg.fragments[0].declared_rules, 1);
2136 assert_eq!(cfg.fragments[0].active_rules, 1);
2137 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
2138 let ids: Vec<_> = compiled
2139 .specs(Lang::Rs)
2140 .into_iter()
2141 .map(|rule| rule.rule_id)
2142 .collect();
2143 assert!(
2144 ids.iter().any(|id| id == "rust.fn.check.parser-only"),
2145 "{ids:?}"
2146 );
2147 }
2148
2149 #[test]
2150 fn workspace_symbol_fragment_keeps_namespace_and_profile_key() {
2151 let dir = tempfile::tempdir().unwrap();
2152 let root = dir.path().join(".code-moniker.toml");
2153 std::fs::write(
2154 &root,
2155 r#"
2156 default_rules = false
2157
2158 [profiles.only_workspace]
2159 enable = ["^workspace\\.symbol\\.architecture\\."]
2160 "#,
2161 )
2162 .unwrap();
2163 write_fragment(
2164 dir.path(),
2165 "architecture",
2166 r#"
2167 fragment = "architecture"
2168
2169 [[workspace.symbol.where]]
2170 id = "repositories-under-infra"
2171 expr = "name =~ Repository$ => uri ~ '**/dir:infra/**'"
2172 "#,
2173 );
2174 let mut cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
2175 cfg.apply_profile("only_workspace")
2176 .expect("profile applies");
2177 let compiled =
2178 crate::check::workspace_eval::compile_workspace_rules(&cfg, "code+moniker://")
2179 .expect("workspace fragment compiles");
2180 let specs = compiled.specs();
2181 assert_eq!(specs.len(), 1);
2182 assert_eq!(
2183 specs[0].rule_id,
2184 "workspace.symbol.architecture.repositories-under-infra"
2185 );
2186 assert_eq!(cfg.fragments[0].active_rules, 1);
2187 }
2188
2189 #[test]
2190 fn workspace_group_fragment_rewrites_aliases_and_keeps_namespace() {
2191 let dir = tempfile::tempdir().unwrap();
2192 let root = dir.path().join(".code-moniker.toml");
2193 std::fs::write(&root, "default_rules = false\n").unwrap();
2194 write_fragment(
2195 dir.path(),
2196 "architecture",
2197 r#"
2198 fragment = "architecture"
2199
2200 [aliases]
2201 types = "shape = 'type'"
2202
2203 [[workspace.group.where]]
2204 id = "unique-types"
2205 members = "$types"
2206 group_by = ["lang", "name"]
2207 expr = "count(member) <= 1"
2208 "#,
2209 );
2210 let cfg = load_with_overrides(Some(&root)).expect("group fragment loads");
2211 let compiled =
2212 crate::check::workspace_eval::compile_workspace_rules(&cfg, "code+moniker://")
2213 .expect("group fragment compiles");
2214 let specs = compiled.specs();
2215 assert_eq!(specs.len(), 1);
2216 assert_eq!(
2217 specs[0].rule_id,
2218 "workspace.group.architecture.unique-types"
2219 );
2220 assert!(specs[0].expanded_expr.contains("shape = 'type'"));
2221 assert_eq!(cfg.fragments[0].active_rules, 1);
2222 }
2223
2224 #[test]
2225 fn workspace_path_fragment_rewrites_both_selectors_and_keeps_namespace() {
2226 let dir = tempfile::tempdir().unwrap();
2227 let root = dir.path().join(".code-moniker.toml");
2228 std::fs::write(&root, "default_rules = false\n").unwrap();
2229 write_fragment(
2230 dir.path(),
2231 "architecture",
2232 r#"
2233 fragment = "architecture"
2234
2235 [aliases]
2236 entries = "shape = 'callable' AND name =~ ^entry"
2237 sinks = "shape = 'callable' AND name =~ ^sink"
2238
2239 [[workspace.path]]
2240 id = "entries-must-not-reach-sinks"
2241 from = "$entries"
2242 to = "$sinks"
2243 expect = "no_path"
2244 "#,
2245 );
2246 let cfg = load_with_overrides(Some(&root)).expect("path fragment loads");
2247 let compiled =
2248 crate::check::workspace_eval::compile_workspace_rules(&cfg, "code+moniker://")
2249 .expect("path fragment compiles");
2250 let specs = compiled.specs();
2251 assert_eq!(specs.len(), 1);
2252 assert_eq!(
2253 specs[0].rule_id,
2254 "workspace.path.architecture.entries-must-not-reach-sinks"
2255 );
2256 assert!(specs[0].expanded_expr.contains("name =~ ^entry"));
2257 assert!(specs[0].expanded_expr.contains("name =~ ^sink"));
2258 assert_eq!(cfg.fragments[0].active_rules, 1);
2259 }
2260
2261 #[test]
2262 fn disabled_fragment_is_reported_but_not_merged() {
2263 let dir = tempfile::tempdir().unwrap();
2264 let root = dir.path().join(".code-moniker.toml");
2265 std::fs::write(&root, "default_rules = false\n").unwrap();
2266 write_fragment(
2267 dir.path(),
2268 "src",
2269 r#"
2270 fragment = "local"
2271 enabled = false
2272
2273 [[rust.fn.where]]
2274 id = "parked"
2275 expr = "$missing_while_disabled"
2276 "#,
2277 );
2278
2279 let cfg = load_with_overrides(Some(&root)).expect("disabled fragment loads");
2280
2281 assert_eq!(cfg.fragments.len(), 1);
2282 assert_eq!(cfg.fragments[0].id, "local");
2283 assert!(!cfg.fragments[0].enabled);
2284 assert_eq!(cfg.fragments[0].declared_rules, 1);
2285 assert_eq!(cfg.fragments[0].active_rules, 0);
2286 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
2287 let ids: Vec<_> = compiled
2288 .specs(Lang::Rs)
2289 .into_iter()
2290 .map(|rule| rule.rule_id)
2291 .collect();
2292 assert!(
2293 !ids.iter().any(|id| id == "rust.fn.local.parked"),
2294 "{ids:?}"
2295 );
2296 }
2297
2298 #[test]
2299 fn profile_recomputes_fragment_active_rules() {
2300 let dir = tempfile::tempdir().unwrap();
2301 let root = dir.path().join(".code-moniker.toml");
2302 std::fs::write(
2303 &root,
2304 r#"
2305 default_rules = false
2306
2307 [profiles.none]
2308 disable = ["^rust\\.fn\\.local\\.parked$"]
2309 "#,
2310 )
2311 .unwrap();
2312 write_fragment(
2313 dir.path(),
2314 "src",
2315 r#"
2316 fragment = "local"
2317
2318 [[rust.fn.where]]
2319 id = "parked"
2320 expr = "lines <= 10"
2321 "#,
2322 );
2323 let mut cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
2324
2325 cfg.apply_profile("none").expect("profile applies");
2326
2327 assert_eq!(cfg.fragments[0].declared_rules, 1);
2328 assert_eq!(cfg.fragments[0].active_rules, 0);
2329 }
2330
2331 #[test]
2332 fn disabled_fragment_still_rejects_missing_rule_ids() {
2333 let dir = tempfile::tempdir().unwrap();
2334 let root = dir.path().join(".code-moniker.toml");
2335 std::fs::write(&root, "default_rules = false\n").unwrap();
2336 write_fragment(
2337 dir.path(),
2338 "src",
2339 r#"
2340 fragment = "local"
2341 enabled = false
2342
2343 [[rust.fn.where]]
2344 expr = "lines <= 10"
2345 "#,
2346 );
2347
2348 match load_with_overrides(Some(&root)) {
2349 Err(ConfigError::FragmentRuleMissingId { fragment, at, .. }) => {
2350 assert_eq!(fragment, "local");
2351 assert_eq!(at, "rust.fn");
2352 }
2353 other => panic!("expected FragmentRuleMissingId error, got {other:?}"),
2354 }
2355 }
2356
2357 #[test]
2358 fn fragment_local_aliases_are_namespaced_and_usable() {
2359 let dir = tempfile::tempdir().unwrap();
2360 let root = dir.path().join(".code-moniker.toml");
2361 std::fs::write(&root, "default_rules = false\n").unwrap();
2362 write_fragment(
2363 dir.path(),
2364 "src",
2365 r#"
2366 fragment = "local"
2367
2368 [aliases]
2369 local_name = "name = 'Ok'"
2370
2371 [[rust.fn.where]]
2372 id = "uses-local"
2373 expr = "$local_name"
2374 "#,
2375 );
2376
2377 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
2378
2379 assert_eq!(
2380 cfg.aliases.get("local_local_name").map(|s| s.as_str()),
2381 Some("name = 'Ok'")
2382 );
2383 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
2384 let specs = compiled.specs(Lang::Rs);
2385 let rule = specs
2386 .iter()
2387 .find(|rule| rule.rule_id == "rust.fn.local.uses-local")
2388 .expect("fragment rule is compiled");
2389 assert!(
2390 rule.expanded_expr.contains("name = 'Ok'"),
2391 "{}",
2392 rule.expanded_expr
2393 );
2394 }
2395
2396 #[test]
2397 fn fragment_local_alias_can_reference_global_alias() {
2398 let dir = tempfile::tempdir().unwrap();
2399 let root = dir.path().join(".code-moniker.toml");
2400 std::fs::write(
2401 &root,
2402 r#"
2403 default_rules = false
2404
2405 [aliases]
2406 global_name = "name = 'Ok'"
2407 "#,
2408 )
2409 .unwrap();
2410 write_fragment(
2411 dir.path(),
2412 "src",
2413 r#"
2414 fragment = "local"
2415
2416 [aliases]
2417 local_name = "$global_name"
2418
2419 [[rust.fn.where]]
2420 id = "uses-local"
2421 expr = "$local_name"
2422 "#,
2423 );
2424
2425 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
2426 let resolved = resolve_aliases(&cfg.aliases).expect("aliases resolve");
2427 assert_eq!(
2428 resolved.get("local_local_name").map(|s| s.as_str()),
2429 Some("(name = 'Ok')")
2430 );
2431 }
2432
2433 #[test]
2434 fn fragment_local_alias_can_reference_another_local_alias() {
2435 let dir = tempfile::tempdir().unwrap();
2436 let root = dir.path().join(".code-moniker.toml");
2437 std::fs::write(&root, "default_rules = false\n").unwrap();
2438 write_fragment(
2439 dir.path(),
2440 "src",
2441 r#"
2442 fragment = "local"
2443
2444 [aliases]
2445 leaf = "name = 'Ok'"
2446 composed = "$leaf AND lines <= 10"
2447
2448 [[rust.fn.where]]
2449 id = "uses-composed"
2450 expr = "$composed"
2451 "#,
2452 );
2453
2454 let cfg = load_with_overrides(Some(&root)).expect("fragment config loads");
2455
2456 assert_eq!(
2457 cfg.aliases.get("local_composed").map(|s| s.as_str()),
2458 Some("$local_leaf AND lines <= 10")
2459 );
2460 let compiled = crate::check::compile_rules(&cfg, Lang::Rs, "code+moniker://").unwrap();
2461 let specs = compiled.specs(Lang::Rs);
2462 let rule = specs
2463 .iter()
2464 .find(|rule| rule.rule_id == "rust.fn.local.uses-composed")
2465 .expect("fragment rule is compiled");
2466 assert!(
2467 rule.expanded_expr.contains("name = 'Ok'"),
2468 "{}",
2469 rule.expanded_expr
2470 );
2471 assert!(
2472 rule.expanded_expr.contains("lines <= 10"),
2473 "{}",
2474 rule.expanded_expr
2475 );
2476 }
2477
2478 #[test]
2479 fn fragment_aliases_cannot_reference_other_fragments() {
2480 let dir = tempfile::tempdir().unwrap();
2481 let root = dir.path().join(".code-moniker.toml");
2482 std::fs::write(&root, "default_rules = false\n").unwrap();
2483 write_fragment(
2484 dir.path(),
2485 "a",
2486 r#"
2487 fragment = "first"
2488
2489 [aliases]
2490 shared = "name = 'Shared'"
2491 "#,
2492 );
2493 write_fragment(
2494 dir.path(),
2495 "b",
2496 r#"
2497 fragment = "second"
2498
2499 [aliases]
2500 local = "$first_shared"
2501
2502 [[rust.fn.where]]
2503 id = "uses-local"
2504 expr = "$local"
2505 "#,
2506 );
2507
2508 match load_with_overrides(Some(&root)) {
2509 Err(ConfigError::UnknownAlias { name, at }) => {
2510 assert_eq!(name, "first_shared");
2511 assert_eq!(at, "alias `second_local`");
2512 }
2513 other => panic!("expected UnknownAlias error, got {other:?}"),
2514 }
2515 }
2516
2517 #[test]
2518 fn fragment_alias_local_name_must_not_shadow_existing_alias() {
2519 let dir = tempfile::tempdir().unwrap();
2520 let root = dir.path().join(".code-moniker.toml");
2521 std::fs::write(
2522 &root,
2523 r#"
2524 default_rules = false
2525
2526 [aliases]
2527 shared = "name = 'Global'"
2528 "#,
2529 )
2530 .unwrap();
2531 write_fragment(
2532 dir.path(),
2533 "src",
2534 r#"
2535 fragment = "local"
2536
2537 [aliases]
2538 shared = "name = 'Local'"
2539 "#,
2540 );
2541
2542 match load_with_overrides(Some(&root)) {
2543 Err(ConfigError::FragmentAliasShadowsExisting {
2544 fragment, alias, ..
2545 }) => {
2546 assert_eq!(fragment, "local");
2547 assert_eq!(alias, "shared");
2548 }
2549 other => panic!("expected FragmentAliasShadowsExisting error, got {other:?}"),
2550 }
2551 }
2552
2553 #[test]
2554 fn fragment_alias_effective_key_collision_is_rejected() {
2555 let dir = tempfile::tempdir().unwrap();
2556 let root = dir.path().join(".code-moniker.toml");
2557 std::fs::write(
2558 &root,
2559 r#"
2560 default_rules = false
2561
2562 [aliases]
2563 local_shared = "name = 'Global'"
2564 "#,
2565 )
2566 .unwrap();
2567 write_fragment(
2568 dir.path(),
2569 "src",
2570 r#"
2571 fragment = "local"
2572
2573 [aliases]
2574 shared = "name = 'Local'"
2575 "#,
2576 );
2577
2578 match load_with_overrides(Some(&root)) {
2579 Err(ConfigError::FragmentAliasCollision {
2580 alias, existing, ..
2581 }) => {
2582 assert_eq!(alias, "local_shared");
2583 assert_eq!(existing, "<effective config>");
2584 }
2585 other => panic!("expected FragmentAliasCollision error, got {other:?}"),
2586 }
2587 }
2588
2589 #[test]
2590 fn fragment_alias_ids_must_match_reference_grammar() {
2591 let dir = tempfile::tempdir().unwrap();
2592 let root = dir.path().join(".code-moniker.toml");
2593 std::fs::write(&root, "default_rules = false\n").unwrap();
2594 write_fragment(
2595 dir.path(),
2596 "src",
2597 r#"
2598 fragment = "local"
2599
2600 [aliases]
2601 "bad-name" = "name = 'X'"
2602 "#,
2603 );
2604
2605 match load_with_overrides(Some(&root)) {
2606 Err(ConfigError::InvalidFragmentAliasId {
2607 fragment, alias, ..
2608 }) => {
2609 assert_eq!(fragment, "local");
2610 assert_eq!(alias, "bad-name");
2611 }
2612 other => panic!("expected InvalidFragmentAliasId error, got {other:?}"),
2613 }
2614 }
2615
2616 #[test]
2617 fn duplicate_fragment_ids_are_rejected() {
2618 let dir = tempfile::tempdir().unwrap();
2619 let root = dir.path().join(".code-moniker.toml");
2620 std::fs::write(&root, "default_rules = false\n").unwrap();
2621 write_fragment(dir.path(), "a", "fragment = \"local\"\n");
2622 write_fragment(dir.path(), "b", "fragment = \"local\"\n");
2623
2624 match load_with_overrides(Some(&root)) {
2625 Err(ConfigError::DuplicateFragment { id, first, second }) => {
2626 assert_eq!(id, "local");
2627 assert!(first.ends_with("a/code-moniker.fragment.toml"), "{first}");
2628 assert!(second.ends_with("b/code-moniker.fragment.toml"), "{second}");
2629 }
2630 other => panic!("expected DuplicateFragment error, got {other:?}"),
2631 }
2632 }
2633
2634 #[test]
2635 fn fragment_rules_must_have_explicit_ids() {
2636 let dir = tempfile::tempdir().unwrap();
2637 let root = dir.path().join(".code-moniker.toml");
2638 std::fs::write(&root, "default_rules = false\n").unwrap();
2639 write_fragment(
2640 dir.path(),
2641 "src",
2642 r#"
2643 fragment = "local"
2644
2645 [[rust.fn.where]]
2646 expr = "lines <= 10"
2647 "#,
2648 );
2649
2650 match load_with_overrides(Some(&root)) {
2651 Err(ConfigError::FragmentRuleMissingId { fragment, at, .. }) => {
2652 assert_eq!(fragment, "local");
2653 assert_eq!(at, "rust.fn");
2654 }
2655 other => panic!("expected FragmentRuleMissingId error, got {other:?}"),
2656 }
2657 }
2658
2659 #[test]
2660 fn fragment_rule_collisions_are_rejected() {
2661 let dir = tempfile::tempdir().unwrap();
2662 let root = dir.path().join(".code-moniker.toml");
2663 std::fs::write(
2664 &root,
2665 r#"
2666 default_rules = false
2667
2668 [[rust.fn.where]]
2669 id = "local.small"
2670 expr = "lines <= 10"
2671 "#,
2672 )
2673 .unwrap();
2674 write_fragment(
2675 dir.path(),
2676 "src",
2677 r#"
2678 fragment = "local"
2679
2680 [[rust.fn.where]]
2681 id = "small"
2682 expr = "lines <= 20"
2683 "#,
2684 );
2685
2686 match load_with_overrides(Some(&root)) {
2687 Err(ConfigError::FragmentRuleCollision {
2688 rule_id, existing, ..
2689 }) => {
2690 assert_eq!(rule_id, "rust.fn.local.small");
2691 assert_eq!(existing, "<effective config>");
2692 }
2693 other => panic!("expected FragmentRuleCollision error, got {other:?}"),
2694 }
2695 }
2696
2697 #[test]
2698 fn fragment_unknown_alias_is_reported_at_fragment_rule() {
2699 let dir = tempfile::tempdir().unwrap();
2700 let root = dir.path().join(".code-moniker.toml");
2701 std::fs::write(&root, "default_rules = false\n").unwrap();
2702 write_fragment(
2703 dir.path(),
2704 "src",
2705 r#"
2706 fragment = "local"
2707
2708 [[rust.fn.where]]
2709 id = "uses-alias"
2710 expr = "$missing_alias"
2711 "#,
2712 );
2713
2714 match load_with_overrides(Some(&root)) {
2715 Err(ConfigError::UnknownAlias { name, at }) => {
2716 assert_eq!(name, "missing_alias");
2717 assert!(at.contains("code-moniker.fragment.toml:rust.fn.local.uses-alias"));
2718 }
2719 other => panic!("expected UnknownAlias error, got {other:?}"),
2720 }
2721 }
2722
2723 #[test]
2724 fn profile_enable_filters_in() {
2725 let mut cfg = parse(
2726 r#"
2727 [[ts.class.where]]
2728 id = "keep"
2729 expr = "lines <= 99"
2730
2731 [[ts.class.where]]
2732 id = "drop"
2733 expr = "lines <= 99"
2734
2735 [profiles.only_keep]
2736 enable = ["\\.keep$"]
2737 "#,
2738 )
2739 .unwrap();
2740 cfg.apply_profile("only_keep").unwrap();
2741 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
2742 assert_eq!(r.rules.len(), 1);
2743 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
2744 }
2745
2746 #[test]
2747 fn profile_disable_filters_out() {
2748 let mut cfg = parse(
2749 r#"
2750 [[ts.class.where]]
2751 id = "keep"
2752 expr = "lines <= 99"
2753
2754 [[ts.class.where]]
2755 id = "drop"
2756 expr = "lines <= 99"
2757
2758 [profiles.drop_one]
2759 disable = ["\\.drop$"]
2760 "#,
2761 )
2762 .unwrap();
2763 cfg.apply_profile("drop_one").unwrap();
2764 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
2765 assert_eq!(r.rules.len(), 1);
2766 assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
2767 }
2768
2769 #[test]
2770 fn profile_enable_then_disable() {
2771 let mut cfg = parse(
2772 r#"
2773 [[ts.class.where]]
2774 id = "a"
2775 expr = "lines <= 99"
2776
2777 [[ts.class.where]]
2778 id = "b"
2779 expr = "lines <= 99"
2780
2781 [[ts.class.where]]
2782 id = "c"
2783 expr = "lines <= 99"
2784
2785 [profiles.p]
2786 enable = ["ts\\.class\\.(a|b)$"]
2787 disable = ["ts\\.class\\.b$"]
2788 "#,
2789 )
2790 .unwrap();
2791 cfg.apply_profile("p").unwrap();
2792 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
2793 assert_eq!(r.rules.len(), 1);
2794 assert_eq!(r.rules[0].id.as_deref(), Some("a"));
2795 }
2796
2797 #[test]
2798 fn profile_filters_refs_top_level() {
2799 let mut cfg = parse(
2800 r#"
2801 [[refs.where]]
2802 id = "stay"
2803 expr = "kind = 'call'"
2804
2805 [[refs.where]]
2806 id = "go"
2807 expr = "kind = 'call'"
2808
2809 [profiles.p]
2810 disable = ["^refs\\.go$"]
2811 "#,
2812 )
2813 .unwrap();
2814 cfg.apply_profile("p").unwrap();
2815 assert_eq!(cfg.refs.rules.len(), 1);
2816 assert_eq!(cfg.refs.rules[0].id.as_deref(), Some("stay"));
2817 }
2818
2819 #[test]
2820 fn profile_filters_per_lang_refs() {
2821 let mut cfg = parse(
2822 r#"
2823 [[ts.refs.where]]
2824 id = "stay"
2825 expr = "kind = 'call'"
2826
2827 [[ts.refs.where]]
2828 id = "go"
2829 expr = "kind = 'call'"
2830
2831 [profiles.p]
2832 disable = ["^ts\\.refs\\.go$"]
2833 "#,
2834 )
2835 .unwrap();
2836 cfg.apply_profile("p").unwrap();
2837 let r = cfg.ts.kinds.get("refs").unwrap();
2838 assert_eq!(r.rules.len(), 1);
2839 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
2840 }
2841
2842 #[test]
2843 fn profile_filters_shape_scopes() {
2844 let mut cfg = parse(
2845 r#"
2846 [[shape.callable.where]]
2847 id = "stay"
2848 expr = "lines <= 99"
2849
2850 [[shape.callable.where]]
2851 id = "go"
2852 expr = "lines <= 99"
2853
2854 [[ts.shape.type.where]]
2855 id = "go"
2856 expr = "lines <= 99"
2857
2858 [profiles.p]
2859 disable = ["^shape\\.callable\\.go$", "^ts\\.shape\\.type\\.go$"]
2860 "#,
2861 )
2862 .unwrap();
2863 cfg.apply_profile("p").unwrap();
2864 assert_eq!(cfg.shape["callable"].rules.len(), 1);
2865 assert_eq!(cfg.shape["callable"].rules[0].id.as_deref(), Some("stay"));
2866 assert!(cfg.ts.shape["type"].rules.is_empty());
2867 }
2868
2869 #[test]
2870 fn profile_filters_default_section() {
2871 let mut cfg = parse(
2872 r#"
2873 [[default.module.where]]
2874 id = "stay"
2875 expr = "lines <= 99"
2876
2877 [[default.module.where]]
2878 id = "go"
2879 expr = "lines <= 99"
2880
2881 [profiles.p]
2882 disable = ["^default\\.module\\.go$"]
2883 "#,
2884 )
2885 .unwrap();
2886 cfg.apply_profile("p").unwrap();
2887 let r = cfg.default.kinds.get("module").unwrap();
2888 assert_eq!(r.rules.len(), 1);
2889 assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
2890 }
2891
2892 #[test]
2893 fn unknown_profile_returns_error() {
2894 let mut cfg = parse(
2895 r#"
2896 [profiles.known]
2897 disable = []
2898 "#,
2899 )
2900 .unwrap();
2901 match cfg.apply_profile("nope") {
2902 Err(ConfigError::UnknownProfile { name, known }) => {
2903 assert_eq!(name, "nope");
2904 assert!(known.contains("known"), "{known}");
2905 }
2906 other => panic!("expected UnknownProfile, got {other:?}"),
2907 }
2908 }
2909
2910 #[test]
2911 fn bad_regex_returns_error() {
2912 let mut cfg = parse(
2913 r#"
2914 [profiles.p]
2915 enable = ["(unclosed"]
2916 "#,
2917 )
2918 .unwrap();
2919 match cfg.apply_profile("p") {
2920 Err(ConfigError::BadProfileRegex {
2921 profile,
2922 field,
2923 pattern,
2924 ..
2925 }) => {
2926 assert_eq!(profile, "p");
2927 assert_eq!(field, "enable");
2928 assert_eq!(pattern, "(unclosed");
2929 }
2930 other => panic!("expected BadProfileRegex, got {other:?}"),
2931 }
2932 }
2933
2934 #[test]
2935 fn fallback_where_n_id_matches() {
2936 let mut cfg = parse(
2937 r#"
2938 [[ts.class.where]]
2939 expr = "lines <= 99"
2940
2941 [[ts.class.where]]
2942 expr = "lines <= 99"
2943
2944 [profiles.p]
2945 disable = ["^ts\\.class\\.where_0$"]
2946 "#,
2947 )
2948 .unwrap();
2949 cfg.apply_profile("p").unwrap();
2950 let r = cfg.rules_for(Lang::Ts, "class").unwrap();
2951 assert_eq!(r.rules.len(), 1);
2952 }
2953
2954 #[test]
2955 fn user_profile_overrides_preset_by_name() {
2956 let user = parse(
2957 r#"
2958 [profiles.bugfix]
2959 enable = ["^user$"]
2960 disable = []
2961 "#,
2962 )
2963 .unwrap();
2964 let mut base = parse(
2965 r#"
2966 [profiles.bugfix]
2967 enable = ["^base$"]
2968 disable = []
2969 "#,
2970 )
2971 .unwrap();
2972 merge_into(&mut base, user);
2973 let p = base.profiles.get("bugfix").unwrap();
2974 assert_eq!(p.enable, vec!["^user$".to_string()]);
2975 }
2976
2977 #[test]
2978 fn default_preset_ships_at_least_one_rule_per_language() {
2979 let cfg = load_default().unwrap();
2980 for lang in Lang::ALL {
2981 let lr = cfg.for_lang(*lang);
2982 assert!(
2983 !lr.kinds.is_empty(),
2984 "{} should ship at least one default rule",
2985 lang.tag()
2986 );
2987 }
2988 }
2989}