crawk 0.7.0

Dependency crawler for Rust. It crawls so you don't have to untangle
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
//! Architectural rule checking.
//!
//! Evaluates user-defined module dependency contracts (loaded from a
//! `crawk.toml` / `.crawk.toml` file) against the crate's
//! [`DependencyGraph`](crate::DependencyGraph). A violation is **data**
//! ([`Violation`]), never an `Err` — only operational problems (missing or
//! malformed config, unknown module in a rule) surface as
//! [`AnalysisError`](crate::AnalysisError).
//!
//! Four check categories are supported: `layers` (named layer groups, each an
//! independent total order over a subtree of the module hierarchy; groups may
//! overlap — a module that falls under several groups is checked in each),
//! `deny` (an explicit ban on edges matching a `from` -> `to` pattern pair),
//! `restrict` (an allow-list of dependency targets for a module scope), and
//! `deny-cycles` (a ban on dependency loops, with an [`AllowedCycle`] list that
//! grandfathers the loops a crate already has).
//!
//! `restrict` is the complement of `deny`: deny blacklists specific edges,
//! restrict whitelists everything an edge leaving its scope may point at. Both
//! only *add* violations — neither can wave an edge through the other's check —
//! so they compose without any precedence rules.
//!
//! The config is **required**: a *missing* file is an operational error (so a
//! typo fails CI rather than passing silently), whereas an *empty* `[check]`
//! table is valid and yields zero rules (always clean). `crawk check --init`
//! ([`scaffold_config`]) writes a starter file from the discovered modules.

mod eval;
mod load;

use std::collections::BTreeSet;
use std::fmt::{Display, Formatter, Result as FmtResult};
use std::path::PathBuf;

use crate::graph::DependencyGraphOptions;
use crate::module_path::is_in_subtree;

pub(crate) use eval::evaluate;
pub(crate) use load::{resolve_config_path, scaffold_config};

/// Options controlling a `check` run.
#[derive(Debug, Clone, Default)]
pub struct CheckOptions {
    /// Explicit rule-config path. `None` triggers `crawk.toml` / `.crawk.toml`
    /// discovery in the crate root.
    pub config: Option<PathBuf>,
    /// Include `#[cfg(test)]` modules and test targets in the graph.
    pub include_tests: bool,
    /// Annotate violations with the API symbols that create the offending edge.
    pub show_apis: bool,
}

impl CheckOptions {
    /// Map to [`DependencyGraphOptions`]. Depth is left at `None` so layer
    /// checking sees full-granularity module paths.
    pub(crate) const fn graph_opts(&self) -> DependencyGraphOptions {
        DependencyGraphOptions {
            include_tests: self.include_tests,
            depth: None,
            show_apis: self.show_apis,
        }
    }
}

/// What `crawk check --init` wrote.
///
/// This type is marked `#[non_exhaustive]`; new fields may be added without a
/// breaking change.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct InitOutcome {
    /// Path of the config file that was created.
    pub path: PathBuf,
    /// How many existing dependency loops were frozen as `[[check.allow-cycle]]`
    /// entries, so `deny-cycles` starts green.
    pub frozen_cycles: usize,
}

/// A module match pattern: an exact module, or a subtree (`foo::*`).
///
/// Matching is always on `::` segment boundaries — `format` never matches
/// `format_helper`.
#[derive(Debug, Clone)]
pub(crate) struct ModulePattern {
    base: String,
    subtree: bool,
}

impl ModulePattern {
    /// Parse a pattern. A trailing `::*` (or a lone `*`) marks a subtree match.
    pub(crate) fn parse(text: &str) -> Self {
        if text == "*" {
            return Self {
                base: String::new(),
                subtree: true,
            };
        }
        text.strip_suffix("::*").map_or_else(
            || Self {
                base: text.to_owned(),
                subtree: false,
            },
            |base| Self {
                base: base.to_owned(),
                subtree: true,
            },
        )
    }

    /// Parse a pattern that always covers the subtree. Used by `layers`, where a
    /// bare module name implicitly includes all of its descendants.
    pub(crate) fn parse_subtree(text: &str) -> Self {
        let mut pattern = Self::parse(text);
        pattern.subtree = true;
        pattern
    }

    /// Segment count of the base — higher means a more specific match.
    fn specificity(&self) -> usize {
        if self.base.is_empty() {
            0
        } else {
            self.base.split("::").count()
        }
    }

