Skip to main content

ctx/
rules.rs

1//! Architecture rules for `ctx check`.
2//!
3//! Rules live in `.ctx/rules.toml` and are checked against the code
4//! intelligence index. The file declares named *layers* (glob patterns over
5//! indexed file paths) plus four kinds of rules:
6//!
7//! - `[[rules.forbidden]]` -- layer A must not depend on layer B
8//! - `[[rules.allowed_dependents]]` -- only the listed layers may depend on a layer
9//! - `[[rules.limit]]` -- metric thresholds (fan-in/fan-out/complexity/file symbols)
10//! - `[[rules.no_new_dependents]]` -- frozen paths that must not gain callers
11//!
12//! This module contains the serde model, glob compilation/validation, and
13//! pure evaluation functions over a pre-built list of [`FileDep`]s, so the
14//! rule engine is unit-testable without a database.
15
16use std::collections::{BTreeMap, HashSet};
17
18use globset::{Glob, GlobSet, GlobSetBuilder};
19use serde::{Deserialize, Serialize};
20
21use crate::db::{FileComplexity, SymbolMetrics};
22use crate::error::{CtxError, Result};
23use crate::json::SymbolRef;
24
25/// Default location of the rules file, relative to the project root.
26pub const DEFAULT_RULES_PATH: &str = ".ctx/rules.toml";
27
28/// The rules file format version this build understands.
29pub const SUPPORTED_VERSION: u32 = 1;
30
31// ============================================================================
32// Serde model
33// ============================================================================
34
35/// Parsed `.ctx/rules.toml`.
36#[derive(Debug, Default, Deserialize, Serialize)]
37#[serde(deny_unknown_fields)]
38pub struct RulesFile {
39    /// Format version (currently always `1`).
40    #[serde(default = "default_version")]
41    pub version: u32,
42
43    /// Layer name -> glob patterns over indexed file paths.
44    #[serde(default)]
45    pub layers: BTreeMap<String, Vec<String>>,
46
47    /// The rule groups.
48    #[serde(default)]
49    pub rules: RuleGroups,
50}
51
52fn default_version() -> u32 {
53    SUPPORTED_VERSION
54}
55
56/// The `[rules]` table.
57#[derive(Debug, Default, Deserialize, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct RuleGroups {
60    #[serde(default)]
61    pub forbidden: Vec<ForbiddenRule>,
62
63    #[serde(default)]
64    pub allowed_dependents: Vec<AllowedDependentsRule>,
65
66    #[serde(default)]
67    pub limit: Vec<LimitRule>,
68
69    #[serde(default)]
70    pub no_new_dependents: Vec<NoNewDependentsRule>,
71}
72
73/// `[[rules.forbidden]]`: dependencies from `from` to `to` are violations.
74#[derive(Debug, Deserialize, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct ForbiddenRule {
77    pub from: String,
78    pub to: String,
79    #[serde(default)]
80    pub reason: Option<String>,
81}
82
83/// `[[rules.allowed_dependents]]`: only layers in `only` may depend on `layer`.
84///
85/// Files that belong to no layer are exempt: per the layer model, unlayered
86/// files are unconstrained, which keeps incremental rollout sane (you can
87/// start with a couple of layers without instantly flagging the rest of the
88/// codebase).
89#[derive(Debug, Deserialize, Serialize)]
90#[serde(deny_unknown_fields)]
91pub struct AllowedDependentsRule {
92    pub layer: String,
93    pub only: Vec<String>,
94    #[serde(default)]
95    pub reason: Option<String>,
96}
97
98/// `[[rules.limit]]`: a metric threshold over symbols or files.
99#[derive(Debug, Deserialize, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct LimitRule {
102    pub metric: Metric,
103    #[serde(default)]
104    pub scope: Scope,
105    pub max: i64,
106    #[serde(default)]
107    pub exclude: Vec<String>,
108}
109
110/// `[[rules.no_new_dependents]]`: frozen paths that must not gain dependents.
111#[derive(Debug, Deserialize, Serialize)]
112#[serde(deny_unknown_fields)]
113pub struct NoNewDependentsRule {
114    pub paths: Vec<String>,
115    #[serde(default)]
116    pub reason: Option<String>,
117}
118
119/// Metric checked by a `[[rules.limit]]` rule.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
121#[serde(rename_all = "snake_case")]
122pub enum Metric {
123    FanIn,
124    FanOut,
125    Complexity,
126    FileSymbols,
127}
128
129impl Metric {
130    pub fn as_str(self) -> &'static str {
131        match self {
132            Metric::FanIn => "fan_in",
133            Metric::FanOut => "fan_out",
134            Metric::Complexity => "complexity",
135            Metric::FileSymbols => "file_symbols",
136        }
137    }
138}
139
140/// Scope of a `[[rules.limit]]` rule.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
142#[serde(rename_all = "snake_case")]
143pub enum Scope {
144    #[default]
145    Symbol,
146    File,
147}
148
149impl Scope {
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Scope::Symbol => "symbol",
153            Scope::File => "file",
154        }
155    }
156}
157
158// ============================================================================
159// Compilation and validation
160// ============================================================================
161
162/// A [`RulesFile`] with all glob patterns compiled.
163#[derive(Debug)]
164pub struct CompiledRules {
165    pub file: RulesFile,
166    /// Layer name + compiled globs, in declaration (BTreeMap) order.
167    layer_globs: Vec<(String, GlobSet)>,
168    /// Compiled `exclude` globs, parallel to `file.rules.limit`.
169    limit_excludes: Vec<GlobSet>,
170    /// Compiled `paths` globs, parallel to `file.rules.no_new_dependents`.
171    frozen_paths: Vec<GlobSet>,
172}
173
174impl CompiledRules {
175    /// Compile all glob patterns in `file`.
176    ///
177    /// Fails on unsupported versions and invalid glob patterns.
178    pub fn compile(file: RulesFile) -> Result<Self> {
179        if file.version != SUPPORTED_VERSION {
180            return Err(CtxError::Other(format!(
181                "unsupported rules version {} (this build supports version {})",
182                file.version, SUPPORTED_VERSION
183            )));
184        }
185
186        let mut layer_globs = Vec::new();
187        for (name, patterns) in &file.layers {
188            let set = build_globset(patterns, &format!("layer '{}'", name))?;
189            layer_globs.push((name.clone(), set));
190        }
191
192        let mut limit_excludes = Vec::new();
193        for (i, rule) in file.rules.limit.iter().enumerate() {
194            limit_excludes.push(build_globset(
195                &rule.exclude,
196                &format!("rules.limit[{}].exclude", i),
197            )?);
198        }
199
200        let mut frozen_paths = Vec::new();
201        for (i, rule) in file.rules.no_new_dependents.iter().enumerate() {
202            frozen_paths.push(build_globset(
203                &rule.paths,
204                &format!("rules.no_new_dependents[{}].paths", i),
205            )?);
206        }
207
208        Ok(CompiledRules {
209            file,
210            layer_globs,
211            limit_excludes,
212            frozen_paths,
213        })
214    }
215
216    /// The layer `path` belongs to, or `None` if it matches no layer.
217    ///
218    /// [`Self::validate`] guarantees indexed files match at most one layer.
219    pub fn layer_of(&self, path: &str) -> Option<&str> {
220        self.layer_globs
221            .iter()
222            .find(|(_, set)| set.is_match(path))
223            .map(|(name, _)| name.as_str())
224    }
225
226    /// All layers `path` belongs to (used for overlap detection).
227    pub fn layers_of(&self, path: &str) -> Vec<&str> {
228        self.layer_globs
229            .iter()
230            .filter(|(_, set)| set.is_match(path))
231            .map(|(name, _)| name.as_str())
232            .collect()
233    }
234
235    /// Validate the rules against the set of indexed files.
236    ///
237    /// Errors on:
238    /// - rules referencing undeclared layer names
239    /// - an indexed file matching two or more layers
240    /// - `metric = "file_symbols"` combined with `scope = "symbol"`
241    pub fn validate(&self, indexed_files: &[String]) -> Result<()> {
242        // Unknown layer references.
243        for rule in &self.file.rules.forbidden {
244            self.require_layer(&rule.from, "rules.forbidden.from")?;
245            self.require_layer(&rule.to, "rules.forbidden.to")?;
246        }
247        for rule in &self.file.rules.allowed_dependents {
248            self.require_layer(&rule.layer, "rules.allowed_dependents.layer")?;
249            for only in &rule.only {
250                self.require_layer(only, "rules.allowed_dependents.only")?;
251            }
252        }
253
254        // file_symbols is a per-file metric; symbol scope makes no sense.
255        for rule in &self.file.rules.limit {
256            if rule.metric == Metric::FileSymbols && rule.scope == Scope::Symbol {
257                return Err(CtxError::Other(
258                    "rules.limit: metric \"file_symbols\" requires scope = \"file\" \
259                     (it counts symbols per file and has no per-symbol value)"
260                        .to_string(),
261                ));
262            }
263        }
264
265        // Overlapping layers: every indexed file must match at most one layer.
266        for path in indexed_files {
267            let layers = self.layers_of(path);
268            if layers.len() >= 2 {
269                return Err(CtxError::Other(format!(
270                    "layer overlap: file '{}' matches layers [{}]; \
271                     each file may belong to at most one layer",
272                    path,
273                    layers.join(", ")
274                )));
275            }
276        }
277
278        Ok(())
279    }
280
281    fn require_layer(&self, name: &str, context: &str) -> Result<()> {
282        if self.file.layers.contains_key(name) {
283            Ok(())
284        } else {
285            let known: Vec<&str> = self.file.layers.keys().map(|k| k.as_str()).collect();
286            Err(CtxError::Other(format!(
287                "{}: unknown layer '{}' (declared layers: [{}])",
288                context,
289                name,
290                known.join(", ")
291            )))
292        }
293    }
294
295    /// Compiled `exclude` globset for `file.rules.limit[i]`.
296    pub fn limit_exclude(&self, i: usize) -> &GlobSet {
297        &self.limit_excludes[i]
298    }
299
300    /// Compiled `paths` globset for `file.rules.no_new_dependents[i]`.
301    pub fn frozen_path(&self, i: usize) -> &GlobSet {
302        &self.frozen_paths[i]
303    }
304
305    /// Count of indexed files per layer, in declaration order.
306    pub fn layer_file_counts(&self, indexed_files: &[String]) -> Vec<(String, usize)> {
307        self.layer_globs
308            .iter()
309            .map(|(name, set)| {
310                let n = indexed_files.iter().filter(|f| set.is_match(f)).count();
311                (name.clone(), n)
312            })
313            .collect()
314    }
315}
316
317fn build_globset(patterns: &[String], what: &str) -> Result<GlobSet> {
318    let mut builder = GlobSetBuilder::new();
319    for pattern in patterns {
320        let glob = Glob::new(pattern).map_err(|e| {
321            CtxError::Other(format!("invalid glob '{}' in {}: {}", pattern, what, e))
322        })?;
323        builder.add(glob);
324    }
325    builder
326        .build()
327        .map_err(|e| CtxError::Other(format!("failed to build globs for {}: {}", what, e)))
328}
329
330// ============================================================================
331// Dependency and violation model
332// ============================================================================
333
334/// One endpoint of a dependency: either a resolved symbol or a whole file
335/// (file-level endpoints come from import resolution).
336#[derive(Debug, Clone, Serialize)]
337#[serde(untagged)]
338pub enum Endpoint {
339    Symbol(SymbolRef),
340    File { file: String },
341}
342
343impl Endpoint {
344    /// The file this endpoint lives in.
345    pub fn file(&self) -> &str {
346        match self {
347            Endpoint::Symbol(s) => &s.file,
348            Endpoint::File { file } => file,
349        }
350    }
351}
352
353/// A cross-file dependency (call/implements/extends/uses edge or resolved import).
354#[derive(Debug, Clone)]
355pub struct FileDep {
356    pub from: Endpoint,
357    pub to: Endpoint,
358    /// Edge kind (`calls`, `implements`, `extends`, `uses`) or `import`.
359    pub kind: String,
360    /// Line in the `from` file where the dependency occurs (unknown for
361    /// imports read from module metadata).
362    pub line: Option<i64>,
363}
364
365/// Which rule group a violation belongs to.
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
367#[serde(rename_all = "snake_case")]
368pub enum RuleKind {
369    Forbidden,
370    AllowedDependents,
371    Limit,
372    NoNewDependents,
373}
374
375impl RuleKind {
376    pub fn as_str(self) -> &'static str {
377        match self {
378            RuleKind::Forbidden => "forbidden",
379            RuleKind::AllowedDependents => "allowed_dependents",
380            RuleKind::Limit => "limit",
381            RuleKind::NoNewDependents => "no_new_dependents",
382        }
383    }
384}
385
386/// One rule violation.
387///
388/// Dependency violations (`forbidden`, `allowed_dependents`,
389/// `no_new_dependents`) carry `from`/`to` endpoints; `limit` violations carry
390/// a `subject` endpoint plus the metric fields.
391#[derive(Debug, Clone, Serialize)]
392pub struct Violation {
393    pub rule: RuleKind,
394    /// Stable identifier of the specific rule instance, used for grouping
395    /// (e.g. `forbidden: domain -> infrastructure`).
396    pub rule_id: String,
397    pub reason: String,
398    /// One-line human description of the violating dependency or metric.
399    pub message: String,
400    /// Primary file (`from` side for dependency rules, subject file for limits).
401    pub file: String,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub line: Option<i64>,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub from: Option<Endpoint>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub to: Option<Endpoint>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub subject: Option<Endpoint>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub metric: Option<Metric>,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub scope: Option<Scope>,
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub value: Option<i64>,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub max: Option<i64>,
418}
419
420impl Violation {
421    fn dep(rule: RuleKind, rule_id: String, reason: String, dep: &FileDep) -> Violation {
422        let location = match dep.line {
423            Some(line) => format!("{}:{}", dep.from.file(), line),
424            None => dep.from.file().to_string(),
425        };
426        let target = match &dep.to {
427            Endpoint::Symbol(s) => format!("{} [{} {}]", s.file, dep.kind, s.name),
428            Endpoint::File { file } => format!("{} [{}]", file, dep.kind),
429        };
430        let message = format!("{} -> {}", location, target);
431        Violation {
432            rule,
433            rule_id,
434            reason,
435            message,
436            file: dep.from.file().to_string(),
437            line: dep.line,
438            from: Some(dep.from.clone()),
439            to: Some(dep.to.clone()),
440            subject: None,
441            metric: None,
442            scope: None,
443            value: None,
444            max: None,
445        }
446    }
447
448    /// Files referenced by this violation (for `--against` filtering).
449    fn endpoint_files(&self) -> Vec<&str> {
450        let mut files = vec![self.file.as_str()];
451        for endpoint in [&self.from, &self.to, &self.subject].into_iter().flatten() {
452            files.push(endpoint.file());
453        }
454        files
455    }
456}
457
458// ============================================================================
459// Evaluation
460// ============================================================================
461
462/// Evaluate all rules and return the violations.
463///
464/// `changed` is the `--against REF` change set. When present it acts as a
465/// filter (documented v1 approximation):
466/// - `no_new_dependents`: only inbound deps whose *source* file changed count
467///   (that is what "new dependent" means); without `--against` **all** inbound
468///   deps are reported so users can see the current state.
469/// - all other rules: a violation is kept when at least one endpoint's file
470///   is in the change set.
471pub fn evaluate(
472    compiled: &CompiledRules,
473    deps: &[FileDep],
474    symbol_metrics: &[SymbolMetrics],
475    file_complexity: &[FileComplexity],
476    changed: Option<&HashSet<String>>,
477) -> Vec<Violation> {
478    let mut violations = Vec::new();
479
480    violations.extend(eval_forbidden(compiled, deps));
481    violations.extend(eval_allowed_dependents(compiled, deps));
482    violations.extend(eval_limits(compiled, symbol_metrics, file_complexity));
483    violations.extend(eval_no_new_dependents(compiled, deps, changed));
484
485    if let Some(changed) = changed {
486        violations.retain(|v| {
487            // no_new_dependents already applied its own (source-side) filter.
488            v.rule == RuleKind::NoNewDependents
489                || v.endpoint_files().iter().any(|f| changed.contains(*f))
490        });
491    }
492
493    violations
494}
495
496/// C2: dependency edges from `from`-layer files into `to`-layer files.
497fn eval_forbidden(compiled: &CompiledRules, deps: &[FileDep]) -> Vec<Violation> {
498    let mut violations = Vec::new();
499    for rule in &compiled.file.rules.forbidden {
500        let rule_id = format!("forbidden: {} -> {}", rule.from, rule.to);
501        let reason = rule
502            .reason
503            .clone()
504            .unwrap_or_else(|| format!("layer '{}' must not depend on '{}'", rule.from, rule.to));
505        for dep in deps {
506            if compiled.layer_of(dep.from.file()) == Some(rule.from.as_str())
507                && compiled.layer_of(dep.to.file()) == Some(rule.to.as_str())
508            {
509                violations.push(Violation::dep(
510                    RuleKind::Forbidden,
511                    rule_id.clone(),
512                    reason.clone(),
513                    dep,
514                ));
515            }
516        }
517    }
518    violations
519}
520
521/// C3: inbound deps into `layer` from a file whose layer is not in `only`.
522///
523/// Files in no layer are exempt (unlayered files are unconstrained, matching
524/// the C1 layer model), and a layer may always depend on itself.
525fn eval_allowed_dependents(compiled: &CompiledRules, deps: &[FileDep]) -> Vec<Violation> {
526    let mut violations = Vec::new();
527    for rule in &compiled.file.rules.allowed_dependents {
528        let rule_id = format!(
529            "allowed_dependents: {} only [{}]",
530            rule.layer,
531            rule.only.join(", ")
532        );
533        let reason = rule.reason.clone().unwrap_or_else(|| {
534            format!(
535                "only [{}] may depend on layer '{}'",
536                rule.only.join(", "),
537                rule.layer
538            )
539        });
540        for dep in deps {
541            if compiled.layer_of(dep.to.file()) != Some(rule.layer.as_str()) {
542                continue;
543            }
544            let Some(from_layer) = compiled.layer_of(dep.from.file()) else {
545                continue; // unlayered files are unconstrained
546            };
547            if from_layer == rule.layer || rule.only.iter().any(|o| o == from_layer) {
548                continue;
549            }
550            violations.push(Violation::dep(
551                RuleKind::AllowedDependents,
552                rule_id.clone(),
553                format!("{} (found dependent in layer '{}')", reason, from_layer),
554                dep,
555            ));
556        }
557    }
558    violations
559}
560
561/// C4: metric thresholds over symbols or files.
562fn eval_limits(
563    compiled: &CompiledRules,
564    symbol_metrics: &[SymbolMetrics],
565    file_complexity: &[FileComplexity],
566) -> Vec<Violation> {
567    let mut violations = Vec::new();
568
569    for (i, rule) in compiled.file.rules.limit.iter().enumerate() {
570        let exclude = compiled.limit_exclude(i);
571        let rule_id = format!(
572            "limit: {} <= {} ({})",
573            rule.metric.as_str(),
574            rule.max,
575            rule.scope.as_str()
576        );
577
578        match rule.scope {
579            Scope::Symbol => {
580                // metric != file_symbols is guaranteed by validate().
581                for m in symbol_metrics {
582                    if exclude.is_match(&m.file_path) {
583                        continue;
584                    }
585                    let value = symbol_metric_value(rule.metric, m);
586                    if value > rule.max {
587                        violations.push(limit_violation(
588                            rule,
589                            rule_id.clone(),
590                            Endpoint::Symbol(symbol_ref_of(m)),
591                            format!("{}:{}", m.file_path, m.line_start),
592                            m.file_path.clone(),
593                            Some(m.line_start),
594                            Some(&m.name),
595                            value,
596                        ));
597                    }
598                }
599            }
600            Scope::File => {
601                // file_symbols comes straight from per-file symbol counts;
602                // other metrics are summed over the file's functions/methods.
603                let per_file: BTreeMap<String, i64> = if rule.metric == Metric::FileSymbols {
604                    file_complexity
605                        .iter()
606                        .map(|f| (f.file_path.clone(), f.symbol_count))
607                        .collect()
608                } else {
609                    let mut sums: BTreeMap<String, i64> = BTreeMap::new();
610                    for m in symbol_metrics {
611                        *sums.entry(m.file_path.clone()).or_insert(0) +=
612                            symbol_metric_value(rule.metric, m);
613                    }
614                    sums
615                };
616
617                for (file, value) in per_file {
618                    if exclude.is_match(&file) || value <= rule.max {
619                        continue;
620                    }
621                    violations.push(limit_violation(
622                        rule,
623                        rule_id.clone(),
624                        Endpoint::File { file: file.clone() },
625                        file.clone(),
626                        file,
627                        None,
628                        None,
629                        value,
630                    ));
631                }
632            }
633        }
634    }
635
636    violations
637}
638
639fn symbol_metric_value(metric: Metric, m: &SymbolMetrics) -> i64 {
640    match metric {
641        Metric::FanIn => m.fan_in,
642        Metric::FanOut => m.fan_out,
643        Metric::Complexity => m.complexity,
644        // Unreachable for symbol scope (rejected by validate()); a file-level
645        // sum of file_symbols never consults symbol metrics.
646        Metric::FileSymbols => 0,
647    }
648}
649
650#[allow(clippy::too_many_arguments)]
651fn limit_violation(
652    rule: &LimitRule,
653    rule_id: String,
654    subject: Endpoint,
655    location: String,
656    file: String,
657    line: Option<i64>,
658    name: Option<&str>,
659    value: i64,
660) -> Violation {
661    let what = match name {
662        Some(name) => format!("{} ({})", location, name),
663        None => location,
664    };
665    Violation {
666        rule: RuleKind::Limit,
667        rule_id,
668        reason: format!(
669            "{} {} exceeds max {}",
670            rule.metric.as_str(),
671            value,
672            rule.max
673        ),
674        message: format!(
675            "{}: {} {} exceeds max {}",
676            what,
677            rule.metric.as_str(),
678            value,
679            rule.max
680        ),
681        file,
682        line,
683        from: None,
684        to: None,
685        subject: Some(subject),
686        metric: Some(rule.metric),
687        scope: Some(rule.scope),
688        value: Some(value),
689        max: Some(rule.max),
690    }
691}
692
693fn symbol_ref_of(m: &SymbolMetrics) -> SymbolRef {
694    SymbolRef {
695        name: m.name.clone(),
696        qualified_name: m.qualified_name.clone(),
697        kind: m.kind.clone(),
698        file: m.file_path.clone(),
699        line_start: m.line_start,
700        line_end: m.line_end,
701    }
702}
703
704/// C5: inbound deps into frozen `paths` from outside those paths.
705///
706/// With `--against` (`changed` is `Some`), only deps whose source file
707/// changed are violations ("new dependents"). Without it, **all** inbound
708/// deps are reported so users can see the current state.
709fn eval_no_new_dependents(
710    compiled: &CompiledRules,
711    deps: &[FileDep],
712    changed: Option<&HashSet<String>>,
713) -> Vec<Violation> {
714    let mut violations = Vec::new();
715    for (i, rule) in compiled.file.rules.no_new_dependents.iter().enumerate() {
716        let paths = compiled.frozen_path(i);
717        let rule_id = format!("no_new_dependents: [{}]", rule.paths.join(", "));
718        let reason = rule
719            .reason
720            .clone()
721            .unwrap_or_else(|| format!("[{}] must not gain new dependents", rule.paths.join(", ")));
722        for dep in deps {
723            if !paths.is_match(dep.to.file()) || paths.is_match(dep.from.file()) {
724                continue;
725            }
726            if let Some(changed) = changed {
727                if !changed.contains(dep.from.file()) {
728                    continue;
729                }
730            }
731            violations.push(Violation::dep(
732                RuleKind::NoNewDependents,
733                rule_id.clone(),
734                reason.clone(),
735                dep,
736            ));
737        }
738    }
739    violations
740}
741
742// ============================================================================
743// Tests
744// ============================================================================
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    const FULL_RULES: &str = r#"
751version = 1
752
753[layers]
754domain         = ["src/domain/**"]
755application    = ["src/app/**"]
756infrastructure = ["src/infra/**", "src/db/**"]
757
758[[rules.forbidden]]
759from   = "domain"
760to     = "infrastructure"
761reason = "Domain layer must stay persistence-agnostic"
762
763[[rules.allowed_dependents]]
764layer = "infrastructure"
765only  = ["application"]
766
767[[rules.limit]]
768metric  = "fan_in"
769scope   = "symbol"
770max     = 25
771exclude = ["src/core/**"]
772
773[[rules.limit]]
774metric = "file_symbols"
775scope  = "file"
776max    = 50
777
778[[rules.no_new_dependents]]
779paths  = ["src/legacy/**"]
780reason = "Legacy module is frozen; do not add new callers"
781"#;
782
783    fn compile(toml_src: &str) -> CompiledRules {
784        let file: RulesFile = toml::from_str(toml_src).unwrap();
785        CompiledRules::compile(file).unwrap()
786    }
787
788    fn file_dep(from: &str, to: &str) -> FileDep {
789        FileDep {
790            from: Endpoint::File {
791                file: from.to_string(),
792            },
793            to: Endpoint::File {
794                file: to.to_string(),
795            },
796            kind: "import".to_string(),
797            line: None,
798        }
799    }
800
801    fn symbol_dep(from_file: &str, from_name: &str, to_file: &str, to_name: &str) -> FileDep {
802        let sym = |file: &str, name: &str| {
803            Endpoint::Symbol(SymbolRef {
804                name: name.to_string(),
805                qualified_name: None,
806                kind: "function".to_string(),
807                file: file.to_string(),
808                line_start: 1,
809                line_end: 2,
810            })
811        };
812        FileDep {
813            from: sym(from_file, from_name),
814            to: sym(to_file, to_name),
815            kind: "calls".to_string(),
816            line: Some(1),
817        }
818    }
819
820    fn metrics(
821        name: &str,
822        file: &str,
823        fan_in: i64,
824        fan_out: i64,
825        complexity: i64,
826    ) -> SymbolMetrics {
827        SymbolMetrics {
828            id: format!("{}::{}", file, name),
829            name: name.to_string(),
830            qualified_name: None,
831            kind: "function".to_string(),
832            file_path: file.to_string(),
833            line_start: 10,
834            line_end: 20,
835            fan_in,
836            fan_out,
837            complexity,
838        }
839    }
840
841    // ---------- parsing ----------
842
843    #[test]
844    fn test_parse_full_rules_file() {
845        let file: RulesFile = toml::from_str(FULL_RULES).unwrap();
846        assert_eq!(file.version, 1);
847        assert_eq!(file.layers.len(), 3);
848        assert_eq!(file.layers["infrastructure"].len(), 2);
849        assert_eq!(file.rules.forbidden.len(), 1);
850        assert_eq!(file.rules.forbidden[0].from, "domain");
851        assert_eq!(file.rules.allowed_dependents.len(), 1);
852        assert_eq!(file.rules.limit.len(), 2);
853        assert_eq!(file.rules.limit[0].metric, Metric::FanIn);
854        assert_eq!(file.rules.limit[0].scope, Scope::Symbol);
855        assert_eq!(file.rules.limit[0].max, 25);
856        assert_eq!(file.rules.no_new_dependents.len(), 1);
857    }
858
859    #[test]
860    fn test_parse_defaults() {
861        // Empty file: everything defaults.
862        let file: RulesFile = toml::from_str("").unwrap();
863        assert_eq!(file.version, 1);
864        assert!(file.layers.is_empty());
865        assert!(file.rules.forbidden.is_empty());
866
867        // Scope defaults to symbol; exclude defaults to empty.
868        let file: RulesFile = toml::from_str(
869            r#"
870            [[rules.limit]]
871            metric = "fan_out"
872            max = 10
873            "#,
874        )
875        .unwrap();
876        assert_eq!(file.rules.limit[0].scope, Scope::Symbol);
877        assert!(file.rules.limit[0].exclude.is_empty());
878    }
879
880    #[test]
881    fn test_parse_rejects_unknown_fields_and_bad_values() {
882        // Typo in a rule field.
883        let err = toml::from_str::<RulesFile>(
884            r#"
885            [[rules.forbidden]]
886            form = "domain"
887            to = "infrastructure"
888            "#,
889        )
890        .unwrap_err();
891        assert!(err.to_string().contains("form"), "err: {}", err);
892
893        // Unknown rule group.
894        assert!(toml::from_str::<RulesFile>("[[rules.forbid]]\nx = 1").is_err());
895
896        // Invalid metric value.
897        let err = toml::from_str::<RulesFile>(
898            r#"
899            [[rules.limit]]
900            metric = "fan_inn"
901            max = 5
902            "#,
903        )
904        .unwrap_err();
905        assert!(err.to_string().contains("fan_inn"), "err: {}", err);
906
907        // Invalid TOML syntax.
908        assert!(toml::from_str::<RulesFile>("[layers\ndomain = 1").is_err());
909    }
910
911    #[test]
912    fn test_compile_rejects_unsupported_version() {
913        let file: RulesFile = toml::from_str("version = 2").unwrap();
914        let err = CompiledRules::compile(file).unwrap_err();
915        assert!(err.to_string().contains("version 2"), "err: {}", err);
916    }
917
918    #[test]
919    fn test_compile_rejects_invalid_glob() {
920        let file: RulesFile = toml::from_str(
921            r#"
922            [layers]
923            domain = ["src/[oops"]
924            "#,
925        )
926        .unwrap();
927        let err = CompiledRules::compile(file).unwrap_err();
928        assert!(err.to_string().contains("src/[oops"), "err: {}", err);
929    }
930
931    // ---------- validation ----------
932
933    #[test]
934    fn test_validate_ok() {
935        let compiled = compile(FULL_RULES);
936        let files = vec![
937            "src/domain/order.ts".to_string(),
938            "src/app/checkout.ts".to_string(),
939            "src/infra/db.ts".to_string(),
940            "src/unlayered.ts".to_string(),
941        ];
942        compiled.validate(&files).unwrap();
943    }
944
945    #[test]
946    fn test_validate_unknown_layer() {
947        let compiled = compile(
948            r#"
949            [layers]
950            domain = ["src/domain/**"]
951
952            [[rules.forbidden]]
953            from = "domain"
954            to = "infrastruture"
955            "#,
956        );
957        let err = compiled.validate(&[]).unwrap_err();
958        let msg = err.to_string();
959        assert!(
960            msg.contains("unknown layer 'infrastruture'"),
961            "err: {}",
962            msg
963        );
964        assert!(msg.contains("domain"), "err: {}", msg);
965    }
966
967    #[test]
968    fn test_validate_layer_overlap_names_file_and_layers() {
969        let compiled = compile(
970            r#"
971            [layers]
972            domain = ["src/domain/**"]
973            everything = ["src/**"]
974            "#,
975        );
976        let files = vec!["src/domain/order.ts".to_string()];
977        let err = compiled.validate(&files).unwrap_err();
978        let msg = err.to_string();
979        assert!(msg.contains("src/domain/order.ts"), "err: {}", msg);
980        assert!(msg.contains("domain"), "err: {}", msg);
981        assert!(msg.contains("everything"), "err: {}", msg);
982    }
983
984    #[test]
985    fn test_validate_file_symbols_symbol_scope_is_error() {
986        let compiled = compile(
987            r#"
988            [[rules.limit]]
989            metric = "file_symbols"
990            scope = "symbol"
991            max = 50
992            "#,
993        );
994        let err = compiled.validate(&[]).unwrap_err();
995        assert!(err.to_string().contains("file_symbols"), "err: {}", err);
996    }
997
998    #[test]
999    fn test_layer_of() {
1000        let compiled = compile(FULL_RULES);
1001        assert_eq!(compiled.layer_of("src/domain/order.ts"), Some("domain"));
1002        assert_eq!(compiled.layer_of("src/db/pool.ts"), Some("infrastructure"));
1003        assert_eq!(compiled.layer_of("src/other/x.ts"), None);
1004    }
1005
1006    // ---------- evaluation ----------
1007
1008    #[test]
1009    fn test_eval_forbidden() {
1010        let compiled = compile(FULL_RULES);
1011        let deps = vec![
1012            file_dep("src/domain/order.ts", "src/infra/db.ts"), // violation
1013            file_dep("src/app/checkout.ts", "src/infra/db.ts"), // fine
1014            file_dep("src/domain/order.ts", "src/domain/item.ts"), // fine
1015            symbol_dep("src/domain/pay.ts", "pay", "src/db/pool.ts", "query"), // violation
1016        ];
1017        let violations = eval_forbidden(&compiled, &deps);
1018        assert_eq!(violations.len(), 2);
1019        assert_eq!(violations[0].rule, RuleKind::Forbidden);
1020        assert_eq!(violations[0].file, "src/domain/order.ts");
1021        assert_eq!(
1022            violations[0].reason,
1023            "Domain layer must stay persistence-agnostic"
1024        );
1025        assert_eq!(violations[1].file, "src/domain/pay.ts");
1026        assert_eq!(violations[1].line, Some(1));
1027    }
1028
1029    #[test]
1030    fn test_eval_allowed_dependents_exempts_unlayered_and_self() {
1031        let compiled = compile(FULL_RULES);
1032        let deps = vec![
1033            file_dep("src/app/checkout.ts", "src/infra/db.ts"), // allowed
1034            file_dep("src/domain/order.ts", "src/infra/db.ts"), // violation
1035            file_dep("src/scripts/tool.ts", "src/infra/db.ts"), // unlayered -> exempt
1036            file_dep("src/infra/cache.ts", "src/infra/db.ts"),  // same layer -> exempt
1037        ];
1038        let violations = eval_allowed_dependents(&compiled, &deps);
1039        assert_eq!(violations.len(), 1);
1040        assert_eq!(violations[0].rule, RuleKind::AllowedDependents);
1041        assert_eq!(violations[0].file, "src/domain/order.ts");
1042        assert!(
1043            violations[0].reason.contains("domain"),
1044            "{}",
1045            violations[0].reason
1046        );
1047    }
1048
1049    #[test]
1050    fn test_eval_limit_symbol_scope_with_exclude() {
1051        let compiled = compile(FULL_RULES); // fan_in <= 25 (symbol), exclude src/core/**
1052        let symbol_metrics = vec![
1053            metrics("hot", "src/app/hub.ts", 30, 0, 30), // violation
1054            metrics("ok", "src/app/x.ts", 25, 0, 25),    // at the limit: fine
1055            metrics("core", "src/core/bus.ts", 99, 0, 99), // excluded
1056        ];
1057        let violations = eval_limits(&compiled, &symbol_metrics, &[]);
1058        assert_eq!(violations.len(), 1);
1059        let v = &violations[0];
1060        assert_eq!(v.rule, RuleKind::Limit);
1061        assert_eq!(v.metric, Some(Metric::FanIn));
1062        assert_eq!(v.value, Some(30));
1063        assert_eq!(v.max, Some(25));
1064        assert_eq!(v.file, "src/app/hub.ts");
1065        match v.subject.as_ref().unwrap() {
1066            Endpoint::Symbol(s) => assert_eq!(s.name, "hot"),
1067            other => panic!("expected symbol subject, got {:?}", other),
1068        }
1069    }
1070
1071    #[test]
1072    fn test_eval_limit_file_scope_sums_and_counts() {
1073        let compiled = compile(
1074            r#"
1075            [[rules.limit]]
1076            metric = "fan_out"
1077            scope = "file"
1078            max = 10
1079
1080            [[rules.limit]]
1081            metric = "file_symbols"
1082            scope = "file"
1083            max = 2
1084            "#,
1085        );
1086        let symbol_metrics = vec![
1087            metrics("a", "src/big.ts", 0, 7, 14),
1088            metrics("b", "src/big.ts", 0, 6, 12), // sum fan_out = 13 > 10
1089            metrics("c", "src/small.ts", 0, 3, 6),
1090        ];
1091        let file_complexity = vec![
1092            FileComplexity {
1093                file_path: "src/big.ts".to_string(),
1094                complexity: 26,
1095                fan_out: 13,
1096                symbol_count: 3, // > 2 -> violation
1097            },
1098            FileComplexity {
1099                file_path: "src/small.ts".to_string(),
1100                complexity: 6,
1101                fan_out: 3,
1102                symbol_count: 1,
1103            },
1104        ];
1105        let violations = eval_limits(&compiled, &symbol_metrics, &file_complexity);
1106        assert_eq!(violations.len(), 2);
1107        assert_eq!(violations[0].metric, Some(Metric::FanOut));
1108        assert_eq!(violations[0].value, Some(13));
1109        assert_eq!(violations[0].file, "src/big.ts");
1110        assert_eq!(violations[1].metric, Some(Metric::FileSymbols));
1111        assert_eq!(violations[1].value, Some(3));
1112    }
1113
1114    #[test]
1115    fn test_eval_no_new_dependents_without_against_reports_all_inbound() {
1116        let compiled = compile(FULL_RULES);
1117        let deps = vec![
1118            file_dep("src/app/checkout.ts", "src/legacy/billing.ts"), // inbound
1119            file_dep("src/legacy/a.ts", "src/legacy/billing.ts"),     // internal: fine
1120            file_dep("src/app/checkout.ts", "src/app/cart.ts"),       // unrelated
1121        ];
1122        let violations = eval_no_new_dependents(&compiled, &deps, None);
1123        assert_eq!(violations.len(), 1);
1124        assert_eq!(violations[0].rule, RuleKind::NoNewDependents);
1125        assert_eq!(violations[0].file, "src/app/checkout.ts");
1126        assert_eq!(
1127            violations[0].reason,
1128            "Legacy module is frozen; do not add new callers"
1129        );
1130    }
1131
1132    #[test]
1133    fn test_eval_no_new_dependents_with_against_requires_changed_source() {
1134        let compiled = compile(FULL_RULES);
1135        let deps = vec![
1136            file_dep("src/app/old.ts", "src/legacy/billing.ts"), // pre-existing
1137            file_dep("src/app/new.ts", "src/legacy/billing.ts"), // new dependent
1138        ];
1139        let changed: HashSet<String> = ["src/app/new.ts".to_string()].into();
1140        let violations = eval_no_new_dependents(&compiled, &deps, Some(&changed));
1141        assert_eq!(violations.len(), 1);
1142        assert_eq!(violations[0].file, "src/app/new.ts");
1143    }
1144
1145    #[test]
1146    fn test_evaluate_against_filters_by_endpoint() {
1147        let compiled = compile(FULL_RULES);
1148        let deps = vec![
1149            file_dep("src/domain/old.ts", "src/infra/db.ts"), // untouched
1150            file_dep("src/domain/new.ts", "src/infra/db.ts"), // changed source
1151        ];
1152        let symbol_metrics = vec![
1153            metrics("hot", "src/app/hub.ts", 30, 0, 30), // untouched
1154            metrics("warm", "src/app/new2.ts", 40, 0, 40), // changed file
1155        ];
1156
1157        // Without --against: everything is reported.
1158        let all = evaluate(&compiled, &deps, &symbol_metrics, &[], None);
1159        // 2 forbidden + 2 allowed_dependents + 2 limit = 6
1160        assert_eq!(all.len(), 6);
1161
1162        let changed: HashSet<String> = [
1163            "src/domain/new.ts".to_string(),
1164            "src/app/new2.ts".to_string(),
1165        ]
1166        .into();
1167        let filtered = evaluate(&compiled, &deps, &symbol_metrics, &[], Some(&changed));
1168        assert_eq!(filtered.len(), 3);
1169        assert!(filtered
1170            .iter()
1171            .all(|v| v.endpoint_files().iter().any(|f| changed.contains(*f))));
1172    }
1173
1174    #[test]
1175    fn test_violation_json_shape() {
1176        let compiled = compile(FULL_RULES);
1177        let deps = vec![symbol_dep(
1178            "src/domain/pay.ts",
1179            "pay",
1180            "src/infra/db.ts",
1181            "query",
1182        )];
1183        let violations = eval_forbidden(&compiled, &deps);
1184        let value = serde_json::to_value(&violations[0]).unwrap();
1185        assert_eq!(value["rule"], "forbidden");
1186        assert_eq!(
1187            value["reason"],
1188            "Domain layer must stay persistence-agnostic"
1189        );
1190        // Symbol endpoints serialize as full SymbolRefs.
1191        assert_eq!(value["from"]["name"], "pay");
1192        assert_eq!(value["from"]["file"], "src/domain/pay.ts");
1193        assert_eq!(value["to"]["kind"], "function");
1194        // Absent fields are omitted, not null.
1195        assert!(value.get("subject").is_none());
1196        assert!(value.get("metric").is_none());
1197
1198        // File endpoints serialize as {"file": ...} objects.
1199        let file_violations = eval_forbidden(
1200            &compiled,
1201            &[file_dep("src/domain/order.ts", "src/infra/db.ts")],
1202        );
1203        let value = serde_json::to_value(&file_violations[0]).unwrap();
1204        assert_eq!(
1205            value["from"],
1206            serde_json::json!({"file": "src/domain/order.ts"})
1207        );
1208    }
1209}