use std::time::Duration;
mod format;
mod model;
pub use format::*;
pub use model::*;
use format::{auth_label, format_record_start_timestamp, platform_label};
use crate::auth::AuthManager;
use crate::config::{AccountConfig, BehaviorConfig, PlayerPlatform};
use crate::paths::AppPaths;
use crate::sync::{SyncOptions, SyncService};
use crate::Config;
struct AppContext {
paths: AppPaths,
config_path: std::path::PathBuf,
config: Config,
}
impl AppContext {
fn load(ensure_paths: bool) -> Result<Self, String> {
let paths = AppPaths::discover().map_err(|error| error.to_string())?;
if ensure_paths {
paths.ensure().map_err(|error| error.to_string())?;
}
let config_path = paths.config_file();
let config = Config::load_or_default(&config_path).map_err(|error| error.to_string())?;
Ok(Self {
paths,
config_path,
config,
})
}
fn sync_service(self) -> SyncService {
SyncService::new(self.paths, self.config)
}
fn save_config(&self) -> Result<(), String> {
self.config
.save(&self.config_path)
.map_err(|error| error.to_string())
}
}
pub fn load_summary() -> AppSummary {
match AppContext::load(false) {
Ok(context) => summary_from_config(&context.paths, &context.config_path, &context.config),
Err(error) => unavailable_summary(error),
}
}
fn summary_from_config(
paths: &AppPaths,
config_path: &std::path::Path,
config: &Config,
) -> AppSummary {
let selected_account = config.behavior.selected_account.clone();
let selected_upload_destination = config.behavior.selected_upload_destination.clone();
let auto_upload = config.behavior.auto_upload;
AppSummary {
config_path: config_path.display().to_string(),
accounts: config
.accounts
.iter()
.map(|account| AccountSummary {
id: account.id,
name: account.name.clone(),
platform: platform_label(&account.platform).to_string(),
sync_enabled: account.sync_enabled,
selected: selected_account.as_ref() == Some(&account.name),
saved_auth: account.platform == PlayerPlatform::Epic
&& AuthManager::for_account(paths, account)
.has_saved_login()
.unwrap_or(false),
})
.collect(),
upload_destinations: config
.upload_destinations
.iter()
.map(|target| UploadDestinationSummary {
name: target.name.clone(),
url: target.url.to_string(),
upload_enabled: target.replay_upload.enabled,
automatic: auto_upload
&& target.replay_upload.enabled
&& selected_upload_destination
.as_ref()
.is_none_or(|selected| selected == &target.name),
auth: auth_label(&target.auth),
})
.collect(),
auto_upload,
upload_on_launch: config.behavior.upload_on_launch,
no_upload_while_connected: config.behavior.no_upload_while_connected,
selected_account,
selected_upload_destination,
auto_upload_interval_minutes: config.behavior.auto_upload_interval.as_secs() / 60,
auto_upload_jitter_minutes: config.behavior.auto_upload_jitter_max.as_secs() / 60,
interval: format!(
"Every {} minutes",
config.behavior.auto_upload_interval.as_secs() / 60
),
jitter: format!(
"{} minutes",
config.behavior.auto_upload_jitter_max.as_secs() / 60
),
status: "Ready for auth, sync, and uploader runs".to_string(),
}
}
fn unavailable_summary(error: String) -> AppSummary {
AppSummary {
config_path: error,
accounts: Vec::new(),
upload_destinations: Vec::new(),
auto_upload: false,
upload_on_launch: false,
no_upload_while_connected: false,
selected_account: None,
selected_upload_destination: None,
auto_upload_interval_minutes: 45,
auto_upload_jitter_minutes: 15,
interval: "Unavailable".to_string(),
jitter: "Unavailable".to_string(),
status: "Could not load local app state".to_string(),
}
}
pub async fn load_history() -> Result<Vec<HistoryRow>, String> {
let entries = AppContext::load(false)?
.sync_service()
.current_history(None)
.await
.map_err(|error| error.to_string())?;
Ok(entries
.into_iter()
.map(|entry| {
let upload_destinations = entry
.upload_states
.into_iter()
.map(|state| {
let label = if state.cached {
"Uploaded"
} else if !state.upload_enabled {
"Disabled"
} else {
"Not uploaded"
};
HistoryUploadDestination {
target_name: state.target_name,
state: label.to_string(),
uploaded: state.cached,
upload_enabled: state.upload_enabled,
location: state.location,
}
})
.collect();
HistoryRow {
account: entry.account_name,
match_id: entry.match_id,
timestamp: format_record_start_timestamp(entry.record_start_timestamp),
map_name: entry.map_name,
playlist: entry.playlist.to_string(),
score: format!("{}-{}", entry.team0_score, entry.team1_score),
upload_destinations,
}
})
.collect())
}
pub async fn backfill_upload_destinations() -> Result<BackfillSummary, String> {
let summary = AppContext::load(true)?
.sync_service()
.run_once_with_options(SyncOptions {
include_online: true,
target_name: None,
force: false,
match_ids: Vec::new(),
})
.await
.map_err(|error| error.to_string())?;
Ok(summary.into())
}
pub async fn upload_history_replay(
request: ReplayUploadRequest,
) -> Result<BackfillSummary, String> {
let summary = AppContext::load(true)?
.sync_service()
.run_once_with_options(SyncOptions {
include_online: true,
target_name: Some(request.target_name),
force: true,
match_ids: vec![request.match_id],
})
.await
.map_err(|error| error.to_string())?;
if summary.matches_seen == 0 {
return Err("No matching replay was found in current RL API history".to_string());
}
Ok(summary.into())
}
pub fn add_account(input: AccountFormData) -> Result<AppSummary, String> {
let name = input.name.trim();
if name.is_empty() {
return Err("Account name is required".to_string());
}
let platform = parse_platform(&input.platform)?;
let mut context = AppContext::load(true)?;
if context
.config
.accounts
.iter()
.any(|account| account.name == name)
{
return Err(format!("Account {name:?} already exists"));
}
let account = if !context.config_path.exists()
&& context.config.accounts == vec![AccountConfig::default()]
{
context.config.accounts[0] =
AccountConfig::new(0, name.to_string(), platform, input.sync_enabled);
context.config.accounts[0].clone()
} else {
let next_id = context
.config
.accounts
.iter()
.map(|account| account.id)
.max()
.unwrap_or(0)
.saturating_add(1);
let account = AccountConfig::new(next_id, name.to_string(), platform, input.sync_enabled);
context.config.accounts.push(account.clone());
account
};
context.config.behavior.selected_account = Some(account.name);
context.save_config()?;
Ok(load_summary())
}
pub fn begin_account_auth(account_id: u32) -> Result<AccountAuthPrompt, String> {
let context = AppContext::load(true)?;
let account = context
.config
.accounts
.iter()
.find(|account| account.id == account_id)
.ok_or_else(|| format!("Account ID {account_id} no longer exists"))?;
if account.platform != PlayerPlatform::Epic {
return Err("Only Epic accounts can be authenticated".to_string());
}
let auth = AuthManager::for_account(&context.paths, account);
Ok(AccountAuthPrompt {
account_id,
account_name: account.name.clone(),
login_url: auth.login_url(),
})
}
pub async fn finish_account_auth(
prompt: AccountAuthPrompt,
code: String,
) -> Result<String, String> {
let context = AppContext::load(true)?;
let account = context
.config
.accounts
.iter()
.find(|account| account.id == prompt.account_id)
.ok_or_else(|| format!("Account ID {} no longer exists", prompt.account_id))?;
let code = code.trim();
if code.is_empty() {
return Err("Epic authorization code is required".to_string());
}
let auth = AuthManager::for_account(&context.paths, account);
let eos = auth
.authenticate_with_code(code)
.await
.map_err(|error| error.to_string())?;
Ok(format!(
"Authenticated {} as Epic account {}",
prompt.account_name, eos.account_id
))
}
pub fn remove_account(account_id: u32) -> Result<AppSummary, String> {
update_config(|config| {
if config.accounts.len() <= 1 {
return Err("Config must keep at least one account".to_string());
}
let Some(index) = config
.accounts
.iter()
.position(|account| account.id == account_id)
else {
return Err(format!("Account ID {account_id} no longer exists"));
};
let removed = config.accounts.remove(index);
if config.behavior.selected_account.as_ref() == Some(&removed.name) {
config.behavior.selected_account = None;
}
Ok(())
})
}
pub fn save_auto_upload(enabled: bool) -> Result<AppSummary, String> {
update_behavior(|behavior| behavior.auto_upload = enabled)
}
pub fn save_overview_config(input: OverviewConfigFormData) -> Result<AppSummary, String> {
let interval_minutes = parse_minutes(
"sync interval",
&input.auto_upload_interval_minutes,
Some(1),
)?;
let jitter_minutes = parse_minutes("jitter max", &input.auto_upload_jitter_minutes, None)?;
update_behavior(|behavior| {
behavior.auto_upload_interval = Duration::from_secs(interval_minutes * 60);
behavior.auto_upload_jitter_max = Duration::from_secs(jitter_minutes * 60);
behavior.upload_on_launch = input.upload_on_launch;
behavior.no_upload_while_connected = input.no_upload_while_connected;
})
}
fn update_config(
mut update: impl FnMut(&mut Config) -> Result<(), String>,
) -> Result<AppSummary, String> {
let mut context = AppContext::load(true)?;
update(&mut context.config)?;
context.save_config()?;
Ok(load_summary())
}
fn update_behavior(mut update: impl FnMut(&mut BehaviorConfig)) -> Result<AppSummary, String> {
update_config(|config| {
update(&mut config.behavior);
Ok(())
})
}
fn parse_platform(value: &str) -> Result<PlayerPlatform, String> {
match value {
"epic" => Ok(PlayerPlatform::Epic),
"steam" => Ok(PlayerPlatform::Steam),
"play_station" => Ok(PlayerPlatform::PlayStation),
"xbox" => Ok(PlayerPlatform::Xbox),
"nintendo" => Ok(PlayerPlatform::Nintendo),
_ => Err(format!("Unsupported platform {value:?}")),
}
}
fn parse_minutes(label: &str, value: &str, minimum: Option<u64>) -> Result<u64, String> {
let minutes = value
.trim()
.parse::<u64>()
.map_err(|_| format!("{label} must be a whole number of minutes"))?;
if let Some(minimum) = minimum {
if minutes < minimum {
return Err(format!("{label} must be at least {minimum} minute"));
}
}
Ok(minutes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dedupes_failed_uploads_by_target_and_match() {
let requests = vec![
ReplayUploadRequest {
target_name: "Rocket Sense".to_string(),
match_id: "abc".to_string(),
reason: Some("old".to_string()),
},
ReplayUploadRequest {
target_name: "Rocket Sense".to_string(),
match_id: "abc".to_string(),
reason: Some("new".to_string()),
},
];
assert_eq!(
dedupe_upload_requests(requests),
vec![ReplayUploadRequest {
target_name: "Rocket Sense".to_string(),
match_id: "abc".to_string(),
reason: Some("new".to_string()),
}]
);
}
#[test]
fn formats_failed_backfill_message_with_first_issue() {
let message = format_backfill_message(
"Backfill complete: 0 uploaded, 0 duplicates, 0 cached, 1 failed".to_string(),
&[ReplayUploadRequest {
target_name: "Rocket Sense".to_string(),
match_id: "123456789".to_string(),
reason: Some("token missing".to_string()),
}],
);
assert_eq!(
message,
"Backfill complete: 0 uploaded, 0 duplicates, 0 cached, 1 failed; first issue: 12345678 to Rocket Sense: token missing"
);
}
#[test]
fn sync_run_state_transitions_preserve_previous_context() {
let previous = SyncRunState {
running: false,
last_started_at: Some("old-start".to_string()),
last_completed_at: Some("old-complete".to_string()),
last_summary: Some(BackfillSummary {
uploaded: 1,
duplicates: 2,
cached: 3,
failed: 4,
failed_match_ids: vec!["old-match".to_string()],
failed_uploads: Vec::new(),
}),
last_error: Some("old-error".to_string()),
};
let running = previous.started("new-start".to_string());
assert!(running.running);
assert_eq!(running.last_started_at.as_deref(), Some("new-start"));
assert_eq!(running.last_completed_at.as_deref(), Some("old-complete"));
assert!(running.last_summary.is_some());
assert_eq!(running.last_error, None);
let completed = running.completed(
"new-complete".to_string(),
BackfillSummary {
uploaded: 5,
duplicates: 0,
cached: 0,
failed: 0,
failed_match_ids: Vec::new(),
failed_uploads: Vec::new(),
},
);
assert!(!completed.running);
assert_eq!(completed.last_started_at.as_deref(), Some("new-start"));
assert_eq!(completed.last_completed_at.as_deref(), Some("new-complete"));
assert_eq!(
completed
.last_summary
.as_ref()
.map(|summary| summary.uploaded),
Some(5)
);
assert_eq!(completed.last_error, None);
let failed = completed.failed("failed-at".to_string(), "boom".to_string());
assert!(!failed.running);
assert_eq!(failed.last_completed_at.as_deref(), Some("failed-at"));
assert_eq!(
failed.last_summary.as_ref().map(|summary| summary.uploaded),
Some(5)
);
assert_eq!(failed.last_error.as_deref(), Some("boom"));
}
}