    /// Does `module` fall under this pattern?
    ///
    /// A subtree pattern delegates to [`is_in_subtree`] — including the empty
    /// base (`*`), which denotes the crate root and therefore matches every
    /// module. Both constructors guarantee an empty base implies `subtree`.
    pub(crate) fn matches(&self, module: &str) -> bool {
        if self.subtree {
            is_in_subtree(module, &self.base)
        } else {
            module == self.base
        }
    }

    /// Is there at least one known module this pattern could refer to?
    fn references_known(&self, modules: &BTreeSet<String>) -> bool {
        self.base.is_empty() || modules.iter().any(|m| self.matches(m))
    }

    /// The referenced module path, for diagnostics (the implicit subtree `::*`
    /// suffix is omitted — `["cli", "typo"]` reports `typo`, not `typo::*`).
    fn display(&self) -> String {
        if self.base.is_empty() {
            "*".to_owned()
        } else {
            self.base.clone()
        }
    }

    /// The pattern as the user wrote it, keeping the `::*` subtree suffix.
    ///
    /// Used by `deny`, where subtree matching is opt-in (explicit `::*`) and the
    /// suffix must be quoted verbatim in diagnostics — unlike `layers`, where
    /// subtree coverage is implicit and [`display`](Self::display) hides it.
    fn pattern_display(&self) -> String {
        if self.base.is_empty() {
            "*".to_owned()
        } else if self.subtree {
            format!("{}::*", self.base)
        } else {
            self.base.clone()
        }
    }
}

/// An explicit edge ban: no module matching `from` may depend on a module
/// matching `to`. Patterns match the subtree only with an explicit `::*`.
#[derive(Debug, Clone)]
pub(crate) struct DenyRule {
    pub(crate) from: ModulePattern,
    pub(crate) to: ModulePattern,
}

impl DenyRule {
    /// Human-readable rule citation for diagnostics and violation reports.
    pub(crate) fn display(&self) -> String {
        format!(
            "deny {} -> {}",
            self.from.pattern_display(),
            self.to.pattern_display()
        )
    }
}

/// An allow-list of dependency targets: a module matching `from` may depend
/// only on modules matching one of `to`. Patterns match the subtree only with
/// an explicit `::*`. Edges staying inside the rule's own scope — the target
/// also matches `from`, or one endpoint is the other's ancestor — are exempt.
#[derive(Debug, Clone)]
pub(crate) struct RestrictRule {
    pub(crate) from: ModulePattern,
    pub(crate) to: Vec<ModulePattern>,
}

impl RestrictRule {
    /// Human-readable rule citation for diagnostics and violation reports.
    ///
    /// Quotes the full allowance — `restrict format::* -> [lib, graph::*]` — so
    /// a CI log says what *was* allowed without a trip to the config file.
    pub(crate) fn display(&self) -> String {
        let targets = self
            .to
            .iter()
            .map(ModulePattern::pattern_display)
            .collect::<Vec<_>>()
            .join(", ");
        format!("restrict {} -> [{targets}]", self.from.pattern_display())
    }
}

/// Is this loop just a module tangled with its own descendants?
///
/// A parent that re-exports a submodule while the child reaches back with
/// `use super::…` forms an SCC in nearly every Rust crate — containment, not an
/// architectural tangle. Detected as "one module of the loop is an ancestor of
/// all the others". Evaluation skips such loops by default, and scaffolding
/// leaves them out of the generated allowlist for the same reason.
fn is_parent_child_cycle(modules: &BTreeSet<String>) -> bool {
    modules
        .iter()
        .any(|root| modules.iter().all(|module| is_in_subtree(module, root)))
}

/// A grandfathered dependency loop: one cycle that `deny-cycles` lets through
/// until it is untangled.
///
/// Entries name exact modules, never patterns — a `foo::*` would also wave
/// through loops that do not exist yet, and the point of the list is to ratchet.
#[derive(Debug, Clone)]
pub(crate) struct AllowedCycle {
    /// Modules of the known loop.
    pub(crate) modules: BTreeSet<String>,
    /// Why the loop is tolerated, quoted back when the entry goes stale.
    pub(crate) reason: Option<String>,
}

