liber-cli 0.1.0

AI-agent-readable company directory CLI
//! Output formatting, exit codes, and structured errors.

use std::io::{IsTerminal, Write};

use serde_json::{json, Value};

/// Distinct exit codes per error category (from agent-friendly-cli rules).
pub mod exit {
    pub const OK: i32 = 0;
    pub const VALIDATION: i32 = 2;
    pub const NOT_FOUND: i32 = 4;
    pub const CONFLICT: i32 = 5;
    pub const DATA: i32 = 6;
}

#[derive(Debug)]
pub struct CliError {
    pub code: i32,
    pub message: String,
    pub hint: Option<String>,
    pub retryable: bool,
}

impl CliError {
    pub fn validation(message: String, hint: Option<String>) -> Self {
        Self { code: exit::VALIDATION, message, hint, retryable: false }
    }
    pub fn not_found(message: String, hint: Option<String>) -> Self {
        Self { code: exit::NOT_FOUND, message, hint, retryable: false }
    }
    pub fn conflict(message: String, hint: Option<String>) -> Self {
        Self { code: exit::CONFLICT, message, hint, retryable: false }
    }
    pub fn data(message: String, hint: Option<String>) -> Self {
        Self { code: exit::DATA, message, hint, retryable: false }
    }
}

impl std::fmt::Display for CliError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl std::error::Error for CliError {}

/// Global emit context. Built once from CLI flags + TTY detection.
#[derive(Clone, Copy)]
pub struct Ctx {
    pub json: bool,
    pub quiet: bool,
    pub full: bool,
}

impl Ctx {
    /// `_no_interactive` is accepted (and the flag is registered on every
    /// subcommand per rule 2 of agent-friendly-cli) but unused: this CLI
    /// has no interactive prompts to suppress.
    pub fn new(json_flag: bool, quiet: bool, full: bool, _no_interactive: bool) -> Self {
        let stdout_tty = std::io::stdout().is_terminal();
        Self {
            json: json_flag || !stdout_tty,
            quiet: quiet || !stdout_tty,
            full,
        }
    }
}

/// Emit a value. JSON mode is one shot; TTY mode pretty-prints by shape.
pub fn emit(ctx: Ctx, value: &Value) {
    if ctx.json {
        let mut out = std::io::stdout().lock();
        let _ = serde_json::to_writer_pretty(&mut out, value);
        let _ = out.write_all(b"\n");
        return;
    }
    print_pretty(value, ctx.full);
}

fn print_pretty(value: &Value, full: bool) {
    match value {
        Value::Array(arr) => {
            if arr.is_empty() {
                println!("(empty)");
                return;
            }
            for item in arr {
                print_brief(item, false);
            }
        }
        Value::Object(map) => {
            let has_record_key = map.contains_key("name") || map.contains_key("slug");
            if has_record_key {
                print_brief(value, full);
            } else {
                for (k, v) in map {
                    if let Some(s) = v.as_str() {
                        println!("  {k}: {s}");
                    } else {
                        println!("  {k}: {v}");
                    }
                }
            }
        }
        Value::String(s) => println!("{s}"),
        Value::Null => println!("(null)"),
        other => println!("{other}"),
    }
}

pub fn print_brief(item: &Value, full: bool) {
    let obj = match item.as_object() {
        Some(o) => o,
        None => {
            println!("  {item}");
            return;
        }
    };
    let s = |k: &str| obj.get(k).and_then(|v| v.as_str()).unwrap_or("");
    let has = |k: &str| obj.get(k).map(|v| !v.is_null()).unwrap_or(false);

    // A repo carries 'url' and 'visibility'; a customer carries 'related_products'
    // or 'chats'; a product is any other slug+name record.
    let is_repo = has("slug") && (has("url") || has("visibility"));
    let is_customer = has("slug")
        && (obj.contains_key("related_products") || obj.contains_key("chats"));

    if is_repo {
        println!(
            "  {:<30}  [{:<10}]  {}",
            s("slug"),
            s("visibility"),
            s("description")
        );
        if full {
            if has("url") {
                println!("    url:        {}", s("url"));
            }
        }
    } else if is_customer {
        let mut line = format!("  {:<14}  {}", s("slug"), s("name"));
        if has("name_en") {
            line.push_str(&format!("  ({})", s("name_en")));
        }
        println!("{line}");
        if full {
            if let Some(arr) = obj.get("related_products").and_then(|v| v.as_array()) {
                if !arr.is_empty() {
                    println!("    products: {}", join_str(arr));
                }
            }
            if let Some(arr) = obj.get("chats").and_then(|v| v.as_array()) {
                if !arr.is_empty() {
                    println!("    chats:    {}", join_str(arr));
                }
            }
            if has("notes") {
                println!("    notes:    {}", s("notes"));
            }
            if let Some(chat_ids) = obj.get("chat_ids").and_then(|v| v.as_object()) {
                for (name, id) in chat_ids {
                    println!("    chat:     {name} -> {}", id.as_str().unwrap_or(""));
                }
            }
            if let Some(prods) = obj.get("products_detail").and_then(|v| v.as_array()) {
                for p in prods {
                    print_brief(p, false);
                }
            }
        }
    } else if has("slug") {
        // product (slim: slug + name + description)
        println!("  {:<24}  {}", s("slug"), s("name"));
        if full && has("description") {
            println!("    {}", s("description"));
        }
    } else if has("name") {
        // person
        let mut line = format!("  {:<10}", s("name"));
        if has("department") {
            line.push_str(&format!("  ({})", s("department")));
        }
        if has("github") {
            line.push_str(&format!("  gh:{}", s("github")));
        }
        println!("{line}");
        if full {
            for key in ["user_id", "open_id", "email"] {
                if has(key) {
                    println!("    {key:<8}  {}", s(key));
                }
            }
            if let Some(arr) = obj.get("git_aliases").and_then(|v| v.as_array()) {
                if !arr.is_empty() {
                    println!("    aliases:  {}", join_str(arr));
                }
            }
            if has("role") {
                println!("    role:     {}", s("role"));
            }
        }
    } else {
        println!("  {item}");
    }
}

fn join_str(arr: &[Value]) -> String {
    arr.iter()
        .filter_map(|v| v.as_str())
        .collect::<Vec<_>>()
        .join(", ")
}

pub fn emit_no_op(ctx: Ctx, message: &str) {
    if ctx.json {
        emit(ctx, &json!({ "ok": true, "no_op": true, "message": message }));
    } else if !ctx.quiet {
        println!("{message}");
    }
}

pub fn emit_ok(ctx: Ctx, payload: Value) {
    if ctx.json {
        emit(ctx, &payload);
    } else if !ctx.quiet {
        if let Some(msg) = payload.get("message").and_then(|v| v.as_str()) {
            println!("{msg}");
        } else {
            print_pretty(&payload, ctx.full);
        }
    }
}

pub fn report(err: &CliError) {
    let stderr_tty = std::io::stderr().is_terminal();
    let want_json = !stderr_tty || std::env::var("LIBER_JSON_ERRORS").is_ok();
    let mut err_out = std::io::stderr().lock();
    if want_json {
        let payload = json!({
            "error": {
                "code": err.code,
                "message": err.message,
                "hint": err.hint,
                "retryable": err.retryable,
            }
        });
        let _ = serde_json::to_writer(&mut err_out, &payload);
        let _ = err_out.write_all(b"\n");
    } else {
        let _ = writeln!(err_out, "error: {}", err.message);
        if let Some(h) = &err.hint {
            let _ = writeln!(err_out, "hint:  {h}");
        }
    }
}