Skip to main content

grip/
app.rs

1// Copyright 2026 Umberto Gotti <umberto.gotti@umbertogotti.dev>
2// Licensed under the MIT License
3// SPDX-License-Identifier: MIT
4
5use std::path::Path;
6use std::process::ExitCode;
7
8use anyhow::Result;
9
10use crate::cache::Cache;
11use crate::collector::Collector;
12use crate::config::Config;
13use crate::grip_report::GripReport;
14use crate::item_counts::ItemCounts;
15use crate::overall_stats::OverallStats;
16use crate::traits::reporter::Reporter;
17use crate::traits::scorer::Scorer;
18use crate::traits::walk::Walk;
19
20#[derive(Debug)]
21pub struct App<W: Walk, S: Scorer, R: Reporter> {
22    walker: W,
23    scorer: S,
24    reporter: R,
25    config: Config,
26}
27
28impl
29    App<
30        crate::fs_walk::FsWalk,
31        crate::default_scorer::DefaultScorer,
32        crate::stdout_reporter::StdoutReporter,
33    >
34{
35    #[must_use]
36    pub fn new(config: Config) -> Self {
37        Self {
38            walker: crate::fs_walk::FsWalk::new(&config.path),
39            scorer: crate::default_scorer::DefaultScorer::new(),
40            reporter: crate::stdout_reporter::StdoutReporter::new(config.json),
41            config,
42        }
43    }
44}
45
46impl<W: Walk, S: Scorer, R: Reporter> App<W, S, R> {
47    #[must_use]
48    pub fn with_deps(walker: W, scorer: S, reporter: R, config: Config) -> Self {
49        Self {
50            walker,
51            scorer,
52            reporter,
53            config,
54        }
55    }
56
57    #[must_use]
58    pub fn reporter(&self) -> &R {
59        &self.reporter
60    }
61
62    pub fn run(&self) -> Result<ExitCode> {
63        let mut cache = Cache::new(&self.config.path);
64        let indexed = self.collect_files(&mut cache)?;
65        cache.flush();
66
67        if indexed.is_empty() {
68            return Err(anyhow::anyhow!(
69                "no Rust source files found in {}",
70                self.config.path.display()
71            ));
72        }
73
74        let report = self.compute_report(indexed);
75        self.handle_output(&report)
76    }
77
78    fn collect_files(&self, cache: &mut Cache) -> Result<Vec<(String, ItemCounts)>> {
79        let files = self.walker.rust_files()?;
80        let mut indexed = Vec::with_capacity(files.len());
81        for (path, source) in files {
82            let module = self.module_from_path(&path);
83            let counts = if let Some(cached) = cache.get(&path) {
84                cached
85            } else {
86                let counts = Collector::collect(&source);
87                cache.set(&path, &source, &counts);
88                counts
89            };
90            indexed.push((module, counts));
91        }
92        Ok(indexed)
93    }
94
95    fn compute_report(&self, indexed: Vec<(String, ItemCounts)>) -> GripReport {
96        let (overall_counts, modules) = self.scorer.agg_modules(indexed);
97        let (grip_score, pure_ratio, public_ratio) = self.scorer.score_counts(&overall_counts);
98        let overall = OverallStats {
99            grip_score,
100            public_items: overall_counts.public_items,
101            total_functions: overall_counts.total_functions,
102            pure_functions: overall_counts.pure_functions,
103            pure_ratio,
104            public_ratio,
105        };
106        let target = self
107            .config
108            .path
109            .file_name()
110            .and_then(|n| n.to_str())
111            .unwrap_or(".")
112            .to_string();
113        let module_stats = self.scorer.module_stats(modules);
114        let offender_threshold = self.config.threshold.unwrap_or(50);
115        let offenders = module_stats
116            .iter()
117            .filter(|m| m.grip_score < offender_threshold)
118            .map(|m| crate::offender::Offender {
119                path: m.path.clone(),
120                grip_score: m.grip_score,
121            })
122            .collect();
123        GripReport {
124            version: env!("CARGO_PKG_VERSION").to_string(),
125            target,
126            overall,
127            modules: module_stats,
128            offenders,
129            offender_threshold,
130        }
131    }
132
133    fn handle_output(&self, report: &GripReport) -> Result<ExitCode> {
134        if let Some(min) = self.config.threshold {
135            return Ok(if report.overall.grip_score >= min {
136                ExitCode::SUCCESS
137            } else {
138                ExitCode::FAILURE
139            });
140        }
141        self.reporter.write(report)?;
142        Ok(ExitCode::SUCCESS)
143    }
144
145    fn module_from_path(&self, path: &Path) -> String {
146        let relative = path.strip_prefix(&self.config.path).unwrap_or(path);
147        let s = relative.to_string_lossy().replace('\\', "/");
148        let without_src = s.strip_prefix("src/").map(|s| s.to_string()).unwrap_or(s);
149        if let Some(pos) = without_src.rfind('/') {
150            without_src[..pos].to_string()
151        } else {
152            ".".to_string()
153        }
154    }
155}