impl AllowedCycle {
    /// Human-readable citation for diagnostics: `allow-cycle [alpha, beta]`.
    pub(crate) fn display(&self) -> String {
        let list = self
            .modules
            .iter()
            .map(String::as_str)
            .collect::<Vec<_>>()
            .join(", ");
        format!("allow-cycle [{list}]")
    }

    /// Does this entry cover a detected cycle?
    ///
    /// Subset, not equality: a loop that shrank after a partial fix stays
    /// covered, while a module joining the loop escapes the entry and is
    /// reported.
    pub(crate) fn covers(&self, cycle: &BTreeSet<String>) -> bool {
        cycle.is_subset(&self.modules)
    }
}

/// A named layer group: an independent total order over a fragment of the
/// module tree. `order[0]` is the highest layer.
#[derive(Debug, Clone)]
pub(crate) struct LayerRule {
    pub(crate) name: String,
    pub(crate) order: Vec<ModulePattern>,
    /// Whether a dependency between two modules in the *same* layer of this
    /// group is a violation. Resolved at load time from the group's own
    /// `deny-same-layer`, falling back to the `[check]`-level default.
    pub(crate) deny_same_layer: bool,
}

/// Where a module sits: which layer group, and its index within that group.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct LayerPos {
    pub(crate) group: usize,
    pub(crate) index: usize,
}

/// A validated set of architectural rules, ready to evaluate.
///
/// Construct via [`RuleSet::load`]. Holds `layers` groups, `deny` rules,
/// `restrict` rules, and the cycle policy (`deny_cycles` plus its allowlist).
#[derive(Debug, Clone, Default)]
pub(crate) struct RuleSet {
    layers: Vec<LayerRule>,
    deny: Vec<DenyRule>,
    restrict: Vec<RestrictRule>,
    /// Loops that pass despite `deny_cycles`.
    allow_cycles: Vec<AllowedCycle>,
    strict_layers: bool,
    deny_cycles: bool,
    /// Report loops between a module and its own descendants too. Off by
    /// default: a parent re-exporting a submodule that reaches back with
    /// `use super::…` is containment, not an architectural tangle.
    deny_parent_child_cycles: bool,
}

impl RuleSet {
    /// All layer positions a module occupies — one per group that covers it.
    ///
    /// Groups are independent and may overlap, so a module can belong to
    /// several. Within a single group, the longest-prefix (most specific)
    /// matching pattern wins, with ties broken by the lowest index (highest
    /// layer); that yields at most one position per group.
    pub(crate) fn memberships(&self, module: &str) -> Vec<LayerPos> {
        let mut positions = Vec::new();
        for (group, layer) in self.layers.iter().enumerate() {
            // Pick the most specific matching pattern; on a specificity tie the
            // lowest index (highest layer) wins.
            let index = layer
                .order
                .iter()
                .enumerate()
                .filter(|(_, pattern)| pattern.matches(module))
                .map(|(index, pattern)| (pattern.specificity(), index))
                .max_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.cmp(&a.1)))
                .map(|(_, index)| index);
            if let Some(index) = index {
                positions.push(LayerPos { group, index });
            }
        }
        positions
    }
}

/// The kind of architectural rule that was violated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum ViolationKind {
    /// A `deny` rule matched the edge (explicitly banned dependency).
    ///
    /// Declared before `Layer` so `DENY` rows sort first in reports (the
    /// derived `Ord` on [`Violation`] compares `kind` first).
    Deny,
    /// A `restrict` rule matched the edge (target outside the allow-list).
    ///
    /// Declared between `Deny` and `Layer`: both bans on a concrete edge sort
    /// together, the more specific one (a single `from` -> `to` pair) first.
    Restrict,
    /// A `layers` ordering was broken (dependency points "upward").
    Layer,
    /// The edge takes part in a dependency cycle banned by `deny-cycles`.
    ///
    /// Declared last so `CYCLE` rows sort after `DENY`, `RESTRICT` and `LAYER`.
    Cycle,
}

impl Display for ViolationKind {
    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
        // `pad`, not `write_str`: report rendering aligns the kind column with
        // a width spec (`{:<8}`), which only `pad` honors.
        let name = match self {
            Self::Deny => "DENY",
            Self::Restrict => "RESTRICT",
            Self::Layer => "LAYER",
            Self::Cycle => "CYCLE",
        };
        f.pad(name)
    }
}

