Skip to main content

code_moniker_cli/check/
config.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use regex::Regex;
5use serde::Deserialize;
6use thiserror::Error;
7
8use code_moniker_core::lang::Lang;
9
10const DEFAULT_PRESET: &str = include_str!("presets/default.toml");
11
12pub(crate) use code_moniker_core::lang::kinds::INTERNAL_KINDS;
13
14/// Reserved keys under `[<lang>.…]` that aren't def kinds. `refs` is treated
15/// as the per-lang ref rule list (parallel to top-level `[[refs.where]]`).
16const RESERVED_LANG_KEYS: &[&str] = &["refs"];
17
18#[derive(Debug, Default, Deserialize, Clone)]
19#[serde(deny_unknown_fields)]
20pub struct Config {
21	#[serde(default)]
22	pub aliases: HashMap<String, String>,
23	#[serde(default)]
24	pub refs: RefsRules,
25	#[serde(default)]
26	pub default: LangRules,
27	#[serde(default)]
28	pub ts: LangRules,
29	#[serde(default)]
30	pub rust: LangRules,
31	#[serde(default)]
32	pub java: LangRules,
33	#[serde(default)]
34	pub python: LangRules,
35	#[serde(default)]
36	pub go: LangRules,
37	#[serde(default)]
38	pub cs: LangRules,
39	#[serde(default)]
40	pub sql: LangRules,
41	#[serde(default)]
42	pub profiles: HashMap<String, Profile>,
43}
44
45#[derive(Debug, Default, Deserialize, Clone)]
46#[serde(deny_unknown_fields)]
47pub struct Profile {
48	#[serde(default)]
49	pub enable: Vec<String>,
50	#[serde(default)]
51	pub disable: Vec<String>,
52}
53
54#[derive(Debug, Default, Deserialize, Clone)]
55#[serde(deny_unknown_fields)]
56pub struct RefsRules {
57	#[serde(default, rename = "where")]
58	pub rules: Vec<RuleEntry>,
59}
60
61#[derive(Debug, Default, Deserialize, Clone)]
62pub struct LangRules {
63	#[serde(flatten)]
64	pub kinds: HashMap<String, KindRules>,
65}
66
67#[derive(Debug, Default, Deserialize, Clone)]
68#[serde(deny_unknown_fields)]
69pub struct KindRules {
70	#[serde(default, rename = "where")]
71	pub rules: Vec<RuleEntry>,
72	pub require_doc_comment: Option<String>,
73}
74
75#[derive(Debug, Deserialize, Clone)]
76#[serde(deny_unknown_fields)]
77pub struct RuleEntry {
78	#[serde(default)]
79	pub id: Option<String>,
80	pub expr: String,
81	#[serde(default)]
82	pub message: Option<String>,
83}
84
85#[derive(Debug, Error)]
86pub enum ConfigError {
87	#[error("default preset embedded in the binary is invalid: {0}")]
88	DefaultPresetInvalid(toml::de::Error),
89	#[error("user config `{path}`: {error}")]
90	UserConfig {
91		path: String,
92		error: toml::de::Error,
93	},
94	#[error("cannot read `{path}`: {error}")]
95	Io { path: String, error: std::io::Error },
96	#[error("invalid expression at `{at}`: {error}")]
97	InvalidExpr {
98		at: String,
99		error: super::expr::ParseError,
100	},
101	#[error("unknown kind `{kind}` under `[{section}.{kind}]` (allowed: {allowed})")]
102	UnknownKind {
103		section: String,
104		kind: String,
105		allowed: String,
106	},
107	#[error(
108		"require_doc_comment = `{value}` under `[{section}.{kind}]` is not a recognised visibility for that language (allowed: {allowed})"
109	)]
110	UnknownDocVisibility {
111		section: String,
112		kind: String,
113		value: String,
114		allowed: String,
115	},
116	#[error("alias cycle through `{chain}`")]
117	AliasCycle { chain: String },
118	#[error("unknown alias `${name}` referenced under `{at}`")]
119	UnknownAlias { name: String, at: String },
120	#[error("unknown profile `{name}` (known: {known})")]
121	UnknownProfile { name: String, known: String },
122	#[error("invalid regex `{pattern}` in profile `{profile}` ({field}): {error}")]
123	BadProfileRegex {
124		profile: String,
125		field: &'static str,
126		pattern: String,
127		error: regex::Error,
128	},
129}
130
131pub fn load_default() -> Result<Config, ConfigError> {
132	let cfg: Config = toml::from_str(DEFAULT_PRESET).map_err(ConfigError::DefaultPresetInvalid)?;
133	validate(&cfg, "<embedded preset>")?;
134	Ok(cfg)
135}
136
137/// Load the embedded defaults and merge `user_path` on top if it exists.
138/// Missing user config is not an error — defaults stand alone.
139pub fn load_with_overrides(user_path: Option<&Path>) -> Result<Config, ConfigError> {
140	let mut cfg = load_default()?;
141	if let Some(p) = user_path {
142		if !p.exists() {
143			return Ok(cfg);
144		}
145		let raw = std::fs::read_to_string(p).map_err(|error| ConfigError::Io {
146			path: p.display().to_string(),
147			error,
148		})?;
149		let user: Config = toml::from_str(&raw).map_err(|error| ConfigError::UserConfig {
150			path: p.display().to_string(),
151			error,
152		})?;
153		validate(&user, &p.display().to_string())?;
154		merge_into(&mut cfg, user);
155	}
156	Ok(cfg)
157}
158
159fn merge_into(base: &mut Config, ov: Config) {
160	for (k, v) in ov.aliases {
161		base.aliases.insert(k, v);
162	}
163	for (k, v) in ov.profiles {
164		base.profiles.insert(k, v);
165	}
166	merge_refs(&mut base.refs, ov.refs);
167	merge_lang(&mut base.default, ov.default);
168	merge_lang(&mut base.ts, ov.ts);
169	merge_lang(&mut base.rust, ov.rust);
170	merge_lang(&mut base.java, ov.java);
171	merge_lang(&mut base.python, ov.python);
172	merge_lang(&mut base.go, ov.go);
173	merge_lang(&mut base.cs, ov.cs);
174	merge_lang(&mut base.sql, ov.sql);
175}
176
177fn merge_refs(base: &mut RefsRules, ov: RefsRules) {
178	for ov_rule in ov.rules {
179		match ov_rule
180			.id
181			.as_deref()
182			.and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
183		{
184			Some(idx) => base.rules[idx] = ov_rule,
185			None => base.rules.push(ov_rule),
186		}
187	}
188}
189
190fn merge_lang(base: &mut LangRules, ov: LangRules) {
191	for (kind, ov_rules) in ov.kinds {
192		match base.kinds.get_mut(&kind) {
193			Some(base_rules) => merge_kind(base_rules, ov_rules),
194			None => {
195				base.kinds.insert(kind, ov_rules);
196			}
197		}
198	}
199}
200
201/// `where` rules are concatenated when both sides supply entries: an entry
202/// from `ov` whose `id` matches an existing base entry replaces that base
203/// entry; otherwise it's appended. `require_doc_comment` overrides if set.
204fn merge_kind(base: &mut KindRules, ov: KindRules) {
205	for ov_rule in ov.rules {
206		match ov_rule
207			.id
208			.as_deref()
209			.and_then(|id| base.rules.iter().position(|r| r.id.as_deref() == Some(id)))
210		{
211			Some(idx) => base.rules[idx] = ov_rule,
212			None => base.rules.push(ov_rule),
213		}
214	}
215	if ov.require_doc_comment.is_some() {
216		base.require_doc_comment = ov.require_doc_comment;
217	}
218}
219
220/// Resolve every alias to its fully-expanded form. Reports a cycle when one
221/// is detected and an unknown-alias error if a referenced `$name` doesn't
222/// exist among the aliases (referenced names inside rule `expr` are
223/// validated lazily at compile time, not here).
224pub(crate) fn resolve_aliases(
225	aliases: &HashMap<String, String>,
226) -> Result<HashMap<String, String>, ConfigError> {
227	let mut resolved: HashMap<String, String> = HashMap::new();
228	for name in aliases.keys() {
229		let mut stack: Vec<String> = Vec::new();
230		resolve_one(name, aliases, &mut resolved, &mut stack)?;
231	}
232	Ok(resolved)
233}
234
235fn resolve_one(
236	name: &str,
237	src: &HashMap<String, String>,
238	resolved: &mut HashMap<String, String>,
239	stack: &mut Vec<String>,
240) -> Result<String, ConfigError> {
241	if let Some(v) = resolved.get(name) {
242		return Ok(v.clone());
243	}
244	if stack.iter().any(|s| s == name) {
245		stack.push(name.to_string());
246		return Err(ConfigError::AliasCycle {
247			chain: stack.join(" → "),
248		});
249	}
250	let Some(body) = src.get(name) else {
251		return Err(ConfigError::UnknownAlias {
252			name: name.to_string(),
253			at: format!("alias `{}`", stack.last().unwrap_or(&"<root>".to_string())),
254		});
255	};
256	stack.push(name.to_string());
257	let expanded = expand_refs(body, src, resolved, stack)?;
258	stack.pop();
259	resolved.insert(name.to_string(), expanded.clone());
260	Ok(expanded)
261}
262
263fn expand_refs(
264	body: &str,
265	src: &HashMap<String, String>,
266	resolved: &mut HashMap<String, String>,
267	stack: &mut Vec<String>,
268) -> Result<String, ConfigError> {
269	let mut out = String::with_capacity(body.len());
270	let bytes = body.as_bytes();
271	let mut i = 0;
272	while i < bytes.len() {
273		if bytes[i] == b'$' {
274			let start = i + 1;
275			let mut j = start;
276			while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
277				j += 1;
278			}
279			if j > start {
280				let name = &body[start..j];
281				let expanded = resolve_one(name, src, resolved, stack)?;
282				out.push('(');
283				out.push_str(&expanded);
284				out.push(')');
285				i = j;
286				continue;
287			}
288		}
289		out.push(bytes[i] as char);
290		i += 1;
291	}
292	Ok(out)
293}
294
295/// Substitute `$name` references in `expr` against an already-resolved alias
296/// map. Unknown alias → error tagged with the rule location.
297pub(crate) fn substitute_aliases(
298	expr: &str,
299	resolved: &HashMap<String, String>,
300	at: &str,
301) -> Result<String, ConfigError> {
302	let mut out = String::with_capacity(expr.len());
303	let bytes = expr.as_bytes();
304	let mut i = 0;
305	while i < bytes.len() {
306		if bytes[i] == b'$' {
307			let start = i + 1;
308			let mut j = start;
309			while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
310				j += 1;
311			}
312			if j > start {
313				let name = &expr[start..j];
314				let Some(expanded) = resolved.get(name) else {
315					return Err(ConfigError::UnknownAlias {
316						name: name.to_string(),
317						at: at.to_string(),
318					});
319				};
320				out.push('(');
321				out.push_str(expanded);
322				out.push(')');
323				i = j;
324				continue;
325			}
326		}
327		out.push(bytes[i] as char);
328		i += 1;
329	}
330	Ok(out)
331}
332
333/// Aliases are resolved first so cycles surface before any kind / visibility check.
334fn validate(cfg: &Config, path: &str) -> Result<(), ConfigError> {
335	resolve_aliases(&cfg.aliases)?;
336	validate_lang_section(
337		&cfg.default,
338		"default",
339		&allowed_kinds_set(None),
340		None,
341		path,
342	)?;
343	for lang in Lang::ALL {
344		let allowed = allowed_kinds_set(Some(*lang));
345		validate_lang_section(
346			cfg.for_lang(*lang),
347			config_section(*lang),
348			&allowed,
349			Some(*lang),
350			path,
351		)?;
352	}
353	Ok(())
354}
355
356fn allowed_kinds_set(lang: Option<Lang>) -> Vec<&'static str> {
357	let mut out: Vec<&'static str> = INTERNAL_KINDS.to_vec();
358	if let Some(l) = lang {
359		out.extend(l.allowed_kinds().iter().copied());
360	} else {
361		for l in Lang::ALL {
362			out.extend(l.allowed_kinds().iter().copied());
363		}
364	}
365	out.sort();
366	out.dedup();
367	out
368}
369
370/// Kinds legitimately usable in DSL `count(<kind>)` for `lang` — `lang`'s
371/// extractor vocabulary plus internal kinds (`module`, `local`, `param`,
372/// `comment`).
373pub(crate) fn allowed_kinds_for(lang: Lang) -> Vec<&'static str> {
374	allowed_kinds_set(Some(lang))
375}
376
377/// `lang`'s visibility vocabulary plus `"any"`. `"any"` is a special token
378/// that means "ignore the visibility and require a doc comment everywhere".
379fn allowed_doc_vis_for(lang: Lang) -> Vec<&'static str> {
380	let mut out: Vec<&'static str> = vec!["any"];
381	out.extend(lang.allowed_visibilities().iter().copied());
382	out
383}
384
385/// TOML section / rule-id segment for a language. `Lang::Rs` aliases to
386/// `rust` for readability — every other lang uses its `LANG_TAG` verbatim.
387pub(crate) fn config_section(lang: Lang) -> &'static str {
388	match lang {
389		Lang::Rs => "rust",
390		other => other.tag(),
391	}
392}
393
394fn validate_lang_section(
395	lr: &LangRules,
396	section: &str,
397	allowed: &[&str],
398	lang: Option<Lang>,
399	_path: &str,
400) -> Result<(), ConfigError> {
401	for (kind, kr) in lr.kinds.iter() {
402		if RESERVED_LANG_KEYS.contains(&kind.as_str()) {
403			continue;
404		}
405		if !allowed.contains(&kind.as_str()) {
406			return Err(ConfigError::UnknownKind {
407				section: section.to_string(),
408				kind: kind.clone(),
409				allowed: allowed.join(", "),
410			});
411		}
412		if let (Some(value), Some(l)) = (&kr.require_doc_comment, lang) {
413			let allowed_vis = allowed_doc_vis_for(l);
414			if !allowed_vis.contains(&value.as_str()) {
415				return Err(ConfigError::UnknownDocVisibility {
416					section: section.to_string(),
417					kind: kind.clone(),
418					value: value.clone(),
419					allowed: allowed_vis.join(", "),
420				});
421			}
422		}
423	}
424	Ok(())
425}
426
427impl Config {
428	pub fn for_lang(&self, lang: Lang) -> &LangRules {
429		match lang {
430			Lang::Ts => &self.ts,
431			Lang::Rs => &self.rust,
432			Lang::Java => &self.java,
433			Lang::Python => &self.python,
434			Lang::Go => &self.go,
435			Lang::Cs => &self.cs,
436			Lang::Sql => &self.sql,
437		}
438	}
439
440	pub fn for_lang_mut(&mut self, lang: Lang) -> &mut LangRules {
441		match lang {
442			Lang::Ts => &mut self.ts,
443			Lang::Rs => &mut self.rust,
444			Lang::Java => &mut self.java,
445			Lang::Python => &mut self.python,
446			Lang::Go => &mut self.go,
447			Lang::Cs => &mut self.cs,
448			Lang::Sql => &mut self.sql,
449		}
450	}
451
452	pub fn rules_for(&self, lang: Lang, kind: &str) -> Option<&KindRules> {
453		self.for_lang(lang)
454			.kinds
455			.get(kind)
456			.or_else(|| self.default.kinds.get(kind))
457	}
458
459	pub fn apply_profile(&mut self, name: &str) -> Result<(), ConfigError> {
460		let profile = self
461			.profiles
462			.get(name)
463			.ok_or_else(|| ConfigError::UnknownProfile {
464				name: name.to_string(),
465				known: self.known_profiles(),
466			})?
467			.clone();
468		let enable = compile_patterns(&profile.enable, name, "enable")?;
469		let disable = compile_patterns(&profile.disable, name, "disable")?;
470		filter_rules(&mut self.refs.rules, "refs", &enable, &disable);
471		filter_lang(&mut self.default, "default", &enable, &disable);
472		for lang in Lang::ALL {
473			filter_lang(
474				self.for_lang_mut(*lang),
475				config_section(*lang),
476				&enable,
477				&disable,
478			);
479		}
480		Ok(())
481	}
482
483	fn known_profiles(&self) -> String {
484		let mut names: Vec<&str> = self.profiles.keys().map(|s| s.as_str()).collect();
485		names.sort();
486		names.join(", ")
487	}
488}
489
490impl RuleEntry {
491	pub(crate) fn fallback_id(&self, idx: usize) -> String {
492		self.id.clone().unwrap_or_else(|| format!("where_{idx}"))
493	}
494}
495
496fn compile_patterns(
497	patterns: &[String],
498	profile: &str,
499	field: &'static str,
500) -> Result<Vec<Regex>, ConfigError> {
501	patterns
502		.iter()
503		.map(|p| {
504			Regex::new(p).map_err(|error| ConfigError::BadProfileRegex {
505				profile: profile.to_string(),
506				field,
507				pattern: p.clone(),
508				error,
509			})
510		})
511		.collect()
512}
513
514fn filter_lang(lr: &mut LangRules, section: &str, enable: &[Regex], disable: &[Regex]) {
515	for (kind, kr) in lr.kinds.iter_mut() {
516		let prefix = format!("{section}.{kind}");
517		filter_rules(&mut kr.rules, &prefix, enable, disable);
518	}
519}
520
521fn filter_rules(rules: &mut Vec<RuleEntry>, prefix: &str, enable: &[Regex], disable: &[Regex]) {
522	if rules.is_empty() || (enable.is_empty() && disable.is_empty()) {
523		return;
524	}
525	let mut idx = 0;
526	rules.retain(|r| {
527		let full = format!("{prefix}.{}", r.fallback_id(idx));
528		idx += 1;
529		(enable.is_empty() || enable.iter().any(|re| re.is_match(&full)))
530			&& !disable.iter().any(|re| re.is_match(&full))
531	});
532}
533
534#[cfg(test)]
535mod tests {
536	use super::*;
537
538	fn parse(s: &str) -> Result<Config, ConfigError> {
539		let cfg: Config = toml::from_str(s).map_err(|e| ConfigError::UserConfig {
540			path: "<test>".to_string(),
541			error: e,
542		})?;
543		validate(&cfg, "<test>")?;
544		Ok(cfg)
545	}
546
547	#[test]
548	fn embedded_default_parses() {
549		let cfg = load_default().expect("default preset must parse");
550		assert!(cfg.ts.kinds.contains_key("class"));
551		assert!(cfg.ts.kinds.contains_key("function"));
552	}
553
554	#[test]
555	fn ts_class_ships_at_least_one_rule_in_default() {
556		let cfg = load_default().unwrap();
557		let r = cfg.rules_for(Lang::Ts, "class").expect("ts.class present");
558		assert!(!r.rules.is_empty(), "preset must ship rules for ts.class");
559	}
560
561	#[test]
562	fn rules_for_falls_back_to_default_section() {
563		let cfg = parse(
564			r#"
565			[[default.module.where]]
566			id   = "stub"
567			expr = "lines <= 99"
568
569			[[ts.class.where]]
570			expr = "name =~ ^X"
571			"#,
572		)
573		.unwrap();
574		let r = cfg
575			.rules_for(Lang::Ts, "module")
576			.expect("falls back to default.module");
577		assert_eq!(r.rules.len(), 1);
578		assert_eq!(r.rules[0].id.as_deref(), Some("stub"));
579	}
580
581	#[test]
582	fn override_with_same_id_replaces_preset_rule() {
583		let user = parse(
584			r#"
585			[[ts.function.where]]
586			id   = "max-lines"
587			expr = "lines <= 999"
588			"#,
589		)
590		.unwrap();
591		let mut base = parse(
592			r#"
593			[[ts.function.where]]
594			id   = "name-camel"
595			expr = "name =~ ^[a-z]"
596
597			[[ts.function.where]]
598			id   = "max-lines"
599			expr = "lines <= 60"
600			"#,
601		)
602		.unwrap();
603		merge_into(&mut base, user);
604		let f = base.rules_for(Lang::Ts, "function").unwrap();
605		assert_eq!(f.rules.len(), 2, "id-matched override replaces in place");
606		let max_lines = f
607			.rules
608			.iter()
609			.find(|r| r.id.as_deref() == Some("max-lines"))
610			.unwrap();
611		assert!(max_lines.expr.contains("999"), "user override applied");
612		assert!(
613			f.rules
614				.iter()
615				.any(|r| r.id.as_deref() == Some("name-camel")),
616			"sibling rule preserved"
617		);
618	}
619
620	#[test]
621	fn override_with_new_id_appends_to_preset() {
622		let user = parse(
623			r#"
624			[[ts.class.where]]
625			id   = "extra"
626			expr = "name !~ ^Internal"
627			"#,
628		)
629		.unwrap();
630		let mut base = parse(
631			r#"
632			[[ts.class.where]]
633			id   = "name-pascal"
634			expr = "name =~ ^[A-Z]"
635			"#,
636		)
637		.unwrap();
638		merge_into(&mut base, user);
639		let r = base.rules_for(Lang::Ts, "class").unwrap();
640		assert_eq!(r.rules.len(), 2);
641	}
642
643	#[test]
644	fn unknown_field_in_kind_rules_is_rejected() {
645		let r = toml::from_str::<Config>(
646			r#"
647			[ts.function]
648			max_lines = 10
649			"#,
650		);
651		assert!(r.is_err(), "deny_unknown_fields rejects legacy fields");
652	}
653
654	#[test]
655	fn alias_section_parses() {
656		let cfg = parse(
657			r#"
658			[aliases]
659			domain = "moniker ~ '**/module:domain/**'"
660			"#,
661		)
662		.unwrap();
663		assert_eq!(
664			cfg.aliases.get("domain").map(|s| s.as_str()),
665			Some("moniker ~ '**/module:domain/**'"),
666		);
667	}
668
669	#[test]
670	fn alias_cycle_is_rejected() {
671		let r = parse(
672			r#"
673			[aliases]
674			a = "$b"
675			b = "$a"
676			"#,
677		);
678		match r {
679			Err(ConfigError::AliasCycle { chain }) => {
680				assert!(chain.contains("a") && chain.contains("b"), "{chain}");
681			}
682			other => panic!("expected AliasCycle, got {other:?}"),
683		}
684	}
685
686	#[test]
687	fn alias_chain_resolves() {
688		let cfg = parse(
689			r#"
690			[aliases]
691			a = "name = 'X'"
692			b = "$a OR name = 'Y'"
693			c = "$b AND lines <= 10"
694			"#,
695		)
696		.unwrap();
697		let resolved = resolve_aliases(&cfg.aliases).unwrap();
698		let final_c = resolved.get("c").unwrap();
699		assert!(final_c.contains("name = 'X'"), "{final_c}");
700		assert!(final_c.contains("name = 'Y'"), "{final_c}");
701		assert!(final_c.contains("lines <= 10"), "{final_c}");
702	}
703
704	#[test]
705	fn alias_substitution_wraps_in_parens() {
706		// `$x OR Y` → `(<x-body>) OR Y` so precedence is preserved.
707		let mut src = HashMap::new();
708		src.insert("x".to_string(), "A AND B".to_string());
709		let resolved = resolve_aliases(&src).unwrap();
710		let out = substitute_aliases("$x OR C", &resolved, "test").unwrap();
711		assert_eq!(out, "(A AND B) OR C");
712	}
713
714	#[test]
715	fn unknown_alias_is_rejected_at_substitution() {
716		let resolved = HashMap::new();
717		match substitute_aliases("$bogus AND name = 'X'", &resolved, "ts.class.r1") {
718			Err(ConfigError::UnknownAlias { name, at }) => {
719				assert_eq!(name, "bogus");
720				assert_eq!(at, "ts.class.r1");
721			}
722			other => panic!("expected UnknownAlias, got {other:?}"),
723		}
724	}
725
726	#[test]
727	fn unknown_top_level_lang_section_is_rejected() {
728		let r = toml::from_str::<Config>(
729			r#"
730			[[typescript.class.where]]
731			expr = "name =~ ^[A-Z]"
732			"#,
733		);
734		assert!(
735			r.is_err(),
736			"deny_unknown_fields must reject unknown lang sections"
737		);
738	}
739
740	#[test]
741	fn unknown_require_doc_visibility_is_rejected() {
742		let r = parse(
743			r#"
744			[ts.class]
745			require_doc_comment = "publc"
746			"#,
747		);
748		match r {
749			Err(ConfigError::UnknownDocVisibility { value, .. }) => assert_eq!(value, "publc"),
750			other => panic!("expected UnknownDocVisibility, got {other:?}"),
751		}
752	}
753
754	#[test]
755	fn doc_visibility_any_is_accepted() {
756		let r = parse(
757			r#"
758			[ts.class]
759			require_doc_comment = "any"
760			"#,
761		);
762		assert!(r.is_ok(), "any is always valid");
763	}
764
765	#[test]
766	fn unknown_kind_section_is_rejected() {
767		let r = parse(
768			r#"
769			[[ts.classs.where]]
770			expr = "name =~ ^X"
771			"#,
772		);
773		match r {
774			Err(ConfigError::UnknownKind { kind, .. }) => assert_eq!(kind, "classs"),
775			other => panic!("expected UnknownKind, got {other:?}"),
776		}
777	}
778
779	#[test]
780	fn missing_user_file_is_not_an_error() {
781		let cfg = load_with_overrides(Some(Path::new("/no/such/file.toml")))
782			.expect("missing file falls back to defaults");
783		assert!(cfg.ts.kinds.contains_key("class"));
784	}
785
786	#[test]
787	fn malformed_user_file_returns_user_config_error() {
788		let dir = tempfile::tempdir().unwrap();
789		let p = dir.path().join("bad.toml");
790		std::fs::write(&p, "this is not toml = = =").unwrap();
791		match load_with_overrides(Some(&p)) {
792			Err(ConfigError::UserConfig { .. }) => {}
793			other => panic!("expected UserConfig error, got {other:?}"),
794		}
795	}
796
797	#[test]
798	fn profile_enable_filters_in() {
799		let mut cfg = parse(
800			r#"
801			[[ts.class.where]]
802			id   = "keep"
803			expr = "lines <= 99"
804
805			[[ts.class.where]]
806			id   = "drop"
807			expr = "lines <= 99"
808
809			[profiles.only_keep]
810			enable = ["\\.keep$"]
811			"#,
812		)
813		.unwrap();
814		cfg.apply_profile("only_keep").unwrap();
815		let r = cfg.rules_for(Lang::Ts, "class").unwrap();
816		assert_eq!(r.rules.len(), 1);
817		assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
818	}
819
820	#[test]
821	fn profile_disable_filters_out() {
822		let mut cfg = parse(
823			r#"
824			[[ts.class.where]]
825			id   = "keep"
826			expr = "lines <= 99"
827
828			[[ts.class.where]]
829			id   = "drop"
830			expr = "lines <= 99"
831
832			[profiles.drop_one]
833			disable = ["\\.drop$"]
834			"#,
835		)
836		.unwrap();
837		cfg.apply_profile("drop_one").unwrap();
838		let r = cfg.rules_for(Lang::Ts, "class").unwrap();
839		assert_eq!(r.rules.len(), 1);
840		assert_eq!(r.rules[0].id.as_deref(), Some("keep"));
841	}
842
843	#[test]
844	fn profile_enable_then_disable() {
845		let mut cfg = parse(
846			r#"
847			[[ts.class.where]]
848			id   = "a"
849			expr = "lines <= 99"
850
851			[[ts.class.where]]
852			id   = "b"
853			expr = "lines <= 99"
854
855			[[ts.class.where]]
856			id   = "c"
857			expr = "lines <= 99"
858
859			[profiles.p]
860			enable  = ["ts\\.class\\.(a|b)$"]
861			disable = ["ts\\.class\\.b$"]
862			"#,
863		)
864		.unwrap();
865		cfg.apply_profile("p").unwrap();
866		let r = cfg.rules_for(Lang::Ts, "class").unwrap();
867		assert_eq!(r.rules.len(), 1);
868		assert_eq!(r.rules[0].id.as_deref(), Some("a"));
869	}
870
871	#[test]
872	fn profile_filters_refs_top_level() {
873		let mut cfg = parse(
874			r#"
875			[[refs.where]]
876			id   = "stay"
877			expr = "kind = 'call'"
878
879			[[refs.where]]
880			id   = "go"
881			expr = "kind = 'call'"
882
883			[profiles.p]
884			disable = ["^refs\\.go$"]
885			"#,
886		)
887		.unwrap();
888		cfg.apply_profile("p").unwrap();
889		assert_eq!(cfg.refs.rules.len(), 1);
890		assert_eq!(cfg.refs.rules[0].id.as_deref(), Some("stay"));
891	}
892
893	#[test]
894	fn profile_filters_per_lang_refs() {
895		let mut cfg = parse(
896			r#"
897			[[ts.refs.where]]
898			id   = "stay"
899			expr = "kind = 'call'"
900
901			[[ts.refs.where]]
902			id   = "go"
903			expr = "kind = 'call'"
904
905			[profiles.p]
906			disable = ["^ts\\.refs\\.go$"]
907			"#,
908		)
909		.unwrap();
910		cfg.apply_profile("p").unwrap();
911		let r = cfg.ts.kinds.get("refs").unwrap();
912		assert_eq!(r.rules.len(), 1);
913		assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
914	}
915
916	#[test]
917	fn profile_filters_default_section() {
918		let mut cfg = parse(
919			r#"
920			[[default.module.where]]
921			id   = "stay"
922			expr = "lines <= 99"
923
924			[[default.module.where]]
925			id   = "go"
926			expr = "lines <= 99"
927
928			[profiles.p]
929			disable = ["^default\\.module\\.go$"]
930			"#,
931		)
932		.unwrap();
933		cfg.apply_profile("p").unwrap();
934		let r = cfg.default.kinds.get("module").unwrap();
935		assert_eq!(r.rules.len(), 1);
936		assert_eq!(r.rules[0].id.as_deref(), Some("stay"));
937	}
938
939	#[test]
940	fn unknown_profile_returns_error() {
941		let mut cfg = parse(
942			r#"
943			[profiles.known]
944			disable = []
945			"#,
946		)
947		.unwrap();
948		match cfg.apply_profile("nope") {
949			Err(ConfigError::UnknownProfile { name, known }) => {
950				assert_eq!(name, "nope");
951				assert!(known.contains("known"), "{known}");
952			}
953			other => panic!("expected UnknownProfile, got {other:?}"),
954		}
955	}
956
957	#[test]
958	fn bad_regex_returns_error() {
959		let mut cfg = parse(
960			r#"
961			[profiles.p]
962			enable = ["(unclosed"]
963			"#,
964		)
965		.unwrap();
966		match cfg.apply_profile("p") {
967			Err(ConfigError::BadProfileRegex {
968				profile,
969				field,
970				pattern,
971				..
972			}) => {
973				assert_eq!(profile, "p");
974				assert_eq!(field, "enable");
975				assert_eq!(pattern, "(unclosed");
976			}
977			other => panic!("expected BadProfileRegex, got {other:?}"),
978		}
979	}
980
981	#[test]
982	fn fallback_where_n_id_matches() {
983		let mut cfg = parse(
984			r#"
985			[[ts.class.where]]
986			expr = "lines <= 99"
987
988			[[ts.class.where]]
989			expr = "lines <= 99"
990
991			[profiles.p]
992			disable = ["^ts\\.class\\.where_0$"]
993			"#,
994		)
995		.unwrap();
996		cfg.apply_profile("p").unwrap();
997		let r = cfg.rules_for(Lang::Ts, "class").unwrap();
998		assert_eq!(r.rules.len(), 1);
999	}
1000
1001	#[test]
1002	fn user_profile_overrides_preset_by_name() {
1003		let user = parse(
1004			r#"
1005			[profiles.bugfix]
1006			enable  = ["^user$"]
1007			disable = []
1008			"#,
1009		)
1010		.unwrap();
1011		let mut base = parse(
1012			r#"
1013			[profiles.bugfix]
1014			enable  = ["^base$"]
1015			disable = []
1016			"#,
1017		)
1018		.unwrap();
1019		merge_into(&mut base, user);
1020		let p = base.profiles.get("bugfix").unwrap();
1021		assert_eq!(p.enable, vec!["^user$".to_string()]);
1022	}
1023
1024	#[test]
1025	fn default_preset_ships_at_least_one_rule_per_language() {
1026		let cfg = load_default().unwrap();
1027		for lang in Lang::ALL {
1028			let lr = cfg.for_lang(*lang);
1029			assert!(
1030				!lr.kinds.is_empty(),
1031				"{} should ship at least one default rule",
1032				lang.tag()
1033			);
1034		}
1035	}
1036}