formanator 4.0.0

Submit Forma <https://joinforma.com> benefit claims from the command line, with support for AI-powered receipt analysis via OpenAI or GitHub Models
use anyhow::{Result, bail};
use colored::Colorize;

use crate::claims::{claim_input_to_create_options, read_claims_from_csv};
use crate::cli::SubmitClaimsFromCsvArgs;
use crate::config::resolve_access_token;
use crate::forma::{create_claim, get_benefits_with_categories};
use crate::llm::{infer_all_from_receipt, infer_category_and_benefit};
use crate::verbose;

pub fn run(args: SubmitClaimsFromCsvArgs) -> Result<()> {
    verbose::set(args.verbose);
    let access_token = resolve_access_token(args.access_token.as_deref())?;

    if !args.input_path.exists() {
        bail!("File '{}' doesn't exist.", args.input_path.display());
    }

    let claims = read_claims_from_csv(&args.input_path)?;
    if claims.is_empty() {
        bail!("Your CSV doesn't seem to contain any claims. Have you filled out the template?");
    }

    let benefits = get_benefits_with_categories(&access_token)?;
    let total = claims.len();

    for (index, mut claim) in claims.into_iter().enumerate() {
        let row_number = index + 2;
        println!(
            "Submitting claim {}/{} (row {row_number})",
            index + 1,
            total
        );

        let result = (|| -> Result<()> {
            let benefit_category_empty = claim.benefit.is_empty() && claim.category.is_empty();
            let other_details_empty = claim.amount.is_empty()
                && claim.merchant.is_empty()
                && claim.purchase_date.is_empty()
                && claim.description.is_empty();
            let other_details_all_filled = !claim.amount.is_empty()
                && !claim.merchant.is_empty()
                && !claim.purchase_date.is_empty()
                && !claim.description.is_empty();

            if !claim.benefit.is_empty() && !claim.category.is_empty() {
                let opts = claim_input_to_create_options(&claim, &access_token)?;
                if args.dry_run {
                    println!("{}", "Dry run: skipping claim submission.".yellow());
                    Ok(())
                } else {
                    create_claim(&opts)
                }
            } else if benefit_category_empty && other_details_empty {
                if claim.receipt_path.is_empty() {
                    anyhow::bail!(
                        "To infer all claim details from the receipt, you must provide at least one path in the `receiptPath` column."
                    );
                }
                let inferred = infer_all_from_receipt(
                    &claim.receipt_path[0],
                    &benefits,
                    args.openai_api_key.as_deref(),
                    args.github_models_token.as_deref(),
                    args.copilot_cli_path.as_deref(),
                )?;
                println!("Inferred amount: {}", inferred.amount);
                println!("Inferred merchant: {}", inferred.merchant);
                println!("Inferred purchase date: {}", inferred.purchase_date);
                println!("Inferred description: {}", inferred.description);
                println!("Inferred benefit: {}", inferred.benefit);
                println!("Inferred category: {}", inferred.category);
                claim.benefit = inferred.benefit;
                claim.category = inferred.category;
                claim.amount = inferred.amount;
                claim.merchant = inferred.merchant;
                claim.purchase_date = inferred.purchase_date;
                claim.description = inferred.description;
                let opts = claim_input_to_create_options(&claim, &access_token)?;
                if args.dry_run {
                    println!("{}", "Dry run: skipping claim submission.".yellow());
                    Ok(())
                } else {
                    create_claim(&opts)
                }
            } else if benefit_category_empty && other_details_all_filled {
                let inferred = infer_category_and_benefit(
                    &claim.merchant,
                    &claim.description,
                    &benefits,
                    args.openai_api_key.as_deref(),
                    args.github_models_token.as_deref(),
                    args.copilot_cli_path.as_deref(),
                )?;
                claim.benefit = inferred.benefit;
                claim.category = inferred.category;
                println!("Inferred benefit: {}", claim.benefit);
                println!("Inferred category: {}", claim.category);
                let opts = claim_input_to_create_options(&claim, &access_token)?;
                if args.dry_run {
                    println!("{}", "Dry run: skipping claim submission.".yellow());
                    Ok(())
                } else {
                    create_claim(&opts)
                }
            } else {
                anyhow::bail!(
                    "To use LLM inference, a row must either leave every column except `receiptPath` blank (full receipt inference), or fill every column except `benefit` and `category` (benefit/category inference only)."
                );
            }
        })();

        match result {
            Ok(()) => println!(
                "{}",
                format!(
                    "Successfully submitted claim {}/{} (row {row_number})",
                    index + 1,
                    total
                )
                .green()
            ),
            Err(e) => eprintln!(
                "{}",
                format!(
                    "Error submitting claim {}/{}: {e} (row {row_number})",
                    index + 1,
                    total
                )
                .red()
            ),
        }
    }

    Ok(())
}