/// A single architectural rule violation.
///
/// This is **data**, not an error — a non-empty [`CheckReport`] maps to exit
/// code `1`, distinct from operational failures (`AnalysisError`, exit `2`).
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Violation {
    /// Which rule type was broken.
    pub kind: ViolationKind,
    /// The dependent module (edge source).
    pub source: String,
    /// The depended-on module (edge target).
    pub target: String,
    /// Human-readable description of the broken rule, for CI logs.
    pub rule: String,
    /// API symbols that create the offending edge (empty unless `show_apis`).
    pub apis: BTreeSet<String>,
}

/// The result of evaluating architectural rules against a dependency graph.
#[derive(Debug, Clone, Default)]
pub struct CheckReport {
    /// All violations found, sorted for deterministic output.
    pub violations: Vec<Violation>,
}

impl CheckReport {
    /// `true` when no violations were found.
    #[must_use]
    pub const fn is_clean(&self) -> bool {
        self.violations.is_empty()
    }

    /// Process exit code: `0` when clean, `1` when violations exist.
    #[must_use]
    pub fn exit_code(&self) -> i32 {
        i32::from(!self.violations.is_empty())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn matches_exact_pattern_only() {
        let pattern = ModulePattern::parse("format");
        assert!(pattern.matches("format"));
        assert!(!pattern.matches("format::use_cmd"));
    }

    #[test]
    fn matches_subtree_covers_base_and_descendants() {
        let pattern = ModulePattern::parse("format::*");
        assert!(pattern.matches("format"));
        assert!(pattern.matches("format::use_cmd"));
        assert!(pattern.matches("format::use_cmd::inner"));
    }

    #[test]
    fn matches_respects_segment_boundary() {
        for pattern in [
            ModulePattern::parse("format"),
            ModulePattern::parse("format::*"),
            ModulePattern::parse_subtree("format"),
        ] {
            assert!(!pattern.matches("format_helper"));
        }
    }

    #[test]
    fn parse_subtree_upgrades_a_bare_name() {
        let pattern = ModulePattern::parse_subtree("format");
        assert!(pattern.matches("format"));
        assert!(pattern.matches("format::use_cmd"));
    }

    fn allowed(modules: &[&str]) -> AllowedCycle {
        AllowedCycle {
            modules: modules.iter().map(ToString::to_string).collect(),
            reason: None,
        }
    }

    fn cycle_of(modules: &[&str]) -> BTreeSet<String> {
        modules.iter().map(ToString::to_string).collect()
    }

    #[test]
    fn allowed_cycle_covers_subset_and_exact_match() {
        let entry = allowed(&["alpha", "beta", "gamma"]);
        assert!(entry.covers(&cycle_of(&["alpha", "beta", "gamma"])));
        assert!(entry.covers(&cycle_of(&["alpha", "beta"])));
    }

    #[test]
    fn allowed_cycle_does_not_cover_a_grown_loop() {
        let entry = allowed(&["alpha", "beta"]);
        assert!(!entry.covers(&cycle_of(&["alpha", "beta", "gamma"])));
        assert!(!entry.covers(&cycle_of(&["delta", "epsilon"])));
    }

    #[test]
    fn allowed_cycle_display_lists_modules_alphabetically() {
        let entry = allowed(&["gamma", "alpha"]);
        assert_eq!(entry.display(), "allow-cycle [alpha, gamma]");
    }

    fn restrict(from: &str, to: &[&str]) -> RestrictRule {
        RestrictRule {
            from: ModulePattern::parse(from),
            to: to
                .iter()
                .map(|target| ModulePattern::parse(target))
                .collect(),
        }
    }

    #[test]
    fn restrict_display_cites_a_single_target() {
        assert_eq!(
            restrict("format::*", &["lib"]).display(),
            "restrict format::* -> [lib]"
        );
    }

    #[test]
    fn restrict_display_cites_the_full_allowance() {
        assert_eq!(
            restrict("format::*", &["lib", "graph::*"]).display(),
            "restrict format::* -> [lib, graph::*]"
        );
    }

    #[test]
    fn restrict_display_with_empty_allowance() {
        assert_eq!(restrict("web::*", &[]).display(), "restrict web::* -> []");
    }

    #[test]
    fn star_matches_every_module_including_the_root() {
        for text in ["*", "::*"] {
            let pattern = ModulePattern::parse(text);
            assert!(pattern.matches(""));
            assert!(pattern.matches("format"));
            assert!(pattern.matches("format::use_cmd"));
        }
    }
}