svccat 0.14.0

Detect drift between your declared service catalog and what actually lives in the repo.
Documentation
use crate::discovery::{discover_services, DiscoveredService};
use crate::manifest::{DiscoveryConfig, Manifest, DEFAULT_DISCOVERY_PATHS};
use anyhow::{bail, Result};
use std::path::{Path, PathBuf};

/// Language inferred from the marker files present in a service directory.
pub(crate) fn infer_language(root: &Path, service_path: &str) -> Option<String> {
    let dir = root.join(service_path);
    let candidates: &[(&str, &str)] = &[
        ("Cargo.toml", "Rust"),
        ("go.mod", "Go"),
        ("package.json", "TypeScript"),
        ("pyproject.toml", "Python"),
        ("requirements.txt", "Python"),
        ("pom.xml", "Java"),
        ("build.gradle", "Java"),
        ("build.gradle.kts", "Kotlin"),
        ("CMakeLists.txt", "C++"),
        ("Directory.Build.props", "C#"),
        ("Gemfile", "Ruby"),
        ("mix.exs", "Elixir"),
        ("pubspec.yaml", "Dart"),
    ];
    for (marker, lang) in candidates {
        if dir.join(marker).exists() {
            return Some(lang.to_string());
        }
    }
    None
}

/// Render a human-friendly, annotated YAML scaffold for the discovered services.
pub fn render_yaml(services: &[DiscoveredService], root: &Path) -> String {
    let mut out = String::new();

    out.push_str("# Generated by `svccat init`\n");
    out.push_str("# Fill in the ~placeholder~ fields and commit this file.\n");
    out.push_str("# Run `svccat check` to verify there is no drift.\n\n");

    out.push_str("version: \"1\"\n\n");

    out.push_str("discovery:\n");
    out.push_str("  paths:\n");
    for p in DEFAULT_DISCOVERY_PATHS {
        out.push_str(&format!("    - {}\n", p));
    }
    out.push('\n');

    out.push_str("services:\n");

    if services.is_empty() {
        out.push_str("  []  # no services detected — add entries manually\n");
    } else {
        for svc in services {
            let lang = infer_language(root, &svc.path).unwrap_or_else(|| "~".to_string());
            out.push_str(&format!("  - name: {}\n", svc.name));
            out.push_str(&format!("    path: {}\n", svc.path));
            out.push_str(&format!("    language: {}\n", lang));
            out.push_str("    platform: ~  # e.g. gcp-cloud-run, fly.io, vercel, aws-lambda\n");
            out.push_str("    role: ~      # e.g. api, worker, frontend, database\n");
            out.push_str("    url: ~       # e.g. https://my-service.example.com\n");
        }
    }

    out
}

/// Discover services and write a `services.yaml` scaffold to `output_path`.
pub fn run(root: &Path, output_path: PathBuf, force: bool) -> Result<()> {
    if output_path.exists() && !force {
        bail!(
            "{} already exists. Use --force to overwrite.",
            output_path.display()
        );
    }

    // Bootstrap discovery with a manifest that has no declared services.
    let bootstrap = Manifest {
        version: "1".to_string(),
        discovery: DiscoveryConfig {
            paths: vec![],
            markers: crate::manifest::default_markers_pub(),
            ignore: vec![],
        },
        policy: Default::default(),
        services: vec![],
    };

    let discovered = discover_services(root, &bootstrap);
    let yaml = render_yaml(&discovered, root);

    std::fs::write(&output_path, &yaml)?;

    let verb = if output_path.exists() {
        "Wrote"
    } else {
        "Created"
    };
    println!(
        "{} {} ({} service{} detected)",
        verb,
        output_path.display(),
        discovered.len(),
        if discovered.len() == 1 { "" } else { "s" }
    );
    println!("Edit the file, then run `svccat check` to verify.");

    Ok(())
}