nils-memo-cli 0.3.3

CLI crate for nils-memo-cli in the nils-cli workspace.
Documentation
use chrono::{Datelike, Duration, TimeZone, Utc};
use serde_json::json;

use crate::cli::{OutputMode, ReportArgs, ReportPeriod};
use crate::errors::AppError;
use crate::output::{emit_json_result, text};
use crate::storage::Storage;
use crate::storage::search::{self, ReportRangeQuery};
use crate::timestamps::{format_utc, parse_rfc3339_utc, parse_timezone};

pub fn run(storage: &Storage, output_mode: OutputMode, args: &ReportArgs) -> Result<(), AppError> {
    let query = resolve_report_range(args)?;
    let summary =
        storage.with_connection(|conn| search::report_summary_with_range(conn, &query))?;

    if output_mode.is_json() {
        return emit_json_result(
            "memo-cli.report.v1",
            "memo-cli report",
            json!({
                "period": summary.period,
                "range": summary.range,
                "totals": summary.totals,
                "top_categories": summary.top_categories,
                "top_tags": summary.top_tags,
                "top_content_types": summary.top_content_types,
                "validation_status_totals": summary.validation_status_totals,
            }),
        );
    }

    text::print_report(&summary);

    Ok(())
}

fn resolve_report_range(args: &ReportArgs) -> Result<ReportRangeQuery, AppError> {
    let tz_name = args.tz.clone().unwrap_or_else(|| "UTC".to_string());
    let tz = parse_timezone(&tz_name)?;
    let period = period_label(args.period).to_string();

    match (args.from.as_deref(), args.to.as_deref()) {
        (Some(from_raw), Some(to_raw)) => {
            let from = parse_rfc3339_utc(from_raw, "--from")?;
            let to = parse_rfc3339_utc(to_raw, "--to")?;
            if from > to {
                return Err(AppError::usage("--from must be less than or equal to --to")
                    .with_code("invalid-time-range"));
            }

            Ok(ReportRangeQuery {
                period,
                from: format_utc(from),
                to: format_utc(to),
                timezone: tz_name,
            })
        }
        (None, None) => {
            let now_tz = Utc::now().with_timezone(&tz);
            let from_tz = match args.period {
                ReportPeriod::Week => now_tz - Duration::days(7),
                ReportPeriod::Month => tz
                    .with_ymd_and_hms(now_tz.year(), now_tz.month(), 1, 0, 0, 0)
                    .single()
                    .ok_or_else(|| {
                        AppError::runtime("failed to calculate month boundary for timezone")
                            .with_code("invalid-timezone")
                    })?,
            };

            Ok(ReportRangeQuery {
                period,
                from: format_utc(from_tz.with_timezone(&Utc)),
                to: format_utc(now_tz.with_timezone(&Utc)),
                timezone: tz_name,
            })
        }
        _ => Err(AppError::usage("--from and --to must be provided together")
            .with_code("invalid-arguments")),
    }
}

fn period_label(period: ReportPeriod) -> &'static str {
    match period {
        ReportPeriod::Week => "week",
        ReportPeriod::Month => "month",
    }
}