checkleft 0.1.0-alpha.8

Experimental repository convention checker; API and behavior may change without notice
Documentation
use anyhow::{Context, Result};
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 TodoExpiryCheck;

#[async_trait]
impl Check for TodoExpiryCheck {
    fn id(&self) -> &str {
        "todo-expiry"
    }

    fn description(&self) -> &str {
        "requires TODO/FIXME annotations to include owner and date tags"
    }

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

#[async_trait]
impl ConfiguredCheck for CompiledTodoExpiryConfig {
    async fn run(&self, changeset: &ChangeSet, tree: &dyn SourceTree) -> Result<CheckResult> {
        let mut findings = Vec::new();

        for changed_file in &changeset.changed_files {
            if matches!(changed_file.kind, ChangeKind::Deleted) {
                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() {
                if !self.todo_detector.is_match(line) {
                    continue;
                }
                if self.required_format.is_match(line) {
                    continue;
                }

                findings.push(Finding {
                    severity: self.severity,
                    message: "TODO/FIXME must include `(@owner,YYYY-MM-DD)` metadata".to_owned(),
                    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: "todo-expiry".to_owned(),
            findings,
        })
    }
}

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

struct CompiledTodoExpiryConfig {
    todo_detector: Regex,
    required_format: Regex,
    severity: Severity,
    remediation: String,
}

fn parse_config(config: &toml::Value) -> Result<CompiledTodoExpiryConfig> {
    let parsed: TodoExpiryConfig = config
        .clone()
        .try_into()
        .context("invalid todo-expiry config")?;

    let required_pattern = parsed.required_pattern.unwrap_or_else(|| {
        r"(?i)\b(?:TODO|FIXME)\s*\(@[A-Za-z0-9._-]+,\s*\d{4}-\d{2}-\d{2}\)\s*:".to_owned()
    });
    let required_format = Regex::new(&required_pattern)
        .with_context(|| format!("invalid required_pattern regex: {required_pattern}"))?;

    Ok(CompiledTodoExpiryConfig {
        todo_detector: Regex::new(
            r"(?i)^\s*(?://|#|/\*|\*|<!--)\s*(?:TODO|FIXME)\s*(?:\([^)]*\)\s*)?:",
        )
        .expect("valid detector regex"),
        required_format,
        severity: Severity::parse_with_default(parsed.severity.as_deref(), Severity::Warning),
        remediation: parsed.remediation.unwrap_or_else(|| {
            "Use format `TODO(@owner,YYYY-MM-DD): ...` or `FIXME(@owner,YYYY-MM-DD): ...`."
                .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::TodoExpiryCheck;

    #[tokio::test]
    async fn flags_todo_without_owner_and_date() {
        let temp = tempdir().expect("create temp dir");
        fs::write(
            temp.path().join("notes.txt"),
            "// TODO: clean this up later\n",
        )
        .expect("write file");

        let check = TodoExpiryCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new("notes.txt").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(Default::default()),
            )
            .await
            .expect("run check");

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

    #[tokio::test]
    async fn accepts_todo_with_owner_and_date() {
        let temp = tempdir().expect("create temp dir");
        fs::write(
            temp.path().join("notes.txt"),
            "// TODO(@brian,2026-02-14): clean this up later\n",
        )
        .expect("write file");

        let check = TodoExpiryCheck;
        let tree = LocalSourceTree::new(temp.path()).expect("create tree");
        let result = check
            .run(
                &ChangeSet::new(vec![ChangedFile {
                    path: Path::new("notes.txt").to_path_buf(),
                    kind: ChangeKind::Modified,
                    old_path: None,
                }]),
                &tree,
                &toml::Value::Table(Default::default()),
            )
            .await
            .expect("run check");

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