evenframe 0.6.0

A unified framework for TypeScript type generation and database schema synchronization
//! Check command - reports whether the scan cache matches the Rust sources.

use crate::cli::CheckArgs;
use crate::scan_cache::{CACHE_REFRESH_COMMAND, CacheStatus, ScanCache};
use crate::target::Target;
use evenframe_core::{
    error::{EvenframeError, Result},
    scan::ScanConfig,
};
use serde::Serialize;

/// Runs the check command for each focused project, against its own cache.
pub async fn run(args: CheckArgs) -> Result<()> {
    let target = Target::discover()?;
    let reports = target
        .focused()
        .into_iter()
        .map(|(project, config)| {
            let scan = ScanConfig::from_config(config);
            Ok(Report::new(
                project,
                ScanCache::path(&scan.scan_path).display().to_string(),
                ScanCache::status(&scan)?,
            ))
        })
        .collect::<Result<Vec<_>>>()?;

    if args.json {
        // A workspace root reports its projects as a list, one project alone.
        let json = match reports.as_slice() {
            [only] if !target.covers_workspace() => serde_json::to_string_pretty(only)?,
            _ => serde_json::to_string_pretty(&reports)?,
        };
        println!("{json}");
    } else {
        for report in &reports {
            report.print();
        }
    }

    if !reports.iter().all(Report::in_sync) {
        return Err(EvenframeError::Validation(
            "the scan cache is not current".to_string(),
        ));
    }
    Ok(())
}

/// What the command reports for one project, in both its human and its JSON
/// form.
#[derive(Debug, Serialize)]
struct Report {
    /// The project's name in its workspace, empty for a project on its own.
    #[serde(skip_serializing_if = "String::is_empty")]
    project: String,
    cache_path: String,
    #[serde(flatten)]
    status: ReportStatus,
}

#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum ReportStatus {
    InSync { types: TypeCounts },
    Missing,
    Stale { reason: String },
}

#[derive(Debug, Serialize)]
struct TypeCounts {
    tables: usize,
    objects: usize,
    enums: usize,
}

impl Report {
    fn new(project: &str, cache_path: String, status: CacheStatus) -> Self {
        let status = match status {
            CacheStatus::Current(cache) => ReportStatus::InSync {
                types: TypeCounts {
                    tables: cache.configs.tables.len(),
                    objects: cache.configs.objects.len(),
                    enums: cache.configs.enums.len(),
                },
            },
            CacheStatus::Absent => ReportStatus::Missing,
            CacheStatus::Stale(reason) => ReportStatus::Stale { reason },
        };
        Self {
            project: project.to_string(),
            cache_path,
            status,
        }
    }

    fn in_sync(&self) -> bool {
        matches!(self.status, ReportStatus::InSync { .. })
    }

    fn print(&self) {
        if !self.project.is_empty() {
            println!("Project {}", self.project);
        }
        match &self.status {
            ReportStatus::InSync { types } => {
                println!("Types are in sync with {}", self.cache_path);
                println!(
                    "  tables: {}, objects: {}, enums: {}",
                    types.tables, types.objects, types.enums
                );
            }
            ReportStatus::Missing => {
                println!("No scan cache at {}", self.cache_path);
                println!("  Run `{CACHE_REFRESH_COMMAND}` to write it");
            }
            ReportStatus::Stale { reason } => {
                println!("Types are out of sync: {reason}");
                println!(
                    "  Run `{CACHE_REFRESH_COMMAND}` to refresh {}",
                    self.cache_path
                );
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{CacheStatus, Report, ScanCache};
    use crate::scan_cache::CACHE_FORMAT_VERSION;
    use evenframe_core::types::{AllConfigs, StructConfig};
    use std::collections::BTreeMap;

    fn cache_with_one_object() -> ScanCache {
        ScanCache {
            format_version: CACHE_FORMAT_VERSION,
            evenframe_version: "0.0.0".to_string(),
            stamped_at_ns: 0,
            inputs: BTreeMap::new(),
            configs: AllConfigs {
                objects: BTreeMap::from([("Address".to_string(), StructConfig::default())]),
                ..AllConfigs::default()
            },
        }
    }

    fn report(status: CacheStatus) -> Report {
        Report::new("", ".evenframe/cache.json".to_string(), status)
    }

    #[test]
    fn only_a_current_cache_is_in_sync() {
        assert!(report(CacheStatus::Current(cache_with_one_object())).in_sync());
        assert!(!report(CacheStatus::Absent).in_sync());
        assert!(!report(CacheStatus::Stale("src/lib.rs changed".to_string())).in_sync());
    }

    #[test]
    fn json_carries_the_status_counts_and_reason() {
        let json =
            serde_json::to_value(report(CacheStatus::Current(cache_with_one_object()))).unwrap();
        assert_eq!(json["status"], "in_sync");
        assert_eq!(json.get("project"), None);
        assert_eq!(json["cache_path"], ".evenframe/cache.json");
        assert_eq!(json["types"]["objects"], 1);
        assert_eq!(json["types"]["tables"], 0);

        let json = serde_json::to_value(report(CacheStatus::Absent)).unwrap();
        assert_eq!(json["status"], "missing");

        let json = serde_json::to_value(Report::new(
            "app",
            "app/.evenframe/cache.json".to_string(),
            CacheStatus::Absent,
        ))
        .unwrap();
        assert_eq!(json["project"], "app");

        let json =
            serde_json::to_value(report(CacheStatus::Stale("src/lib.rs changed".to_string())))
                .unwrap();
        assert_eq!(json["status"], "stale");
        assert_eq!(json["reason"], "src/lib.rs changed");
    }
}