koban-cli 0.2.0

A Rust CLI for Invoice Ninja, built for humans and AI agents
use koban::{ApiClient, KobanError, Result};

use crate::{
    cli::{EndpointArgs, EndpointCommand, HttpMethod, OutputFormat},
    commands::push_include,
    invoice::{render_dry_run, require_confirmation},
    payload::resource_payload,
    render::render_value,
};

pub(crate) async fn execute_endpoint(
    client: &ApiClient,
    output: OutputFormat,
    default_endpoint: &str,
    command: EndpointCommand,
) -> Result<String> {
    match command {
        EndpointCommand::Run(args) => {
            execute_endpoint_run(client, output, default_endpoint, args).await
        }
    }
}

async fn execute_endpoint_run(
    client: &ApiClient,
    output: OutputFormat,
    default_endpoint: &str,
    args: EndpointArgs,
) -> Result<String> {
    let custom_endpoint = args.endpoint.is_some();
    let endpoint = args
        .endpoint
        .unwrap_or_else(|| default_endpoint.to_string());
    validate_endpoint_path(&endpoint)?;
    let method = args
        .method
        .unwrap_or_else(|| default_method(default_endpoint));
    if custom_endpoint
        && !is_scoped_query_endpoint(default_endpoint, &endpoint)
        && !matches!(method, HttpMethod::Get)
    {
        return Err(KobanError::InvalidRequest {
            message: "custom and utility endpoint runners are read-only; use --method get"
                .to_string(),
        });
    }
    let path = format!("api/v1/{endpoint}");
    let body = resource_payload(
        args.payload,
        matches!(method, HttpMethod::Post | HttpMethod::Put),
    )?;
    let has_body = body.as_object().is_some_and(|body| !body.is_empty());
    if has_body && matches!(method, HttpMethod::Get | HttpMethod::Delete) {
        return Err(KobanError::InvalidRequest {
            message: format!(
                "{} endpoint commands do not send request bodies; use --method post or --method put for payload fields",
                method.label()
            ),
        });
    }
    let mut query = Vec::new();
    push_include(&mut query, args.include);
    if args.safety.dry_run {
        return render_dry_run(
            method.label(),
            &path,
            &query,
            has_body.then_some(&body),
            None,
        );
    }

    if !matches!(method, HttpMethod::Get) {
        let action = format!("endpoint {}", method.label().to_ascii_lowercase());
        require_confirmation(&action, &args.safety)?;
    }

    let json = match method {
        HttpMethod::Get => client.get_json(&path, &query).await?,
        HttpMethod::Post => client.post_json(&path, &query, &body).await?,
        HttpMethod::Put => client.put_json(&path, &query, &body).await?,
        HttpMethod::Delete => client.delete_json(&path, &query).await?,
    };
    render_value(output, None, &json)
}

fn default_method(default_endpoint: &str) -> HttpMethod {
    if default_endpoint == "ping" {
        HttpMethod::Get
    } else {
        HttpMethod::Post
    }
}

fn is_scoped_query_endpoint(default_endpoint: &str, endpoint: &str) -> bool {
    matches!(
        (default_endpoint, endpoint),
        ("reports", endpoint) if endpoint.starts_with("reports/")
    ) || matches!(
        (default_endpoint, endpoint),
        ("charts", endpoint) if endpoint.starts_with("charts/")
    )
}

fn validate_endpoint_path(path: &str) -> Result<()> {
    let is_safe = !path.is_empty()
        && !path.starts_with('/')
        && !path.contains("..")
        && path
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'/'));

    if is_safe {
        Ok(())
    } else {
        Err(KobanError::InvalidRequest {
            message: "endpoint must be a relative /api/v1 path".to_string(),
        })
    }
}