checkleft 0.1.0-alpha.8

Experimental repository convention checker; API and behavior may change without notice
Documentation
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use regex::Regex;
use serde::Deserialize;
use std::sync::Arc;

use crate::check::{Check, ConfiguredCheck};
use crate::input::{ChangeKind, ChangeSet, SourceTree};
use crate::output::{CheckResult, Finding, Location, Severity};

#[derive(Debug, Default)]
pub struct FrontendNoLegacyApiCheck;

#[async_trait]
impl Check for FrontendNoLegacyApiCheck {
    fn id(&self) -> &str {
        "frontend-no-legacy-api"
    }

    fn description(&self) -> &str {
        "prevents frontend imports from deprecated API modules"
    }

    fn configure(&self, config: &toml::Value) -> Result<Arc<dyn ConfiguredCheck>> {
        Ok(Arc::new(parse_config(config)?))
    }
}

#[async_trait]
impl ConfiguredCheck for CompiledFrontendNoLegacyApiConfig {
    async fn run(&self, changeset: &ChangeSet, tree: &dyn SourceTree) -> Result<CheckResult> {
        let import_re =
            Regex::new(r#"^\s*import\b[^;]*\bfrom\s*["']([^"']+)["']"#).expect("valid regex");
        let mut findings = Vec::new();

        for changed_file in &changeset.changed_files {
            if matches!(changed_file.kind, ChangeKind::Deleted) {
                continue;
            }
            if !is_frontend_source_file(&changed_file.path) {
                continue;
            }

            let Ok(contents) = tree.read_file(&changed_file.path) else {
                continue;
            };
            let Ok(contents) = String::from_utf8(contents) else {
                continue;
            };

            for (line_index, line) in contents.lines().enumerate() {
                let Some(captures) = import_re.captures(line) else {
                    continue;
                };
                let Some(module) = captures.get(1).map(|capture| capture.as_str()) else {
                    continue;
                };

                let normalized = normalize_import_module(module);
                let Some(legacy_match) = self
                    .legacy_modules
                    .iter()
                    .find(|legacy| normalized.ends_with(legacy.as_str()))
                else {
                    continue;
                };

                findings.push(Finding {
                    severity: self.severity,
                    message: format!("import from deprecated frontend API module `{legacy_match}`"),
                    location: Some(Location {
                        path: changed_file.path.clone(),
                        line: Some((line_index + 1) as u32),
                        column: Some(1),
                    }),
                    remediation: Some(self.remediation.clone()),
                    suggested_fix: None,
                });
            }
        }

        Ok(CheckResult {
            check_id: "frontend-no-legacy-api".to_owned(),
            findings,
        })
    }
}

#[derive(Debug, Deserialize)]
struct FrontendNoLegacyApiConfig {
    #[serde(default)]
    legacy_modules: Vec<String>,
    #[serde(default)]
    severity: Option<String>,
    #[serde(default)]
    remediation: Option<String>,
}

struct CompiledFrontendNoLegacyApiConfig {
    legacy_modules: Vec<String>,
    severity: Severity,
    remediation: String,
}

fn parse_config(config: &toml::Value) -> Result<CompiledFrontendNoLegacyApiConfig> {
    let parsed: FrontendNoLegacyApiConfig = config
        .clone()
        .try_into()
        .context("invalid frontend-no-legacy-api config")?;

    if parsed.legacy_modules.is_empty() {
        bail!("frontend-no-legacy-api config must contain at least one `legacy_modules` entry");
    }

    Ok(CompiledFrontendNoLegacyApiConfig {
        legacy_modules: parsed
            .legacy_modules
            .into_iter()
            .map(|module| module.trim_matches('/').to_owned())
            .collect(),
        severity: Severity::parse_with_default(parsed.severity.as_deref(), Severity::Error),
        remediation: parsed.remediation.unwrap_or_else(|| {
            "Import from supported modules under `frontend/src/api/` instead.".to_owned()
        }),
    })
}

fn is_frontend_source_file(path: &std::path::Path) -> bool {
    if !path.starts_with(std::path::Path::new("frontend/src")) {
        return false;
    }
    matches!(
        path.extension().and_then(|ext| ext.to_str()),
        Some("ts") | Some("tsx")
    )
}

fn normalize_import_module(module: &str) -> String {
    let mut normalized = module.trim_matches('\'').trim_matches('"').to_owned();
    while let Some(stripped) = normalized.strip_prefix("./") {
        normalized = stripped.to_owned();
    }
    while let Some(stripped) = normalized.strip_prefix("../") {
        normalized = stripped.to_owned();
    }
    normalized.trim_matches('/').to_owned()
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::Path;

    use tempfile::tempdir;

    use crate::check::Check;
    use crate::input::{ChangeKind, ChangeSet, ChangedFile};
    use crate::source_tree::LocalSourceTree;

    use super::FrontendNoLegacyApiCheck;

    #[tokio::test]
    async fn flags_legacy_api_import() {
        let temp = tempdir().expect("create temp dir");
        fs::create_dir_all(temp.path().join("frontend/src/components")).expect("create dirs");
        fs::write(
            temp.path().join("frontend/src/components/Foo.tsx"),
            "import { x } from \"../api/fencingtracker\";\n",
        )
        .expect("write source");

        let check = FrontendNoLegacyApiCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new("frontend/src/components/Foo.tsx").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(toml::toml! {
                    legacy_modules = ["api/fencingtracker", "api/usafencing"]
                }),
            )
            .await
            .expect("run check");

        assert_eq!(result.findings.len(), 1);
    }

    #[tokio::test]
    async fn ignores_non_legacy_imports() {
        let temp = tempdir().expect("create temp dir");
        fs::create_dir_all(temp.path().join("frontend/src/components")).expect("create dirs");
        fs::write(
            temp.path().join("frontend/src/components/Foo.tsx"),
            "import { getStatusz } from \"../api/statusz\";\n",
        )
        .expect("write source");

        let check = FrontendNoLegacyApiCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new("frontend/src/components/Foo.tsx").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(toml::toml! {
                    legacy_modules = ["api/fencingtracker", "api/usafencing"]
                }),
            )
            .await
            .expect("run check");

        assert!(result.findings.is_empty());
    }
}