repopilot 0.11.0

Local-first CLI for repository audit, architecture risk detection, baseline tracking, and CI-friendly code review.
Documentation
use crate::baseline::model::{BASELINE_SCHEMA_VERSION, Baseline};
use crate::findings::types::Severity;
use serde_json::Value;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};

#[derive(Debug)]
pub enum BaselineReadError {
    Missing {
        path: PathBuf,
    },
    Empty {
        path: PathBuf,
    },
    InvalidJson {
        path: PathBuf,
        source: serde_json::Error,
    },
    InvalidBaseline {
        path: PathBuf,
        reason: String,
    },
    UnsupportedSchemaVersion {
        found: u32,
    },
    Io {
        path: PathBuf,
        source: io::Error,
    },
}

impl fmt::Display for BaselineReadError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            BaselineReadError::Missing { path } => {
                write!(
                    formatter,
                    "Failed to read baseline file: {}\nReason: file does not exist",
                    path.display()
                )
            }
            BaselineReadError::Empty { path } => {
                write!(
                    formatter,
                    "Failed to read baseline file: {}\nReason: baseline file is empty",
                    path.display()
                )
            }
            BaselineReadError::InvalidJson { path, .. } => {
                write!(
                    formatter,
                    "Failed to read baseline file: {}\nReason: invalid JSON",
                    path.display()
                )
            }
            BaselineReadError::InvalidBaseline { path, reason } => {
                write!(
                    formatter,
                    "Failed to read baseline file: {}\nReason: {reason}",
                    path.display()
                )
            }
            BaselineReadError::UnsupportedSchemaVersion { found } => {
                write!(
                    formatter,
                    "Unsupported baseline schema version: {found}\nSupported version: {BASELINE_SCHEMA_VERSION}"
                )
            }
            BaselineReadError::Io { path, source } => {
                write!(
                    formatter,
                    "Failed to read baseline file: {}\nReason: {source}",
                    path.display()
                )
            }
        }
    }
}

impl std::error::Error for BaselineReadError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            BaselineReadError::InvalidJson { source, .. } => Some(source),
            BaselineReadError::Io { source, .. } => Some(source),
            _ => None,
        }
    }
}

pub fn read_baseline(path: &Path) -> Result<Baseline, BaselineReadError> {
    let content = fs::read_to_string(path).map_err(|source| match source.kind() {
        io::ErrorKind::NotFound => BaselineReadError::Missing {
            path: path.to_path_buf(),
        },
        _ => BaselineReadError::Io {
            path: path.to_path_buf(),
            source,
        },
    })?;

    if content.trim().is_empty() {
        return Err(BaselineReadError::Empty {
            path: path.to_path_buf(),
        });
    }

    let value: Value =
        serde_json::from_str(&content).map_err(|source| BaselineReadError::InvalidJson {
            path: path.to_path_buf(),
            source,
        })?;

    let schema_version = value
        .get("schema_version")
        .and_then(Value::as_u64)
        .ok_or_else(|| BaselineReadError::InvalidBaseline {
            path: path.to_path_buf(),
            reason: "missing required field `schema_version`".to_string(),
        })?;

    if schema_version != u64::from(BASELINE_SCHEMA_VERSION) {
        return Err(BaselineReadError::UnsupportedSchemaVersion {
            found: schema_version as u32,
        });
    }

    let baseline: Baseline =
        serde_json::from_value(value).map_err(|source| BaselineReadError::InvalidBaseline {
            path: path.to_path_buf(),
            reason: source.to_string(),
        })?;

    validate_baseline(&baseline, path)?;

    Ok(baseline)
}

fn validate_baseline(baseline: &Baseline, path: &Path) -> Result<(), BaselineReadError> {
    if baseline.tool.trim().is_empty() {
        return invalid(path, "missing required field `tool`");
    }

    if baseline.created_at.trim().is_empty() {
        return invalid(path, "missing required field `created_at`");
    }

    if baseline.root.trim().is_empty() {
        return invalid(path, "missing required field `root`");
    }

    for finding in &baseline.findings {
        if finding.key.trim().is_empty() {
            return invalid(path, "finding entry is missing required field `key`");
        }

        if finding.rule_id.trim().is_empty() {
            return invalid(path, "finding entry is missing required field `rule_id`");
        }

        if Severity::from_lowercase_label(&finding.severity).is_none() {
            return invalid(path, "finding entry has unsupported `severity`");
        }

        if finding.path.trim().is_empty() {
            return invalid(path, "finding entry is missing required field `path`");
        }

        if finding.message.trim().is_empty() {
            return invalid(path, "finding entry is missing required field `message`");
        }
    }

    Ok(())
}

fn invalid<T>(path: &Path, reason: &str) -> Result<T, BaselineReadError> {
    Err(BaselineReadError::InvalidBaseline {
        path: path.to_path_buf(),
        reason: reason.to_string(),
    })
}