tovuk 0.1.104

Deploy Rust workers, static frontends, and full-stack services to Tovuk.
use crate::cli::config::TovukConfig;
use serde::{Serialize, ser::SerializeStruct};
use std::path::Path;

#[derive(Clone, Debug)]
pub(crate) struct QualityCheck {
    pub(crate) name: String,
    pub(crate) ok: bool,
    pub(crate) message: String,
    pub(crate) agent_instruction: Option<String>,
}

impl Serialize for QualityCheck {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        let mut state = serializer.serialize_struct("QualityCheck", 5)?;
        state.serialize_field("name", &self.name)?;
        state.serialize_field("ok", &self.ok)?;
        state.serialize_field("status", quality_check_status(self.ok))?;
        state.serialize_field("message", &self.message)?;
        state.serialize_field("agent_instruction", &self.agent_instruction)?;
        state.end()
    }
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct QualityReport {
    pub(crate) ok: bool,
    pub(crate) project: String,
    pub(crate) config: Option<TovukConfig>,
    pub(crate) checks: Vec<QualityCheck>,
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct ProjectQualityReport {
    pub(crate) relative: String,
    pub(crate) ok: bool,
    pub(crate) project: String,
    pub(crate) config: Option<TovukConfig>,
    pub(crate) checks: Vec<QualityCheck>,
}

#[derive(Clone, Debug, Serialize)]
pub(crate) struct WorkspaceQualityReport {
    pub(crate) ok: bool,
    pub(crate) workspace: String,
    pub(crate) projects: Vec<ProjectQualityReport>,
}

#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum QualityReportKind {
    Project(Box<QualityReport>),
    Workspace(WorkspaceQualityReport),
}

impl QualityReportKind {
    pub(crate) fn ok(&self) -> bool {
        match self {
            Self::Project(report) => report.ok,
            Self::Workspace(report) => report.ok,
        }
    }

    pub(crate) fn checks(&self) -> Vec<QualityCheck> {
        match self {
            Self::Project(report) => report.checks.clone(),
            Self::Workspace(report) => report
                .projects
                .iter()
                .flat_map(|project| project.checks.clone())
                .collect(),
        }
    }
}

pub(super) fn quality_report(
    project_dir: &Path,
    config: Option<TovukConfig>,
    checks: Vec<QualityCheck>,
) -> QualityReport {
    QualityReport {
        ok: checks.iter().all(|check| check.ok),
        project: project_dir.display().to_string(),
        config,
        checks,
    }
}

pub(super) fn print_quality_report(report: &QualityReportKind) {
    match report {
        QualityReportKind::Project(report) => print_checks(&report.checks),
        QualityReportKind::Workspace(report) => {
            for project in &report.projects {
                println!("project {}", project.relative);
                print_checks(&project.checks);
            }
        }
    }
}

pub(super) fn print_checks(checks: &[QualityCheck]) {
    for line in quality_check_lines(checks) {
        println!("{line}");
    }
}

fn quality_check_lines(checks: &[QualityCheck]) -> Vec<String> {
    checks
        .iter()
        .flat_map(quality_check_lines_for_check)
        .collect()
}

fn quality_check_lines_for_check(check: &QualityCheck) -> Vec<String> {
    let mut lines = vec![format!(
        "{} {}{}",
        if check.ok { "ok" } else { "fail" },
        check.name,
        if check.message.is_empty() {
            String::new()
        } else {
            format!(" - {}", check.message)
        }
    )];

    if !check.ok
        && let Some(instruction) = &check.agent_instruction
    {
        lines.push(format!("  fix {instruction}"));
    }

    lines
}

pub(crate) fn quality_check(
    name: &str,
    ok: bool,
    success: &str,
    failure: &str,
    instruction: &str,
) -> QualityCheck {
    QualityCheck {
        name: name.to_owned(),
        ok,
        message: if ok { success } else { failure }.to_owned(),
        agent_instruction: if ok {
            None
        } else {
            Some(instruction.to_owned())
        },
    }
}

fn quality_check_status(ok: bool) -> &'static str {
    if ok { "passed" } else { "failed" }
}

#[cfg(test)]
mod tests {
    use super::{QualityCheck, quality_check_lines};

    #[test]
    fn quality_check_json_includes_status() -> Result<(), Box<dyn std::error::Error>> {
        let value = serde_json::to_value(QualityCheck {
            name: "npm run typecheck".to_owned(),
            ok: false,
            message: "missing types".to_owned(),
            agent_instruction: Some("Run npm install, then retry.".to_owned()),
        })?;

        if value["ok"] != false {
            return Err(format!("unexpected ok value: {}", value["ok"]).into());
        }
        if value["status"] != "failed" {
            return Err(format!("unexpected status: {}", value["status"]).into());
        }
        if value["name"] != "npm run typecheck" {
            return Err(format!("unexpected name: {}", value["name"]).into());
        }
        Ok(())
    }

    #[test]
    fn human_check_output_includes_failed_fix_instruction() {
        let lines = quality_check_lines(&[
            QualityCheck {
                name: "source archive".to_owned(),
                ok: true,
                message: "ready".to_owned(),
                agent_instruction: None,
            },
            QualityCheck {
                name: "Next static export".to_owned(),
                ok: false,
                message: "next.config missing".to_owned(),
                agent_instruction: Some("Add output: \"export\" and rerun tovuk check.".to_owned()),
            },
        ]);

        assert_eq!(
            lines,
            vec![
                "ok source archive - ready",
                "fail Next static export - next.config missing",
                "  fix Add output: \"export\" and rerun tovuk check.",
            ]
        );
    }
}