1use std::collections::{BTreeMap, HashMap};
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use anyhow::{anyhow, Context, Result};
8use clap::{Args, Subcommand, ValueEnum};
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use time::format_description::well_known::Rfc3339;
12use time::{OffsetDateTime, UtcOffset};
13use zip::ZipArchive;
14
15use crate::cli_presentation::CliPresentation;
16use crate::client::{RommClient, SaveUploadOptions};
17use crate::commands::OutputFormat;
18use crate::endpoints::device::{
19 DeviceSchema, GetDevice, ListDevices, RegisterDevice, SyncMode as EndpointSyncMode,
20};
21use crate::endpoints::sync::{
22 CompleteSyncSession, GetSyncSession, ListSyncSessions, NegotiateSync, SyncNegotiateResponse,
23 TriggerPushPull,
24};
25use crate::feature_compat::{save_sync_compatibility, SAVE_SYNC_UNSUPPORTED_MESSAGE};
26use crate::openapi::EndpointRegistry;
27
28#[derive(Args, Debug)]
29#[command(after_help = "Examples:\n \
30 romm-cli sync plan --device-id abc --manifest saves.json\n \
31 romm-cli sync run --device-id abc --manifest saves.json --download-dir ./saves")]
32pub struct SyncCommand {
33 #[arg(long, global = true)]
35 pub json: bool,
36
37 #[command(subcommand)]
38 pub action: SyncAction,
39}
40
41#[derive(Subcommand, Debug)]
42pub enum SyncAction {
43 Device(SyncDeviceCommand),
45 Plan(SyncPlanArgs),
47 Run(SyncRunArgs),
49 Sessions(SyncSessionsCommand),
51 PushPull {
53 device_id: String,
55 },
56}
57
58#[derive(Args, Debug)]
59pub struct SyncDeviceCommand {
60 #[command(subcommand)]
61 pub action: SyncDeviceAction,
62}
63
64#[derive(Subcommand, Debug)]
65pub enum SyncDeviceAction {
66 Register {
68 #[arg(long)]
69 name: Option<String>,
70 #[arg(long)]
71 platform: Option<String>,
72 #[arg(long, default_value = "romm-cli")]
73 client: String,
74 #[arg(long)]
75 client_version: Option<String>,
76 #[arg(long)]
77 hostname: Option<String>,
78 #[arg(long)]
79 mac_address: Option<String>,
80 #[arg(long)]
81 ip_address: Option<String>,
82 #[arg(long, value_enum, default_value_t = CliSyncMode::Api)]
83 sync_mode: CliSyncMode,
84 #[arg(long)]
86 sync_config_json: Option<String>,
87 #[arg(long)]
88 allow_duplicate: bool,
89 #[arg(long)]
90 reset_syncs: bool,
91 },
92 List,
94 Get {
96 device_id: String,
98 },
99}
100
101#[derive(Args, Debug)]
102pub struct SyncPlanArgs {
103 #[arg(long)]
104 pub device_id: String,
105 #[arg(long)]
106 pub manifest: PathBuf,
107}
108
109#[derive(Args, Debug)]
110pub struct SyncRunArgs {
111 #[arg(long)]
112 pub device_id: String,
113 #[arg(long)]
114 pub manifest: PathBuf,
115 #[arg(long)]
117 pub download_dir: Option<PathBuf>,
118 #[arg(long, value_enum, default_value_t = ConflictPolicy::Fail)]
119 pub conflict: ConflictPolicy,
120}
121
122#[derive(Args, Debug)]
123pub struct SyncSessionsCommand {
124 #[command(subcommand)]
125 pub action: SyncSessionsAction,
126}
127
128#[derive(Subcommand, Debug)]
129pub enum SyncSessionsAction {
130 List {
132 #[arg(long)]
133 device_id: Option<String>,
134 #[arg(long)]
135 limit: Option<u32>,
136 },
137 Get { session_id: u64 },
139}
140
141#[derive(Debug, Clone, Copy, ValueEnum)]
142pub enum CliSyncMode {
143 Api,
144 FileTransfer,
145 PushPull,
146}
147
148impl From<CliSyncMode> for EndpointSyncMode {
149 fn from(value: CliSyncMode) -> Self {
150 match value {
151 CliSyncMode::Api => EndpointSyncMode::Api,
152 CliSyncMode::FileTransfer => EndpointSyncMode::FileTransfer,
153 CliSyncMode::PushPull => EndpointSyncMode::PushPull,
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
159pub enum ConflictPolicy {
160 Fail,
161 Skip,
162}
163
164#[derive(Debug, Clone, Deserialize)]
165struct SyncManifest {
166 saves: Vec<ManifestSave>,
167}
168
169#[derive(Debug, Clone, Deserialize)]
170struct ManifestSave {
171 rom_id: u64,
172 path: PathBuf,
173 file_name: Option<String>,
174 slot: Option<String>,
175 emulator: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize)]
179struct ClientSaveState {
180 rom_id: u64,
181 file_name: String,
182 #[serde(skip_serializing_if = "Option::is_none")]
183 slot: Option<String>,
184 #[serde(skip_serializing_if = "Option::is_none")]
185 emulator: Option<String>,
186 content_hash: String,
187 updated_at: String,
188 file_size_bytes: u64,
189}
190
191#[derive(Debug, Clone)]
192struct PreparedSave {
193 path: PathBuf,
194 client: ClientSaveState,
195}
196
197#[derive(Debug, Clone, Copy, Default)]
198struct RunCounts {
199 uploaded: u64,
200 downloaded: u64,
201 no_op: u64,
202 conflicts_skipped: u64,
203 completed: u64,
204 failed: u64,
205}
206
207pub async fn handle(
208 cmd: SyncCommand,
209 client: &RommClient,
210 presentation: CliPresentation,
211) -> Result<()> {
212 let format = presentation.format;
213 preflight_save_sync_compatibility(client, format).await?;
214
215 match cmd.action {
216 SyncAction::Device(device_cmd) => handle_device(device_cmd, client, format).await,
217 SyncAction::Plan(args) => handle_plan(args, client, format).await,
218 SyncAction::Run(args) => handle_run(args, client, format).await,
219 SyncAction::Sessions(cmd) => handle_sessions(cmd, client, format).await,
220 SyncAction::PushPull { device_id } => {
221 let out = client.call(&TriggerPushPull { device_id }).await?;
222 print_output(format, &out)
223 }
224 }
225}
226
227async fn preflight_save_sync_compatibility(
228 client: &RommClient,
229 format: OutputFormat,
230) -> Result<()> {
231 let openapi = match client.fetch_openapi_json().await {
232 Ok(body) => body,
233 Err(e) => {
234 tracing::warn!(
235 "Skipping save-sync compatibility preflight: {}",
236 e.redacted_for_log()
237 );
238 return Ok(());
239 }
240 };
241 let registry = match EndpointRegistry::from_openapi_json(&openapi) {
242 Ok(registry) => registry,
243 Err(e) => {
244 tracing::warn!(
245 "Skipping save-sync compatibility preflight; OpenAPI parse failed: {e:#}"
246 );
247 return Ok(());
248 }
249 };
250 let compat = save_sync_compatibility(®istry);
251 if compat.supported {
252 return Ok(());
253 }
254
255 if matches!(format, OutputFormat::Json) {
256 println!(
257 "{}",
258 serde_json::to_string_pretty(&json!({
259 "error": "save_sync_unsupported",
260 "message": SAVE_SYNC_UNSUPPORTED_MESSAGE,
261 "missing_endpoints": compat
262 .missing
263 .iter()
264 .map(|ep| ep.label())
265 .collect::<Vec<_>>()
266 }))?
267 );
268 }
269
270 anyhow::bail!("{}", compat.unsupported_message())
271}
272
273async fn handle_device(
274 cmd: SyncDeviceCommand,
275 client: &RommClient,
276 format: OutputFormat,
277) -> Result<()> {
278 match cmd.action {
279 SyncDeviceAction::Register {
280 name,
281 platform,
282 client: client_name,
283 client_version,
284 hostname,
285 mac_address,
286 ip_address,
287 sync_mode,
288 sync_config_json,
289 allow_duplicate,
290 reset_syncs,
291 } => {
292 let sync_config = match sync_config_json {
293 Some(raw) => Some(
294 serde_json::from_str::<serde_json::Value>(&raw)
295 .with_context(|| "invalid --sync-config-json (must be a JSON object)")?,
296 ),
297 None => None,
298 };
299 if let Some(v) = &sync_config {
300 if !v.is_object() {
301 anyhow::bail!("--sync-config-json must decode to a JSON object");
302 }
303 }
304
305 let body = json!({
306 "name": name,
307 "platform": platform,
308 "client": client_name,
309 "client_version": client_version,
310 "hostname": hostname,
311 "mac_address": mac_address,
312 "ip_address": ip_address,
313 "sync_mode": EndpointSyncMode::from(sync_mode),
314 "sync_config": sync_config,
315 "allow_existing": true,
316 "allow_duplicate": allow_duplicate,
317 "reset_syncs": reset_syncs
318 });
319 let created = client.call(&RegisterDevice { body }).await?;
320 print_output(format, &created)
321 }
322 SyncDeviceAction::List => {
323 let rows: Vec<DeviceSchema> = client.call(&ListDevices).await?;
324 print_output(format, &rows)
325 }
326 SyncDeviceAction::Get { device_id } => {
327 let row = client.call(&GetDevice { device_id }).await?;
328 print_output(format, &row)
329 }
330 }
331}
332
333async fn handle_plan(args: SyncPlanArgs, client: &RommClient, format: OutputFormat) -> Result<()> {
334 let prepared = load_manifest_and_prepare(&args.manifest)?;
335 let negotiate = negotiate(client, &args.device_id, &prepared).await?;
336 print_output(format, &negotiate)
337}
338
339async fn handle_run(args: SyncRunArgs, client: &RommClient, format: OutputFormat) -> Result<()> {
340 let prepared = load_manifest_and_prepare(&args.manifest)?;
341 let prepared_by_key = prepared_by_key(&prepared)?;
342 let negotiate = negotiate(client, &args.device_id, &prepared).await?;
343
344 let download_base = match args.download_dir {
345 Some(dir) => dir,
346 None => args
347 .manifest
348 .parent()
349 .map(Path::to_path_buf)
350 .unwrap_or_else(|| PathBuf::from(".")),
351 };
352 std::fs::create_dir_all(&download_base).with_context(|| {
353 format!(
354 "failed to create download directory {}",
355 download_base.display()
356 )
357 })?;
358
359 let mut counts = RunCounts::default();
360 let mut hard_conflict = false;
361 for op in &negotiate.operations {
362 match op.action.as_str() {
363 "upload" => {
364 let key = (op.rom_id, op.file_name.clone());
365 let Some(local) = prepared_by_key.get(&key) else {
366 counts.failed += 1;
367 eprintln!(
368 "Missing local manifest entry for upload operation rom_id={} file_name={}",
369 op.rom_id, op.file_name
370 );
371 continue;
372 };
373 let options = SaveUploadOptions {
374 emulator: local.client.emulator.as_deref(),
375 slot: local.client.slot.as_deref(),
376 device_id: Some(args.device_id.as_str()),
377 session_id: Some(negotiate.session_id),
378 overwrite: false,
379 };
380 match client
381 .upload_save_file_with_options(local.client.rom_id, &local.path, &options)
382 .await
383 {
384 Ok(_) => {
385 counts.uploaded += 1;
386 counts.completed += 1;
387 }
388 Err(err) => {
389 counts.failed += 1;
390 eprintln!(
391 "Upload failed for rom_id={} file_name={}: {:#}",
392 local.client.rom_id, local.client.file_name, err
393 );
394 }
395 }
396 }
397 "download" => {
398 let Some(save_id) = op.save_id else {
399 counts.failed += 1;
400 eprintln!(
401 "Download operation missing save_id for rom_id={} file_name={}",
402 op.rom_id, op.file_name
403 );
404 continue;
405 };
406 match client
407 .download_save_content(
408 save_id,
409 Some(args.device_id.as_str()),
410 Some(negotiate.session_id),
411 )
412 .await
413 {
414 Ok(bytes) => {
415 let target = download_base.join(&op.file_name);
416 if let Some(parent) = target.parent() {
417 std::fs::create_dir_all(parent).with_context(|| {
418 format!("failed to create parent folder {}", parent.display())
419 })?;
420 }
421 let mut f = File::create(&target).with_context(|| {
422 format!("failed to create download file {}", target.display())
423 })?;
424 f.write_all(&bytes).with_context(|| {
425 format!("failed to write download file {}", target.display())
426 })?;
427 counts.downloaded += 1;
428 counts.completed += 1;
429 }
430 Err(err) => {
431 counts.failed += 1;
432 eprintln!("Download failed for save_id={save_id}: {err:#}");
433 }
434 }
435 }
436 "no_op" => {
437 counts.no_op += 1;
438 }
439 "conflict" => match args.conflict {
440 ConflictPolicy::Skip => {
441 counts.conflicts_skipped += 1;
442 }
443 ConflictPolicy::Fail => {
444 counts.failed += 1;
445 hard_conflict = true;
446 eprintln!(
447 "Conflict for rom_id={} file_name={}: {}",
448 op.rom_id, op.file_name, op.reason
449 );
450 }
451 },
452 other => {
453 counts.failed += 1;
454 eprintln!(
455 "Unknown sync operation '{}' for rom_id={} file_name={}",
456 other, op.rom_id, op.file_name
457 );
458 }
459 }
460 }
461
462 let completion = client
463 .call(&CompleteSyncSession {
464 session_id: negotiate.session_id,
465 body: json!({
466 "operations_completed": counts.completed,
467 "operations_failed": counts.failed
468 }),
469 })
470 .await?;
471
472 if matches!(format, OutputFormat::Json) {
473 let out = json!({
474 "negotiate": negotiate,
475 "counts": {
476 "uploaded": counts.uploaded,
477 "downloaded": counts.downloaded,
478 "no_op": counts.no_op,
479 "conflicts_skipped": counts.conflicts_skipped,
480 "completed": counts.completed,
481 "failed": counts.failed
482 },
483 "completion": completion
484 });
485 println!("{}", serde_json::to_string_pretty(&out)?);
486 } else {
487 println!(
488 "session={} uploaded={} downloaded={} no_op={} conflicts_skipped={} completed={} failed={}",
489 completion.session.id,
490 counts.uploaded,
491 counts.downloaded,
492 counts.no_op,
493 counts.conflicts_skipped,
494 counts.completed,
495 counts.failed
496 );
497 }
498
499 if hard_conflict || counts.failed > 0 {
500 anyhow::bail!(
501 "sync completed with {} failed operation(s); session {} marked complete",
502 counts.failed,
503 completion.session.id
504 );
505 }
506
507 Ok(())
508}
509
510async fn handle_sessions(
511 cmd: SyncSessionsCommand,
512 client: &RommClient,
513 format: OutputFormat,
514) -> Result<()> {
515 match cmd.action {
516 SyncSessionsAction::List { device_id, limit } => {
517 let out = client.call(&ListSyncSessions { device_id, limit }).await?;
518 print_output(format, &out)
519 }
520 SyncSessionsAction::Get { session_id } => {
521 let out = client.call(&GetSyncSession { session_id }).await?;
522 print_output(format, &out)
523 }
524 }
525}
526
527async fn negotiate(
528 client: &RommClient,
529 device_id: &str,
530 prepared: &[PreparedSave],
531) -> Result<SyncNegotiateResponse> {
532 let saves: Vec<ClientSaveState> = prepared.iter().map(|p| p.client.clone()).collect();
533 client
534 .call(&NegotiateSync {
535 body: json!({
536 "device_id": device_id,
537 "saves": saves
538 }),
539 })
540 .await
541 .map_err(Into::into)
542}
543
544fn print_output<T: Serialize>(format: OutputFormat, value: &T) -> Result<()> {
545 match format {
546 OutputFormat::Json | OutputFormat::Text => {
547 println!("{}", serde_json::to_string_pretty(value)?);
548 }
549 }
550 Ok(())
551}
552
553fn prepared_by_key(prepared: &[PreparedSave]) -> Result<HashMap<(u64, String), PreparedSave>> {
554 let mut map = HashMap::new();
555 for item in prepared {
556 let key = (item.client.rom_id, item.client.file_name.clone());
557 if map.insert(key.clone(), item.clone()).is_some() {
558 anyhow::bail!(
559 "duplicate manifest mapping for rom_id={} file_name={}",
560 key.0,
561 key.1
562 );
563 }
564 }
565 Ok(map)
566}
567
568fn load_manifest_and_prepare(manifest_path: &Path) -> Result<Vec<PreparedSave>> {
569 let raw = std::fs::read_to_string(manifest_path)
570 .with_context(|| format!("read manifest {}", manifest_path.display()))?;
571 let manifest: SyncManifest = serde_json::from_str(&raw)
572 .with_context(|| format!("parse manifest {}", manifest_path.display()))?;
573 let base_dir = manifest_path
574 .parent()
575 .map(Path::to_path_buf)
576 .unwrap_or_else(|| PathBuf::from("."));
577
578 let mut out = Vec::new();
579 for row in manifest.saves {
580 let path = if row.path.is_absolute() {
581 row.path.clone()
582 } else {
583 base_dir.join(&row.path)
584 };
585 if !path.is_file() {
586 anyhow::bail!("manifest save path is not a file: {}", path.display());
587 }
588 let file_name = match row.file_name {
589 Some(name) if !name.trim().is_empty() => name.trim().to_string(),
590 _ => path
591 .file_name()
592 .and_then(|n| n.to_str())
593 .ok_or_else(|| {
594 anyhow!("save path must have a unicode filename: {}", path.display())
595 })?
596 .to_string(),
597 };
598 let meta = std::fs::metadata(&path)
599 .with_context(|| format!("read metadata for {}", path.display()))?;
600 let updated_at = format_system_time_utc_rfc3339(
601 meta.modified()
602 .with_context(|| format!("read modified timestamp for {}", path.display()))?,
603 )?;
604 let content_hash = compute_content_hash(&path)?;
605 out.push(PreparedSave {
606 path,
607 client: ClientSaveState {
608 rom_id: row.rom_id,
609 file_name,
610 slot: row.slot.filter(|s| !s.trim().is_empty()),
611 emulator: row.emulator.filter(|s| !s.trim().is_empty()),
612 content_hash,
613 updated_at,
614 file_size_bytes: meta.len(),
615 },
616 });
617 }
618
619 Ok(out)
620}
621
622fn format_system_time_utc_rfc3339(t: SystemTime) -> Result<String> {
623 let odt: OffsetDateTime = t.into();
624 let utc = odt.to_offset(UtcOffset::UTC);
625 utc.format(&Rfc3339)
626 .map_err(|e| anyhow!("format timestamp as RFC3339: {e}"))
627}
628
629fn compute_content_hash(path: &Path) -> Result<String> {
630 if let Ok(file) = File::open(path) {
631 if ZipArchive::new(file).is_ok() {
632 return compute_zip_hash(path);
633 }
634 }
635 compute_file_hash(path)
636}
637
638fn compute_file_hash(path: &Path) -> Result<String> {
639 let mut file =
640 File::open(path).with_context(|| format!("open file for hashing {}", path.display()))?;
641 let mut ctx = md5::Context::new();
642 let mut buf = [0u8; 8192];
643 loop {
644 let n = file
645 .read(&mut buf)
646 .with_context(|| format!("read file for hashing {}", path.display()))?;
647 if n == 0 {
648 break;
649 }
650 ctx.consume(&buf[..n]);
651 }
652 Ok(format!("{:x}", ctx.finalize()))
653}
654
655fn compute_zip_hash(path: &Path) -> Result<String> {
656 let file = File::open(path)
657 .with_context(|| format!("open zip file for hashing {}", path.display()))?;
658 let mut archive = ZipArchive::new(file)
659 .with_context(|| format!("read zip archive for hashing {}", path.display()))?;
660 let mut row_hashes = BTreeMap::new();
661 for i in 0..archive.len() {
662 let mut entry = archive
663 .by_index(i)
664 .with_context(|| format!("read zip entry {} in {}", i, path.display()))?;
665 if entry.is_dir() {
666 continue;
667 }
668 let name = entry.name().to_string();
669 let mut ctx = md5::Context::new();
670 let mut buf = [0u8; 8192];
671 loop {
672 let n = entry
673 .read(&mut buf)
674 .with_context(|| format!("hash zip entry {} in {}", name, path.display()))?;
675 if n == 0 {
676 break;
677 }
678 ctx.consume(&buf[..n]);
679 }
680 row_hashes.insert(name, format!("{:x}", ctx.finalize()));
681 }
682 let combined = row_hashes
683 .into_iter()
684 .map(|(name, hash)| format!("{name}:{hash}"))
685 .collect::<Vec<_>>()
686 .join("\n");
687 Ok(format!("{:x}", md5::compute(combined.as_bytes())))
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use std::fs;
694 use std::io::Write;
695
696 use zip::write::SimpleFileOptions;
697 use zip::ZipWriter;
698
699 fn temp_path(name: &str) -> PathBuf {
700 let nanos = SystemTime::now()
701 .duration_since(SystemTime::UNIX_EPOCH)
702 .expect("unix epoch")
703 .as_nanos();
704 std::env::temp_dir().join(format!("romm-cli-sync-test-{nanos}-{name}"))
705 }
706
707 #[test]
708 fn file_hash_matches_md5_hex() {
709 let path = temp_path("plain.sav");
710 fs::write(&path, b"abc123").expect("write");
711 let got = compute_file_hash(&path).expect("hash");
712 let expected = format!("{:x}", md5::compute(b"abc123"));
713 assert_eq!(got, expected);
714 let _ = fs::remove_file(path);
715 }
716
717 #[test]
718 fn zip_hash_matches_sorted_entry_scheme() {
719 let path = temp_path("archive.zip");
720 {
721 let f = File::create(&path).expect("create");
722 let mut writer = ZipWriter::new(f);
723 writer
724 .start_file("b.sav", SimpleFileOptions::default())
725 .expect("start file");
726 writer.write_all(b"bbb").expect("write b");
727 writer
728 .start_file("a.sav", SimpleFileOptions::default())
729 .expect("start file");
730 writer.write_all(b"aaa").expect("write a");
731 writer.finish().expect("finish");
732 }
733
734 let hash = compute_zip_hash(&path).expect("hash zip");
735 let a = format!("{:x}", md5::compute(b"aaa"));
736 let b = format!("{:x}", md5::compute(b"bbb"));
737 let combined = format!("a.sav:{a}\nb.sav:{b}");
738 let expected = format!("{:x}", md5::compute(combined.as_bytes()));
739 assert_eq!(hash, expected);
740 let _ = fs::remove_file(path);
741 }
742
743 #[test]
744 fn duplicate_manifest_keys_fail() {
745 let p = temp_path("dup.sav");
746 fs::write(&p, b"x").expect("write");
747 let prepared = vec![
748 PreparedSave {
749 path: p.clone(),
750 client: ClientSaveState {
751 rom_id: 1,
752 file_name: "same.sav".into(),
753 slot: None,
754 emulator: None,
755 content_hash: "h1".into(),
756 updated_at: "2026-01-01T00:00:00Z".into(),
757 file_size_bytes: 1,
758 },
759 },
760 PreparedSave {
761 path: p.clone(),
762 client: ClientSaveState {
763 rom_id: 1,
764 file_name: "same.sav".into(),
765 slot: None,
766 emulator: None,
767 content_hash: "h2".into(),
768 updated_at: "2026-01-01T00:00:01Z".into(),
769 file_size_bytes: 1,
770 },
771 },
772 ];
773 let err = prepared_by_key(&prepared).expect_err("duplicate should fail");
774 assert!(err.to_string().contains("duplicate manifest mapping"));
775 let _ = fs::remove_file(p);
776 }
777}