toggl-jira-sync 0.1.19

Local Toggl to Jira worklog sync CLI with SQLite state and a Ratatui status UI
Documentation
use anyhow::Context;

use crate::{
    cli::RecoverArgs,
    commands::config::{
        load_default_credentials, resolve_config_path, resolve_db_path, LocalCredentials,
    },
    config::AppConfig,
    db::{Database, NewSyncRun},
    jira::JiraClient,
    sync::{
        planner::{extract_issue_keys, IssueSiteMapping},
        recovery::{recover, RecoveryInput, RecoveryReport, RecoverySite},
        resolver::{IssueSiteResolutionError, IssueSiteResolver, ResolverSite},
    },
    toggl::{TogglClient, TogglClientConfig, TogglTimeEntry},
};

const SECONDS_PER_DAY: i64 = 24 * 60 * 60;

pub async fn run(args: RecoverArgs) -> anyhow::Result<()> {
    let uses_default_config = args.paths.config.is_none();
    let config_path = resolve_config_path(args.paths.config)?;
    let config = AppConfig::from_path(&config_path)
        .with_context(|| format!("failed to load config {}", config_path.display()))?;
    let credentials = if uses_default_config {
        load_default_credentials()?
    } else {
        LocalCredentials::default()
    };
    let db_path = resolve_db_path(
        args.paths.db,
        &config_path,
        config.runtime.sqlite_path.as_deref(),
        "recover",
    )?;

    let database = Database::open(&db_path)
        .with_context(|| format!("failed to open SQLite DB {}", db_path.display()))?;
    database
        .run_migrations()
        .context("failed to run DB migrations")?;
    let lock = database
        .acquire_sync_lock("recover")
        .context("failed to acquire sync lock")?;
    database
        .insert_sync_run(&NewSyncRun {
            run_id: &format!("recover-{}", current_unix_seconds()),
            mode: "recover",
            status: "running",
        })
        .context("failed to insert recovery audit row")?;

    let toggl_token = credentials.get_secret(&config.toggl.api_token_env)?;
    let toggl_config =
        TogglClientConfig::from_app_config(&config, toggl_token, config.toggl.base_url.clone())
            .context("failed to build Toggl client config")?;
    let toggl = TogglClient::new(toggl_config).context("failed to build Toggl client")?;
    let since = recovery_since(
        current_unix_seconds(),
        config.runtime.recovery_from_month.as_deref(),
        config.runtime.recovery_scan_days,
    );
    let fetch = toggl
        .fetch_time_entries_since(since)
        .await
        .context("failed to fetch Toggl entries for recovery")?;

    let recovery_sites = build_recovery_sites(&config, &credentials)?;
    let issue_site_mappings =
        resolve_issue_site_mappings(&config, &credentials, &database, &fetch.entries)
            .await
            .context("failed to resolve Jira issue sites for recovery")?;
    let report = recover(RecoveryInput {
        database: &database,
        entries: fetch.entries,
        issue_site_mappings,
        recovery_sites,
        recovery_scan_days: config.runtime.recovery_scan_days,
        requested_scan_days: None,
    })
    .await
    .context("failed to recover Jira worklog links")?;

    lock.release().context("failed to release sync lock")?;

    if args.json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        println!("{}", human_report(&report));
    }

    Ok(())
}

async fn resolve_issue_site_mappings(
    config: &AppConfig,
    credentials: &LocalCredentials,
    database: &Database,
    entries: &[TogglTimeEntry],
) -> anyhow::Result<Vec<IssueSiteMapping>> {
    let resolver = IssueSiteResolver::new(database, build_resolver_sites(config, credentials)?);
    let mut issue_keys = entries
        .iter()
        .flat_map(|entry| extract_issue_keys(entry.description.as_deref().unwrap_or_default()))
        .collect::<Vec<_>>();
    issue_keys.sort();
    issue_keys.dedup();

    let mut mappings = Vec::with_capacity(issue_keys.len());
    for issue_key in issue_keys {
        match resolver.resolve_issue_key(&issue_key).await {
            Ok(resolved) => mappings.push(resolved.into()),
            Err(
                IssueSiteResolutionError::NoMatchingSite { .. }
                | IssueSiteResolutionError::MultipleMatchingSites { .. },
            ) => {}
            Err(error) => return Err(error.into()),
        }
    }
    Ok(mappings)
}

fn build_resolver_sites(
    config: &AppConfig,
    credentials: &LocalCredentials,
) -> anyhow::Result<Vec<ResolverSite>> {
    config
        .enabled_jira_sites()
        .into_iter()
        .map(|site| {
            let email = credentials.get_secret(&site.email_env)?;
            let token = credentials.get_secret(&site.api_token_env)?;
            Ok(ResolverSite {
                key: site.key.clone(),
                client: JiraClient::from_credentials(site.base_url.clone(), email, token),
            })
        })
        .collect()
}

fn build_recovery_sites(
    config: &AppConfig,
    credentials: &LocalCredentials,
) -> anyhow::Result<Vec<RecoverySite>> {
    config
        .enabled_jira_sites()
        .into_iter()
        .map(|site| {
            let email = credentials.get_secret(&site.email_env)?;
            let token = credentials.get_secret(&site.api_token_env)?;
            Ok(RecoverySite {
                key: site.key.clone(),
                client: JiraClient::from_credentials(site.base_url.clone(), email, token),
            })
        })
        .collect()
}

fn recovery_since(
    now_unix_seconds: i64,
    recovery_from_month: Option<&str>,
    recovery_scan_days: u32,
) -> i64 {
    if let Some(month) = recovery_from_month.and_then(crate::time::month_start_since) {
        return month;
    }
    now_unix_seconds - i64::from(recovery_scan_days) * SECONDS_PER_DAY
}

fn human_report(report: &RecoveryReport) -> String {
    format!(
        "Recovery scanned {} Toggl entries across {} Jira issues and {} worklogs; recovered {} links; conflicts: {}; warnings: {}",
        report.scanned_entries,
        report.scanned_issues,
        report.scanned_worklogs,
        report.recovered_links,
        report.conflicts.len(),
        report.warnings.len()
    )
}

fn current_unix_seconds() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_secs() as i64)
        .unwrap_or_default()
}