Skip to main content

code_moniker_check/check/config/
mod.rs

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
17/// Reserved keys under `[<lang>.…]` that aren't def kinds. `refs` is treated
18/// as the per-lang ref rule list (parallel to top-level `[[refs.where]]`).
19const 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
400/// Load the embedded defaults and merge `user_path` on top if it exists.
401/// Missing user config is not an error — defaults stand alone.
402pub fn load_with_overrides(user_path: Option<&Path>) -> Result<Config, ConfigError> {
403	load_with_options(user_path, true)
404}
405
406/// Load rule config with explicit CLI precedence for `--default-rules`.
407/// `Some(on|off)` wins over the user config flag; `None` lets the user config
408/// decide and defaults to enabled.
409pub 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
416/// Load project rules plus command-line inline TOML overlays with explicit CLI
417/// precedence for `--default-rules`. Inline overlays merge after root config
418/// and fragments, so command invocations can temporarily refine project rules.
419pub 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
427/// Load rules for a concrete workspace root. Structural workspace fields are
428/// accepted only when `user_path` is that root's canonical
429/// `.code-moniker.toml`; alternate `--rules` files remain rules-only inputs.
430pub 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
512/// Load rule config, optionally starting from the embedded defaults.
513/// Missing user config is not an error: with defaults enabled they stand
514/// alone; with defaults disabled the resulting config is empty.
515pub(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
717/// `where` rules are concatenated when both sides supply entries: an entry
718/// from `ov` whose `id` matches an existing base entry replaces that base
719/// entry; otherwise it's appended. `require_doc_comment` overrides if set.
720fn 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
736/// Resolve every alias to its fully-expanded form. Reports a cycle when one
737/// is detected and an unknown-alias error if a referenced `$name` doesn't
738/// exist among the aliases (referenced names inside rule `expr` are
739/// validated lazily at compile time, not here).
740pub(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
811/// Substitute `$name` references in `expr` against an already-resolved alias
812/// map. Unknown alias → error tagged with the rule location.
813pub(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
849/// Aliases are resolved first so cycles surface before any kind / visibility check.
850fn 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
1009/// Kinds legitimately usable in DSL `count(<kind>)` for `lang` — `lang`'s
1010/// extractor vocabulary plus internal kinds (`module`, `local`, `param`,
1011/// `comment`).
1012pub(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
1020/// `lang`'s visibility vocabulary plus `"any"`. `"any"` is a special token
1021/// that means "ignore the visibility and require a doc comment everywhere".
1022fn 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
1038/// TOML section / rule-id segment for a language. `Lang::Rs` aliases to
1039/// `rust` for readability — every other lang uses its `LANG_TAG` verbatim.
1040pub(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		// `$x OR Y` → `(<x-body>) OR Y` so precedence is preserved.
1831		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}