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