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::ffi::OsString;
6use std::path::Path;
7use std::process::ExitCode;
8
9use anyhow::{Result, anyhow};
10
11use crate::args::Args;
12use crate::cache::Cache;
13use crate::collector::Collector;
14use crate::config::Config;
15use crate::default_scorer::DefaultScorer;
16use crate::fs_walk::FsWalk;
17use crate::function_info::FunctionInfo;
18use crate::grip_report::GripReport;
19use crate::item_counts::ItemCounts;
20use crate::method_purity_registry::MethodPurityRegistry;
21use crate::offender::Offender;
22use crate::overall_stats::OverallStats;
23use crate::stdout_reporter::StdoutReporter;
24use crate::struct_registry::StructRegistry;
25use crate::traits::cache_store::CacheStore;
26use crate::traits::reporter::Reporter;
27use crate::traits::scorer::Scorer;
28use crate::traits::walk::Walk;
29
30type CollectedFiles = (Vec<(String, ItemCounts)>, Vec<FunctionInfo>);
31
32pub struct App {
33    walker: Box<dyn Walk>,
34    scorer: Box<dyn Scorer>,
35    reporter: Box<dyn Reporter>,
36    cache: Box<dyn CacheStore>,
37    config: Config,
38}
39
40impl App {
41    #[must_use]
42    pub fn new(config: Config) -> Self {
43        Self {
44            walker: Box::new(FsWalk::new(&config.path)),
45            scorer: Box::new(DefaultScorer::new()),
46            reporter: Box::new(StdoutReporter::new(config.json, config.verbose)),
47            cache: Box::new(Cache::new(&config.path)),
48            config,
49        }
50    }
51
52    #[must_use]
53    pub fn with_deps(
54        walker: Box<dyn Walk>,
55        scorer: Box<dyn Scorer>,
56        reporter: Box<dyn Reporter>,
57        cache: Box<dyn CacheStore>,
58        config: Config,
59    ) -> Self {
60        Self {
61            walker,
62            scorer,
63            reporter,
64            cache,
65            config,
66        }
67    }
68
69    pub fn run(&self) -> Result<ExitCode> {
70        let (indexed, functions) = self.collect_files()?;
71        self.cache.flush();
72
73        if indexed.is_empty() {
74            return Err(anyhow!(
75                "no Rust source files found in {}",
76                self.config.path.display()
77            ));
78        }
79
80        let report = self.compute_report(indexed, functions);
81        self.handle_output(&report)
82    }
83
84    fn collect_files(&self) -> Result<CollectedFiles> {
85        let files = self.walker.rust_files()?;
86        let registry = StructRegistry::build(&files);
87        let method_purity = MethodPurityRegistry::build(&files, &registry);
88        let mut indexed = Vec::with_capacity(files.len());
89        let mut all_functions = Vec::new();
90        for (path, source) in files {
91            let module = self.module_from_path(&path);
92            let (counts, functions) = Collector::collect(&source, &path, &registry, &method_purity);
93            all_functions.extend(functions);
94            if self.cache.get(&path).is_none() {
95                self.cache.set(&path, &source, &counts);
96            }
97            indexed.push((module, counts));
98        }
99        Ok((indexed, all_functions))
100    }
101
102    fn compute_report(
103        &self,
104        indexed: Vec<(String, ItemCounts)>,
105        functions: Vec<FunctionInfo>,
106    ) -> GripReport {
107        let (overall_counts, modules) = self.scorer.agg_modules(indexed);
108        let (grip_score, pure_ratio, public_ratio, trait_ratio, avg_contribution, clean_fn_ratio) =
109            self.scorer.score_counts(&overall_counts);
110        let overall = OverallStats {
111            grip_score,
112            public_items: overall_counts.public_items,
113            total_functions: overall_counts.total_functions,
114            pure_functions: overall_counts.pure_functions,
115            pure_ratio,
116            public_ratio,
117            inherent_methods: overall_counts.inherent_methods,
118            local_trait_methods: overall_counts.local_trait_methods,
119            trait_ratio,
120            avg_contribution,
121            clean_fn_ratio,
122            grip_absolute_total: overall_counts.total_contribution,
123        };
124        let target = self
125            .config
126            .path
127            .file_name()
128            .and_then(|n| n.to_str())
129            .unwrap_or(".")
130            .to_string();
131        let module_stats = self.scorer.module_stats(modules);
132        let offender_threshold = self.config.threshold.unwrap_or(50);
133        let offenders = module_stats
134            .iter()
135            .filter_map(|m| {
136                let score = m.grip_score?;
137                (score < offender_threshold).then(|| Offender {
138                    path: m.path.clone(),
139                    grip_score: score,
140                })
141            })
142            .collect();
143        GripReport {
144            version: env!("CARGO_PKG_VERSION").to_string(),
145            target,
146            overall,
147            modules: module_stats,
148            offenders,
149            offender_threshold,
150            functions,
151        }
152    }
153
154    fn handle_output(&self, report: &GripReport) -> Result<ExitCode> {
155        if let Some(min) = self.config.threshold {
156            return Ok(if report.overall.grip_score.is_none_or(|s| s >= min) {
157                ExitCode::SUCCESS
158            } else {
159                ExitCode::FAILURE
160            });
161        }
162        self.reporter.write(report)?;
163        Ok(ExitCode::SUCCESS)
164    }
165
166    fn module_from_path(&self, path: &Path) -> String {
167        let relative = path.strip_prefix(&self.config.path).unwrap_or(path);
168        let s = relative.to_string_lossy().replace('\\', "/");
169        let without_src = s.strip_prefix("src/").map(|s| s.to_string()).unwrap_or(s);
170        if let Some(pos) = without_src.rfind('/') {
171            without_src[..pos].to_string()
172        } else {
173            ".".to_string()
174        }
175    }
176}
177
178pub fn run() -> Result<ExitCode> {
179    let args = Args::parse_cargo();
180    let config = Config::from_args(args);
181    App::new(config).run()
182}
183
184pub fn run_from_args<I, T>(args: I) -> Result<ExitCode>
185where
186    I: IntoIterator<Item = T>,
187    T: Into<OsString> + Clone,
188{
189    let args = Args::parse_from_args(args);
190    let config = Config::from_args(args);
191    App::new(config).run()
192}