use bat_adapters::official::yostar_jp::PatchPlatform; use bat_infrastructure::{ OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateService, OfficialUpdateStatus, }; use serde::Serialize; use std::env; use std::path::PathBuf; use std::thread; use std::time::Duration; const EXIT_ERROR: i32 = 1; const EXIT_LOCKED: i32 = 75; const DEFAULT_WATCH_INTERVAL_SECONDS: u64 = 60 * 60; const DEFAULT_ERROR_RETRY_SECONDS: u64 = 60; fn main() { match run() { Ok(()) => {} Err(error) => { let exit_code = if error.to_string().contains("locked") { EXIT_LOCKED } else { EXIT_ERROR }; let payload = ErrorReport { status: "error", exit_code, error: error.to_string(), next_retry_seconds: None, }; eprintln!( "{}", serde_json::to_string_pretty(&payload) .unwrap_or_else(|_| "{\"status\":\"error\"}".to_string()) ); std::process::exit(exit_code); } } } fn run() -> anyhow::Result<()> { let options = parse_args()?; if options.watch { run_watch(options)?; } else { let report = OfficialUpdateService::new().run(&options.config)?; if should_print_status(report.update_status, options.quiet_up_to_date) { println!("{}", serde_json::to_string_pretty(&report)?); } } Ok(()) } #[derive(Debug, Serialize)] struct ErrorReport<'a> { status: &'a str, exit_code: i32, error: String, #[serde(skip_serializing_if = "Option::is_none")] next_retry_seconds: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct CliOptions { config: OfficialUpdateConfig, watch: bool, interval: Duration, error_retry_interval: Duration, quiet_up_to_date: bool, quiet_up_to_date_explicit: bool, } impl Default for CliOptions { fn default() -> Self { Self { config: OfficialUpdateConfig::default(), watch: false, interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS), error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS), quiet_up_to_date: false, quiet_up_to_date_explicit: false, } } } fn run_watch(options: CliOptions) -> anyhow::Result<()> { if options.config.dry_run { return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run")); } if options.interval.is_zero() { return Err(anyhow::anyhow!("watch interval must be greater than zero")); } if options.error_retry_interval.is_zero() { return Err(anyhow::anyhow!( "watch error retry interval must be greater than zero" )); } let service = OfficialUpdateService::new(); loop { let mut sleep_for = options.interval; match service.run(&options.config) { Ok(report) => { if should_print_status(report.update_status, options.quiet_up_to_date) { println!("{}", serde_json::to_string_pretty(&report)?); } } Err(error) => { sleep_for = options.error_retry_interval; let payload = ErrorReport { status: "error", exit_code: EXIT_ERROR, error: error.to_string(), next_retry_seconds: Some(sleep_for.as_secs()), }; eprintln!("{}", serde_json::to_string_pretty(&payload)?); } } thread::sleep(sleep_for); } } fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool { !(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate) } fn parse_args() -> anyhow::Result { parse_args_from(env::args()) } fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result { let mut args = raw_args.into_iter(); let binary = args .next() .unwrap_or_else(|| "bat-official-sync".to_string()); let mut options = CliOptions::default(); while let Some(flag) = args.next() { match flag.as_str() { "--auto-discover" => { options.config.auto_discover = true; } "--server-info-url" => { options.config.server_info_source = Some(OfficialServerInfoSource::OfficialUrl( next_option_value(&mut args, &flag)?, )); } "--server-info-file" => { options.config.server_info_source = Some(OfficialServerInfoSource::OfficialFile( next_option_value(&mut args, &flag)?, )); } "--server-info-path" => { options.config.server_info_source = Some(OfficialServerInfoSource::LocalPath( PathBuf::from(next_option_value(&mut args, &flag)?), )); } "--connection-group" => { options.config.connection_group = Some(next_option_value(&mut args, &flag)?); } "--app-version" => { options.config.app_version = Some(next_option_value(&mut args, &flag)?); } "--launcher-version" => { options.config.launcher_version = next_option_value(&mut args, &flag)?; } "--platforms" => { let value = next_option_value(&mut args, &flag)?; options.config.platforms = Some(parse_platforms(&value).map_err(anyhow::Error::msg)?); } "--output" => { options.config.output_root = PathBuf::from(next_option_value(&mut args, &flag)?); } "--snapshot" => { options.config.snapshot_path = Some(PathBuf::from(next_option_value(&mut args, &flag)?)); } "--curl" => { options.config.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?); } "--unzip" => { options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?); } "--dry-run" => { options.config.dry_run = true; } "--plan" => { options.config.plan = true; } "--force" => { options.config.force = true; } "--audit-local" => { options.config.audit_local = true; } "--no-audit-local" => { options.config.audit_local = false; } "--repair" => { options.config.repair = true; } "--no-repair" => { options.config.repair = false; } "--watch" => { options.watch = true; } "--interval" => { options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?; } "--interval-seconds" => { let seconds = next_option_value(&mut args, &flag)? .parse::() .map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?; options.interval = Duration::from_secs(seconds); } "--error-retry" => { options.error_retry_interval = parse_duration(&next_option_value(&mut args, &flag)?)?; } "--error-retry-seconds" => { let seconds = next_option_value(&mut args, &flag)? .parse::() .map_err(|error| anyhow::anyhow!("invalid seconds for {flag}: {error}"))?; options.error_retry_interval = Duration::from_secs(seconds); } "--quiet-up-to-date" => { options.quiet_up_to_date = true; options.quiet_up_to_date_explicit = true; } "--no-quiet-up-to-date" => { options.quiet_up_to_date = false; options.quiet_up_to_date_explicit = true; } "--help" | "-h" => { print_usage(&binary); std::process::exit(0); } _ => return Err(anyhow::anyhow!("unknown argument: {flag}")), } } if options.watch && options.config.dry_run { return Err(anyhow::anyhow!("watch mode cannot be used with --dry-run")); } if options.interval.is_zero() { return Err(anyhow::anyhow!("watch interval must be greater than zero")); } if options.error_retry_interval.is_zero() { return Err(anyhow::anyhow!( "watch error retry interval must be greater than zero" )); } if options.watch && !options.quiet_up_to_date_explicit { options.quiet_up_to_date = true; } Ok(options) } fn next_option_value( args: &mut impl Iterator, flag: &str, ) -> anyhow::Result { args.next() .ok_or_else(|| anyhow::anyhow!("missing value for {flag}")) } fn print_usage(binary: &str) { eprintln!( "usage: {binary} [--auto-discover | --server-info-url | --server-info-file | --server-info-path ] \ [--app-version 1.70.0] [--connection-group ] [--launcher-version 1.7.2] \ [--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \ [--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \ [--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date]" ); } fn parse_duration(value: &str) -> anyhow::Result { let value = value.trim(); if value.is_empty() { return Err(anyhow::anyhow!("duration must not be empty")); } let (number, multiplier) = if let Some(number) = value.strip_suffix("ms") { let millis = number .parse::() .map_err(|error| anyhow::anyhow!("invalid millisecond duration {value}: {error}"))?; return Ok(Duration::from_millis(millis)); } else if let Some(number) = value.strip_suffix('s') { (number, 1) } else if let Some(number) = value.strip_suffix('m') { (number, 60) } else if let Some(number) = value.strip_suffix('h') { (number, 60 * 60) } else { (value, 1) }; let amount = number .parse::() .map_err(|error| anyhow::anyhow!("invalid duration {value}: {error}"))?; Ok(Duration::from_secs(amount.saturating_mul(multiplier))) } fn parse_platforms(value: &str) -> Result, String> { value .split(',') .map(str::trim) .filter(|part| !part.is_empty()) .map(parse_platform) .collect() } fn parse_platform(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "windows" | "win" => Ok(PatchPlatform::Windows), "android" => Ok(PatchPlatform::Android), _ => Err(format!("unsupported platform: {value}")), } } #[cfg(test)] mod tests { use super::*; fn parse(values: &[&str]) -> anyhow::Result { parse_args_from(values.iter().map(|value| value.to_string())) } #[test] fn parses_auto_discover_sync_args() { let options = parse(&[ "bat-official-sync", "--auto-discover", "--platforms", "Windows,Android", "--snapshot", "/tmp/snapshot.json", "--dry-run", "--plan", ]) .unwrap(); let config = options.config; assert!(config.auto_discover); assert_eq!( config.platforms.as_deref(), Some([PatchPlatform::Windows, PatchPlatform::Android].as_slice()) ); assert_eq!( config.snapshot_path, Some(PathBuf::from("/tmp/snapshot.json")) ); assert!(config.dry_run); assert!(config.plan); assert!(config.audit_local); assert!(config.repair); assert!(!options.watch); assert_eq!( options.interval, Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS) ); assert_eq!( options.error_retry_interval, Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) ); assert!(!options.quiet_up_to_date); assert!(!options.quiet_up_to_date_explicit); } #[test] fn parses_explicit_source_and_disable_repair() { let options = parse(&[ "bat-official-sync", "--server-info-url", "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json", "--connection-group", "Prod-Audit", "--app-version", "1.70.0", "--no-audit-local", "--no-repair", "--unzip", "/usr/bin/unzip", ]) .unwrap(); let config = options.config; assert!(!config.auto_discover); assert_eq!(config.connection_group.as_deref(), Some("Prod-Audit")); assert_eq!(config.app_version.as_deref(), Some("1.70.0")); assert!(!config.audit_local); assert!(!config.repair); assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip")); assert!(matches!( config.server_info_source, Some(OfficialServerInfoSource::OfficialUrl(ref url)) if url == "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" )); } #[test] fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() { let options = parse(&[ "bat-official-sync", "--auto-discover", "--watch", "--interval", "30m", ]) .unwrap(); assert!(options.watch); assert_eq!(options.interval, Duration::from_secs(30 * 60)); assert_eq!( options.error_retry_interval, Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS) ); assert!(options.quiet_up_to_date); assert!(!options.quiet_up_to_date_explicit); } #[test] fn parses_explicit_watch_error_retry_interval() { let options = parse(&[ "bat-official-sync", "--watch", "--interval", "1h", "--error-retry", "45s", ]) .unwrap(); assert_eq!(options.interval, Duration::from_secs(3600)); assert_eq!(options.error_retry_interval, Duration::from_secs(45)); let options = parse(&[ "bat-official-sync", "--watch", "--error-retry-seconds", "30", ]) .unwrap(); assert_eq!(options.error_retry_interval, Duration::from_secs(30)); } #[test] fn explicit_no_quiet_up_to_date_overrides_watch_default() { let options = parse(&[ "bat-official-sync", "--watch", "--no-quiet-up-to-date", "--interval", "1h", ]) .unwrap(); assert!(options.watch); assert!(!options.quiet_up_to_date); assert!(options.quiet_up_to_date_explicit); } #[test] fn rejects_watch_dry_run_and_zero_interval() { let error = parse(&["bat-official-sync", "--watch", "--dry-run"]).unwrap_err(); assert!(error.to_string().contains("watch mode")); let error = parse(&["bat-official-sync", "--watch", "--interval-seconds", "0"]).unwrap_err(); assert!(error.to_string().contains("greater than zero")); let error = parse(&["bat-official-sync", "--watch", "--error-retry-seconds", "0"]).unwrap_err(); assert!(error.to_string().contains("error retry interval")); } #[test] fn parses_duration_units() { assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); assert_eq!(parse_duration("60m").unwrap(), Duration::from_secs(3600)); assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90)); assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600)); } #[test] fn quiet_up_to_date_suppresses_only_clean_reports() { assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true)); assert!(should_print_status(OfficialUpdateStatus::Downloaded, true)); assert!(should_print_status( OfficialUpdateStatus::WouldDownload, true )); assert!(should_print_status(OfficialUpdateStatus::UpToDate, false)); } #[test] fn status_string_is_stable() { assert_eq!(OfficialUpdateStatus::UpToDate.as_str(), "up_to_date"); assert_eq!( OfficialUpdateStatus::WouldDownload.as_str(), "would_download" ); assert_eq!(OfficialUpdateStatus::Downloaded.as_str(), "downloaded"); } }