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