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