edinet_cli 0.0.3

A command-line interface for EDINET.
// SPDX-FileCopyrightText: 2026 Strike Group Co., Ltd.
//
// SPDX-License-Identifier: Apache-2.0

use anyhow::Context;
use clap::ArgGroup;

#[derive(clap::Args, Debug)]
#[command(group(
    ArgGroup::new("duration")
        .args(["from_date", "to_date"])
        .multiple(true)
))]
pub struct UpdateArgs {
    #[arg(
        long = "from",
        short = 'f',
        value_name = "DATE",
        help = "取り込み期間の開始日を指定します(YYYY-MM-DD、`--to` と併用)"
    )]
    pub from_date: Option<String>,
    #[arg(
        long = "to",
        short = 't',
        value_name = "DATE",
        help = "取り込み期間の終了日を指定します(YYYY-MM-DD、`--from` と併用)"
    )]
    pub to_date: Option<String>,
    #[arg(
        long,
        short = 'y',
        value_name = "N",
        conflicts_with_all = ["duration", "today"],
        help = "今日を含む直近 N 年の未更新日を取り込みます(N は 1 以上)"
    )]
    pub years: Option<std::num::NonZeroU32>,
    #[arg(
        long,
        short = 'd',
        conflicts_with = "duration",
        help = "今日の書類情報だけを取り込みます"
    )]
    pub today: bool,
    #[arg(
        long,
        short = 's',
        conflicts_with = "concurrency",
        help = "日次 API 取得を直列実行します"
    )]
    pub sequential: bool,
    #[arg(
        long,
        short = 'F',
        help = "更新済み日付を無視して対象期間をすべて再取得します"
    )]
    pub force: bool,
    #[arg(
        long,
        short = 'p',
        value_name = "N",
        default_value = "4",
        help = "日次 API 取得の最大並列数を指定します(N は 1 以上)"
    )]
    pub concurrency: std::num::NonZeroUsize,
    #[arg(
        long,
        short = 'k',
        value_name = "API_KEY",
        help = "この実行で使う EDINET API キー(登録済みキーより優先)"
    )]
    pub key: Option<String>,
}

pub async fn run(args: UpdateArgs) -> anyhow::Result<()> {
    let concurrency = if args.sequential {
        1
    } else {
        args.concurrency.get()
    };
    let years_range = args
        .years
        .map(|years| {
            let today = chrono::Local::now().date_naive();
            calculate_year_window_start(&today, years).map(|start_date| (start_date, today))
        })
        .transpose()?;
    let db = crate::store::open_db::open_db_connection().await?;
    let api_key = crate::app_config::resolve_api_key(args.key.as_deref())?;
    if args.today {
        let today = chrono::Local::now().date_naive();
        crate::updater::update_documents::update_one_day(&db, &today, &api_key).await?;
        return Ok(());
    }
    if let Some((start_date, today)) = years_range {
        crate::updater::update_documents::update_days(
            &db,
            &start_date,
            &today,
            &api_key,
            concurrency,
            args.force,
        )
        .await?;
        return Ok(());
    }
    if args.from_date.is_none() && args.to_date.is_none() {
        let today = chrono::Local::now().date_naive();
        crate::updater::update_documents::update_recent_days(
            &db,
            &today,
            &api_key,
            concurrency,
            args.force,
        )
        .await?;
        return Ok(());
    }
    if args.from_date.is_none() || args.to_date.is_none() {
        return Err(anyhow::anyhow!("--from and --to must be used together"));
    }
    let from_date = parse_date(
        args.from_date
            .as_deref()
            .expect("--from should be present after validation"),
    )?;
    let to_date = parse_date(
        args.to_date
            .as_deref()
            .expect("--to should be present after validation"),
    )?;
    crate::updater::update_documents::update_range(
        &db,
        &from_date,
        &to_date,
        &api_key,
        concurrency,
    )
    .await?;

    Ok(())
}

fn parse_date(date: &str) -> anyhow::Result<chrono::NaiveDate> {
    chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
        .with_context(|| format!("failed to parse date: {}", date))
}

fn calculate_year_window_start(
    today: &chrono::NaiveDate,
    years: std::num::NonZeroU32,
) -> anyhow::Result<chrono::NaiveDate> {
    let months = years
        .get()
        .checked_mul(12)
        .context("year count is too large")?;
    today
        .checked_sub_months(chrono::Months::new(months))
        .and_then(|date| date.succ_opt())
        .context("failed to calculate update start date")
}

#[cfg(test)]
mod tests {
    use super::calculate_year_window_start;

    #[test]
    fn calculates_inclusive_calendar_year_window() {
        let today = chrono::NaiveDate::from_ymd_opt(2026, 8, 11).unwrap();
        let years = std::num::NonZeroU32::new(2).unwrap();

        assert_eq!(
            calculate_year_window_start(&today, years).unwrap(),
            chrono::NaiveDate::from_ymd_opt(2024, 8, 12).unwrap()
        );
    }

    #[test]
    fn calculates_calendar_year_window_across_leap_day() {
        let today = chrono::NaiveDate::from_ymd_opt(2025, 2, 28).unwrap();
        let years = std::num::NonZeroU32::new(1).unwrap();

        assert_eq!(
            calculate_year_window_start(&today, years).unwrap(),
            chrono::NaiveDate::from_ymd_opt(2024, 2, 29).unwrap()
        );
    }
}