Skip to main content

fallow_engine/
guard.rs

1//! Typed guard report assembly for pre-edit architecture guidance.
2
3use std::fmt;
4use std::path::{Component, Path};
5
6use fallow_config::{ResolvedBoundaryConfig, ResolvedConfig, RulePackRule, RulePackRuleKind};
7use fallow_types::guard::{
8    GuardBoundary, GuardFileReport, GuardPolicyRule, GuardReport, GuardSeverities, GuardZone,
9};
10use rustc_hash::FxHashSet;
11
12/// Error returned when a guard target cannot be represented safely.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum GuardError {
15    /// The requested target is outside the resolved project root.
16    OutsideRoot(String),
17}
18
19impl fmt::Display for GuardError {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            Self::OutsideRoot(path) => write!(f, "guard target is outside project root: {path}"),
23        }
24    }
25}
26
27impl std::error::Error for GuardError {}
28
29/// Build a typed guard report for one or more target files.
30///
31/// Paths may be project-relative or absolute under `config.root`. Returned
32/// paths are project-root-relative and use forward slashes.
33///
34/// # Errors
35///
36/// Returns [`GuardError::OutsideRoot`] for absolute paths outside the project
37/// root or relative paths containing parent-directory traversal.
38pub fn build_guard_report(
39    config: &ResolvedConfig,
40    files: &[String],
41) -> Result<GuardReport, GuardError> {
42    let mut reports = Vec::with_capacity(files.len());
43    for file in files {
44        reports.push(build_file_report(config, file)?);
45    }
46    Ok(GuardReport { files: reports })
47}
48
49fn build_file_report(config: &ResolvedConfig, input: &str) -> Result<GuardFileReport, GuardError> {
50    let rel_path = normalize_target_path(config, input)?;
51    let full_path = config.root.join(&rel_path);
52    let rules = config.resolve_rules_for_path(&full_path);
53    let zone_name = config.boundaries.classify_zone(&rel_path);
54    let zone = zone_name.and_then(|name| guard_zone(&config.boundaries, name));
55    let notes = guard_notes(config, zone_name);
56
57    Ok(GuardFileReport {
58        exists: full_path.exists(),
59        boundary: guard_boundary(&config.boundaries, &rel_path, zone_name),
60        policy_rules: guard_policy_rules(config, &rel_path, rules.policy_violation),
61        severities: GuardSeverities {
62            boundary_violation: rules.boundary_violation.to_string(),
63            policy_violation: rules.policy_violation.to_string(),
64        },
65        path: rel_path,
66        zone,
67        notes,
68    })
69}
70
71fn normalize_target_path(config: &ResolvedConfig, input: &str) -> Result<String, GuardError> {
72    let normalized = input.replace('\\', "/");
73    let path = Path::new(&normalized);
74    if looks_windows_absolute(&normalized) && !path.is_absolute() {
75        return Err(GuardError::OutsideRoot(input.to_string()));
76    }
77    let relative = if path.is_absolute() {
78        path.strip_prefix(&config.root)
79            .map_err(|_| GuardError::OutsideRoot(input.to_string()))?
80    } else {
81        path
82    };
83    normalize_relative_path(relative, input)
84}
85
86fn looks_windows_absolute(path: &str) -> bool {
87    let bytes = path.as_bytes();
88    bytes.len() >= 3 && bytes[1] == b':' && bytes[2] == b'/'
89}
90
91fn normalize_relative_path(path: &Path, original: &str) -> Result<String, GuardError> {
92    let mut parts = Vec::new();
93    for component in path.components() {
94        match component {
95            Component::CurDir => {}
96            Component::Normal(part) => parts.push(part.to_string_lossy().replace('\\', "/")),
97            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
98                return Err(GuardError::OutsideRoot(original.to_string()));
99            }
100        }
101    }
102    Ok(parts.join("/"))
103}
104
105fn guard_zone(boundaries: &ResolvedBoundaryConfig, name: &str) -> Option<GuardZone> {
106    boundaries
107        .zones
108        .iter()
109        .find(|zone| zone.name == name)
110        .map(|zone| GuardZone {
111            name: zone.name.clone(),
112            patterns: zone.patterns.clone(),
113        })
114}
115
116fn guard_boundary(
117    boundaries: &ResolvedBoundaryConfig,
118    rel_path: &str,
119    zone_name: Option<&str>,
120) -> GuardBoundary {
121    let configured = boundaries_configured(boundaries);
122    let coverage_required = zone_name.is_none()
123        && boundaries.coverage.require_all_files
124        && !boundaries.allows_unmatched(rel_path);
125
126    let Some(zone_name) = zone_name else {
127        return GuardBoundary {
128            configured,
129            unrestricted: true,
130            allowed_zones: Vec::new(),
131            allowed_type_only_zones: Vec::new(),
132            forbidden_calls: Vec::new(),
133            coverage_required,
134        };
135    };
136
137    let forbidden_calls = boundaries
138        .calls_forbidden_by_zone
139        .get(zone_name)
140        .cloned()
141        .unwrap_or_default();
142    let Some(rule) = boundaries
143        .rules
144        .iter()
145        .find(|rule| rule.from_zone == zone_name)
146    else {
147        return GuardBoundary {
148            configured,
149            unrestricted: true,
150            allowed_zones: Vec::new(),
151            allowed_type_only_zones: Vec::new(),
152            forbidden_calls,
153            coverage_required,
154        };
155    };
156
157    let mut allowed_zones = vec![zone_name.to_string()];
158    allowed_zones.extend(rule.allowed_zones.iter().cloned());
159    allowed_zones.sort();
160    allowed_zones.dedup();
161
162    GuardBoundary {
163        configured,
164        unrestricted: false,
165        allowed_zones,
166        allowed_type_only_zones: rule.allow_type_only_zones.clone(),
167        forbidden_calls,
168        coverage_required,
169    }
170}
171
172fn guard_notes(config: &ResolvedConfig, zone_name: Option<&str>) -> Vec<String> {
173    let mut notes = Vec::new();
174    if boundaries_configured(&config.boundaries) && zone_name.is_none() {
175        notes.push("Files outside every zone are unrestricted for boundary checks.".to_string());
176    }
177    if !boundaries_configured(&config.boundaries) && config.rule_packs.is_empty() {
178        notes.push("No boundary zones or rule packs are configured.".to_string());
179    }
180    if zone_name.is_some() {
181        notes.push("Same-zone imports are always allowed.".to_string());
182    }
183    notes
184}
185
186fn boundaries_configured(boundaries: &ResolvedBoundaryConfig) -> bool {
187    !boundaries.zones.is_empty() || !boundaries.logical_groups.is_empty()
188}
189
190fn guard_policy_rules(
191    config: &ResolvedConfig,
192    rel_path: &str,
193    master_severity: fallow_config::Severity,
194) -> Vec<GuardPolicyRule> {
195    if master_severity == fallow_config::Severity::Off {
196        return Vec::new();
197    }
198
199    rules_applying_to_path(config, rel_path)
200        .into_iter()
201        .filter_map(|(pack, rule)| guard_policy_rule(pack, rule, master_severity))
202        .collect()
203}
204
205fn rules_applying_to_path<'a>(
206    config: &'a ResolvedConfig,
207    rel_path: &str,
208) -> Vec<(&'a str, &'a RulePackRule)> {
209    let zone = config.boundaries.classify_zone(rel_path);
210    config
211        .rule_packs
212        .iter()
213        .flat_map(|pack| {
214            pack.rules
215                .iter()
216                .filter(move |rule| {
217                    raw_rule_scope_applies(rule, &config.boundaries, rel_path, zone)
218                })
219                .map(|rule| (pack.name.as_str(), rule))
220        })
221        .collect()
222}
223
224fn raw_rule_scope_applies(
225    rule: &RulePackRule,
226    boundaries: &ResolvedBoundaryConfig,
227    relative: &str,
228    zone: Option<&str>,
229) -> bool {
230    let files = compile_scope_globs(&rule.files);
231    let exclude = compile_scope_globs(&rule.exclude);
232    let zones = rule.zones.iter().cloned().collect();
233    let zone = zone.or_else(|| boundaries.classify_zone(relative));
234    compiled_scope_applies(&files, &exclude, &zones, relative, zone)
235}
236
237fn compile_scope_globs(patterns: &[String]) -> Vec<globset::GlobMatcher> {
238    patterns
239        .iter()
240        .filter_map(|pattern| globset::Glob::new(pattern).ok())
241        .map(|glob| glob.compile_matcher())
242        .collect()
243}
244
245fn compiled_scope_applies(
246    files: &[globset::GlobMatcher],
247    exclude: &[globset::GlobMatcher],
248    zones: &FxHashSet<String>,
249    relative: &str,
250    zone: Option<&str>,
251) -> bool {
252    (files.is_empty() || files.iter().any(|matcher| matcher.is_match(relative)))
253        && !exclude.iter().any(|matcher| matcher.is_match(relative))
254        && (zones.is_empty() || zone.is_some_and(|zone| zones.contains(zone)))
255}
256
257fn guard_policy_rule(
258    pack: &str,
259    rule: &RulePackRule,
260    master_severity: fallow_config::Severity,
261) -> Option<GuardPolicyRule> {
262    let severity = rule.severity.unwrap_or(master_severity);
263    if severity == fallow_config::Severity::Off {
264        return None;
265    }
266
267    Some(GuardPolicyRule {
268        pack: pack.to_string(),
269        rule_id: rule.id.clone(),
270        kind: rule_kind(rule.kind).to_string(),
271        patterns: rule_patterns(rule),
272        message: rule.message.clone(),
273        severity: severity.to_string(),
274        suppress_token: format!("policy-violation:{pack}/{}", rule.id),
275    })
276}
277
278const fn rule_kind(kind: RulePackRuleKind) -> &'static str {
279    match kind {
280        RulePackRuleKind::BannedCall => "banned-call",
281        RulePackRuleKind::BannedImport => "banned-import",
282        RulePackRuleKind::BannedEffect => "banned-effect",
283        RulePackRuleKind::BannedExport => "banned-export",
284    }
285}
286
287fn rule_patterns(rule: &RulePackRule) -> Vec<String> {
288    match rule.kind {
289        RulePackRuleKind::BannedCall => rule.callees.clone(),
290        RulePackRuleKind::BannedImport => rule.specifiers.clone(),
291        RulePackRuleKind::BannedEffect => rule
292            .effects
293            .iter()
294            .map(|effect| effect.as_str().to_string())
295            .collect(),
296        RulePackRuleKind::BannedExport => rule.exports.clone(),
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use fallow_config::{
304        BoundaryCallsConfig, BoundaryConfig, BoundaryCoverageConfig, BoundaryRule, BoundaryZone,
305        EffectKind, FallowConfig, ForbiddenCallRule, ForbiddenCallee, OutputFormat, RulePackDef,
306        RulePackRule, RulePackRuleKind, RulesConfig, Severity,
307    };
308    use std::fs;
309
310    fn rule(id: &str, kind: RulePackRuleKind) -> RulePackRule {
311        RulePackRule {
312            id: id.to_string(),
313            kind,
314            callees: Vec::new(),
315            specifiers: Vec::new(),
316            effects: Vec::new(),
317            exports: Vec::new(),
318            ignore_type_only: false,
319            files: Vec::new(),
320            exclude: Vec::new(),
321            zones: Vec::new(),
322            message: None,
323            severity: None,
324        }
325    }
326
327    fn pack(rules: Vec<RulePackRule>) -> RulePackDef {
328        RulePackDef {
329            schema: None,
330            version: 1,
331            name: "team-policy".to_string(),
332            description: None,
333            rules,
334        }
335    }
336
337    fn resolve(root: &Path, configure: impl FnOnce(&mut FallowConfig)) -> ResolvedConfig {
338        let mut config = FallowConfig {
339            rules: RulesConfig {
340                policy_violation: Severity::Warn,
341                ..RulesConfig::default()
342            },
343            ..FallowConfig::default()
344        };
345        configure(&mut config);
346        config.resolve(root.to_path_buf(), OutputFormat::Json, 1, true, true, None)
347    }
348
349    #[test]
350    fn zoned_file_reports_allow_rule_and_forbidden_call() {
351        let temp = tempfile::tempdir().expect("tempdir");
352        fs::create_dir_all(temp.path().join("src/domain")).expect("create dir");
353        fs::write(temp.path().join("src/domain/user.ts"), "").expect("write file");
354        let config = resolve(temp.path(), |config| {
355            config.boundaries = BoundaryConfig {
356                zones: vec![
357                    BoundaryZone {
358                        name: "domain".to_string(),
359                        patterns: vec!["src/domain/**".to_string()],
360                        auto_discover: Vec::new(),
361                        root: None,
362                    },
363                    BoundaryZone {
364                        name: "shared".to_string(),
365                        patterns: vec!["src/shared/**".to_string()],
366                        auto_discover: Vec::new(),
367                        root: None,
368                    },
369                ],
370                rules: vec![BoundaryRule {
371                    from: "domain".to_string(),
372                    allow: vec!["shared".to_string()],
373                    allow_type_only: vec!["ui".to_string()],
374                }],
375                calls: BoundaryCallsConfig {
376                    forbidden: vec![ForbiddenCallRule {
377                        from: "domain".to_string(),
378                        callee: ForbiddenCallee::Single("child_process.*".to_string()),
379                    }],
380                },
381                ..BoundaryConfig::default()
382            };
383        });
384
385        let report =
386            build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
387        let file = &report.files[0];
388
389        assert!(file.exists);
390        assert_eq!(
391            file.zone.as_ref().map(|zone| zone.name.as_str()),
392            Some("domain")
393        );
394        assert!(!file.boundary.unrestricted);
395        assert_eq!(file.boundary.allowed_zones, vec!["domain", "shared"]);
396        assert_eq!(file.boundary.allowed_type_only_zones, vec!["ui"]);
397        assert_eq!(file.boundary.forbidden_calls, vec!["child_process.*"]);
398        assert!(file.notes.iter().any(|note| note.contains("Same-zone")));
399    }
400
401    #[test]
402    fn unzoned_file_reports_required_coverage() {
403        let temp = tempfile::tempdir().expect("tempdir");
404        let config = resolve(temp.path(), |config| {
405            config.boundaries = BoundaryConfig {
406                zones: vec![BoundaryZone {
407                    name: "domain".to_string(),
408                    patterns: vec!["src/domain/**".to_string()],
409                    auto_discover: Vec::new(),
410                    root: None,
411                }],
412                coverage: BoundaryCoverageConfig {
413                    require_all_files: true,
414                    allow_unmatched: vec!["src/generated/**".to_string()],
415                },
416                ..BoundaryConfig::default()
417            };
418        });
419
420        let report =
421            build_guard_report(&config, &["src/ui/button.ts".to_string()]).expect("report");
422        let file = &report.files[0];
423
424        assert!(file.zone.is_none());
425        assert!(file.boundary.unrestricted);
426        assert!(file.boundary.coverage_required);
427        assert!(
428            file.notes
429                .iter()
430                .any(|note| note.contains("outside every zone"))
431        );
432
433        let allowed =
434            build_guard_report(&config, &["src/generated/client.ts".to_string()]).expect("report");
435        assert!(!allowed.files[0].boundary.coverage_required);
436    }
437
438    #[test]
439    fn pack_rule_scope_filters_policy_rules() {
440        let temp = tempfile::tempdir().expect("tempdir");
441        let mut domain_rule = rule("pure-domain", RulePackRuleKind::BannedEffect);
442        domain_rule.effects = vec![EffectKind::Network];
443        domain_rule.files = vec!["src/domain/**".to_string()];
444        let mut excluded_rule = rule("no-generated-process", RulePackRuleKind::BannedCall);
445        excluded_rule.callees = vec!["child_process.*".to_string()];
446        excluded_rule.exclude = vec!["src/domain/**".to_string()];
447        let mut config = resolve(temp.path(), |_| {});
448        config.rule_packs = vec![pack(vec![domain_rule, excluded_rule])];
449
450        let report =
451            build_guard_report(&config, &["src/domain/user.ts".to_string()]).expect("report");
452        let rules = &report.files[0].policy_rules;
453
454        assert_eq!(rules.len(), 1);
455        assert_eq!(rules[0].rule_id, "pure-domain");
456        assert_eq!(rules[0].kind, "banned-effect");
457        assert_eq!(rules[0].patterns, vec!["network"]);
458        assert_eq!(
459            rules[0].suppress_token,
460            "policy-violation:team-policy/pure-domain"
461        );
462        assert_eq!(rules[0].severity, "warn");
463    }
464
465    #[test]
466    fn nonexistent_target_reports_exists_false() {
467        let temp = tempfile::tempdir().expect("tempdir");
468        let config = resolve(temp.path(), |_| {});
469
470        let report = build_guard_report(&config, &["src/missing.ts".to_string()]).expect("report");
471
472        assert_eq!(report.files[0].path, "src/missing.ts");
473        assert!(!report.files[0].exists);
474    }
475
476    #[test]
477    fn path_outside_root_errors() {
478        let temp = tempfile::tempdir().expect("tempdir");
479        let config = resolve(temp.path(), |_| {});
480
481        let err = build_guard_report(&config, &["../outside.ts".to_string()]).unwrap_err();
482
483        assert!(matches!(err, GuardError::OutsideRoot(_)));
484    }
485}