koban-cli 0.2.0

A Rust CLI for Invoice Ninja, built for humans and AI agents
use std::path::PathBuf;

use serde_json::{Value, json};

use koban::{KobanError, Result};

use crate::{
    cli::{InvoicePayloadArgs, InvoiceTriggerArgs, WriteSafetyArgs},
    payload::{insert_string, parse_json_payload, parse_scalar},
};

pub(crate) fn require_confirmation(operation: &str, safety: &WriteSafetyArgs) -> Result<()> {
    if safety.dry_run || safety.yes {
        Ok(())
    } else {
        Err(KobanError::ConfirmationRequired {
            operation: operation.to_string(),
        })
    }
}

pub(crate) fn validate_invoice_triggers(triggers: &InvoiceTriggerArgs) -> Result<()> {
    if triggers.amount_paid.is_some() && !triggers.paid {
        return Err(KobanError::InvalidPayload {
            message: "--amount-paid requires --paid".to_string(),
        });
    }
    Ok(())
}

pub(crate) fn validate_path_segment(label: &str, value: &str) -> Result<()> {
    let is_safe = !value.is_empty()
        && value != "."
        && value != ".."
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'));

    if is_safe {
        Ok(())
    } else {
        Err(KobanError::InvalidPayload {
            message: format!("{label} must be a safe single path segment"),
        })
    }
}

pub(crate) fn invoice_payload(
    args: InvoicePayloadArgs,
    require_payload: bool,
    allow_empty_for_trigger: bool,
) -> Result<Value> {
    let has_raw = args.data.is_some() || args.data_file.is_some() || args.stdin;
    let has_guided = args.has_guided_fields();

    if has_raw && has_guided {
        return Err(KobanError::InvalidPayload {
            message: "raw JSON input cannot be combined with guided invoice flags".to_string(),
        });
    }

    if let Some(data) = args.data {
        return parse_json_payload(&data, "invoice payload");
    }
    if let Some(path) = args.data_file {
        let data = std::fs::read_to_string(&path).map_err(|source| KobanError::InvalidPayload {
            message: format!("could not read {}: {source}", path.display()),
        })?;
        return parse_json_payload(&data, "invoice payload");
    }
    if args.stdin {
        let mut data = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut data).map_err(|source| {
            KobanError::InvalidPayload {
                message: format!("could not read standard input: {source}"),
            }
        })?;
        return parse_json_payload(&data, "invoice payload");
    }

    if has_guided {
        return guided_invoice_payload(args);
    }

    if allow_empty_for_trigger {
        return Ok(json!({}));
    }

    if require_payload {
        Err(KobanError::InvalidPayload {
            message: "create requires JSON input or guided invoice flags".to_string(),
        })
    } else {
        Err(KobanError::InvalidPayload {
            message: "update requires JSON input, guided invoice flags, or a trigger flag"
                .to_string(),
        })
    }
}

pub(crate) fn guided_invoice_payload(args: InvoicePayloadArgs) -> Result<Value> {
    let mut body = serde_json::Map::new();
    insert_string(&mut body, "client_id", args.client_id);
    insert_string(&mut body, "date", args.date);
    insert_string(&mut body, "due_date", args.due_date);
    insert_string(&mut body, "number", args.number);
    insert_string(&mut body, "po_number", args.po_number);
    insert_string(&mut body, "public_notes", args.public_notes);
    insert_string(&mut body, "private_notes", args.private_notes);
    insert_string(&mut body, "terms", args.terms);
    insert_string(&mut body, "footer", args.footer);
    insert_string(&mut body, "project_id", args.project_id);

    if !args.line_items.is_empty() {
        let line_items = args
            .line_items
            .into_iter()
            .map(|line_item| parse_line_item(&line_item))
            .collect::<Result<Vec<_>>>()?;
        body.insert("line_items".to_string(), Value::Array(line_items));
    }

    Ok(Value::Object(body))
}

pub(crate) fn parse_line_item(input: &str) -> Result<Value> {
    let mut item = serde_json::Map::new();
    for part in input.split(',') {
        let Some((key, value)) = part.split_once('=') else {
            return Err(KobanError::InvalidPayload {
                message: format!("line item part `{part}` must use key=value"),
            });
        };
        let key = key.trim();
        if key.is_empty() {
            return Err(KobanError::InvalidPayload {
                message: format!("line item part `{part}` has an empty key"),
            });
        }
        item.insert(key.to_string(), parse_scalar(value.trim()));
    }

    if item.is_empty() {
        return Err(KobanError::InvalidPayload {
            message: "line item cannot be empty".to_string(),
        });
    }

    Ok(Value::Object(item))
}

impl InvoicePayloadArgs {
    pub(crate) fn has_guided_fields(&self) -> bool {
        self.client_id.is_some()
            || self.date.is_some()
            || self.due_date.is_some()
            || self.number.is_some()
            || self.po_number.is_some()
            || self.public_notes.is_some()
            || self.private_notes.is_some()
            || self.terms.is_some()
            || self.footer.is_some()
            || self.project_id.is_some()
            || !self.line_items.is_empty()
    }
}

impl InvoiceTriggerArgs {
    pub(crate) fn has_any(&self) -> bool {
        self.send_email
            || self.mark_sent
            || self.paid
            || self.amount_paid.is_some()
            || self.cancel
            || self.save_default_footer
            || self.save_default_terms
            || self.retry_e_send
    }

    pub(crate) fn requires_confirmation(&self) -> bool {
        self.has_any()
    }
}

pub(crate) fn push_invoice_triggers(
    query: &mut Vec<(String, String)>,
    triggers: &InvoiceTriggerArgs,
) {
    push_bool_query(query, "send_email", triggers.send_email);
    push_bool_query(query, "mark_sent", triggers.mark_sent);
    push_bool_query(query, "paid", triggers.paid);
    if let Some(amount_paid) = &triggers.amount_paid {
        query.push(("amount_paid".to_string(), amount_paid.clone()));
    }
    push_bool_query(query, "cancel", triggers.cancel);
    push_bool_query(query, "save_default_footer", triggers.save_default_footer);
    push_bool_query(query, "save_default_terms", triggers.save_default_terms);
    push_bool_query(query, "retry_e_send", triggers.retry_e_send);
}

fn push_bool_query(query: &mut Vec<(String, String)>, key: &str, enabled: bool) {
    if enabled {
        query.push((key.to_string(), "true".to_string()));
    }
}

pub(crate) fn render_dry_run(
    method: &str,
    path: &str,
    query: &[(String, String)],
    body: Option<&Value>,
    files: Option<&[PathBuf]>,
) -> Result<String> {
    let value = json!({
        "dry_run": true,
        "method": method,
        "path": path,
        "query": query.iter().map(|(key, value)| json!({"key": key, "value": value})).collect::<Vec<_>>(),
        "body": body,
        "files": files.map(|files| files.iter().map(|file| file.display().to_string()).collect::<Vec<_>>()),
    });
    serde_json::to_string_pretty(&value).map_err(|source| KobanError::Decode {
        message: source.to_string(),
    })
}