cufflink-cli 0.8.37

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use comfy_table::{presets::NOTHING, Table, TableComponent};
use eyre::Result;
use serde::Deserialize;
use std::path::Path;

#[derive(Debug, Deserialize)]
pub struct PackageManifest {
    pub package: PackageInfo,
    #[serde(default)]
    pub services: Vec<ServiceEntry>,
    #[serde(default)]
    pub frontends: Vec<FrontendEntry>,
    #[serde(default)]
    pub config: PackageConfig,
}

#[derive(Debug, Deserialize)]
pub struct PackageInfo {
    pub name: String,
    pub description: Option<String>,
    pub version: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct ServiceEntry {
    pub name: String,
    pub path: String,
}

#[derive(Debug, Deserialize)]
pub struct FrontendEntry {
    pub name: String,
    pub path: String,
}

#[derive(Debug, Deserialize, Default)]
pub struct PackageConfig {
    #[serde(default)]
    pub required: Vec<String>,
}

impl PackageManifest {
    pub fn load(dir: &Path) -> Result<Self> {
        let path = dir.join("cufflink-package.toml");
        let content = std::fs::read_to_string(&path).map_err(|_| {
            eyre::eyre!(
                "No cufflink-package.toml found. This repository is not a cufflink package."
            )
        })?;
        toml::from_str(&content)
            .map_err(|e| eyre::eyre!("Failed to parse cufflink-package.toml: {}", e))
    }

    pub fn print_summary(&self) {
        println!("Package: {}", self.package.name);
        if let Some(desc) = &self.package.description {
            println!("  {}", desc);
        }
        if let Some(version) = &self.package.version {
            println!("  Version: {}", version);
        }
        println!();

        if !self.services.is_empty() || !self.frontends.is_empty() {
            let mut table = Table::new();
            table.load_preset(NOTHING);
            table.set_style(TableComponent::HeaderLines, '-');
            table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
            table.set_header(vec!["COMPONENT", "TYPE", "PATH"]);

            for s in &self.services {
                table.add_row(vec![&s.name, "service", &s.path]);
            }
            for f in &self.frontends {
                table.add_row(vec![&f.name, "frontend", &f.path]);
            }

            println!("{table}");
            println!();
        }

        if !self.config.required.is_empty() {
            println!("Required config:");
            for key in &self.config.required {
                println!("  - {}", key);
            }
            println!();
        }
    }

    pub fn validate_paths(&self, base: &Path) -> Result<()> {
        let mut missing = Vec::new();

        for s in &self.services {
            let path = base.join(&s.path);
            if !path.exists() {
                missing.push(format!("service '{}' at {}", s.name, s.path));
            }
        }
        for f in &self.frontends {
            let path = base.join(&f.path);
            if !path.exists() {
                missing.push(format!("frontend '{}' at {}", f.name, f.path));
            }
        }

        if missing.is_empty() {
            Ok(())
        } else {
            eyre::bail!("Missing component paths:\n  {}", missing.join("\n  "))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_minimal_manifest() {
        let toml = r#"
            [package]
            name = "test-pkg"
        "#;
        let manifest: PackageManifest = toml::from_str(toml).unwrap();
        assert_eq!(manifest.package.name, "test-pkg");
        assert!(manifest.services.is_empty());
        assert!(manifest.frontends.is_empty());
        assert!(manifest.config.required.is_empty());
    }

    #[test]
    fn test_parse_full_manifest() {
        let toml = r#"
            [package]
            name = "ecommerce"
            description = "Full e-commerce suite"
            version = "1.0.0"

            [[services]]
            name = "orders"
            path = "backend/orders"

            [[services]]
            name = "inventory"
            path = "backend/inventory"

            [[frontends]]
            name = "admin"
            path = "frontend/admin"

            [config]
            required = ["STRIPE_KEY", "WEBHOOK_SECRET"]
        "#;
        let manifest: PackageManifest = toml::from_str(toml).unwrap();
        assert_eq!(manifest.package.name, "ecommerce");
        assert_eq!(manifest.services.len(), 2);
        assert_eq!(manifest.frontends.len(), 1);
        assert_eq!(manifest.config.required.len(), 2);
        assert_eq!(manifest.services[0].name, "orders");
        assert_eq!(manifest.frontends[0].path, "frontend/admin");
    }

    #[test]
    fn test_parse_no_config_section() {
        let toml = r#"
            [package]
            name = "simple"

            [[services]]
            name = "api"
            path = "backend/api"
        "#;
        let manifest: PackageManifest = toml::from_str(toml).unwrap();
        assert!(manifest.config.required.is_empty());
    }
}