ecat-cli 7.2.1

Command-line interface for e-cat
Documentation
// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
use std::fs;
use std::process;

pub(crate) fn proto_add(file: &str) {
    let path = std::path::Path::new(file);
    if path.exists() {
        eprintln!("Error: proto file '{}' already exists", file);
        process::exit(1);
    }
    if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
        fs::create_dir_all(parent).unwrap_or_else(|e| {
            eprintln!("Failed to create directory '{}': {}", parent.display(), e);
            process::exit(1);
        });
    }
    let template = format!("syntax = \"proto3\";\n\npackage {};\n", proto_package(file));
    fs::write(path, template).unwrap_or_else(|e| {
        eprintln!("Failed to write '{}': {}", file, e);
        process::exit(1);
    });
    println!("Created proto file: {}", file);
}

/// Infer a proto package name from the file path (parent dirs joined by '.').
fn proto_package(file: &str) -> String {
    let path = std::path::Path::new(file);
    let mut parts: Vec<String> = Vec::new();
    if let Some(parent) = path.parent() {
        for component in parent.components() {
            if let std::path::Component::Normal(seg) = component
                && let Some(seg) = seg.to_str()
            {
                let clean: String = seg
                    .chars()
                    .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '_')
                    .collect();
                if !clean.is_empty() {
                    parts.push(clean);
                }
            }
        }
    }
    if parts.is_empty() {
        "service".to_string()
    } else {
        parts.join(".")
    }
}

pub(crate) fn proto_generate(file: &str, server: bool, output: Option<&str>) {
    let proto_path = std::path::Path::new(file);
    if !proto_path.exists() {
        eprintln!("Error: proto file '{}' not found", file);
        process::exit(1);
    }
    if !proto_path.starts_with("proto") {
        eprintln!(
            "Error: '{}' is outside the proto/ directory; tonic-build scans proto/ only",
            file
        );
        process::exit(1);
    }
    let kind = if server { "server" } else { "client" };
    let out_dir = match (server, output) {
        (true, Some(out)) => out.to_string(),
        (true, None) => "src/pb_server".to_string(),
        (false, _) => "src/pb_client".to_string(),
    };
    let build_rs = format!(
        r#"// Generated by `ecat proto {kind}`. Do not edit by hand.
fn main() -> Result<(), Box<dyn std::error::Error>> {{
    let protos: Vec<String> = std::fs::read_dir("proto")?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().is_some_and(|x| x == "proto"))
        .map(|e| e.path().to_string_lossy().into_owned())
        .collect();
    if protos.is_empty() {{
        return Ok(());
    }}
    tonic_build::configure()
        .build_client({client})
        .build_server({server})
        .out_dir("{out_dir}")
        .compile(&protos, &["proto"])?;
    Ok(())
}}
"#,
        client = if server { "false" } else { "true" },
        server = if server { "true" } else { "false" }
    );
    if std::path::Path::new("build.rs").exists() {
        println!("Note: overwriting existing build.rs");
    }
    fs::write("build.rs", build_rs).unwrap_or_else(|e| {
        eprintln!("Failed to write build.rs: {}", e);
        process::exit(1);
    });
    let toml = fs::read_to_string("Cargo.toml").unwrap_or_else(|e| {
        eprintln!("Failed to read Cargo.toml: {}", e);
        process::exit(1);
    });
    let toml = ensure_toml_section(&toml, "build-dependencies", &[("tonic-build", "0.12")]);
    let toml = ensure_toml_section(
        &toml,
        "dependencies",
        &[("tonic", "0.12"), ("prost", "0.13")],
    );
    fs::write("Cargo.toml", toml).unwrap_or_else(|e| {
        eprintln!("Failed to write Cargo.toml: {}", e);
        process::exit(1);
    });
    println!("Generated build.rs (tonic-build {kind} codegen)");
    println!("  proto files: proto/ (scanned at build time)");
    println!("  output:      {out_dir}");
    println!("Updated Cargo.toml: [build-dependencies] tonic-build, [dependencies] tonic + prost");
    println!();
    println!("Next steps:");
    println!("  cargo build  (requires protoc in PATH)");
    println!(
        "  then add `mod pb_{};` to src/main.rs or src/lib.rs",
        if server { "server" } else { "client" }
    );
}

/// Ensure a TOML section lists the given key = "version" dependencies.
fn ensure_toml_section(toml: &str, section: &str, deps: &[(&str, &str)]) -> String {
    let lines: Vec<&str> = toml.lines().collect();
    let mut start: Option<usize> = None;
    for (i, line) in lines.iter().enumerate() {
        if line.trim() == format!("[{section}]") {
            start = Some(i);
            break;
        }
    }
    let mut existing: std::collections::HashSet<String> = std::collections::HashSet::new();
    let end = match start {
        None => 0,
        Some(s) => {
            let mut e = lines.len();
            for (i, line) in lines.iter().enumerate().skip(s + 1) {
                let t = line.trim();
                if t.starts_with('[') && t.ends_with(']') {
                    e = i;
                    break;
                }
                if !t.starts_with('#')
                    && let Some(eq) = t.find('=')
                {
                    existing.insert(t[..eq].trim().to_string());
                }
            }
            e
        }
    };
    let missing: Vec<(&str, &str)> = deps
        .iter()
        .copied()
        .filter(|(k, _)| !existing.contains(*k))
        .collect();
    if missing.is_empty() {
        return toml.to_string();
    }
    let insert: Vec<String> = missing
        .iter()
        .map(|(k, v)| format!("{k} = \"{v}\""))
        .collect();
    let mut out: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
    match start {
        Some(_) => {
            out.splice(end..end, insert);
        }
        None => {
            if out.last().is_none_or(|l| !l.is_empty()) {
                out.push(String::new());
            }
            out.push(format!("[{section}]"));
            out.extend(insert);
        }
    }
    out.join("\n") + "\n"
}