rlru 0.1.17

Rocket League replay uploader
Documentation
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::config::{BehaviorConfig, Config};
use crate::paths::AppPaths;
use crate::state_file::write_atomically;
use crate::sync::{SyncService, SyncSummary};

pub async fn run_sync_daemon(
    paths: AppPaths,
    config_path: &Path,
    report_summary: impl Fn(&SyncSummary),
) -> Result<()> {
    let config = Config::load_or_default(config_path)?;
    let mut state = load_daemon_sync_state(&paths);
    if config.behavior.upload_on_launch {
        run_daemon_sync_cycle(&paths, config, &mut state, &report_summary).await;
    } else {
        ensure_next_sync_scheduled(&paths, &config, &mut state);
    }

    loop {
        let config = Config::load_or_default(config_path)?;
        let mut state = load_daemon_sync_state(&paths);

        if config.behavior.auto_upload {
            ensure_next_sync_scheduled(&paths, &config, &mut state);
            let delay = state
                .next_sync_after_at
                .map(duration_until)
                .unwrap_or(Duration::ZERO);

            if delay.is_zero() {
                run_daemon_sync_cycle(&paths, config, &mut state, &report_summary).await;
                continue;
            }

            tokio::select! {
                _ = tokio::signal::ctrl_c() => {
                    println!("stopping");
                    return Ok(());
                }
                _ = tokio::time::sleep(delay) => {}
            }
        } else {
            tokio::select! {
                _ = tokio::signal::ctrl_c() => {
                    println!("stopping");
                    return Ok(());
                }
                _ = tokio::time::sleep(Duration::from_secs(60)) => {}
            }
        }
    }
}

fn daemon_sleep_duration(behavior: &BehaviorConfig) -> Duration {
    behavior.auto_upload_interval.max(Duration::from_secs(60))
        + jitter_duration(behavior.auto_upload_jitter_max)
}

fn jitter_duration(max: Duration) -> Duration {
    let max_secs = max.as_secs();
    if max_secs == 0 {
        return Duration::ZERO;
    }

    let seed = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_nanos();
    Duration::from_secs((seed % (u128::from(max_secs) + 1)) as u64)
}

async fn run_daemon_sync_cycle(
    paths: &AppPaths,
    config: Config,
    state: &mut DaemonSyncState,
    report_summary: &impl Fn(&SyncSummary),
) {
    let behavior = config.behavior.clone();
    state.last_started_at = Some(Utc::now());
    state.last_error = None;
    save_daemon_sync_state(paths, state);

    match SyncService::new(paths.clone(), config).run_once().await {
        Ok(summary) => {
            state.last_completed_at = Some(Utc::now());
            state.last_error = None;
            report_summary(&summary);
        }
        Err(error) => {
            state.last_error = Some(error.to_string());
            tracing::warn!(%error, "sync cycle failed");
        }
    }
    state.next_sync_after_at = Some(next_sync_after(&behavior));
    save_daemon_sync_state(paths, state);
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct DaemonSyncState {
    last_started_at: Option<DateTime<Utc>>,
    last_completed_at: Option<DateTime<Utc>>,
    next_sync_after_at: Option<DateTime<Utc>>,
    last_error: Option<String>,
}

fn ensure_next_sync_scheduled(paths: &AppPaths, config: &Config, state: &mut DaemonSyncState) {
    if state.next_sync_after_at.is_none() {
        state.next_sync_after_at = Some(next_sync_after(&config.behavior));
        save_daemon_sync_state(paths, state);
    }
}

fn next_sync_after(behavior: &BehaviorConfig) -> DateTime<Utc> {
    Utc::now()
        + chrono::Duration::from_std(daemon_sleep_duration(behavior))
            .expect("daemon sync interval fits in chrono::Duration")
}

fn duration_until(time: DateTime<Utc>) -> Duration {
    time.signed_duration_since(Utc::now())
        .to_std()
        .unwrap_or(Duration::ZERO)
}

fn load_daemon_sync_state(paths: &AppPaths) -> DaemonSyncState {
    let path = paths.sync_state_file();
    match fs::read_to_string(&path) {
        Ok(content) => match toml::from_str(&content) {
            Ok(state) => state,
            Err(error) => {
                tracing::warn!(%error, path = %path.display(), "failed to parse daemon sync state");
                DaemonSyncState::default()
            }
        },
        Err(error) if error.kind() == ErrorKind::NotFound => DaemonSyncState::default(),
        Err(error) => {
            tracing::warn!(%error, path = %path.display(), "failed to read daemon sync state");
            DaemonSyncState::default()
        }
    }
}

fn save_daemon_sync_state(paths: &AppPaths, state: &DaemonSyncState) {
    if let Err(error) = write_daemon_sync_state(paths, state) {
        tracing::warn!(%error, "failed to write daemon sync state");
    }
}

fn write_daemon_sync_state(paths: &AppPaths, state: &DaemonSyncState) -> Result<()> {
    let path = paths.sync_state_file();
    let content = toml::to_string_pretty(state).context("failed to serialize daemon sync state")?;
    write_atomically(&path, content)
        .with_context(|| format!("failed to write daemon sync state {}", path.display()))
}