cpp_linter/
run.rs

1//! This module is the native backend of the cpp-linter package written in Rust.
2//!
3//! In python, this module is exposed as `cpp_linter.run` that has 1 function exposed:
4//! `main()`.
5
6use std::{
7    env,
8    path::Path,
9    sync::{Arc, Mutex},
10};
11
12// non-std crates
13use anyhow::{anyhow, Result};
14use clap::Parser;
15use log::{set_max_level, LevelFilter};
16
17// project specific modules/crates
18use crate::{
19    clang_tools::capture_clang_tools_output,
20    cli::{ClangParams, Cli, CliCommand, FeedbackInput, LinesChangedOnly, RequestedVersion},
21    common_fs::FileFilter,
22    logger,
23    rest_api::{github::GithubApiClient, RestApiClient},
24};
25
26const VERSION: &str = env!("CARGO_PKG_VERSION");
27
28/// This is the backend entry point for console applications.
29///
30/// The idea here is that all functionality is implemented in Rust. However, passing
31/// command line arguments is done differently in Python, node.js, or Rust.
32///
33/// - In python, the CLI arguments list is optionally passed to the binding's
34///   `cpp_linter.main()` function (which wraps [`run_main()`]). If no args are passed,
35///   then `cpp_linter.main()` uses [`std::env::args`] without the leading path to the
36///   python interpreter removed.
37/// - In node.js, the `process.argv` array (without the leading path to the node
38///   interpreter removed) is passed from `cli.js` module to rust via `index.node`
39///   module's `main()` (which wraps([`run_main()`])).
40/// - In rust, the [`std::env::args`] is passed to [`run_main()`] in the binary
41///   source `main.rs`.
42///
43/// This is done because of the way the python entry point is invoked. If [`std::env::args`]
44/// is used instead of python's `sys.argv`, then the list of strings includes the entry point
45/// alias ("path/to/cpp-linter.exe"). Thus, the parser in [`crate::cli`] will halt on an error
46/// because it is not configured to handle positional arguments.
47pub async fn run_main(args: Vec<String>) -> Result<()> {
48    let cli = Cli::parse_from(args);
49
50    if matches!(cli.commands, Some(CliCommand::Version))
51        || cli.general_options.version == RequestedVersion::NoValue
52    {
53        println!("cpp-linter v{}", VERSION);
54        return Ok(());
55    }
56
57    logger::try_init();
58
59    if cli.source_options.repo_root != "." {
60        env::set_current_dir(Path::new(&cli.source_options.repo_root)).map_err(|e| {
61            anyhow!(
62                "'{}' is inaccessible or does not exist: {e:?}",
63                cli.source_options.repo_root
64            )
65        })?;
66    }
67
68    let rest_api_client = GithubApiClient::new()?;
69    set_max_level(
70        if cli.general_options.verbosity.is_debug() || rest_api_client.debug_enabled {
71            LevelFilter::Debug
72        } else {
73            LevelFilter::Info
74        },
75    );
76    log::info!("Processing event {}", rest_api_client.event_name);
77    let is_pr = rest_api_client.event_name == "pull_request";
78
79    let mut file_filter = FileFilter::new(
80        &cli.source_options.ignore,
81        cli.source_options.extensions.clone(),
82    );
83    file_filter.parse_submodules();
84    if let Some(files) = &cli.not_ignored {
85        file_filter.not_ignored.extend(files.clone());
86    }
87
88    if !file_filter.ignored.is_empty() {
89        log::info!("Ignored:");
90        for pattern in &file_filter.ignored {
91            log::info!("  {pattern}");
92        }
93    }
94    if !file_filter.not_ignored.is_empty() {
95        log::info!("Not Ignored:");
96        for pattern in &file_filter.not_ignored {
97            log::info!("  {pattern}");
98        }
99    }
100
101    rest_api_client.start_log_group(String::from("Get list of specified source files"));
102    let files = if !matches!(cli.source_options.lines_changed_only, LinesChangedOnly::Off)
103        || cli.source_options.files_changed_only
104    {
105        // parse_diff(github_rest_api_payload)
106        rest_api_client
107            .get_list_of_changed_files(&file_filter, &cli.source_options.lines_changed_only)
108            .await?
109    } else {
110        // walk the folder and look for files with specified extensions according to ignore values.
111        let mut all_files = file_filter.list_source_files(".")?;
112        if is_pr && (cli.feedback_options.tidy_review || cli.feedback_options.format_review) {
113            let changed_files = rest_api_client
114                .get_list_of_changed_files(&file_filter, &LinesChangedOnly::Off)
115                .await?;
116            for changed_file in changed_files {
117                for file in &mut all_files {
118                    if changed_file.name == file.name {
119                        file.diff_chunks = changed_file.diff_chunks.clone();
120                        file.added_lines = changed_file.added_lines.clone();
121                        file.added_ranges = changed_file.added_ranges.clone();
122                    }
123                }
124            }
125        }
126        all_files
127    };
128    let mut arc_files = vec![];
129    log::info!("Giving attention to the following files:");
130    for file in files {
131        log::info!("  ./{}", file.name.to_string_lossy().replace('\\', "/"));
132        arc_files.push(Arc::new(Mutex::new(file)));
133    }
134    rest_api_client.end_log_group();
135
136    let mut clang_params = ClangParams::from(&cli);
137    clang_params.format_review &= is_pr;
138    clang_params.tidy_review &= is_pr;
139    let user_inputs = FeedbackInput::from(&cli);
140    let clang_versions = capture_clang_tools_output(
141        &mut arc_files,
142        &cli.general_options.version,
143        &mut clang_params,
144        &rest_api_client,
145    )
146    .await?;
147    rest_api_client.start_log_group(String::from("Posting feedback"));
148    let checks_failed = rest_api_client
149        .post_feedback(&arc_files, user_inputs, clang_versions)
150        .await?;
151    rest_api_client.end_log_group();
152    if env::var("PRE_COMMIT").is_ok_and(|v| v == "1") && checks_failed > 1 {
153        return Err(anyhow!("Some checks did not pass"));
154    }
155    Ok(())
156}
157
158#[cfg(test)]
159mod test {
160    use super::run_main;
161    use std::env;
162
163    #[tokio::test]
164    async fn normal() {
165        env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests
166        let result = run_main(vec![
167            "cpp-linter".to_string(),
168            "-l".to_string(),
169            "false".to_string(),
170            "--repo-root".to_string(),
171            "tests".to_string(),
172            "demo/demo.cpp".to_string(),
173        ])
174        .await;
175        assert!(result.is_ok());
176    }
177
178    #[tokio::test]
179    async fn version_command() {
180        env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests
181        let result = run_main(vec!["cpp-linter".to_string(), "version".to_string()]).await;
182        assert!(result.is_ok());
183    }
184
185    #[tokio::test]
186    async fn force_debug_output() {
187        env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests
188        let result = run_main(vec![
189            "cpp-linter".to_string(),
190            "-l".to_string(),
191            "false".to_string(),
192            "-v".to_string(),
193            "-i=target|benches/libgit2".to_string(),
194        ])
195        .await;
196        assert!(result.is_ok());
197    }
198
199    #[tokio::test]
200    async fn no_version_input() {
201        env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests
202        let result = run_main(vec![
203            "cpp-linter".to_string(),
204            "-l".to_string(),
205            "false".to_string(),
206            "-V".to_string(),
207        ])
208        .await;
209        assert!(result.is_ok());
210    }
211
212    #[tokio::test]
213    async fn pre_commit_env() {
214        env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests
215        env::set_var("PRE_COMMIT", "1");
216        let result = run_main(vec![
217            "cpp-linter".to_string(),
218            "--lines-changed-only".to_string(),
219            "false".to_string(),
220            "--ignore=target|benches/libgit2".to_string(),
221        ])
222        .await;
223        assert!(result.is_err());
224    }
225
226    // Verifies that the system gracefully handles cases where all analysis is disabled.
227    // This ensures no diagnostic comments are generated when analysis is explicitly skipped.
228    #[tokio::test]
229    async fn no_analysis() {
230        env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests
231        let result = run_main(vec![
232            "cpp-linter".to_string(),
233            "-l".to_string(),
234            "false".to_string(),
235            "--style".to_string(),
236            String::new(),
237            "--tidy-checks=-*".to_string(),
238        ])
239        .await;
240        assert!(result.is_ok());
241    }
242
243    #[tokio::test]
244    async fn bad_repo_root() {
245        env::remove_var("GITHUB_OUTPUT"); // avoid writing to GH_OUT in parallel-running tests
246        let result = run_main(vec![
247            "cpp-linter".to_string(),
248            "--repo-root".to_string(),
249            "some-non-existent-dir".to_string(),
250        ])
251        .await;
252        assert!(result.is_err());
253    }
254}