Files
BlueArchiveToolkit/infrastructure/src/bin/bat_official_sync.rs
T

509 lines
17 KiB
Rust

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<u64>,
}
#[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<CliOptions> {
parse_args_from(env::args())
}
fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result<CliOptions> {
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::<u64>()
.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::<u64>()
.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<Item = String>,
flag: &str,
) -> anyhow::Result<String> {
args.next()
.ok_or_else(|| anyhow::anyhow!("missing value for {flag}"))
}
fn print_usage(binary: &str) {
eprintln!(
"usage: {binary} [--auto-discover | --server-info-url <official-url> | --server-info-file <official-json-name> | --server-info-path <local-json>] \
[--app-version 1.70.0] [--connection-group <name>] [--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<Duration> {
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::<u64>()
.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::<u64>()
.map_err(|error| anyhow::anyhow!("invalid duration {value}: {error}"))?;
Ok(Duration::from_secs(amount.saturating_mul(multiplier)))
}
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
value
.split(',')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(parse_platform)
.collect()
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
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<CliOptions> {
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");
}
}