1use std::{
2 collections::BTreeSet,
3 fs::{self, OpenOptions},
4 io::IsTerminal,
5 io::{self, Read, Write},
6 path::{Path, PathBuf},
7};
8
9use crate::archive::{
10 ArchiveFileEntry, ArchiveReader, infer_object_id_from_snapshot_path,
11 infer_object_ids_from_files,
12};
13use crate::markdown::{SavedObjectKind, save_archive_object};
14use anyhow::{Context, Result, anyhow, bail, ensure};
15use anytype::{
16 prelude::*,
17 process_watcher::{
18 ProcessCompletionFallback, ProcessKind, ProcessWatchCancelToken, ProcessWatchProgress,
19 ProcessWatchRequest, ProcessWatcher,
20 },
21 validation::looks_like_object_id,
22};
23#[cfg(feature = "snapshot-import")]
24use anytype_rpc::anytype::SnapshotWithType;
25use anytype_rpc::{
26 anytype::rpc::object::import::{Request as ObjectImportRequest, request as import_request},
27 auth::with_token,
28};
29use chrono::{
30 DateTime, FixedOffset, Local, NaiveDate, NaiveDateTime, SecondsFormat, TimeZone, Utc,
31};
32use clap::{Args, Subcommand, ValueEnum};
33use indicatif::{ProgressBar, ProgressStyle};
34#[cfg(feature = "snapshot-import")]
35use prost::Message;
36use same_file::Handle as FileIdentity;
37use serde::Serialize;
38use serde_json::Value;
39use tokio::sync::mpsc;
40use tracing::{info, warn};
41
42mod deadline;
43pub mod decode;
44#[cfg(feature = "tui")]
45mod inspector;
46pub mod output;
47
48pub use deadline::WorkflowDeadline;
49pub use output::{CommandOutput, OutputMode, TextBuilder};
50
51use decode::{
52 ExpandedSnapshotEntry, ImportEventProgressReport, ImportReport, MANIFEST_NAME, Manifest,
53 ManifestSummary, ObjectDescriptor, ObjectImportError, archive_binding_from_file, detail_value,
54 format_datetime_display, format_last_modified, manifest_sidecar_path, manifest_summary,
55 parse_expanded_entries, parse_snapshot_details_from_pb, parse_snapshot_details_from_pb_json,
56 read_manifest_from_reader, read_manifest_from_sidecar, read_manifest_prefer_sidecar,
57};
58
59const TMP_BACKUP_PREFIX: &str = "anyback_tmp";
60#[cfg(feature = "snapshot-import")]
61const DEFAULT_IMPORT_MAX_SINGLE_SNAPSHOT_BYTES: usize = 2 * 1024 * 1024;
62#[cfg(feature = "snapshot-import")]
63const DEFAULT_IMPORT_MAX_BATCH_BYTES: usize = 3 * 1024 * 1024;
64#[cfg(feature = "snapshot-import")]
65const DEFAULT_IMPORT_MAX_BATCH_SNAPSHOTS: usize = 128;
66const IMPORT_CANCEL_REASON: &str = "restore canceled by user";
67
68type ImportCancelToken = ProcessWatchCancelToken;
69
70#[derive(Debug)]
71struct ImportCancelState {
72 receiver: mpsc::UnboundedReceiver<ImportCancelToken>,
73}
74
75impl ImportCancelState {
76 fn new(receiver: mpsc::UnboundedReceiver<ImportCancelToken>) -> Self {
77 Self { receiver }
78 }
79
80 fn receiver_mut(&mut self) -> &mut mpsc::UnboundedReceiver<ImportCancelToken> {
81 &mut self.receiver
82 }
83}
84
85fn new_import_cancel_channel() -> (mpsc::UnboundedSender<ImportCancelToken>, ImportCancelState) {
86 let (sender, receiver) = mpsc::unbounded_channel();
87 (sender, ImportCancelState::new(receiver))
88}
89
90fn spawn_import_cancel_signal_forwarder(
91 sender: mpsc::UnboundedSender<ImportCancelToken>,
92) -> tokio::task::JoinHandle<()> {
93 tokio::spawn(async move {
94 #[cfg(unix)]
95 {
96 use tokio::signal::unix::{SignalKind, signal};
97
98 let mut sigterm = match signal(SignalKind::terminate()) {
99 Ok(stream) => stream,
100 Err(err) => {
101 warn!("failed to register SIGTERM handler: {err:#}");
102 let _ = tokio::signal::ctrl_c().await;
103 let _ = sender.send(ImportCancelToken::Requested);
104 return;
105 }
106 };
107
108 tokio::select! {
109 _ = tokio::signal::ctrl_c() => {},
110 _ = sigterm.recv() => {},
111 }
112 }
113 #[cfg(not(unix))]
114 {
115 let _ = tokio::signal::ctrl_c().await;
116 }
117
118 let _ = sender.send(ImportCancelToken::Requested);
119 })
120}
121
122#[derive(Subcommand, Debug)]
123#[command(next_display_order = None)]
124pub enum Commands {
125 Create(BackupCreateArgs),
127
128 Restore(RestoreApplyArgs),
130
131 List(ListArgs),
133
134 Manifest(ManifestArgs),
136
137 Diff(DiffArgs),
139
140 Extract(ExtractArgs),
142
143 Export(BackupCreateArgs),
145
146 Import(RestoreApplyArgs),
148
149 #[cfg(feature = "tui")]
151 Inspect(InspectorArgs),
152}
153
154#[cfg(feature = "tui")]
155#[derive(Args, Debug)]
156pub struct InspectorArgs {
157 pub archive: PathBuf,
159
160 #[arg(long = "max-cache", value_name = "SIZE", default_value = "200", value_parser = parse_cache_size)]
162 pub max_cache: usize,
163}
164
165#[allow(clippy::struct_excessive_bools)]
166#[derive(Args, Debug)]
167pub struct BackupCreateArgs {
168 #[arg(long, value_name = "NAME_OR_ID")]
170 pub space: String,
171
172 #[arg(long, value_name = "FILE|-")]
174 pub objects: Option<String>,
175
176 #[arg(long, value_enum, default_value_t = ExportFormatArg::Pb)]
178 pub format: ExportFormatArg,
179
180 #[arg(long, value_enum, default_value_t = BackupModeArg::Full)]
182 pub mode: BackupModeArg,
183
184 #[arg(long, value_name = "RFC3339", required_if_eq("mode", "incremental"))]
188 pub since: Option<String>,
189
190 #[arg(long, value_enum, default_value_t = SinceModeArg::Exclusive)]
192 pub since_mode: SinceModeArg,
193
194 #[arg(
196 long,
197 value_name = "TYPE_KEY_OR_ID[,TYPE_KEY_OR_ID,...]",
198 value_delimiter = ',',
199 conflicts_with = "objects"
200 )]
201 pub types: Option<Vec<String>>,
202
203 #[arg(long, value_name = "DIR", conflicts_with = "dest")]
205 pub dir: Option<PathBuf>,
206
207 #[arg(long, value_name = "PATH", conflicts_with_all = ["dir", "prefix"])]
209 pub dest: Option<PathBuf>,
210
211 #[arg(long, value_name = "PREFIX")]
213 pub prefix: Option<String>,
214
215 #[arg(long)]
217 pub include_nested: bool,
218
219 #[arg(long)]
221 pub include_files: bool,
222
223 #[arg(long)]
225 pub include_archived: bool,
226
227 #[arg(long)]
229 pub include_backlinks: bool,
230
231 #[arg(long)]
233 pub include_properties: bool,
234}
235
236#[derive(Args, Debug)]
237pub struct RestoreApplyArgs {
238 #[arg(value_name = "ARCHIVE")]
240 pub archive: PathBuf,
241
242 #[arg(long, value_name = "FILE|-")]
244 pub objects: Option<String>,
245
246 #[arg(long, value_name = "NAME_OR_ID")]
248 pub space: Option<String>,
249
250 #[arg(long)]
252 pub dry_run: bool,
253
254 #[arg(long, value_name = "REPORT_OUTPUT")]
256 pub log: Option<PathBuf>,
257
258 #[arg(long, value_enum, default_value_t = ImportModeArg::IgnoreErrors)]
260 pub import_mode: ImportModeArg,
261
262 #[arg(long)]
265 pub replace: bool,
266}
267
268#[derive(Args, Debug, Clone)]
269pub struct ListArgs {
270 pub archive: PathBuf,
272
273 #[arg(long, group = "list_mode")]
275 pub brief: bool,
276
277 #[arg(long, group = "list_mode")]
279 pub expanded: bool,
280
281 #[arg(long, group = "list_mode")]
283 pub files: bool,
284}
285
286#[derive(Args, Debug, Clone)]
287pub struct ManifestArgs {
288 pub archive: PathBuf,
290}
291
292#[derive(Args, Debug, Clone)]
293pub struct DiffArgs {
294 #[arg(value_name = "ARCHIVE1")]
296 pub archive1: PathBuf,
297
298 #[arg(value_name = "ARCHIVE2")]
300 pub archive2: PathBuf,
301}
302
303#[derive(Args, Debug, Clone)]
304pub struct ExtractArgs {
305 #[arg(value_name = "ARCHIVE")]
307 pub archive: PathBuf,
308
309 #[arg(value_name = "ID")]
311 pub object_id: String,
312
313 #[arg(value_name = "OUTPUT")]
315 pub destination: PathBuf,
316}
317
318#[derive(Debug, Clone, Copy, ValueEnum)]
319pub enum ExportFormatArg {
320 Markdown,
321 Pb,
322 PbJson,
323 Json,
324}
325
326#[derive(Debug, Clone, Copy, ValueEnum)]
327pub enum ImportModeArg {
328 AllOrNothing,
329 IgnoreErrors,
330}
331
332#[derive(Debug, Clone, Copy, ValueEnum)]
333pub enum BackupModeArg {
334 Full,
335 Incremental,
336}
337
338impl BackupModeArg {
339 fn as_str(self) -> &'static str {
340 match self {
341 Self::Full => "full",
342 Self::Incremental => "incremental",
343 }
344 }
345}
346
347#[derive(Debug, Clone, Copy, ValueEnum)]
348pub enum SinceModeArg {
349 Exclusive,
350 Inclusive,
351}
352
353impl ImportModeArg {
354 fn to_rpc_mode(self) -> i32 {
355 match self {
356 Self::AllOrNothing => import_request::Mode::AllOrNothing as i32,
357 Self::IgnoreErrors => import_request::Mode::IgnoreErrors as i32,
358 }
359 }
360}
361
362impl ExportFormatArg {
363 fn to_backup_export_format(self) -> BackupExportFormat {
364 match self {
365 Self::Markdown => BackupExportFormat::Markdown,
366 Self::Pb | Self::PbJson => BackupExportFormat::Protobuf,
367 Self::Json => BackupExportFormat::Json,
368 }
369 }
370
371 fn is_pb_json(self) -> bool {
372 matches!(self, Self::PbJson)
373 }
374
375 fn as_str(self) -> &'static str {
376 match self {
377 Self::Markdown => "markdown",
378 Self::Pb => "pb",
379 Self::PbJson => "pb-json",
380 Self::Json => "json",
381 }
382 }
383}
384
385pub struct AppContext {
386 pub client: AnytypeClient,
387 pub output: CommandOutput,
388}
389
390struct WorkflowContext {
391 app: AppContext,
392 deadline: WorkflowDeadline,
393}
394
395impl std::ops::Deref for WorkflowContext {
396 type Target = AppContext;
397
398 fn deref(&self) -> &Self::Target {
399 &self.app
400 }
401}
402
403#[must_use]
406pub const fn command_is_interactive(command: &Commands) -> bool {
407 #[cfg(feature = "tui")]
408 {
409 matches!(command, Commands::Inspect(_))
410 }
411 #[cfg(not(feature = "tui"))]
412 {
413 let _ = command;
414 false
415 }
416}
417
418#[must_use]
420pub const fn command_name(command: &Commands) -> &'static str {
421 match command {
422 Commands::Create(_) => "create",
423 Commands::Restore(_) => "restore",
424 Commands::List(_) => "list",
425 Commands::Manifest(_) => "manifest",
426 Commands::Diff(_) => "diff",
427 Commands::Extract(_) => "extract",
428 Commands::Export(_) => "export",
429 Commands::Import(_) => "import",
430 #[cfg(feature = "tui")]
431 Commands::Inspect(_) => "inspect",
432 }
433}
434
435pub fn validate_command_output(command: &Commands, output: &CommandOutput) -> Result<()> {
441 match command {
442 Commands::Create(args) | Commands::Export(args) => {
443 validate_object_list_output(output, args.objects.as_deref())?;
444 if let Some(dest) = args.dest.as_deref() {
445 validate_archive_output(output, dest, "created archive")?;
446 }
447 }
448 Commands::Restore(args) | Commands::Import(args) => {
449 validate_archive_output(output, &args.archive, "input archive")?;
450 validate_object_list_output(output, args.objects.as_deref())?;
451 if let Some(log) = args.log.as_deref() {
452 output.ensure_distinct_from(log, "restore report")?;
453 }
454 }
455 Commands::List(args) => {
456 validate_archive_output(output, &args.archive, "input archive")?;
457 }
458 Commands::Manifest(args) => {
459 validate_archive_output(output, &args.archive, "input archive")?;
460 }
461 Commands::Diff(args) => {
462 validate_archive_output(output, &args.archive1, "first input archive")?;
463 validate_archive_output(output, &args.archive2, "second input archive")?;
464 }
465 Commands::Extract(args) => {
466 validate_archive_output(output, &args.archive, "input archive")?;
467 output.ensure_distinct_from(&args.destination, "extracted object")?;
468 }
469 #[cfg(feature = "tui")]
470 Commands::Inspect(_) => {}
471 }
472 Ok(())
473}
474
475fn validate_archive_output(
476 output: &CommandOutput,
477 archive: &Path,
478 description: &str,
479) -> Result<()> {
480 output.ensure_distinct_from(archive, description)?;
481 output.ensure_distinct_from(&manifest_sidecar_path(archive), "archive manifest")
482}
483
484fn validate_object_list_output(output: &CommandOutput, spec: Option<&str>) -> Result<()> {
485 if let Some(spec) = spec.filter(|value| *value != "-") {
486 output.ensure_distinct_from(Path::new(spec), "object list input")?;
487 }
488 Ok(())
489}
490
491#[derive(Debug, Clone, Serialize)]
492struct ListReport {
493 archive: String,
494 source: String,
495 file_count: usize,
496 total_bytes: u64,
497 manifest_present: bool,
498 #[serde(skip_serializing_if = "Option::is_none")]
499 manifest_error: Option<String>,
500 #[serde(skip_serializing_if = "Option::is_none")]
501 manifest_summary: Option<ManifestSummary>,
502 #[serde(skip_serializing_if = "Option::is_none")]
503 object_ids: Option<Vec<String>>,
504 #[serde(skip_serializing_if = "Option::is_none")]
505 files: Option<Vec<ArchiveFileEntry>>,
506 #[serde(skip_serializing_if = "Option::is_none")]
507 expanded: Option<Vec<ExpandedSnapshotEntry>>,
508}
509
510#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
511struct ArchiveCmpObject {
512 object_id: String,
513 r#type: String,
514 name: String,
515 size: u64,
516 last_modified: String,
517}
518
519#[derive(Debug, Clone, Serialize)]
520struct ArchiveCmpChanged {
521 left: ArchiveCmpObject,
522 right: ArchiveCmpObject,
523}
524
525#[derive(Debug, Clone, Serialize)]
526struct ArchiveCmpReport {
527 archive1: String,
528 archive2: String,
529 format1: String,
530 format2: String,
531 archive1_only: Vec<ArchiveCmpObject>,
532 archive2_only: Vec<ArchiveCmpObject>,
533 changed: Vec<ArchiveCmpChanged>,
534}
535
536pub async fn run_command(
542 command: Commands,
543 client: AnytypeClient,
544 output: CommandOutput,
545) -> Result<()> {
546 let deadline = if matches!(
547 &command,
548 Commands::Create(_) | Commands::Export(_) | Commands::Restore(_) | Commands::Import(_)
549 ) {
550 WorkflowDeadline::from_env()?
551 } else {
552 WorkflowDeadline::local_command()
553 };
554 Box::pin(run_command_with_deadline(command, client, output, deadline)).await
555}
556
557pub async fn run_command_with_deadline(
559 command: Commands,
560 client: AnytypeClient,
561 output: CommandOutput,
562 deadline: WorkflowDeadline,
563) -> Result<()> {
564 validate_command_output(&command, &output)?;
565 let ctx = WorkflowContext {
566 app: AppContext { client, output },
567 deadline,
568 };
569
570 match command {
571 Commands::Create(args) | Commands::Export(args) => handle_backup_create(&ctx, args).await,
572 Commands::Restore(args) | Commands::Import(args) => handle_restore_apply(&ctx, args).await,
573 Commands::List(args) => handle_list(&ctx.output, &args),
574 Commands::Manifest(args) => handle_manifest(&ctx.output, &args),
575 Commands::Diff(args) => handle_diff(&ctx.output, &args),
576 Commands::Extract(args) => handle_extract(&ctx.output, &args),
577 #[cfg(feature = "tui")]
578 Commands::Inspect(args) => inspector::run_inspector(&args.archive, args.max_cache),
579 }
580}
581
582async fn handle_backup_create(ctx: &WorkflowContext, args: BackupCreateArgs) -> Result<()> {
583 validate_backup_args(&args)?;
584 ctx.deadline.ensure_read_remaining()?;
585 let export_options = backup_export_options(&args);
586
587 let progress = ProgressReporter::new(&ctx.output, "Starting backup");
588 let space = ctx
589 .deadline
590 .run_read(resolve_space(&ctx.client, &args.space))
591 .await??;
592 let backup_target = resolve_backup_target(&args, &space.id)?;
593 validate_archive_output(&ctx.output, &backup_target.archive_path, "created archive")?;
594 progress.set_message("Resolved destination space");
595
596 progress.set_message("Collecting object metadata");
597 let selection = ctx
598 .deadline
599 .run_read(resolve_backup_selection(ctx, &space, &args))
600 .await??;
601
602 progress.set_message("Exporting archive");
603 let mut backup_builder = ctx
604 .client
605 .backup_space(&space.id)
606 .backup_dir(&backup_target.parent_dir)
607 .filename_prefix(TMP_BACKUP_PREFIX)
608 .format(export_options.format)
609 .is_json(export_options.is_json)
610 .zip(backup_target.zip)
611 .include_nested(export_options.include_nested)
612 .include_files(export_options.include_files)
613 .include_archived(export_options.include_archived)
614 .include_backlinks(export_options.include_backlinks)
615 .include_space(export_options.include_space)
616 .md_include_properties_and_schema(export_options.md_include_properties_and_schema);
617
618 if let Some(object_ids) = selection.object_ids.clone() {
619 backup_builder = backup_builder.object_ids(object_ids);
620 }
621
622 let backup = ctx
623 .deadline
624 .run_export(backup_builder.backup())
625 .await?
626 .context(
627 "export request failed; read was aborted and a server-side export artifact may exist",
628 )?;
629 let manifest = Manifest {
630 schema_version: 1,
631 tool: format!("anyback/{}", env!("CARGO_PKG_VERSION")),
632 created_at: Utc::now().to_rfc3339(),
633 created_at_display: Some(local_now_display()),
634 source_space_id: space.id,
635 source_space_name: space.name,
636 format: args.format.as_str().to_string(),
637 object_count: selection.descriptors.len(),
638 objects: selection.descriptors,
639 mode: Some(args.mode.as_str().to_string()),
640 since: selection.since,
641 since_display: selection.since_display,
642 until: selection.until,
643 until_display: selection.until_display,
644 type_ids: selection.type_ids,
645 archive_size: None,
646 archive_sha256: None,
647 };
648
649 let source_path = backup.output_path.clone();
650 let archive_path = backup_target.archive_path.clone();
651 let publication_manifest = manifest.clone();
652 ctx.deadline
653 .run_read_publication(
654 "backup workflow timed out after export; read was aborted and a server-side export artifact may exist",
655 move || prepare_backup_artifacts(source_path, archive_path, &publication_manifest),
656 commit_backup_artifacts,
657 )
658 .await?;
659 progress.finish("Backup completed");
660 publish_backup_result(
661 ctx,
662 backup_target.archive_path,
663 backup.exported,
664 manifest.objects.len(),
665 )
666 .await
667}
668
669async fn publish_backup_result(
670 ctx: &WorkflowContext,
671 archive_path: PathBuf,
672 exported: i32,
673 requested: usize,
674) -> Result<()> {
675 let report = serde_json::json!({
676 "archive": archive_path.clone(),
677 "exported": exported,
678 "requested": requested,
679 });
680 let output = ctx.output.clone();
681 let report_archive_path = archive_path;
682 ctx.deadline
683 .run_read_publication(
684 "backup workflow timed out after export; read was aborted and a server-side export artifact may exist",
685 move || {
686 let Some(rendered) = output.render(&report, || {
687 format!(
688 "archive={} exported={exported}",
689 report_archive_path.display()
690 )
691 })? else {
692 return Ok(output::PreparedOutput::Quiet);
693 };
694 output.prepare_rendered(rendered)
695 },
696 CommandOutput::commit_prepared,
697 )
698 .await
699}
700
701fn validate_backup_args(args: &BackupCreateArgs) -> Result<()> {
702 ensure!(
703 !args.include_properties || matches!(args.format, ExportFormatArg::Markdown),
704 "--include-properties is only valid with --format markdown"
705 );
706 Ok(())
707}
708
709#[derive(Debug, Clone, Copy, PartialEq, Eq)]
710#[allow(clippy::struct_excessive_bools)]
711struct BackupExportOptions {
712 format: BackupExportFormat,
713 is_json: bool,
714 include_nested: bool,
715 include_files: bool,
716 include_archived: bool,
717 include_backlinks: bool,
718 include_space: bool,
719 md_include_properties_and_schema: bool,
720}
721
722fn backup_export_options(args: &BackupCreateArgs) -> BackupExportOptions {
723 BackupExportOptions {
724 format: args.format.to_backup_export_format(),
725 is_json: args.format.is_pb_json(),
726 include_nested: args.include_nested,
727 include_files: args.include_files,
728 include_archived: args.include_archived,
729 include_backlinks: args.include_backlinks,
730 include_space: true,
732 md_include_properties_and_schema: args.include_properties,
733 }
734}
735
736#[derive(Debug)]
737struct BackupTarget {
738 parent_dir: PathBuf,
739 archive_path: PathBuf,
740 zip: bool,
741}
742
743struct BackupSelection {
744 object_ids: Option<Vec<String>>,
745 descriptors: Vec<ObjectDescriptor>,
746 since: Option<String>,
747 since_display: Option<String>,
748 until: Option<String>,
749 until_display: Option<String>,
750 type_ids: Option<Vec<String>>,
751}
752
753struct TypeFilter {
754 keys: BTreeSet<String>,
755 manifest_type_ids: Vec<String>,
756}
757
758async fn resolve_backup_selection(
759 ctx: &AppContext,
760 space: &Space,
761 args: &BackupCreateArgs,
762) -> Result<BackupSelection> {
763 if let Some(spec) = args.objects.as_deref() {
764 let object_ids = load_object_ids_spec(spec)?;
765 ensure!(
766 !object_ids.is_empty(),
767 "no object ids supplied to --objects"
768 );
769 let descriptors = fetch_descriptors_by_ids(&ctx.client, &space.id, &object_ids).await?;
770 return Ok(BackupSelection {
771 object_ids: Some(object_ids),
772 descriptors,
773 since: None,
774 since_display: None,
775 until: None,
776 until_display: None,
777 type_ids: None,
778 });
779 }
780
781 let mut query = ctx.client.objects(&space.id).limit(10_000);
782 let mut use_filtered_query = false;
783 let mut since: Option<String> = None;
784 let mut since_display: Option<String> = None;
785 let mut until: Option<String> = None;
786 let mut until_display: Option<String> = None;
787
788 if matches!(args.mode, BackupModeArg::Incremental) {
789 let since_value = parse_since(args.since.as_ref())?;
790 let since_rfc3339 = to_rfc3339_with_offset(since_value);
791 since_display = Some(format_since_display(since_value));
792 since = Some(since_rfc3339.clone());
793 let until_now = Utc::now();
794 until = Some(until_now.to_rfc3339());
795 until_display = Some(format!("{} UTC", until_now.format("%Y-%m-%d %H:%M:%S")));
796 use_filtered_query = true;
797 query = match args.since_mode {
798 SinceModeArg::Exclusive => {
799 query.filter(Filter::date_greater("last_modified_date", since_rfc3339))
800 }
801 SinceModeArg::Inclusive => query.filter(Filter::date_greater_or_equal(
802 "last_modified_date",
803 since_rfc3339,
804 )),
805 };
806 }
807
808 let type_filter = resolve_type_filter(ctx, &space.id, args.types.as_ref()).await?;
809 if type_filter.is_some() {
810 use_filtered_query = true;
811 }
812
813 if use_filtered_query {
814 let objects = query.list().await?.collect_all().await?;
815 let mut descriptors: Vec<_> = if type_filter.is_some() {
816 let ids: Vec<String> = objects.iter().map(|obj| obj.id.clone()).collect();
817 fetch_descriptors_by_ids(&ctx.client, &space.id, &ids).await?
818 } else {
819 objects.iter().map(object_to_descriptor).collect()
820 };
821 if let Some(filter) = type_filter.as_ref() {
822 descriptors.retain(|descriptor| descriptor_matches_type_filter(descriptor, filter));
823 }
824 let object_ids = descriptors.iter().map(|d| d.id.clone()).collect();
825 return Ok(BackupSelection {
826 object_ids: Some(object_ids),
827 descriptors,
828 since,
829 since_display,
830 until,
831 until_display,
832 type_ids: type_filter.map(|f| f.manifest_type_ids),
833 });
834 }
835
836 let descriptors = ctx
837 .client
838 .objects(&space.id)
839 .limit(10_000)
840 .list()
841 .await?
842 .collect_all()
843 .await?
844 .into_iter()
845 .map(|obj| object_to_descriptor(&obj))
846 .collect();
847
848 Ok(BackupSelection {
849 object_ids: None,
850 descriptors,
851 since: None,
852 since_display: None,
853 until: None,
854 until_display: None,
855 type_ids: None,
856 })
857}
858
859async fn fetch_descriptors_by_ids(
860 client: &AnytypeClient,
861 space_id: &str,
862 object_ids: &[String],
863) -> Result<Vec<ObjectDescriptor>> {
864 let mut descriptors = Vec::with_capacity(object_ids.len());
865 for object_id in object_ids {
866 let object = client
867 .object(space_id, object_id)
868 .get()
869 .await
870 .with_context(|| format!("failed to fetch object {object_id}"))?;
871 descriptors.push(object_to_descriptor(&object));
872 }
873 Ok(descriptors)
874}
875
876fn parse_since(since: Option<&String>) -> Result<DateTime<FixedOffset>> {
877 let since = since.ok_or_else(|| anyhow!("--since is required when --mode incremental"))?;
878 let raw = since.trim();
879 if let Ok(parsed) = DateTime::parse_from_rfc3339(raw) {
880 return Ok(parsed);
881 }
882 if let Some(utc_suffix) = raw
883 .strip_suffix(" UTC")
884 .or_else(|| raw.strip_suffix(" utc"))
885 && let Some(naive) = parse_local_naive(utc_suffix.trim())
886 {
887 let utc = naive.and_utc();
888 if let Some(offset) = FixedOffset::east_opt(0) {
889 return Ok(utc.with_timezone(&offset));
890 }
891 }
892 if let Some(utc_suffix) = raw.strip_suffix("+0").or_else(|| raw.strip_suffix("+00"))
893 && let Some(naive) = parse_local_naive(utc_suffix.trim())
894 {
895 let utc = naive.and_utc();
896 if let Some(offset) = FixedOffset::east_opt(0) {
897 return Ok(utc.with_timezone(&offset));
898 }
899 }
900 parse_local_since(raw).with_context(|| {
901 format!(
902 "invalid --since value: {since}. Expected RFC3339 with timezone/offset, or local/partial time without timezone (e.g. 2026-01-12T10:11:22, 2026-01-12, 2026-01, 2026)"
903 )
904 })
905}
906
907fn parse_local_since(value: &str) -> Result<DateTime<FixedOffset>> {
908 let naive =
909 parse_local_naive(value).ok_or_else(|| anyhow!("unable to parse local timestamp"))?;
910 let local = Local
911 .from_local_datetime(&naive)
912 .single()
913 .ok_or_else(|| anyhow!("ambiguous/non-existent local time due to timezone transition"))?;
914 Ok(local.fixed_offset())
915}
916
917fn parse_local_naive(value: &str) -> Option<NaiveDateTime> {
918 const FORMATS: &[&str] = &[
919 "%Y-%m-%dT%H:%M:%S%.f",
920 "%Y-%m-%d %H:%M:%S%.f",
921 "%Y-%m-%dT%H:%M:%S",
922 "%Y-%m-%d %H:%M:%S",
923 "%Y-%m-%dT%H:%M",
924 "%Y-%m-%d %H:%M",
925 ];
926 for format in FORMATS {
927 if let Ok(dt) = NaiveDateTime::parse_from_str(value, format) {
928 return Some(dt);
929 }
930 }
931 NaiveDate::parse_from_str(value, "%Y-%m-%d")
932 .ok()
933 .and_then(|date| date.and_hms_opt(0, 0, 0))
934 .or_else(|| {
935 NaiveDate::parse_from_str(&format!("{value}-01"), "%Y-%m-%d")
936 .ok()
937 .and_then(|date| date.and_hms_opt(0, 0, 0))
938 })
939 .or_else(|| {
940 NaiveDate::parse_from_str(&format!("{value}-01-01"), "%Y-%m-%d")
941 .ok()
942 .and_then(|date| date.and_hms_opt(0, 0, 0))
943 })
944}
945
946fn to_rfc3339_with_offset(value: DateTime<FixedOffset>) -> String {
947 if value.offset().local_minus_utc() == 0 {
948 value
949 .with_timezone(&Utc)
950 .to_rfc3339_opts(SecondsFormat::Secs, true)
951 } else {
952 value.to_rfc3339_opts(SecondsFormat::Secs, false)
953 }
954}
955
956fn format_since_display(value: DateTime<FixedOffset>) -> String {
957 let tz = if value.offset().local_minus_utc() == 0 {
958 "UTC".to_string()
959 } else {
960 value.offset().to_string()
961 };
962 format!("{} {}", value.format("%Y-%m-%d %H:%M:%S"), tz)
963}
964
965fn local_now_display() -> String {
966 let now = Local::now();
967 format!("{} {}", now.format("%Y-%m-%d %H:%M:%S"), now.format("%Z"))
968}
969
970async fn resolve_type_filter(
971 ctx: &AppContext,
972 space_id: &str,
973 type_values: Option<&Vec<String>>,
974) -> Result<Option<TypeFilter>> {
975 let Some(values) = type_values else {
976 return Ok(None);
977 };
978 let mut keys = BTreeSet::new();
979 let mut manifest_type_ids = Vec::new();
980 let mut manifest_seen = BTreeSet::new();
981 for value in values {
982 let trimmed = value.trim();
983 if trimmed.is_empty() {
984 continue;
985 }
986 if looks_like_object_id(trimmed) {
987 let typ = ctx
988 .client
989 .get_type(space_id, trimmed)
990 .get()
991 .await
992 .with_context(|| format!("type not found for id '{trimmed}'"))?;
993 keys.insert(typ.key.clone());
994 if manifest_seen.insert(typ.id.clone()) {
995 manifest_type_ids.push(typ.id);
996 }
997 } else {
998 let typ = ctx
999 .client
1000 .lookup_type_by_key(space_id, trimmed)
1001 .await
1002 .with_context(|| format!("type not found for key '{trimmed}'"))?;
1003 keys.insert(typ.key.clone());
1004 if manifest_seen.insert(typ.id.clone()) {
1005 manifest_type_ids.push(typ.id);
1006 }
1007 }
1008 }
1009 ensure!(
1010 !keys.is_empty(),
1011 "no valid type entries supplied to --types"
1012 );
1013 Ok(Some(TypeFilter {
1014 keys,
1015 manifest_type_ids,
1016 }))
1017}
1018
1019fn descriptor_matches_type_filter(object: &ObjectDescriptor, filter: &TypeFilter) -> bool {
1020 object
1021 .r#type
1022 .as_ref()
1023 .is_some_and(|type_key| filter.keys.contains(type_key))
1024}
1025
1026fn resolve_backup_target(args: &BackupCreateArgs, space_id: &str) -> Result<BackupTarget> {
1027 let zip = true;
1028
1029 if let Some(dest) = args.dest.as_ref() {
1030 ensure!(
1031 !dest.exists(),
1032 "target archive path already exists: {}",
1033 dest.display()
1034 );
1035 let parent = dest
1036 .parent()
1037 .filter(|p| !p.as_os_str().is_empty())
1038 .unwrap_or_else(|| Path::new("."));
1039 ensure!(
1040 parent.exists(),
1041 "parent directory for --dest does not exist: {}",
1042 parent.display()
1043 );
1044 ensure!(
1045 parent.is_dir(),
1046 "parent path for --dest is not a directory: {}",
1047 parent.display()
1048 );
1049 return Ok(BackupTarget {
1050 parent_dir: parent.to_path_buf(),
1051 archive_path: dest.clone(),
1052 zip,
1053 });
1054 }
1055
1056 let parent_dir = args.dir.clone().unwrap_or_else(|| PathBuf::from("."));
1057 ensure!(
1058 parent_dir.exists(),
1059 "output directory does not exist: {}",
1060 parent_dir.display()
1061 );
1062 ensure!(
1063 parent_dir.is_dir(),
1064 "output path is not a directory: {}",
1065 parent_dir.display()
1066 );
1067
1068 let ts = Utc::now().format("%Y%m%d-%H%M%S");
1069 let prefix = args.prefix.as_deref().unwrap_or("backup");
1070 let mut archive_name = format!("{}_{}_{}", sanitize_path_component(prefix), space_id, ts);
1071 if zip {
1072 archive_name.push_str(".zip");
1073 }
1074 let archive_path = parent_dir.join(archive_name);
1075 ensure!(
1076 !archive_path.exists(),
1077 "target archive path already exists: {}",
1078 archive_path.display()
1079 );
1080 Ok(BackupTarget {
1081 parent_dir,
1082 archive_path,
1083 zip,
1084 })
1085}
1086
1087struct PreparedBackupPublication {
1088 source: PathBuf,
1089 staged_archive: PathBuf,
1090 archive_identity: FileIdentity,
1091 dest: PathBuf,
1092 sidecar: PathBuf,
1093 staged_sidecar: PathBuf,
1094 sidecar_identity: FileIdentity,
1095}
1096
1097impl Drop for PreparedBackupPublication {
1098 fn drop(&mut self) {
1099 let _ = fs::remove_file(&self.source);
1100 let _ = fs::remove_file(&self.staged_archive);
1101 let _ = fs::remove_file(&self.staged_sidecar);
1102 }
1103}
1104
1105fn prepare_backup_artifacts(
1106 source: PathBuf,
1107 dest: PathBuf,
1108 manifest: &Manifest,
1109) -> Result<PreparedBackupPublication> {
1110 prepare_backup_artifacts_with_hook(source, dest, manifest, |_| Ok(()))
1111}
1112
1113fn prepare_backup_artifacts_with_hook(
1114 source: PathBuf,
1115 dest: PathBuf,
1116 manifest: &Manifest,
1117 after_archive_stage: impl FnOnce(&Path) -> Result<()>,
1118) -> Result<PreparedBackupPublication> {
1119 ensure!(
1120 source != dest,
1121 "backup staging path unexpectedly equals destination"
1122 );
1123 let mut source_file = fs::File::open(&source)
1124 .with_context(|| format!("failed to open staged archive {}", source.display()))?;
1125 let (mut archive_stage, archive_stage_path) = create_backup_staging_file(&dest, "archive")?;
1126 if let Err(error) =
1127 io::copy(&mut source_file, &mut archive_stage).and_then(|_| archive_stage.sync_all())
1128 {
1129 drop(archive_stage);
1130 let _ = fs::remove_file(&archive_stage_path);
1131 return Err(error).context("failed to copy and sync owned backup archive staging file");
1132 }
1133 if let Err(error) = after_archive_stage(&archive_stage_path) {
1134 drop(archive_stage);
1135 let _ = fs::remove_file(&archive_stage_path);
1136 return Err(error).context("backup archive staging barrier failed");
1137 }
1138 let (archive_size, archive_sha256) = match archive_binding_from_file(&mut archive_stage) {
1139 Ok(binding) => binding,
1140 Err(error) => {
1141 drop(archive_stage);
1142 let _ = fs::remove_file(&archive_stage_path);
1143 return Err(error).context("failed to bind staged backup archive handle");
1144 }
1145 };
1146 let archive_identity = match FileIdentity::from_file(archive_stage) {
1147 Ok(identity) => identity,
1148 Err(error) => {
1149 let _ = fs::remove_file(&archive_stage_path);
1150 return Err(error).context("failed to retain staged backup archive identity");
1151 }
1152 };
1153 let mut bound_manifest = manifest.clone();
1154 bound_manifest.archive_size = Some(archive_size);
1155 bound_manifest.archive_sha256 = Some(archive_sha256);
1156 let text = match serde_json::to_vec_pretty(&bound_manifest) {
1157 Ok(text) => text,
1158 Err(error) => {
1159 let _ = fs::remove_file(&archive_stage_path);
1160 return Err(error).context("failed to serialize bound backup manifest");
1161 }
1162 };
1163
1164 let sidecar_path = manifest_sidecar_path(&dest);
1165 let (mut stage, stage_path) = match create_backup_staging_file(&sidecar_path, "manifest") {
1166 Ok(staging) => staging,
1167 Err(error) => {
1168 let _ = fs::remove_file(&archive_stage_path);
1169 return Err(error);
1170 }
1171 };
1172 if let Err(error) = stage.write_all(&text).and_then(|()| stage.sync_all()) {
1173 drop(stage);
1174 let _ = fs::remove_file(&archive_stage_path);
1175 let _ = fs::remove_file(&stage_path);
1176 return Err(error).context("failed to sync staged backup manifest");
1177 }
1178 let sidecar_identity = match FileIdentity::from_file(stage) {
1179 Ok(identity) => identity,
1180 Err(error) => {
1181 let _ = fs::remove_file(&archive_stage_path);
1182 let _ = fs::remove_file(&stage_path);
1183 return Err(error).context("failed to retain staged backup manifest identity");
1184 }
1185 };
1186
1187 Ok(PreparedBackupPublication {
1188 source,
1189 staged_archive: archive_stage_path,
1190 archive_identity,
1191 dest,
1192 sidecar: sidecar_path,
1193 staged_sidecar: stage_path,
1194 sidecar_identity,
1195 })
1196}
1197
1198fn commit_backup_artifacts(
1199 prepared: PreparedBackupPublication,
1200 authority: deadline::PublicationCommit,
1201) -> Result<()> {
1202 commit_backup_artifacts_with_hook(prepared, authority, || Ok(()))
1203}
1204
1205fn commit_backup_artifacts_with_hook(
1206 mut prepared: PreparedBackupPublication,
1207 authority: deadline::PublicationCommit,
1208 after_manifest_claim: impl FnOnce() -> Result<()>,
1209) -> Result<()> {
1210 authority.commit(|| {
1211 ensure!(
1212 !prepared.dest.exists(),
1213 "backup archive destination already exists: {}",
1214 prepared.dest.display()
1215 );
1216 claim_owned_staging_file(
1217 &prepared.staged_sidecar,
1218 &prepared.sidecar,
1219 &prepared.sidecar_identity,
1220 )
1221 .with_context(|| {
1222 format!(
1223 "failed to publish backup manifest {} without overwriting an existing destination",
1224 prepared.sidecar.display()
1225 )
1226 })?;
1227
1228 let finish = after_manifest_claim()
1229 .and_then(|()| ensure_file_owned(&prepared.sidecar, &prepared.sidecar_identity))
1230 .and_then(|()| {
1231 claim_owned_staging_file(
1232 &prepared.staged_archive,
1233 &prepared.dest,
1234 &prepared.archive_identity,
1235 )
1236 .with_context(|| {
1237 format!(
1238 "failed to publish backup archive {} without overwriting an existing destination",
1239 prepared.dest.display()
1240 )
1241 })
1242 });
1243 if let Err(error) = finish {
1244 remove_file_if_owned(&prepared.sidecar, &prepared.sidecar_identity).with_context(|| {
1245 format!(
1246 "backup publication failed ({error}); refused to remove a manifest path no longer owned by this publication"
1247 )
1248 })?;
1249 return Err(error);
1250 }
1251
1252 fs::remove_file(&prepared.source).context("failed to remove original archive staging file")?;
1253 fs::remove_file(&prepared.staged_archive)
1254 .context("failed to remove owned archive staging file")?;
1255 fs::remove_file(&prepared.staged_sidecar)
1256 .context("failed to remove owned manifest staging file")?;
1257 prepared.source.clear();
1258 prepared.staged_archive.clear();
1259 prepared.staged_sidecar.clear();
1260 Ok(())
1261 })
1262}
1263
1264fn claim_owned_staging_file(
1265 staging: &Path,
1266 destination: &Path,
1267 identity: &FileIdentity,
1268) -> Result<()> {
1269 ensure_file_owned(staging, identity).context("staging identity changed before publication")?;
1270 fs::hard_link(staging, destination)?;
1271 ensure_file_owned(destination, identity)
1272 .context("published file identity does not match its owned staging file")?;
1273 Ok(())
1274}
1275
1276fn ensure_file_owned(path: &Path, identity: &FileIdentity) -> Result<()> {
1277 let current = FileIdentity::from_path(path).context("failed to inspect file identity")?;
1278 ensure!(¤t == identity, "file identity changed");
1279 Ok(())
1280}
1281
1282fn remove_file_if_owned(path: &Path, identity: &FileIdentity) -> Result<()> {
1283 let current = match FileIdentity::from_path(path) {
1284 Ok(current) => current,
1285 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
1286 Err(error) => return Err(error).context("failed to inspect publication rollback target"),
1287 };
1288 ensure!(
1289 ¤t == identity,
1290 "publication rollback target identity changed"
1291 );
1292 fs::remove_file(path).context("failed to remove owned publication path")
1293}
1294
1295fn create_backup_staging_file(destination: &Path, purpose: &str) -> Result<(fs::File, PathBuf)> {
1296 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
1297 let name = destination
1298 .file_name()
1299 .ok_or_else(|| anyhow!("backup manifest destination must name a file"))?;
1300 for nonce in 0..100_u32 {
1301 let path = parent.join(format!(
1302 ".{}.anyback-stage-{}-{nonce}",
1303 name.to_string_lossy(),
1304 std::process::id()
1305 ));
1306 match OpenOptions::new()
1307 .read(true)
1308 .write(true)
1309 .create_new(true)
1310 .open(&path)
1311 {
1312 Ok(file) => return Ok((file, path)),
1313 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1314 Err(error) => {
1315 return Err(error)
1316 .with_context(|| format!("failed to create backup {purpose} staging file"));
1317 }
1318 }
1319 }
1320 bail!("failed to allocate backup {purpose} staging file")
1321}
1322
1323async fn handle_restore_apply(ctx: &WorkflowContext, args: RestoreApplyArgs) -> Result<()> {
1324 ctx.deadline.ensure_restore_preflight_remaining()?;
1325 let progress = ProgressReporter::new(&ctx.output, "Starting restore");
1326 let (cancel_sender, mut cancel_state) = new_import_cancel_channel();
1327 let signal_forwarder = spawn_import_cancel_signal_forwarder(cancel_sender);
1328 let result = async {
1329 let archive = args.archive.as_path();
1330 let space_name_or_id = args
1331 .space
1332 .as_deref()
1333 .ok_or_else(|| anyhow!("--space is required"))?;
1334 let space = ctx
1335 .deadline
1336 .run_restore_preflight(resolve_space(&ctx.client, space_name_or_id))
1337 .await??;
1338 progress.set_message("Resolved destination space");
1339 let archive_owned = archive.to_path_buf();
1340 let objects_owned = args.objects.clone();
1341 let plan = ctx
1342 .deadline
1343 .run_restore_preflight(tokio::task::spawn_blocking(move || {
1344 build_import_plan(&archive_owned, objects_owned.as_deref())
1345 }))
1346 .await?
1347 .context("restore preflight worker failed")??;
1348 if args.dry_run {
1349 progress.finish("Restore preflight completed");
1350 return publish_restore_dry_run(ctx, archive, &space.id, &plan).await;
1351 }
1352 progress.set_message("Importing archive");
1353 let mut report = init_import_report(archive, &space.id, &plan.selected_ids);
1354 let execution = execute_object_import(
1355 ctx,
1356 &space.id,
1357 &plan.import_path,
1358 args.objects.is_some(),
1359 &plan.selected_ids,
1360 args.import_mode,
1361 args.replace,
1362 progress.enabled(),
1363 &mut cancel_state,
1364 )
1365 .await?;
1366 ctx.deadline.ensure_mutation_remaining()?;
1367 let response = aggregate_import_responses(&execution.responses);
1368 report.event_progress = execution.event_progress;
1369 apply_import_response(
1370 &mut report,
1371 response,
1372 &plan.selected_ids,
1373 plan.manifest.as_ref(),
1374 );
1375 progress.finish("Restore completed");
1376 ctx.deadline.ensure_mutation_remaining()?;
1377 if let Some(path) = args.log.clone() {
1378 let report_for_file = report.clone();
1379 ctx.deadline
1380 .run_mutation_publication(
1381 move || prepare_report(&report_for_file, &path),
1382 CommandOutput::commit_prepared,
1383 )
1384 .await?;
1385 }
1386 log_report_summary(&report);
1387 publish_restore_result(ctx, report).await?;
1388 Ok(())
1389 }
1390 .await;
1391 signal_forwarder.abort();
1392 result
1393}
1394
1395async fn publish_restore_dry_run(
1396 ctx: &WorkflowContext,
1397 archive: &Path,
1398 space_id: &str,
1399 plan: &ImportPlan,
1400) -> Result<()> {
1401 let archive = archive.to_path_buf();
1402 let space_id = space_id.to_string();
1403 let requested = plan.selected_ids.len();
1404 let manifest_present = plan.manifest.is_some();
1405 let payload = serde_json::json!({
1406 "dry_run": true,
1407 "archive": archive.clone(),
1408 "space_id": space_id.clone(),
1409 "requested": requested,
1410 "manifest_present": manifest_present,
1411 });
1412 let output = ctx.output.clone();
1413 ctx.deadline
1414 .run_read_publication(
1415 "restore workflow timed out before mutation dispatch",
1416 move || {
1417 let Some(rendered) = output.render(&payload, || {
1418 format!(
1419 "dry-run ok archive={} space={} requested={} manifest={}",
1420 archive.display(),
1421 space_id,
1422 requested,
1423 if manifest_present {
1424 "present"
1425 } else {
1426 "missing"
1427 }
1428 )
1429 })?
1430 else {
1431 return Ok(output::PreparedOutput::Quiet);
1432 };
1433 output.prepare_rendered(rendered)
1434 },
1435 CommandOutput::commit_prepared,
1436 )
1437 .await
1438}
1439
1440async fn publish_restore_result(ctx: &WorkflowContext, report: ImportReport) -> Result<()> {
1441 let output = ctx.output.clone();
1442 ctx.deadline
1443 .run_mutation_publication(
1444 move || {
1445 let Some(rendered) = output.render(&report, || render_report_summary(&report))?
1446 else {
1447 return Ok(output::PreparedOutput::Quiet);
1448 };
1449 output.prepare_rendered(rendered)
1450 },
1451 CommandOutput::commit_prepared,
1452 )
1453 .await
1454}
1455
1456struct ImportPlan {
1457 manifest: Option<Manifest>,
1458 selected_ids: Vec<String>,
1459 import_path: PathBuf,
1460}
1461
1462#[derive(Debug, Clone)]
1463#[cfg(feature = "snapshot-import")]
1464struct ImportSnapshotEntry {
1465 path: String,
1466 id: String,
1467 sb_type: i32,
1468 snapshot: import_request::Snapshot,
1469 encoded_bytes: usize,
1470}
1471
1472#[allow(clippy::struct_field_names)]
1473#[derive(Debug, Clone, Copy)]
1474#[cfg(feature = "snapshot-import")]
1475struct ImportChunkLimits {
1476 max_single_snapshot_bytes: usize,
1477 max_batch_bytes: usize,
1478 max_batch_snapshots: usize,
1479}
1480
1481fn build_import_plan(archive: &Path, objects_spec: Option<&str>) -> Result<ImportPlan> {
1482 let manifest = read_manifest_from_archive(archive)?;
1483 let selected_ids = if let Some(spec) = objects_spec {
1484 let ids = load_object_ids_spec(spec)?;
1485 ensure!(!ids.is_empty(), "no object ids supplied to --objects");
1486 ids
1487 } else {
1488 infer_object_ids_from_archive(archive).unwrap_or_default()
1489 };
1490
1491 Ok(ImportPlan {
1492 manifest,
1493 selected_ids,
1494 import_path: archive.to_path_buf(),
1495 })
1496}
1497
1498fn infer_object_ids_from_archive(archive: &Path) -> Result<Vec<String>> {
1499 let reader = ArchiveReader::from_path(archive)?;
1500 let files = reader.list_files()?;
1501 Ok(infer_object_ids_from_files(&files))
1502}
1503
1504fn init_import_report(archive: &Path, space_id: &str, selected_ids: &[String]) -> ImportReport {
1505 ImportReport {
1506 archive: archive.display().to_string(),
1507 space_id: space_id.to_string(),
1508 attempted: selected_ids.len(),
1509 imported: 0,
1510 failed: 0,
1511 success: Vec::new(),
1512 errors: Vec::new(),
1513 summary: Vec::new(),
1514 event_progress: None,
1515 }
1516}
1517
1518#[derive(Debug)]
1519struct ImportExecutionOutcome {
1520 responses: Vec<anytype_rpc::anytype::rpc::object::import::Response>,
1521 event_progress: Option<ImportEventProgressReport>,
1522}
1523
1524fn process_progress_to_report(progress: ProcessWatchProgress) -> ImportEventProgressReport {
1525 ImportEventProgressReport {
1526 processes_started: progress.processes_started,
1527 processes_done: progress.processes_done,
1528 process_updates: progress.process_updates,
1529 import_finish_events: progress.import_finish_events,
1530 import_finish_objects: progress.import_finish_objects,
1531 last_process_id: progress.last_process_id,
1532 last_process_state: progress.last_process_state,
1533 last_progress_done: progress.last_progress_done,
1534 last_progress_total: progress.last_progress_total,
1535 last_progress_message: progress.last_progress_message,
1536 last_process_error: progress.last_process_error,
1537 }
1538}
1539
1540#[cfg(feature = "tui")]
1541fn parse_cache_size(raw: &str) -> Result<usize> {
1542 let input = raw.trim();
1543 ensure!(!input.is_empty(), "cache size must not be empty");
1544
1545 let split = input
1546 .find(|ch: char| !ch.is_ascii_digit())
1547 .unwrap_or(input.len());
1548 let (digits, unit_raw) = input.split_at(split);
1549 ensure!(!digits.is_empty(), "cache size must start with a number");
1550 let value = digits.parse::<u64>()?;
1551 ensure!(value > 0, "cache size must be > 0");
1552 let unit = unit_raw.trim().to_ascii_lowercase();
1553
1554 let multiplier = match unit.as_str() {
1555 "" | "m" | "mb" => 1024_u64 * 1024_u64,
1556 "k" | "kb" => 1024_u64,
1557 "g" | "gb" => 1024_u64 * 1024_u64 * 1024_u64,
1558 _ => bail!("unsupported cache size unit: {unit_raw}"),
1559 };
1560
1561 let bytes = value
1562 .checked_mul(multiplier)
1563 .ok_or_else(|| anyhow!("cache size is too large"))?;
1564 usize::try_from(bytes).context("cache size exceeds platform limits")
1565}
1566
1567#[cfg(feature = "snapshot-import")]
1568fn parse_import_limit_env(name: &str, default: usize) -> Result<usize> {
1569 match std::env::var(name) {
1570 Ok(raw) => {
1571 let value = raw
1572 .parse::<usize>()
1573 .with_context(|| format!("invalid {name} value: {raw}"))?;
1574 ensure!(value > 0, "{name} must be > 0");
1575 Ok(value)
1576 }
1577 Err(std::env::VarError::NotPresent) => Ok(default),
1578 Err(err) => Err(anyhow!("failed to read {name}: {err}")),
1579 }
1580}
1581
1582#[cfg(feature = "snapshot-import")]
1583fn import_chunk_limits_from_env() -> Result<ImportChunkLimits> {
1584 let max_single_snapshot_bytes = parse_import_limit_env(
1585 "ANYBACK_IMPORT_MAX_SINGLE_SNAPSHOT_BYTES",
1586 DEFAULT_IMPORT_MAX_SINGLE_SNAPSHOT_BYTES,
1587 )?;
1588 let max_batch_bytes = parse_import_limit_env(
1589 "ANYBACK_IMPORT_MAX_BATCH_BYTES",
1590 DEFAULT_IMPORT_MAX_BATCH_BYTES,
1591 )?;
1592 let max_batch_snapshots = parse_import_limit_env(
1593 "ANYBACK_IMPORT_MAX_BATCH_SNAPSHOTS",
1594 DEFAULT_IMPORT_MAX_BATCH_SNAPSHOTS,
1595 )?;
1596 ensure!(
1597 max_batch_bytes >= max_single_snapshot_bytes,
1598 "ANYBACK_IMPORT_MAX_BATCH_BYTES ({max_batch_bytes}) must be >= ANYBACK_IMPORT_MAX_SINGLE_SNAPSHOT_BYTES ({max_single_snapshot_bytes})"
1599 );
1600 Ok(ImportChunkLimits {
1601 max_single_snapshot_bytes,
1602 max_batch_bytes,
1603 max_batch_snapshots,
1604 })
1605}
1606
1607#[cfg(feature = "snapshot-import")]
1608fn snapshot_id_from_data(data: &anytype_rpc::model::SmartBlockSnapshotBase) -> Option<String> {
1609 let details = data.details.as_ref()?;
1610 let value = details.fields.get("id")?;
1611 let kind = value.kind.as_ref()?;
1612 match kind {
1613 prost_types::value::Kind::StringValue(text) if !text.is_empty() => Some(text.clone()),
1614 _ => None,
1615 }
1616}
1617
1618#[cfg(feature = "snapshot-import")]
1619fn parse_import_snapshot_entry(path: &str, bytes: &[u8]) -> Result<ImportSnapshotEntry> {
1620 let snapshot = SnapshotWithType::decode(bytes)
1621 .with_context(|| format!("failed to decode protobuf snapshot: {path}"))?;
1622 let sb_type = snapshot.sb_type;
1623 let data = snapshot
1624 .snapshot
1625 .and_then(|s| s.data)
1626 .ok_or_else(|| anyhow!("snapshot payload missing data: {path}"))?;
1627 let id = snapshot_id_from_data(&data)
1628 .or_else(|| infer_object_id_from_snapshot_path(path))
1629 .ok_or_else(|| anyhow!("snapshot object id missing or unreadable: {path}"))?;
1630 let request_snapshot = import_request::Snapshot {
1631 id: id.clone(),
1632 snapshot: Some(data),
1633 };
1634 let encoded_bytes = request_snapshot.encoded_len();
1635 Ok(ImportSnapshotEntry {
1636 path: path.to_string(),
1637 id,
1638 sb_type,
1639 snapshot: request_snapshot,
1640 encoded_bytes,
1641 })
1642}
1643
1644#[cfg(feature = "snapshot-import")]
1645fn is_required_support_object_type(sb_type: i32) -> bool {
1646 use anytype_rpc::model::SmartBlockType;
1647 matches!(
1648 SmartBlockType::try_from(sb_type).ok(),
1649 Some(SmartBlockType::Workspace | SmartBlockType::Widget | SmartBlockType::SpaceView)
1650 )
1651}
1652
1653#[allow(clippy::case_sensitive_file_extension_comparisons)]
1654#[cfg(feature = "snapshot-import")]
1655fn collect_import_snapshots(
1656 import_path: &Path,
1657 selected_ids: &[String],
1658) -> Result<Vec<ImportSnapshotEntry>> {
1659 let reader = ArchiveReader::from_path(import_path)?;
1660 let files = reader.list_files()?;
1661 let mut snapshots = Vec::new();
1662 let selected: std::collections::HashSet<&str> =
1663 selected_ids.iter().map(String::as_str).collect();
1664 let selective = !selected.is_empty();
1665 let mut matched_selected = 0usize;
1666
1667 for file in files {
1668 let lower = file.path.to_ascii_lowercase();
1669 if lower.ends_with(".pb.json") {
1670 bail!(
1671 "snapshot transport does not support pb-json yet: {}. Re-run backup with --format pb.",
1672 file.path
1673 );
1674 }
1675 if !lower.ends_with(".pb") {
1676 continue;
1677 }
1678 let bytes = reader.read_bytes(&file.path)?;
1679 let parsed = parse_import_snapshot_entry(&file.path, &bytes)?;
1680 let is_object_snapshot = file.path.starts_with("objects/");
1681 if selective && is_object_snapshot {
1682 let keep = selected.contains(parsed.id.as_str())
1683 || is_required_support_object_type(parsed.sb_type);
1684 if !keep {
1685 continue;
1686 }
1687 if selected.contains(parsed.id.as_str()) {
1688 matched_selected = matched_selected.saturating_add(1);
1689 }
1690 }
1691 snapshots.push(parsed);
1692 }
1693 ensure!(
1694 !snapshots.is_empty(),
1695 "archive contains no protobuf snapshot files (*.pb)"
1696 );
1697 if selective {
1698 ensure!(
1699 matched_selected > 0,
1700 "none of the requested object ids were found in archive snapshots"
1701 );
1702 }
1703 Ok(snapshots)
1704}
1705
1706#[cfg(feature = "snapshot-import")]
1707fn plan_snapshot_batches(
1708 snapshots: &[ImportSnapshotEntry],
1709 limits: ImportChunkLimits,
1710) -> Result<Vec<Vec<import_request::Snapshot>>> {
1711 let mut batches = Vec::<Vec<import_request::Snapshot>>::new();
1712 let mut current = Vec::<import_request::Snapshot>::new();
1713 let mut current_bytes = 0usize;
1714
1715 for entry in snapshots {
1716 ensure!(
1717 entry.encoded_bytes <= limits.max_single_snapshot_bytes,
1718 "snapshot {} ({}) is too large: {} bytes (max {})",
1719 entry.id,
1720 entry.path,
1721 entry.encoded_bytes,
1722 limits.max_single_snapshot_bytes
1723 );
1724
1725 let would_exceed_count = current.len() >= limits.max_batch_snapshots;
1726 let would_exceed_bytes =
1727 !current.is_empty() && current_bytes + entry.encoded_bytes > limits.max_batch_bytes;
1728 if would_exceed_count || would_exceed_bytes {
1729 batches.push(std::mem::take(&mut current));
1730 current_bytes = 0;
1731 }
1732
1733 current_bytes += entry.encoded_bytes;
1734 current.push(entry.snapshot.clone());
1735 }
1736
1737 if !current.is_empty() {
1738 batches.push(current);
1739 }
1740 Ok(batches)
1741}
1742
1743fn aggregate_import_responses(
1744 responses: &[anytype_rpc::anytype::rpc::object::import::Response],
1745) -> anytype_rpc::anytype::rpc::object::import::Response {
1746 let mut objects_count = 0i64;
1747 let mut first_error: Option<anytype_rpc::anytype::rpc::object::import::response::Error> = None;
1748 for response in responses {
1749 objects_count = objects_count.saturating_add(response.objects_count.max(0));
1750 if first_error.is_none() {
1751 first_error = response.error.clone().filter(|error| error.code != 0);
1752 }
1753 }
1754
1755 anytype_rpc::anytype::rpc::object::import::Response {
1756 error: first_error,
1757 collection_id: String::new(),
1758 objects_count,
1759 }
1760}
1761
1762fn import_error_hint(error_code: i64) -> Option<&'static str> {
1763 match error_code {
1764 5 => Some("no objects detected in import source"),
1765 6 => Some("import was canceled"),
1766 7 => Some("CSV rows/relations limit exceeded"),
1767 8 => Some("file load/read error"),
1768 9 => Some("insufficient permissions for import destination"),
1769 10 => Some("unsupported/invalid HTML structure"),
1770 11 => Some("protobuf archive is not valid Anyblock format"),
1771 12 => Some("import source service is unavailable"),
1772 13 => Some("import source rate limit exceeded"),
1773 14 => Some("zip archive contains no importable objects"),
1774 17 => Some("directory contains no importable objects"),
1775 _ => None,
1776 }
1777}
1778
1779fn format_import_api_error(description: &str, error_code: i64) -> String {
1780 import_error_hint(error_code).map_or_else(
1781 || format!("{description} (code {error_code})"),
1782 |hint| format!("{description} (code {error_code}; hint: {hint})"),
1783 )
1784}
1785
1786#[cfg(feature = "snapshot-import")]
1787async fn execute_object_import_batches(
1788 ctx: &WorkflowContext,
1789 space_id: &str,
1790 batches: Vec<Vec<import_request::Snapshot>>,
1791 import_mode: ImportModeArg,
1792 replace_existing: bool,
1793 interactive_output: bool,
1794 cancel_state: &mut ImportCancelState,
1795) -> Result<ImportExecutionOutcome> {
1796 let grpc = ctx
1797 .deadline
1798 .run_restore_preflight(ctx.client.grpc_client())
1799 .await??;
1800 let mut commands = grpc.client_commands();
1801 let timeouts = ctx.deadline.process_timeouts()?;
1802 let mut tracker = ctx
1803 .deadline
1804 .run_restore_preflight(ProcessWatcher::subscribe(&grpc, timeouts))
1805 .await??;
1806 let watch_request = import_watch_request(space_id, interactive_output);
1807 let import_result: Result<_> = async {
1808 let mut responses = Vec::with_capacity(batches.len());
1809 for batch in batches {
1810 ctx.deadline.ensure_restore_preflight_remaining()?;
1811 let generation = tracker.begin_generation().context(
1812 "failed to establish import process event generation before mutation dispatch",
1813 )?;
1814 ctx.deadline.ensure_restore_preflight_remaining()?;
1815 let request = ObjectImportRequest {
1816 space_id: space_id.to_string(),
1817 snapshots: batch,
1818 update_existing_objects: replace_existing,
1819 r#type: anytype_rpc::model::r#import::Type::External as i32,
1820 mode: import_mode.to_rpc_mode(),
1821 no_progress: false,
1822 is_migration: false,
1823 is_new_space: false,
1824 params: None,
1825 };
1826
1827 let request = with_token(tonic::Request::new(request), grpc.token())
1828 .map_err(|err| anyhow!("failed to attach gRPC token: {err}"))?;
1829
1830 let response = ctx
1831 .deadline
1832 .run_mutation(commands.object_import(request))
1833 .await?
1834 .context("object import RPC failed; mutation outcome is indeterminate")
1835 .map(tonic::Response::into_inner)?;
1836 let correlation = tracker
1837 .correlate_generation(generation, &response.collection_id)
1838 .context(
1839 "import response could not be correlated to process completion; mutation outcome is indeterminate",
1840 )?;
1841 ctx.deadline
1842 .run_mutation(tracker.wait_for_generation(
1843 &grpc,
1844 &watch_request,
1845 correlation,
1846 Some(cancel_state.receiver_mut()),
1847 ))
1848 .await?
1849 .context(
1850 "import process completion failed; mutation outcome is indeterminate",
1851 )?;
1852 responses.push(response);
1853 }
1854 Ok(ImportExecutionOutcome {
1855 responses,
1856 event_progress: None,
1857 })
1858 }
1859 .await;
1860
1861 let unsubscribe_result = tracker.unsubscribe(&grpc).await;
1862 if let Err(err) = unsubscribe_result {
1863 if import_result.is_ok() {
1864 return Err(err.into());
1865 }
1866 warn!("failed to unsubscribe process events after restore error: {err:#}");
1867 }
1868
1869 let mut outcome = import_result?;
1870 outcome.event_progress = Some(process_progress_to_report(tracker.into_progress()));
1871 Ok(outcome)
1872}
1873
1874async fn execute_object_import_path(
1875 ctx: &WorkflowContext,
1876 space_id: &str,
1877 archive_path: &Path,
1878 import_mode: ImportModeArg,
1879 replace_existing: bool,
1880 interactive_output: bool,
1881 cancel_state: &mut ImportCancelState,
1882) -> Result<ImportExecutionOutcome> {
1883 let import_paths = pb_import_paths(archive_path)?;
1884 let grpc = ctx
1885 .deadline
1886 .run_restore_preflight(ctx.client.grpc_client())
1887 .await??;
1888 let mut commands = grpc.client_commands();
1889 let timeouts = ctx.deadline.process_timeouts()?;
1890 let mut tracker = ctx
1891 .deadline
1892 .run_restore_preflight(ProcessWatcher::subscribe(&grpc, timeouts))
1893 .await??;
1894 let watch_request = import_watch_request(space_id, interactive_output);
1895 let request = ObjectImportRequest {
1896 space_id: space_id.to_string(),
1897 snapshots: Vec::new(),
1898 update_existing_objects: replace_existing,
1899 r#type: anytype_rpc::model::r#import::Type::Pb as i32,
1900 mode: import_mode.to_rpc_mode(),
1901 no_progress: false,
1902 is_migration: false,
1903 is_new_space: false,
1904 params: Some(import_request::Params::PbParams(import_request::PbParams {
1905 path: import_paths,
1906 no_collection: false,
1907 collection_title: String::new(),
1908 import_type: import_request::pb_params::Type::Space as i32,
1909 })),
1910 };
1911 let import_result: Result<_> = async {
1912 ctx.deadline.ensure_restore_preflight_remaining()?;
1913 let generation = tracker.begin_generation().context(
1914 "failed to establish import process event generation before mutation dispatch",
1915 )?;
1916 ctx.deadline.ensure_restore_preflight_remaining()?;
1917 let request = with_token(tonic::Request::new(request), grpc.token())
1918 .map_err(|err| anyhow!("failed to attach gRPC token: {err}"))?;
1919 let response = ctx
1920 .deadline
1921 .run_mutation(commands.object_import(request))
1922 .await?
1923 .context("object import RPC failed; mutation outcome is indeterminate")
1924 .map(tonic::Response::into_inner)?;
1925 let correlation = tracker
1926 .correlate_generation(generation, &response.collection_id)
1927 .context(
1928 "import response could not be correlated to process completion; mutation outcome is indeterminate",
1929 )?;
1930 ctx.deadline
1931 .run_mutation(tracker.wait_for_generation(
1932 &grpc,
1933 &watch_request,
1934 correlation,
1935 Some(cancel_state.receiver_mut()),
1936 ))
1937 .await?
1938 .context(
1939 "import process completion failed; mutation outcome is indeterminate",
1940 )?;
1941 Ok(ImportExecutionOutcome {
1942 responses: vec![response],
1943 event_progress: None,
1944 })
1945 }
1946 .await;
1947
1948 let unsubscribe_result = tracker.unsubscribe(&grpc).await;
1949 if let Err(err) = unsubscribe_result {
1950 if import_result.is_ok() {
1951 return Err(err.into());
1952 }
1953 warn!("failed to unsubscribe process events after restore error: {err:#}");
1954 }
1955
1956 let mut outcome = import_result?;
1957 outcome.event_progress = Some(process_progress_to_report(tracker.into_progress()));
1958 Ok(outcome)
1959}
1960
1961fn import_watch_request(space_id: &str, interactive_output: bool) -> ProcessWatchRequest {
1962 ProcessWatchRequest::new(ProcessKind::Import, space_id)
1963 .allow_empty_space_id(true)
1964 .completion_fallback(ProcessCompletionFallback::ImportFinishEvent)
1965 .cancel_message(IMPORT_CANCEL_REASON)
1966 .log_progress(interactive_output)
1967}
1968
1969#[allow(clippy::too_many_arguments)]
1970async fn execute_object_import(
1971 ctx: &WorkflowContext,
1972 space_id: &str,
1973 archive_path: &Path,
1974 explicit_object_selection: bool,
1975 #[allow(unused_variables)] selected_ids: &[String],
1976 import_mode: ImportModeArg,
1977 replace_existing: bool,
1978 interactive_output: bool,
1979 cancel_state: &mut ImportCancelState,
1980) -> Result<ImportExecutionOutcome> {
1981 #[cfg(feature = "snapshot-import")]
1982 if explicit_object_selection {
1983 let limits = import_chunk_limits_from_env()?;
1984 let snapshots = collect_import_snapshots(archive_path, selected_ids)?;
1985 let batches = plan_snapshot_batches(&snapshots, limits)?;
1986 return execute_object_import_batches(
1987 ctx,
1988 space_id,
1989 batches,
1990 import_mode,
1991 replace_existing,
1992 interactive_output,
1993 cancel_state,
1994 )
1995 .await;
1996 }
1997
1998 #[cfg(not(feature = "snapshot-import"))]
1999 if explicit_object_selection {
2000 bail!(
2001 "--objects restore requires snapshot transport; rebuild anyback with --features snapshot-import"
2002 );
2003 }
2004
2005 execute_object_import_path(
2006 ctx,
2007 space_id,
2008 archive_path,
2009 import_mode,
2010 replace_existing,
2011 interactive_output,
2012 cancel_state,
2013 )
2014 .await
2015}
2016
2017fn pb_import_paths(archive_path: &Path) -> Result<Vec<String>> {
2018 if !archive_path.is_dir() {
2019 return Ok(vec![archive_path.to_string_lossy().to_string()]);
2020 }
2021 if std::env::var("ANYBACK_PB_IMPORT_ROOT_ONLY")
2022 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2023 {
2024 return Ok(vec![archive_path.to_string_lossy().to_string()]);
2025 }
2026
2027 let mut paths = Vec::new();
2028 let include_files_dir = std::env::var("ANYBACK_PB_IMPORT_INCLUDE_FILES_DIR")
2029 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
2030 for entry in fs::read_dir(archive_path).with_context(|| {
2031 format!(
2032 "failed to read archive directory {}",
2033 archive_path.display()
2034 )
2035 })? {
2036 let entry = entry?;
2037 let path = entry.path();
2038 let file_name = entry.file_name();
2039 let file_name = file_name.to_string_lossy();
2040
2041 if path.is_dir() {
2042 if include_files_dir && file_name.eq_ignore_ascii_case("files") {
2043 paths.push(path.to_string_lossy().to_string());
2044 continue;
2045 }
2046 if dir_contains_pb_or_json(&path)? {
2047 paths.push(path.to_string_lossy().to_string());
2048 }
2049 continue;
2050 }
2051
2052 if file_name == MANIFEST_NAME {
2053 continue;
2054 }
2055
2056 let keep_file = path
2057 .extension()
2058 .and_then(|ext| ext.to_str())
2059 .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "pb" | "json"));
2060 if keep_file || file_name == "config.json" {
2061 paths.push(path.to_string_lossy().to_string());
2062 }
2063 }
2064
2065 if paths.is_empty() {
2066 paths.push(archive_path.to_string_lossy().to_string());
2067 }
2068 paths.sort_unstable();
2069 Ok(paths)
2070}
2071
2072fn dir_contains_pb_or_json(dir: &Path) -> Result<bool> {
2073 let mut stack = vec![dir.to_path_buf()];
2074 while let Some(current) = stack.pop() {
2075 for entry in fs::read_dir(¤t)
2076 .with_context(|| format!("failed to read directory {}", current.display()))?
2077 {
2078 let entry = entry?;
2079 let path = entry.path();
2080 if path.is_dir() {
2081 stack.push(path);
2082 continue;
2083 }
2084 if path
2085 .extension()
2086 .and_then(|ext| ext.to_str())
2087 .is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "pb" | "json"))
2088 {
2089 return Ok(true);
2090 }
2091 }
2092 }
2093 Ok(false)
2094}
2095
2096fn apply_import_response(
2097 report: &mut ImportReport,
2098 response: anytype_rpc::anytype::rpc::object::import::Response,
2099 selected_ids: &[String],
2100 manifest: Option<&Manifest>,
2101) {
2102 let imported_count = usize::try_from(response.objects_count.max(0)).unwrap_or(0);
2103 let selected_descriptors = descriptors_from_selection(selected_ids, manifest);
2104 let api_error = response.error.filter(|error| error.code != 0);
2105
2106 if let Some(error) = api_error {
2107 let message = format_import_api_error(&error.description, i64::from(error.code));
2108 report.imported = imported_count;
2109 report.errors = selected_descriptors
2110 .into_iter()
2111 .map(|descriptor| ObjectImportError {
2112 id: descriptor.id,
2113 name: descriptor.name,
2114 r#type: descriptor.r#type,
2115 last_modified: descriptor.last_modified,
2116 error_code: "import_api_error".to_string(),
2117 message: message.clone(),
2118 status: "partial".to_string(),
2119 })
2120 .collect();
2121 report.failed = report.errors.len();
2122 report
2123 .summary
2124 .push(format!("import API reported error: {message}"));
2125 report.summary.push(
2126 "best-effort mode: partial import may have succeeded; object-id mapping unavailable"
2127 .to_string(),
2128 );
2129 } else if !selected_descriptors.is_empty() {
2130 report.success = selected_descriptors;
2131 report.imported = report.success.len();
2132 report.summary.push(
2133 "per-object new ids are not available from import API in v0.1; success list uses source ids"
2134 .to_string(),
2135 );
2136 } else if let Some(manifest) = manifest {
2137 report.success.clone_from(&manifest.objects);
2138 report.imported = report.success.len();
2139 report.attempted = report.imported;
2140 report.summary.push(
2141 "import completed from full manifest; per-object new id mapping unavailable"
2142 .to_string(),
2143 );
2144 } else {
2145 report.imported = imported_count;
2146 report.summary.push(
2147 "import completed, but per-object details are unavailable without --objects or manifest"
2148 .to_string(),
2149 );
2150 }
2151
2152 if report.attempted == 0 {
2153 report.attempted = report.imported.saturating_add(report.failed);
2154 }
2155 if report.failed > 0 {
2156 report.summary.push(format!(
2157 "imported {}/{} objects, {} failed",
2158 report.imported, report.attempted, report.failed
2159 ));
2160 } else {
2161 report.summary.push(format!(
2162 "imported {}/{} objects",
2163 report.imported, report.attempted
2164 ));
2165 }
2166 if let Some(events) = report.event_progress.as_ref() {
2167 report.summary.push(format!(
2168 "event progress: processes started={} done={} updates={} importFinish={} ({})",
2169 events.processes_started,
2170 events.processes_done,
2171 events.process_updates,
2172 events.import_finish_events,
2173 events.import_finish_objects
2174 ));
2175 if let (Some(id), Some(state)) = (&events.last_process_id, &events.last_process_state) {
2176 report
2177 .summary
2178 .push(format!("event completion: process {id} state {state}"));
2179 }
2180 }
2181}
2182
2183fn handle_diff(output: &CommandOutput, args: &DiffArgs) -> Result<()> {
2184 let (format1, objects1) = collect_cmp_objects(&args.archive1)?;
2185 let (format2, objects2) = collect_cmp_objects(&args.archive2)?;
2186
2187 ensure!(
2188 format1 != "mixed",
2189 "archive has mixed snapshot formats: {}",
2190 args.archive1.display()
2191 );
2192 ensure!(
2193 format2 != "mixed",
2194 "archive has mixed snapshot formats: {}",
2195 args.archive2.display()
2196 );
2197 ensure!(
2198 format1 != "unknown",
2199 "no comparable objects found in {}",
2200 args.archive1.display()
2201 );
2202 ensure!(
2203 format2 != "unknown",
2204 "no comparable objects found in {}",
2205 args.archive2.display()
2206 );
2207 ensure!(
2208 format1 == format2
2209 || matches!(
2210 (format1.as_str(), format2.as_str()),
2211 ("pb", "pb-json") | ("pb-json", "pb")
2212 ),
2213 "archive formats are not comparable: {} ({}) vs {} ({})",
2214 args.archive1.display(),
2215 format1,
2216 args.archive2.display(),
2217 format2
2218 );
2219
2220 let report = build_archive_cmp_report(
2221 &args.archive1.display().to_string(),
2222 &args.archive2.display().to_string(),
2223 &format1,
2224 &format2,
2225 &objects1,
2226 &objects2,
2227 );
2228
2229 output.emit(&report, || {
2230 let archive1_label = archive_basename(&args.archive1);
2231 let archive2_label = archive_basename(&args.archive2);
2232 let mut text = TextBuilder::new();
2233
2234 text.line(format!("< {archive1_label} only"));
2235 for row in &report.archive1_only {
2236 text.line(format!(
2237 "< {} {} {} {} {}",
2238 row.object_id, row.r#type, row.name, row.size, row.last_modified
2239 ));
2240 }
2241 text.blank();
2242 text.line(format!("> {archive2_label} only"));
2243 for row in &report.archive2_only {
2244 text.line(format!(
2245 "> {} {} {} {} {}",
2246 row.object_id, row.r#type, row.name, row.size, row.last_modified
2247 ));
2248 }
2249 text.blank();
2250 text.line("* Changed");
2251 for row in &report.changed {
2252 text.line(format!(
2253 "< {} {} {} {} {}",
2254 row.left.object_id,
2255 row.left.r#type,
2256 row.left.name,
2257 row.left.size,
2258 row.left.last_modified
2259 ));
2260 text.line(format!(
2261 "> {} {} {} {} {}",
2262 row.right.object_id,
2263 row.right.r#type,
2264 row.right.name,
2265 row.right.size,
2266 row.right.last_modified
2267 ));
2268 }
2269 text.finish()
2270 })
2271}
2272
2273fn archive_basename(path: &Path) -> String {
2274 path.file_name()
2275 .and_then(|name| name.to_str())
2276 .map_or_else(|| path.display().to_string(), ToString::to_string)
2277}
2278
2279#[allow(clippy::case_sensitive_file_extension_comparisons)]
2280fn collect_cmp_objects(
2281 archive: &Path,
2282) -> Result<(String, std::collections::BTreeMap<String, ArchiveCmpObject>)> {
2283 let reader = ArchiveReader::from_path(archive)?;
2284 let files = reader.list_files()?;
2285 let mut format = "unknown".to_string();
2286 let mut seen_formats = BTreeSet::new();
2287 let mut out = std::collections::BTreeMap::<String, ArchiveCmpObject>::new();
2288
2289 for file in &files {
2290 let lower = file.path.to_ascii_lowercase();
2291 let is_pb_json = lower.ends_with(".pb.json");
2292 let is_pb = lower.ends_with(".pb");
2293 if !is_pb && !is_pb_json {
2294 continue;
2295 }
2296
2297 let path = Path::new(&file.path);
2298 let under_objects = path
2299 .components()
2300 .next()
2301 .and_then(|c| c.as_os_str().to_str())
2302 .is_some_and(|root| root == "objects");
2303 if !under_objects {
2304 continue;
2305 }
2306
2307 seen_formats.insert(if is_pb_json { "pb-json" } else { "pb" });
2308 let bytes = reader.read_bytes(&file.path)?;
2309 let parsed = if is_pb_json {
2310 parse_snapshot_details_from_pb_json(&bytes)
2311 } else {
2312 parse_snapshot_details_from_pb(&bytes)
2313 };
2314 let Ok((_sb_type, details)) = parsed else {
2315 continue;
2316 };
2317 let id = detail_value(&details, "id")
2318 .and_then(Value::as_str)
2319 .map(ToString::to_string)
2320 .or_else(|| infer_object_id_from_snapshot_path(&file.path));
2321 let Some(object_id) = id else {
2322 continue;
2323 };
2324
2325 let type_value = detail_value(&details, "type")
2326 .cloned()
2327 .unwrap_or(Value::Null);
2328 let type_text = cmp_value_to_text(&type_value);
2329 let name = detail_value(&details, "name")
2330 .and_then(Value::as_str)
2331 .map_or_else(|| "-".to_string(), ToString::to_string);
2332 let last_modified = format_last_modified(detail_value(&details, "lastModifiedDate"))
2333 .unwrap_or_else(|| "-".to_string());
2334
2335 out.insert(
2336 object_id.clone(),
2337 ArchiveCmpObject {
2338 object_id,
2339 r#type: type_text,
2340 name,
2341 size: file.bytes,
2342 last_modified,
2343 },
2344 );
2345 }
2346
2347 if seen_formats.len() == 1 {
2348 format = seen_formats
2349 .iter()
2350 .next()
2351 .map_or_else(|| "unknown".to_string(), |s| (*s).to_string());
2352 } else if seen_formats.len() > 1 {
2353 format = "mixed".to_string();
2354 }
2355
2356 Ok((format, out))
2357}
2358
2359fn build_archive_cmp_report(
2360 archive1: &str,
2361 archive2: &str,
2362 format1: &str,
2363 format2: &str,
2364 objects1: &std::collections::BTreeMap<String, ArchiveCmpObject>,
2365 objects2: &std::collections::BTreeMap<String, ArchiveCmpObject>,
2366) -> ArchiveCmpReport {
2367 let mut archive1_only = Vec::new();
2368 let mut archive2_only = Vec::new();
2369 let mut changed = Vec::new();
2370
2371 let ids: BTreeSet<String> = objects1
2372 .keys()
2373 .chain(objects2.keys())
2374 .map(ToString::to_string)
2375 .collect();
2376
2377 for id in ids {
2378 match (objects1.get(&id), objects2.get(&id)) {
2379 (Some(left), Some(right)) => {
2380 if left != right {
2381 changed.push(ArchiveCmpChanged {
2382 left: left.clone(),
2383 right: right.clone(),
2384 });
2385 }
2386 }
2387 (Some(left), None) => archive1_only.push(left.clone()),
2388 (None, Some(right)) => archive2_only.push(right.clone()),
2389 (None, None) => {}
2390 }
2391 }
2392
2393 ArchiveCmpReport {
2394 archive1: archive1.to_string(),
2395 archive2: archive2.to_string(),
2396 format1: format1.to_string(),
2397 format2: format2.to_string(),
2398 archive1_only,
2399 archive2_only,
2400 changed,
2401 }
2402}
2403
2404fn cmp_value_to_text(value: &Value) -> String {
2405 match value {
2406 Value::Null => "-".to_string(),
2407 Value::String(s) => s.clone(),
2408 Value::Number(n) => n.to_string(),
2409 Value::Bool(b) => b.to_string(),
2410 _ => value.to_string(),
2411 }
2412}
2413
2414fn handle_list(output: &CommandOutput, args: &ListArgs) -> Result<()> {
2415 let reader = ArchiveReader::from_path(&args.archive)?;
2416 let source = reader.source();
2417 let files = reader.list_files()?;
2418 let (manifest, manifest_error) = read_manifest_prefer_sidecar(&args.archive, &reader);
2419 let total_bytes = files
2420 .iter()
2421 .fold(0u64, |sum, entry| sum.saturating_add(entry.bytes));
2422 let inferred_object_ids = infer_object_ids_from_files(&files);
2423 let expanded = args
2424 .expanded
2425 .then(|| parse_expanded_entries(&reader, &files));
2426
2427 let report = ListReport {
2428 archive: args.archive.display().to_string(),
2429 source: source.as_str().to_string(),
2430 file_count: files.len(),
2431 total_bytes,
2432 manifest_present: manifest.is_some(),
2433 manifest_error,
2434 manifest_summary: manifest.as_ref().map(manifest_summary),
2435 object_ids: if args.brief {
2436 None
2437 } else {
2438 Some(inferred_object_ids.clone())
2439 },
2440 files: args.files.then_some(files.clone()),
2441 expanded: expanded.clone(),
2442 };
2443
2444 output.emit(&report, || {
2445 let mut text = TextBuilder::new();
2446 render_list_summary(&mut text, &report, inferred_object_ids.len());
2447 if args.files {
2448 for entry in &files {
2449 text.line(format!("{} {}", entry.bytes, entry.path));
2450 }
2451 } else if let Some(entries) = expanded.as_ref() {
2452 render_expanded_entries(&mut text, entries);
2453 } else if !args.brief {
2454 for object_id in &inferred_object_ids {
2455 text.line(object_id);
2456 }
2457 }
2458 text.finish()
2459 })
2460}
2461
2462fn handle_manifest(output: &CommandOutput, args: &ManifestArgs) -> Result<()> {
2463 let reader = ArchiveReader::from_path(&args.archive)?;
2464 let (manifest, manifest_error) = read_manifest_prefer_sidecar(&args.archive, &reader);
2465 if let Some(manifest) = manifest {
2466 output.emit_json(&manifest)
2468 } else {
2469 if let Some(err) = manifest_error {
2470 bail!("manifest unreadable: {err}");
2471 }
2472 bail!("manifest not found in archive");
2473 }
2474}
2475
2476fn render_list_summary(text: &mut TextBuilder, report: &ListReport, object_count: usize) {
2477 text.line(format!("archive: {}", report.archive));
2478 if let Some(summary) = report.manifest_summary.as_ref() {
2479 text.line(format!(
2480 "space: {} ({})",
2481 summary.source_space_name, summary.source_space_id
2482 ));
2483 let created = summary
2484 .created_at_display
2485 .clone()
2486 .or_else(|| format_datetime_display(&summary.created_at))
2487 .unwrap_or_else(|| summary.created_at.clone());
2488 text.line(format!("created: {created}"));
2489 text.line(format!("format: {}", summary.format));
2490 } else if let Some(err) = report.manifest_error.as_ref() {
2491 text.line(format!("manifest: unreadable ({err})"));
2492 } else {
2493 text.line("manifest: missing");
2494 }
2495 text.line(format!("objects: {object_count}"));
2496 text.line(format!(
2497 "files: {} ({} bytes)",
2498 report.file_count, report.total_bytes
2499 ));
2500}
2501
2502fn render_expanded_entries(text: &mut TextBuilder, entries: &[ExpandedSnapshotEntry]) {
2503 let unreadable = entries.iter().filter(|e| e.status == "unreadable").count();
2504 text.line(format!(
2505 "expanded: parsed={} unreadable={}",
2506 entries.len().saturating_sub(unreadable),
2507 unreadable
2508 ));
2509 for entry in entries {
2510 if entry.status == "unreadable" {
2511 text.line(format!(
2512 "unreadable path={} id={} reason={}",
2513 entry.path,
2514 entry.id.as_deref().unwrap_or("-"),
2515 entry.unreadable_reason.as_deref().unwrap_or("-")
2516 ));
2517 } else {
2518 let object_type = entry
2519 .object_type
2520 .as_ref()
2521 .map_or_else(|| "null".to_string(), ToString::to_string);
2522 text.line(format!(
2523 "ok path={} id={} name={} type={} layout={}({}) archived={}",
2524 entry.path,
2525 entry.id.as_deref().unwrap_or("-"),
2526 entry.name.as_deref().unwrap_or("-"),
2527 object_type,
2528 entry
2529 .layout
2530 .map_or_else(|| "-".to_string(), |n| n.to_string()),
2531 entry.layout_name.as_deref().unwrap_or("-"),
2532 entry
2533 .archived
2534 .map_or_else(|| "-".to_string(), |b| b.to_string())
2535 ));
2536 }
2537 }
2538}
2539
2540fn handle_extract(output: &CommandOutput, args: &ExtractArgs) -> Result<()> {
2541 let kind = save_archive_object(&args.archive, &args.object_id, &args.destination)?;
2542 let label = match kind {
2543 SavedObjectKind::Markdown => "markdown",
2544 SavedObjectKind::Raw => "raw",
2545 };
2546 let report = serde_json::json!({
2547 "archive": args.archive,
2548 "object_id": args.object_id,
2549 "output": args.destination,
2550 "kind": label,
2551 });
2552 output.emit(&report, || {
2553 format!(
2554 "extracted object {} from {} to {} ({label})",
2555 args.object_id,
2556 args.archive.display(),
2557 args.destination.display()
2558 )
2559 })
2560}
2561
2562async fn resolve_space(client: &AnytypeClient, space_id_or_name: &str) -> Result<Space> {
2563 if looks_like_object_id(space_id_or_name) {
2564 return client
2565 .space(space_id_or_name)
2566 .get()
2567 .await
2568 .with_context(|| format!("space not found: {space_id_or_name}"));
2569 }
2570
2571 let spaces = client.spaces().list().await?.collect_all().await?;
2572 let needle = space_id_or_name.to_lowercase();
2573 let matches: Vec<_> = spaces
2574 .into_iter()
2575 .filter(|space| space.name.to_lowercase() == needle)
2576 .collect();
2577
2578 match matches.len() {
2579 0 => Err(anyhow!("space not found: {space_id_or_name}")),
2580 1 => Ok(matches[0].clone()),
2581 _ => Err(anyhow!("space name is ambiguous: {space_id_or_name}")),
2582 }
2583}
2584
2585fn object_to_descriptor(object: &Object) -> ObjectDescriptor {
2586 let last_modified = object
2587 .get_property_date("last_modified_date")
2588 .or_else(|| object.get_property_date("lastModifiedDate"))
2589 .map(|d| d.to_rfc3339());
2590
2591 ObjectDescriptor {
2592 id: object.id.clone(),
2593 new_id: None,
2594 name: object.name.clone(),
2595 r#type: object.r#type.as_ref().map(|typ| typ.key.clone()),
2596 last_modified,
2597 }
2598}
2599
2600fn parse_object_id_lines(input: &str) -> Vec<String> {
2601 let mut ids = Vec::new();
2602 let mut seen = BTreeSet::new();
2603
2604 for line in input.lines() {
2605 let trimmed = line.trim();
2606 if trimmed.is_empty() || trimmed.starts_with('#') {
2607 continue;
2608 }
2609 if seen.insert(trimmed.to_string()) {
2610 ids.push(trimmed.to_string());
2611 }
2612 }
2613
2614 ids
2615}
2616
2617fn load_object_ids_spec(spec: &str) -> Result<Vec<String>> {
2618 if spec == "-" {
2619 let mut input = String::new();
2620 io::stdin()
2621 .read_to_string(&mut input)
2622 .context("failed to read object id list from stdin")?;
2623 return Ok(parse_object_id_lines(&input));
2624 }
2625
2626 let text = std::fs::read_to_string(spec)
2627 .with_context(|| format!("failed to read object list file: {spec}"))?;
2628 Ok(parse_object_id_lines(&text))
2629}
2630
2631fn progress_enabled(output: &CommandOutput, stderr_is_tty: bool) -> bool {
2632 output.allows_progress() && stderr_is_tty
2633}
2634
2635struct ProgressReporter {
2636 bar: Option<ProgressBar>,
2637}
2638
2639impl ProgressReporter {
2640 fn new(output: &CommandOutput, message: &str) -> Self {
2641 let enabled = progress_enabled(output, io::stderr().is_terminal());
2642 if enabled {
2643 let bar = ProgressBar::new_spinner();
2644 let style = ProgressStyle::with_template("{spinner:.green} {msg}")
2645 .unwrap_or_else(|_| ProgressStyle::default_spinner());
2646 bar.set_style(style);
2647 bar.enable_steady_tick(std::time::Duration::from_millis(120));
2648 bar.set_message(message.to_string());
2649 Self { bar: Some(bar) }
2650 } else {
2651 Self { bar: None }
2652 }
2653 }
2654
2655 fn enabled(&self) -> bool {
2656 self.bar.is_some()
2657 }
2658
2659 fn set_message(&self, message: &str) {
2660 if let Some(bar) = &self.bar {
2661 bar.set_message(message.to_string());
2662 }
2663 }
2664
2665 fn finish(&self, message: &str) {
2666 if let Some(bar) = &self.bar {
2667 bar.finish_with_message(message.to_string());
2668 }
2669 }
2670}
2671
2672fn read_manifest_from_archive(path: &Path) -> Result<Option<Manifest>> {
2673 let (sidecar_manifest, sidecar_error) = read_manifest_from_sidecar(path);
2674 if let Some(manifest) = sidecar_manifest {
2675 return Ok(Some(manifest));
2676 }
2677 if let Some(err) = sidecar_error {
2678 bail!(
2679 "invalid sidecar manifest for archive {}: {err}",
2680 path.display()
2681 );
2682 }
2683
2684 let reader = ArchiveReader::from_path(path)?;
2685 let (manifest, manifest_error) = read_manifest_from_reader(&reader);
2686 if let Some(manifest) = manifest {
2687 return Ok(Some(manifest));
2688 }
2689 if let Some(err) = manifest_error {
2690 bail!("invalid manifest in archive {}: {err}", path.display());
2691 }
2692 Ok(None)
2693}
2694
2695fn descriptors_from_selection(
2696 selected_ids: &[String],
2697 manifest: Option<&Manifest>,
2698) -> Vec<ObjectDescriptor> {
2699 if let Some(manifest) = manifest {
2700 let index = manifest
2701 .objects
2702 .iter()
2703 .map(|obj| (obj.id.clone(), obj.clone()))
2704 .collect::<std::collections::HashMap<_, _>>();
2705 return selected_ids
2706 .iter()
2707 .map(|id| {
2708 index.get(id).cloned().unwrap_or_else(|| ObjectDescriptor {
2709 id: id.clone(),
2710 new_id: None,
2711 name: None,
2712 r#type: None,
2713 last_modified: None,
2714 })
2715 })
2716 .collect();
2717 }
2718
2719 selected_ids
2720 .iter()
2721 .map(|id| ObjectDescriptor {
2722 id: id.clone(),
2723 new_id: None,
2724 name: None,
2725 r#type: None,
2726 last_modified: None,
2727 })
2728 .collect()
2729}
2730
2731fn log_report_summary(report: &ImportReport) {
2736 info!(
2737 "import summary: imported={} attempted={} failed={}",
2738 report.imported, report.attempted, report.failed
2739 );
2740 if report.failed > 0 {
2741 warn!("import completed with failures");
2742 }
2743}
2744
2745fn render_report_summary(report: &ImportReport) -> String {
2747 let mut text = TextBuilder::new();
2748 text.line(format!(
2749 "imported {}/{} objects (failed: {})",
2750 report.imported, report.attempted, report.failed
2751 ));
2752 for line in &report.summary {
2753 text.line(format!("- {line}"));
2754 }
2755 text.finish()
2756}
2757
2758fn prepare_report(report: &ImportReport, path: &Path) -> Result<output::PreparedOutput> {
2759 let output = CommandOutput::new(OutputMode::Pretty, Some(path.to_path_buf()));
2760 let rendered = output
2761 .render(report, String::new)?
2762 .ok_or_else(|| anyhow!("report output was unexpectedly suppressed"))?;
2763 output.prepare_rendered(rendered)
2764}
2765
2766fn sanitize_path_component(input: &str) -> String {
2767 const SEP: char = '_';
2768 let mut out = String::with_capacity(input.len());
2769 let mut prev_sep = false;
2770 for ch in input.chars() {
2771 let mapped = if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
2772 ch
2773 } else {
2774 SEP
2775 };
2776 if mapped == SEP {
2777 if !prev_sep {
2778 out.push(SEP);
2779 prev_sep = true;
2780 }
2781 } else {
2782 out.push(mapped);
2783 prev_sep = false;
2784 }
2785 }
2786 out.trim_matches(SEP).to_string()
2787}
2788
2789#[cfg(test)]
2790mod tests {
2791 use std::io::Write;
2792
2793 use clap::Parser;
2794
2795 use super::*;
2796
2797 #[derive(Debug, Parser)]
2798 #[command(name = "anyback")]
2799 struct Cli {
2800 #[command(subcommand)]
2801 command: Commands,
2802 }
2803
2804 fn parse_user_cli(args: &[&str]) -> Cli {
2805 Cli::try_parse_from(args).unwrap()
2806 }
2807
2808 #[test]
2809 fn parse_object_lines_ignores_comments_and_blanks() {
2810 let text = "\n# comment\na\n\n b\n#c\na\n";
2811 let ids = parse_object_id_lines(text);
2812 assert_eq!(ids, vec!["a", "b"]);
2813 }
2814
2815 #[test]
2816 fn parse_direct_object_ids_csv() {
2817 let err = load_object_ids_spec("a,b, c").unwrap_err();
2818 assert!(
2819 err.to_string().contains("failed to read object list file"),
2820 "unexpected error: {err:#}"
2821 );
2822 }
2823
2824 fn publication_test_manifest() -> Manifest {
2825 Manifest {
2826 schema_version: 1,
2827 tool: "anyback/test".to_string(),
2828 created_at: "1970-01-01T00:00:00Z".to_string(),
2829 created_at_display: None,
2830 source_space_id: "space-test".to_string(),
2831 source_space_name: "Test".to_string(),
2832 format: "pb".to_string(),
2833 object_count: 0,
2834 objects: Vec::new(),
2835 mode: Some("full".to_string()),
2836 since: None,
2837 since_display: None,
2838 until: None,
2839 until_display: None,
2840 type_ids: None,
2841 archive_size: None,
2842 archive_sha256: None,
2843 }
2844 }
2845
2846 #[tokio::test]
2847 async fn archive_publication_refuses_existing_destination() {
2848 let temp = tempfile::tempdir().expect("tempdir");
2849 let source = temp.path().join("stage.zip");
2850 let destination = temp.path().join("backup.zip");
2851 fs::write(&source, b"new archive").expect("source");
2852 fs::write(&destination, b"existing archive").expect("destination");
2853 let manifest = publication_test_manifest();
2854 let error = WorkflowDeadline::local_command()
2855 .run_read_publication(
2856 "test timeout",
2857 move || prepare_backup_artifacts(source, destination, &manifest),
2858 commit_backup_artifacts,
2859 )
2860 .await
2861 .expect_err("existing destination must not be replaced");
2862 assert!(error.to_string().contains("already exists"));
2863 assert_eq!(
2864 fs::read(temp.path().join("backup.zip")).expect("destination"),
2865 b"existing archive"
2866 );
2867 assert!(
2868 !manifest_sidecar_path(&temp.path().join("backup.zip")).exists(),
2869 "an archive collision must not leave an orphan manifest"
2870 );
2871 }
2872
2873 #[tokio::test]
2874 async fn expired_blocking_publication_cannot_report_success_or_replace_output() {
2875 let temp = tempfile::tempdir().expect("tempdir");
2876 let destination = temp.path().join("result.json");
2877 fs::write(&destination, b"existing").expect("destination");
2878 let output = CommandOutput::new(OutputMode::Pretty, Some(destination.clone()));
2879 let deadline = WorkflowDeadline::new(
2880 Some(std::time::Duration::from_millis(20)),
2881 ProcessWatcherTimeouts::default(),
2882 );
2883 let result = deadline
2884 .run_read_publication(
2885 "publication timed out",
2886 move || output.prepare_rendered("replacement".to_string()),
2887 |prepared, authority| {
2888 std::thread::sleep(std::time::Duration::from_millis(60));
2891 CommandOutput::commit_prepared(prepared, authority)
2892 },
2893 )
2894 .await;
2895 assert!(result.is_err());
2896 tokio::time::sleep(std::time::Duration::from_millis(80)).await;
2897 assert_eq!(fs::read(destination).expect("destination"), b"existing");
2898 }
2899
2900 #[tokio::test]
2901 async fn claimed_commit_is_joined_across_deadline_instead_of_timing_out() {
2902 let temp = tempfile::tempdir().expect("tempdir");
2903 let destination = temp.path().join("claimed");
2904 let committed = destination.clone();
2905 let deadline = WorkflowDeadline::new(
2906 Some(std::time::Duration::from_millis(20)),
2907 ProcessWatcherTimeouts::default(),
2908 );
2909 deadline
2910 .run_read_publication(
2911 "publication timed out",
2912 || Ok(()),
2913 move |(), authority| {
2914 authority.commit(|| {
2915 std::thread::sleep(std::time::Duration::from_millis(60));
2916 fs::write(committed, b"committed")?;
2917 Ok(())
2918 })
2919 },
2920 )
2921 .await
2922 .expect("a commit claimed before expiry reaches terminal finalization");
2923 assert_eq!(fs::read(destination).expect("committed"), b"committed");
2924 }
2925
2926 #[test]
2927 fn crash_before_manifest_claim_publishes_neither_artifact() {
2928 let temp = tempfile::tempdir().expect("tempdir");
2929 let source = temp.path().join("stage.zip");
2930 let destination = temp.path().join("backup.zip");
2931 fs::write(&source, b"archive").expect("source");
2932 let prepared =
2933 prepare_backup_artifacts(source, destination.clone(), &publication_test_manifest())
2934 .expect("prepare");
2935 drop(prepared);
2936 assert!(!destination.exists());
2937 assert!(!manifest_sidecar_path(&destination).exists());
2938 }
2939
2940 #[test]
2941 fn staging_path_swap_cannot_change_bound_or_published_archive() {
2942 let temp = tempfile::tempdir().expect("tempdir");
2943 let source = temp.path().join("stage.zip");
2944 let destination = temp.path().join("backup.zip");
2945 let retained = temp.path().join("retained-original.zip");
2946 fs::write(&source, b"owned archive bytes").expect("source");
2947 let retained_for_hook = retained.clone();
2948 let prepared = prepare_backup_artifacts_with_hook(
2949 source,
2950 destination.clone(),
2951 &publication_test_manifest(),
2952 move |staging| {
2953 fs::rename(staging, &retained_for_hook)?;
2954 fs::write(staging, b"foreign swapped bytes")?;
2955 Ok(())
2956 },
2957 )
2958 .expect("prepare retains the opened archive handle");
2959
2960 let bound: Manifest = serde_json::from_slice(
2961 &fs::read(&prepared.staged_sidecar).expect("bound staged manifest"),
2962 )
2963 .expect("parse bound manifest");
2964 let (expected_size, expected_digest) =
2965 decode::archive_binding(&retained).expect("binding for retained original archive");
2966 assert_eq!(bound.archive_size, Some(expected_size));
2967 assert_eq!(
2968 bound.archive_sha256.as_deref(),
2969 Some(expected_digest.as_str())
2970 );
2971 assert!(
2972 ensure_file_owned(&prepared.staged_archive, &prepared.archive_identity).is_err(),
2973 "the swapped pathname must not satisfy the retained opened identity"
2974 );
2975 drop(prepared);
2976 assert!(!destination.exists());
2977 assert!(!manifest_sidecar_path(&destination).exists());
2978 }
2979
2980 #[test]
2981 fn crash_after_manifest_claim_cannot_bind_a_missing_or_foreign_archive() {
2982 let temp = tempfile::tempdir().expect("tempdir");
2983 let source = temp.path().join("stage.zip");
2984 let destination = temp.path().join("backup.zip");
2985 fs::write(&source, b"archive").expect("source");
2986 let prepared =
2987 prepare_backup_artifacts(source, destination.clone(), &publication_test_manifest())
2988 .expect("prepare");
2989 claim_owned_staging_file(
2990 &prepared.staged_sidecar,
2991 &prepared.sidecar,
2992 &prepared.sidecar_identity,
2993 )
2994 .expect("manifest claim");
2995 drop(prepared);
2996 assert!(!destination.exists());
2997 let (manifest, missing_error) = read_manifest_from_sidecar(&destination);
2998 assert!(manifest.is_none());
2999 assert_eq!(
3000 missing_error.as_deref(),
3001 Some("sidecar archive binding could not be verified")
3002 );
3003 fs::write(&destination, b"foreign archive").expect("foreign archive");
3004 let (manifest, mismatch_error) = read_manifest_from_sidecar(&destination);
3005 assert!(manifest.is_none());
3006 assert_eq!(
3007 mismatch_error.as_deref(),
3008 Some("sidecar archive binding does not match the selected archive")
3009 );
3010 }
3011
3012 #[tokio::test]
3013 async fn completed_archive_and_manifest_have_a_valid_binding() {
3014 let temp = tempfile::tempdir().expect("tempdir");
3015 let source = temp.path().join("stage.zip");
3016 let destination = temp.path().join("backup.zip");
3017 fs::write(&source, b"archive").expect("source");
3018 let result_path = destination.clone();
3019 WorkflowDeadline::local_command()
3020 .run_read_publication(
3021 "test timeout",
3022 move || prepare_backup_artifacts(source, destination, &publication_test_manifest()),
3023 commit_backup_artifacts,
3024 )
3025 .await
3026 .expect("publish bound pair");
3027 let (manifest, error) = read_manifest_from_sidecar(&result_path);
3028 assert!(error.is_none());
3029 assert!(manifest.is_some());
3030 }
3031
3032 #[tokio::test]
3033 async fn concurrent_archive_replacement_is_preserved_without_an_orphan_manifest() {
3034 let temp = tempfile::tempdir().expect("tempdir");
3035 let source = temp.path().join("stage.zip");
3036 let destination = temp.path().join("backup.zip");
3037 fs::write(&source, b"archive").expect("source");
3038 let hook_path = destination.clone();
3039 let assertion_path = destination.clone();
3040 let result = WorkflowDeadline::local_command()
3041 .run_read_publication(
3042 "test timeout",
3043 move || prepare_backup_artifacts(source, destination, &publication_test_manifest()),
3044 move |prepared, authority| {
3045 commit_backup_artifacts_with_hook(prepared, authority, move || {
3046 fs::write(&hook_path, b"foreign archive replacement")?;
3047 Ok(())
3048 })
3049 },
3050 )
3051 .await;
3052 assert!(result.is_err());
3053 assert_eq!(
3054 fs::read(&assertion_path).expect("replacement"),
3055 b"foreign archive replacement"
3056 );
3057 assert!(!manifest_sidecar_path(&assertion_path).exists());
3058 }
3059
3060 #[tokio::test]
3061 async fn concurrent_manifest_replacement_is_preserved_without_published_archive() {
3062 let temp = tempfile::tempdir().expect("tempdir");
3063 let source = temp.path().join("stage.zip");
3064 let destination = temp.path().join("backup.zip");
3065 fs::write(&source, b"archive").expect("source");
3066 let replacement_path = manifest_sidecar_path(&destination);
3067 let hook_path = replacement_path.clone();
3068 let assertion_archive = destination.clone();
3069 let result = WorkflowDeadline::local_command()
3070 .run_read_publication(
3071 "test timeout",
3072 move || prepare_backup_artifacts(source, destination, &publication_test_manifest()),
3073 move |prepared, authority| {
3074 commit_backup_artifacts_with_hook(prepared, authority, move || {
3075 fs::remove_file(&hook_path).expect("remove owned manifest claim");
3076 fs::write(&hook_path, b"foreign manifest replacement")?;
3077 Ok(())
3078 })
3079 },
3080 )
3081 .await;
3082 assert!(result.is_err());
3083 assert!(!assertion_archive.exists());
3084 assert_eq!(
3085 fs::read(replacement_path).expect("replacement"),
3086 b"foreign manifest replacement"
3087 );
3088 }
3089
3090 #[test]
3091 fn parse_backup_create_from_legacy_export_alias() {
3092 let cli = Cli::try_parse_from([
3093 "anyback",
3094 "export",
3095 "--space",
3096 "test",
3097 "--objects",
3098 "ids.txt",
3099 ])
3100 .unwrap();
3101 assert!(matches!(cli.command, Commands::Export(_)));
3102 }
3103
3104 fn extract_backup_create_args(command: Commands) -> BackupCreateArgs {
3105 match command {
3106 Commands::Create(args) | Commands::Export(args) => args,
3107 _ => panic!("expected backup or export command"),
3108 }
3109 }
3110
3111 fn assert_backup_args_equal(left: &BackupCreateArgs, right: &BackupCreateArgs) {
3112 assert_eq!(left.space, right.space);
3113 assert_eq!(left.objects, right.objects);
3114 assert_eq!(left.format.as_str(), right.format.as_str());
3115 assert_eq!(left.mode.as_str(), right.mode.as_str());
3116 assert_eq!(left.since, right.since);
3117 assert!(matches!(
3118 (left.since_mode, right.since_mode),
3119 (SinceModeArg::Exclusive, SinceModeArg::Exclusive)
3120 | (SinceModeArg::Inclusive, SinceModeArg::Inclusive)
3121 ));
3122 assert_eq!(left.types, right.types);
3123 assert_eq!(left.dir, right.dir);
3124 assert_eq!(left.dest, right.dest);
3125 assert_eq!(left.prefix, right.prefix);
3126 assert_eq!(left.include_nested, right.include_nested);
3127 assert_eq!(left.include_files, right.include_files);
3128 assert_eq!(left.include_archived, right.include_archived);
3129 assert_eq!(left.include_backlinks, right.include_backlinks);
3130 assert_eq!(left.include_properties, right.include_properties);
3131 }
3132
3133 #[test]
3134 fn parse_backup_and_export_alias_map_identically() {
3135 let backup = parse_user_cli(&[
3136 "anyback",
3137 "create",
3138 "--space",
3139 "test-space",
3140 "--objects",
3141 "ids.txt",
3142 "--format",
3143 "pb-json",
3144 "--mode",
3145 "incremental",
3146 "--since",
3147 "2026-01-01T00:00:00Z",
3148 "--since-mode",
3149 "inclusive",
3150 "--include-nested",
3151 "--include-files",
3152 "--include-archived",
3153 "--include-backlinks",
3154 "--prefix",
3155 "pref",
3156 ]);
3157 let export = parse_user_cli(&[
3158 "anyback",
3159 "export",
3160 "--space",
3161 "test-space",
3162 "--objects",
3163 "ids.txt",
3164 "--format",
3165 "pb-json",
3166 "--mode",
3167 "incremental",
3168 "--since",
3169 "2026-01-01T00:00:00Z",
3170 "--since-mode",
3171 "inclusive",
3172 "--include-nested",
3173 "--include-files",
3174 "--include-archived",
3175 "--include-backlinks",
3176 "--prefix",
3177 "pref",
3178 ]);
3179
3180 let backup_args = extract_backup_create_args(backup.command);
3181 let export_args = extract_backup_create_args(export.command);
3182 assert_backup_args_equal(&backup_args, &export_args);
3183 }
3184
3185 #[test]
3186 fn parse_import_from_legacy_alias() {
3187 let cli =
3188 Cli::try_parse_from(["anyback", "import", "--space", "dest", "archive-dir"]).unwrap();
3189 assert!(matches!(cli.command, Commands::Import(_)));
3190 }
3191
3192 #[test]
3193 fn parse_backup_create_dir_dest_conflict() {
3194 let err = Cli::try_parse_from([
3195 "anyback",
3196 "create",
3197 "--space",
3198 "test",
3199 "--dir",
3200 "/tmp",
3201 "--dest",
3202 "/tmp/archive",
3203 ])
3204 .unwrap_err();
3205 let text = err.to_string();
3206 assert!(text.contains("cannot be used with"));
3207 }
3208
3209 #[test]
3210 fn parse_backup_create_dest_prefix_conflict() {
3211 let err = Cli::try_parse_from([
3212 "anyback",
3213 "create",
3214 "--space",
3215 "test",
3216 "--dest",
3217 "/tmp/archive",
3218 "--prefix",
3219 "mybackup",
3220 ])
3221 .unwrap_err();
3222 let text = err.to_string();
3223 assert!(text.contains("cannot be used with"));
3224 }
3225
3226 #[test]
3227 fn parse_backup_create_incremental_requires_since() {
3228 let err = Cli::try_parse_from([
3229 "anyback",
3230 "create",
3231 "--space",
3232 "test",
3233 "--mode",
3234 "incremental",
3235 ])
3236 .unwrap_err();
3237 assert!(err.to_string().contains("--since"));
3238 }
3239
3240 #[test]
3241 fn parse_backup_create_types_objects_conflict() {
3242 let err = Cli::try_parse_from([
3243 "anyback",
3244 "create",
3245 "--space",
3246 "test",
3247 "--objects",
3248 "ids.txt",
3249 "--types",
3250 "page,note",
3251 ])
3252 .unwrap_err();
3253 assert!(err.to_string().contains("cannot be used with"));
3254 }
3255
3256 #[test]
3257 fn parse_backup_create_types_csv() {
3258 let cli = Cli::try_parse_from([
3259 "anyback",
3260 "create",
3261 "--space",
3262 "test",
3263 "--types",
3264 "page,note",
3265 ])
3266 .unwrap();
3267 if let Commands::Create(args) = cli.command {
3268 assert_eq!(
3269 args.types,
3270 Some(vec!["page".to_string(), "note".to_string()])
3271 );
3272 } else {
3273 panic!("expected backup command");
3274 }
3275 }
3276
3277 #[test]
3278 fn parse_restore_apply_import_mode() {
3279 let cli = parse_user_cli(&[
3280 "anyback",
3281 "restore",
3282 "--space",
3283 "dest",
3284 "--import-mode",
3285 "all-or-nothing",
3286 "archive-dir",
3287 ]);
3288 if let Commands::Restore(args) = cli.command {
3289 assert!(matches!(args.import_mode, ImportModeArg::AllOrNothing));
3290 } else {
3291 panic!("expected restore command");
3292 }
3293 }
3294
3295 #[test]
3296 fn parse_diff_command() {
3297 let cli = Cli::try_parse_from(["anyback", "diff", "a.zip", "b.zip"]).unwrap();
3298 assert!(matches!(cli.command, Commands::Diff(_)));
3299 }
3300
3301 #[test]
3302 fn parse_restore_dry_run_flag() {
3303 let cli = Cli::try_parse_from([
3304 "anyback",
3305 "restore",
3306 "--dry-run",
3307 "--space",
3308 "test-space",
3309 "full-archive",
3310 ])
3311 .unwrap();
3312 if let Commands::Restore(args) = cli.command {
3313 assert!(args.dry_run);
3314 } else {
3315 panic!("expected restore command");
3316 }
3317 }
3318
3319 #[test]
3320 fn parse_list_command() {
3321 let cli = Cli::try_parse_from(["anyback", "list", "--files", "archive-dir"]).unwrap();
3322 if let Commands::List(args) = cli.command {
3323 assert!(args.files);
3324 assert!(!args.brief);
3325 assert!(!args.expanded);
3326 } else {
3327 panic!("expected list command");
3328 }
3329 }
3330
3331 #[test]
3332 fn parse_list_brief_flag() {
3333 let cli = Cli::try_parse_from(["anyback", "list", "--brief", "archive-dir"]).unwrap();
3334 if let Commands::List(args) = cli.command {
3335 assert!(args.brief);
3336 assert!(!args.expanded);
3337 assert!(!args.files);
3338 } else {
3339 panic!("expected list command");
3340 }
3341 }
3342
3343 #[test]
3344 fn parse_list_expanded_flag() {
3345 let cli = Cli::try_parse_from(["anyback", "list", "--expanded", "archive-dir"]).unwrap();
3346 if let Commands::List(args) = cli.command {
3347 assert!(args.expanded);
3348 } else {
3349 panic!("expected list command");
3350 }
3351 }
3352
3353 #[test]
3354 fn parse_list_mutually_exclusive_flags() {
3355 let err = Cli::try_parse_from(["anyback", "list", "--brief", "--files", "archive-dir"])
3356 .unwrap_err();
3357 let msg = err.to_string();
3358 assert!(
3359 msg.contains("cannot be used with") || msg.contains("list_mode"),
3360 "expected mutual exclusion error, got: {msg}"
3361 );
3362 }
3363
3364 #[test]
3365 fn parse_manifest_command() {
3366 let cli = Cli::try_parse_from(["anyback", "manifest", "archive-dir"]).unwrap();
3367 assert!(matches!(cli.command, Commands::Manifest(_)));
3368 }
3369
3370 #[test]
3371 fn parse_extract_command() {
3372 let cli = Cli::try_parse_from([
3373 "anyback",
3374 "extract",
3375 "archive-dir",
3376 "bafyreitest",
3377 "/tmp/out.md",
3378 ])
3379 .unwrap();
3380 if let Commands::Extract(args) = cli.command {
3381 assert_eq!(args.object_id, "bafyreitest");
3382 assert_eq!(args.archive, PathBuf::from("archive-dir"));
3383 assert_eq!(args.destination, PathBuf::from("/tmp/out.md"));
3384 } else {
3385 panic!("expected extract command");
3386 }
3387 }
3388
3389 #[cfg(feature = "tui")]
3390 #[test]
3391 fn parse_inspect_command() {
3392 let cli = Cli::try_parse_from(["anyback", "inspect", "archive-dir"]).unwrap();
3393 if let Commands::Inspect(args) = cli.command {
3394 assert_eq!(args.archive, PathBuf::from("archive-dir"));
3395 assert_eq!(args.max_cache, 200 * 1024 * 1024);
3396 } else {
3397 panic!("expected inspect command");
3398 }
3399 }
3400
3401 #[cfg(feature = "tui")]
3402 #[test]
3403 fn parse_inspect_command_with_max_cache_units() {
3404 let cli = Cli::try_parse_from(["anyback", "inspect", "--max-cache", "512k", "archive-dir"])
3405 .unwrap();
3406 if let Commands::Inspect(args) = cli.command {
3407 assert_eq!(args.max_cache, 512 * 1024);
3408 } else {
3409 panic!("expected inspect command");
3410 }
3411 }
3412
3413 #[test]
3414 fn parse_backup_create_rejects_removed_zip_flag() {
3415 let err =
3416 Cli::try_parse_from(["anyback", "create", "--space", "test", "--zip"]).unwrap_err();
3417 assert!(err.to_string().contains("--zip"));
3418 }
3419
3420 #[test]
3421 fn parse_backup_include_flags() {
3422 let cli = parse_user_cli(&[
3423 "anyback",
3424 "create",
3425 "--space",
3426 "test",
3427 "--include-nested",
3428 "--include-files",
3429 "--include-archived",
3430 "--include-backlinks",
3431 "--include-properties",
3432 "--format",
3433 "markdown",
3434 ]);
3435 if let Commands::Create(args) = cli.command {
3436 assert!(args.include_nested);
3437 assert!(args.include_files);
3438 assert!(args.include_archived);
3439 assert!(args.include_backlinks);
3440 assert!(args.include_properties);
3441 } else {
3442 panic!("expected backup command");
3443 }
3444 }
3445
3446 #[test]
3447 fn validate_backup_args_rejects_include_properties_non_markdown() {
3448 let args = BackupCreateArgs {
3449 space: "space".to_string(),
3450 objects: None,
3451 format: ExportFormatArg::Pb,
3452 mode: BackupModeArg::Full,
3453 since: None,
3454 since_mode: SinceModeArg::Exclusive,
3455 types: None,
3456 dir: None,
3457 dest: None,
3458 prefix: None,
3459 include_nested: false,
3460 include_files: false,
3461 include_archived: false,
3462 include_backlinks: false,
3463 include_properties: true,
3464 };
3465 let err = validate_backup_args(&args).unwrap_err();
3466 assert!(err.to_string().contains("--include-properties"));
3467 }
3468
3469 #[test]
3470 fn backup_export_options_maps_include_flags_and_pb_json() {
3471 let args = BackupCreateArgs {
3472 space: "space".to_string(),
3473 objects: None,
3474 format: ExportFormatArg::PbJson,
3475 mode: BackupModeArg::Full,
3476 since: None,
3477 since_mode: SinceModeArg::Exclusive,
3478 types: None,
3479 dir: None,
3480 dest: None,
3481 prefix: None,
3482 include_nested: true,
3483 include_files: true,
3484 include_archived: true,
3485 include_backlinks: true,
3486 include_properties: false,
3487 };
3488
3489 let options = backup_export_options(&args);
3490 assert_eq!(options.format, BackupExportFormat::Protobuf);
3491 assert!(options.is_json);
3492 assert!(options.include_nested);
3493 assert!(options.include_files);
3494 assert!(options.include_archived);
3495 assert!(options.include_backlinks);
3496 assert!(options.include_space);
3497 assert!(!options.md_include_properties_and_schema);
3498 }
3499
3500 #[test]
3501 fn backup_export_options_maps_markdown_include_properties() {
3502 let args = BackupCreateArgs {
3503 space: "space".to_string(),
3504 objects: None,
3505 format: ExportFormatArg::Markdown,
3506 mode: BackupModeArg::Full,
3507 since: None,
3508 since_mode: SinceModeArg::Exclusive,
3509 types: None,
3510 dir: None,
3511 dest: None,
3512 prefix: None,
3513 include_nested: false,
3514 include_files: false,
3515 include_archived: false,
3516 include_backlinks: false,
3517 include_properties: true,
3518 };
3519
3520 let options = backup_export_options(&args);
3521 assert_eq!(options.format, BackupExportFormat::Markdown);
3522 assert!(!options.is_json);
3523 assert!(options.md_include_properties_and_schema);
3524 assert!(options.include_space);
3525 }
3526
3527 #[test]
3528 fn progress_disabled_when_json_enabled() {
3529 assert!(!progress_enabled(&CommandOutput::json(), true));
3530 assert!(!progress_enabled(
3531 &CommandOutput::new(OutputMode::Pretty, None),
3532 true
3533 ));
3534 }
3535
3536 #[test]
3537 fn progress_disabled_when_quiet() {
3538 assert!(!progress_enabled(
3539 &CommandOutput::new(OutputMode::Quiet, None),
3540 true
3541 ));
3542 }
3543
3544 #[test]
3545 fn progress_disabled_for_non_tty() {
3546 assert!(!progress_enabled(&CommandOutput::human(), false));
3547 }
3548
3549 #[test]
3550 fn progress_enabled_for_tty_human_output() {
3551 assert!(progress_enabled(&CommandOutput::human(), true));
3552 }
3553
3554 #[test]
3555 fn progress_reporter_disabled_when_json_enabled() {
3556 let reporter = ProgressReporter::new(&CommandOutput::json(), "hidden");
3557 assert!(!reporter.enabled());
3558 }
3559
3560 #[test]
3561 fn infer_object_ids_from_files_uses_objects_dir() {
3562 let valid_id = "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2xi";
3563 let files = vec![
3564 ArchiveFileEntry {
3565 path: format!("objects/{valid_id}.pb"),
3566 bytes: 42,
3567 },
3568 ArchiveFileEntry {
3569 path: format!("relations/{valid_id}.pb"),
3570 bytes: 10,
3571 },
3572 ArchiveFileEntry {
3573 path: "objects/not-an-object-id.pb".to_string(),
3574 bytes: 12,
3575 },
3576 ];
3577 let inferred = infer_object_ids_from_files(&files);
3578 assert_eq!(inferred, vec![valid_id.to_string()]);
3579 }
3580
3581 #[test]
3582 fn manifest_roundtrip_json() {
3583 let manifest = Manifest {
3584 schema_version: 1,
3585 tool: "anyback/0.1.0".to_string(),
3586 created_at: chrono::DateTime::<Utc>::from_timestamp(0, 0)
3587 .unwrap()
3588 .to_rfc3339(),
3589 created_at_display: Some("1970-01-01 00:00:00 UTC".to_string()),
3590 source_space_id: "space1".to_string(),
3591 source_space_name: "My Space".to_string(),
3592 format: "pb".to_string(),
3593 object_count: 1,
3594 objects: vec![ObjectDescriptor {
3595 id: "obj1".to_string(),
3596 new_id: None,
3597 name: Some("Obj".to_string()),
3598 r#type: Some("page".to_string()),
3599 last_modified: None,
3600 }],
3601 mode: Some("full".to_string()),
3602 since: None,
3603 since_display: None,
3604 until: None,
3605 until_display: None,
3606 type_ids: None,
3607 archive_size: None,
3608 archive_sha256: None,
3609 };
3610
3611 let text = serde_json::to_string(&manifest).unwrap();
3612 let parsed: Manifest = serde_json::from_str(&text).unwrap();
3613 assert_eq!(parsed.schema_version, 1);
3614 assert_eq!(parsed.object_count, 1);
3615 assert_eq!(parsed.objects[0].id, "obj1");
3616 }
3617
3618 #[test]
3619 fn backup_target_dir_must_exist() {
3620 let args = BackupCreateArgs {
3621 space: "space".to_string(),
3622 objects: None,
3623 format: ExportFormatArg::Pb,
3624 mode: BackupModeArg::Full,
3625 since: None,
3626 since_mode: SinceModeArg::Exclusive,
3627 types: None,
3628 dir: Some(PathBuf::from("/this/definitely/does/not/exist")),
3629 dest: None,
3630 prefix: None,
3631 include_nested: false,
3632 include_files: false,
3633 include_archived: false,
3634 include_backlinks: false,
3635 include_properties: false,
3636 };
3637 let err = resolve_backup_target(&args, "space-id").unwrap_err();
3638 assert!(err.to_string().contains("output directory does not exist"));
3639 }
3640
3641 #[test]
3642 fn backup_target_dest_must_not_exist() {
3643 let temp = tempfile::tempdir().unwrap();
3644 let dest = temp.path().join("existing");
3645 std::fs::create_dir_all(&dest).unwrap();
3646 let args = BackupCreateArgs {
3647 space: "space".to_string(),
3648 objects: None,
3649 format: ExportFormatArg::Pb,
3650 mode: BackupModeArg::Full,
3651 since: None,
3652 since_mode: SinceModeArg::Exclusive,
3653 types: None,
3654 dir: None,
3655 dest: Some(dest),
3656 prefix: None,
3657 include_nested: false,
3658 include_files: false,
3659 include_archived: false,
3660 include_backlinks: false,
3661 include_properties: false,
3662 };
3663 let err = resolve_backup_target(&args, "space-id").unwrap_err();
3664 assert!(
3665 err.to_string()
3666 .contains("target archive path already exists"),
3667 "unexpected error: {err:#}"
3668 );
3669 }
3670
3671 #[test]
3672 #[allow(clippy::case_sensitive_file_extension_comparisons)]
3673 fn backup_target_dir_uses_space_id_in_default_name() {
3674 let temp = tempfile::tempdir().unwrap();
3675 let args = BackupCreateArgs {
3676 space: "space".to_string(),
3677 objects: None,
3678 format: ExportFormatArg::Pb,
3679 mode: BackupModeArg::Full,
3680 since: None,
3681 since_mode: SinceModeArg::Exclusive,
3682 types: None,
3683 dir: Some(temp.path().to_path_buf()),
3684 dest: None,
3685 prefix: None,
3686 include_nested: false,
3687 include_files: false,
3688 include_archived: false,
3689 include_backlinks: false,
3690 include_properties: false,
3691 };
3692 let resolved = resolve_backup_target(&args, "spacex").unwrap();
3693 let name = resolved
3694 .archive_path
3695 .file_name()
3696 .and_then(|v| v.to_str())
3697 .unwrap();
3698 assert!(name.starts_with("backup_spacex_"));
3699 assert!(name.ends_with(".zip"));
3700 }
3701
3702 #[test]
3703 fn backup_target_always_uses_zip_extension_for_generated_name() {
3704 let temp = tempfile::tempdir().unwrap();
3705 let args = BackupCreateArgs {
3706 space: "space".to_string(),
3707 objects: None,
3708 format: ExportFormatArg::Pb,
3709 mode: BackupModeArg::Full,
3710 since: None,
3711 since_mode: SinceModeArg::Exclusive,
3712 types: None,
3713 dir: Some(temp.path().to_path_buf()),
3714 dest: None,
3715 prefix: None,
3716 include_nested: false,
3717 include_files: false,
3718 include_archived: false,
3719 include_backlinks: false,
3720 include_properties: false,
3721 };
3722 let resolved = resolve_backup_target(&args, "spacex").unwrap();
3723 assert!(resolved.zip);
3724 assert!(
3725 resolved
3726 .archive_path
3727 .extension()
3728 .and_then(|ext| ext.to_str())
3729 .is_some_and(|ext| ext == "zip")
3730 );
3731 }
3732
3733 #[test]
3734 fn backup_target_is_zip_even_without_dest_zip_extension() {
3735 let temp = tempfile::tempdir().unwrap();
3736 let dest = temp.path().join("backup-out");
3737 let args = BackupCreateArgs {
3738 space: "space".to_string(),
3739 objects: None,
3740 format: ExportFormatArg::Pb,
3741 mode: BackupModeArg::Full,
3742 since: None,
3743 since_mode: SinceModeArg::Exclusive,
3744 types: None,
3745 dir: None,
3746 dest: Some(dest),
3747 prefix: None,
3748 include_nested: false,
3749 include_files: false,
3750 include_archived: false,
3751 include_backlinks: false,
3752 include_properties: false,
3753 };
3754 let resolved = resolve_backup_target(&args, "spacex").unwrap();
3755 assert!(resolved.zip);
3756 }
3757
3758 #[test]
3759 fn build_import_plan_infers_ids_without_manifest_from_directory() {
3760 let temp = tempfile::tempdir().unwrap();
3761 let id = "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2xi";
3762 let objects_dir = temp.path().join("objects");
3763 std::fs::create_dir_all(&objects_dir).unwrap();
3764 std::fs::write(objects_dir.join(format!("{id}.pb")), b"not-proto").unwrap();
3765
3766 let plan = build_import_plan(temp.path(), None).unwrap();
3767 assert_eq!(plan.selected_ids, vec![id.to_string()]);
3768 }
3769
3770 #[test]
3771 fn build_import_plan_infers_ids_without_manifest_from_zip() {
3772 let temp = tempfile::tempdir().unwrap();
3773 let zip_path = temp.path().join("archive.zip");
3774 let id = "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2xi";
3775 {
3776 let file = std::fs::File::create(&zip_path).unwrap();
3777 let mut writer = zip::ZipWriter::new(file);
3778 writer
3779 .start_file(
3780 format!("objects/{id}.pb"),
3781 zip::write::SimpleFileOptions::default(),
3782 )
3783 .unwrap();
3784 writer.write_all(b"not-proto").unwrap();
3785 writer.finish().unwrap();
3786 }
3787
3788 let plan = build_import_plan(&zip_path, None).unwrap();
3789 assert_eq!(plan.selected_ids, vec![id.to_string()]);
3790 }
3791
3792 #[test]
3793 fn build_import_plan_rejects_present_invalid_or_mismatched_sidecar() {
3794 let temp = tempfile::tempdir().expect("tempdir");
3795 let zip_path = temp.path().join("archive.zip");
3796 {
3797 let file = fs::File::create(&zip_path).expect("archive file");
3798 let mut writer = zip::ZipWriter::new(file);
3799 writer
3800 .start_file(
3801 "objects/bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2xi.pb",
3802 zip::write::SimpleFileOptions::default(),
3803 )
3804 .expect("archive entry");
3805 writer.write_all(b"payload").expect("archive payload");
3806 writer.finish().expect("finish archive");
3807 }
3808 let sidecar = manifest_sidecar_path(&zip_path);
3809 fs::write(&sidecar, b"{not-json").expect("invalid sidecar");
3810 let invalid = match build_import_plan(&zip_path, None) {
3811 Ok(_) => panic!("a present invalid sidecar must fail restore planning"),
3812 Err(error) => error.to_string(),
3813 };
3814 assert!(invalid.contains("invalid sidecar manifest"));
3815
3816 let mut mismatched = publication_test_manifest();
3817 mismatched.archive_size = Some(1);
3818 mismatched.archive_sha256 = Some("00".repeat(32));
3819 fs::write(
3820 &sidecar,
3821 serde_json::to_vec(&mismatched).expect("serialize mismatched sidecar"),
3822 )
3823 .expect("mismatched sidecar");
3824 let mismatch = match build_import_plan(&zip_path, None) {
3825 Ok(_) => panic!("a binding mismatch must fail restore planning"),
3826 Err(error) => error.to_string(),
3827 };
3828 assert!(mismatch.contains("does not match"));
3829 }
3830
3831 #[test]
3832 fn build_import_plan_uses_archive_path_directly() {
3833 let temp = tempfile::tempdir().unwrap();
3834 let objects_dir = temp.path().join("objects");
3835 std::fs::create_dir_all(&objects_dir).unwrap();
3836 let id = "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2xi";
3837 std::fs::write(objects_dir.join(format!("{id}.pb")), b"payload").unwrap();
3838 std::fs::write(
3839 temp.path().join("manifest.json"),
3840 serde_json::to_vec(&publication_test_manifest()).unwrap(),
3841 )
3842 .unwrap();
3843
3844 let plan = build_import_plan(temp.path(), None).unwrap();
3845 assert_eq!(plan.import_path, temp.path());
3846 }
3847
3848 #[cfg(feature = "snapshot-import")]
3849 fn sample_snapshot_entry(id: &str, encoded_hint: usize) -> ImportSnapshotEntry {
3850 let details = prost_types::Struct {
3851 fields: std::collections::BTreeMap::from([(
3852 "id".to_string(),
3853 prost_types::Value {
3854 kind: Some(prost_types::value::Kind::StringValue(id.to_string())),
3855 },
3856 )]),
3857 };
3858 let data = anytype_rpc::model::SmartBlockSnapshotBase {
3859 details: Some(details),
3860 ..Default::default()
3861 };
3862 let snapshot = import_request::Snapshot {
3863 id: id.to_string(),
3864 snapshot: Some(data),
3865 };
3866 let encoded_bytes = snapshot.encoded_len().max(encoded_hint);
3867 ImportSnapshotEntry {
3868 path: format!("objects/{id}.pb"),
3869 id: id.to_string(),
3870 sb_type: anytype_rpc::model::SmartBlockType::Page as i32,
3871 snapshot,
3872 encoded_bytes,
3873 }
3874 }
3875
3876 #[cfg(feature = "snapshot-import")]
3877 #[test]
3878 fn plan_snapshot_batches_enforces_single_snapshot_limit() {
3879 let entry = sample_snapshot_entry(
3880 "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2xi",
3881 500,
3882 );
3883 let limits = ImportChunkLimits {
3884 max_single_snapshot_bytes: 100,
3885 max_batch_bytes: 1000,
3886 max_batch_snapshots: 10,
3887 };
3888 let err = plan_snapshot_batches(&[entry], limits).unwrap_err();
3889 assert!(err.to_string().contains("is too large"));
3890 }
3891
3892 #[cfg(feature = "snapshot-import")]
3893 #[test]
3894 fn plan_snapshot_batches_splits_by_batch_limits() {
3895 let entries = vec![
3896 sample_snapshot_entry(
3897 "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2x1",
3898 200,
3899 ),
3900 sample_snapshot_entry(
3901 "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2x2",
3902 200,
3903 ),
3904 sample_snapshot_entry(
3905 "bafyreiaebddr63d7sye3eggmtkyeioqxftoaipobsynceksj6faedvd2x3",
3906 200,
3907 ),
3908 ];
3909 let limits = ImportChunkLimits {
3910 max_single_snapshot_bytes: 300,
3911 max_batch_bytes: 450,
3912 max_batch_snapshots: 2,
3913 };
3914 let batches = plan_snapshot_batches(&entries, limits).unwrap();
3915 assert_eq!(batches.len(), 2);
3916 assert_eq!(batches[0].len(), 2);
3917 assert_eq!(batches[1].len(), 1);
3918 }
3919
3920 #[cfg(feature = "tui")]
3921 #[test]
3922 fn parse_cache_size_defaults_to_mib() {
3923 assert_eq!(parse_cache_size("200").unwrap(), 200 * 1024 * 1024);
3924 }
3925
3926 #[cfg(feature = "tui")]
3927 #[test]
3928 fn parse_cache_size_accepts_units_case_insensitive() {
3929 assert_eq!(parse_cache_size("1k").unwrap(), 1024);
3930 assert_eq!(parse_cache_size("2KB").unwrap(), 2 * 1024);
3931 assert_eq!(parse_cache_size("3m").unwrap(), 3 * 1024 * 1024);
3932 assert_eq!(parse_cache_size("4Mb").unwrap(), 4 * 1024 * 1024);
3933 assert_eq!(parse_cache_size("1G").unwrap(), 1024 * 1024 * 1024);
3934 }
3935
3936 #[cfg(feature = "tui")]
3937 #[test]
3938 fn parse_cache_size_rejects_invalid_unit() {
3939 let err = parse_cache_size("10tb").unwrap_err();
3940 assert!(err.to_string().contains("unsupported cache size unit"));
3941 }
3942
3943 #[cfg(feature = "tui")]
3944 #[test]
3945 fn parse_cache_size_rejects_zero() {
3946 let err = parse_cache_size("0").unwrap_err();
3947 assert!(err.to_string().contains("must be > 0"));
3948 }
3949
3950 #[test]
3951 fn parse_since_accepts_rfc3339_with_offset() {
3952 let input = "2026-01-12T10:11:22+05:30".to_string();
3953 let parsed = parse_since(Some(&input)).unwrap();
3954 assert_eq!(parsed.offset().local_minus_utc(), 5 * 3600 + 30 * 60);
3955 assert_eq!(to_rfc3339_with_offset(parsed), "2026-01-12T10:11:22+05:30");
3956 }
3957
3958 #[test]
3959 fn parse_since_accepts_utc_suffix() {
3960 let input = "2026-01-12 10:11:22 UTC".to_string();
3961 let parsed = parse_since(Some(&input)).unwrap();
3962 assert_eq!(parsed.offset().local_minus_utc(), 0);
3963 assert_eq!(to_rfc3339_with_offset(parsed), "2026-01-12T10:11:22Z");
3964 }
3965
3966 #[test]
3967 fn parse_since_accepts_plus_zero_suffix() {
3968 let input = "2026-01-12 10:11:22 +0".to_string();
3969 let parsed = parse_since(Some(&input)).unwrap();
3970 assert_eq!(parsed.offset().local_minus_utc(), 0);
3971 assert_eq!(to_rfc3339_with_offset(parsed), "2026-01-12T10:11:22Z");
3972 }
3973
3974 #[test]
3975 fn parse_since_accepts_local_time_without_timezone() {
3976 let input = "2026-01-12 10:11:22".to_string();
3977 let parsed = parse_since(Some(&input)).unwrap();
3978 let expected = parse_local_naive("2026-01-12 10:11:22")
3979 .and_then(|naive| Local.from_local_datetime(&naive).single())
3980 .unwrap()
3981 .fixed_offset();
3982 assert_eq!(parsed, expected);
3983 }
3984
3985 #[test]
3986 fn parse_since_accepts_partial_date_variants_equivalently() {
3987 let full = parse_since(Some(&"2026-01-01 00:00:00".to_string())).unwrap();
3988 let hm = parse_since(Some(&"2026-01-01 00:00".to_string())).unwrap();
3989 let day = parse_since(Some(&"2026-01-01".to_string())).unwrap();
3990 let month = parse_since(Some(&"2026-01".to_string())).unwrap();
3991 let year = parse_since(Some(&"2026".to_string())).unwrap();
3992 assert_eq!(full, hm);
3993 assert_eq!(full, day);
3994 assert_eq!(full, month);
3995 assert_eq!(full, year);
3996 }
3997
3998 #[test]
3999 fn pb_import_paths_skips_manifest_for_directory() {
4000 let temp = tempfile::tempdir().unwrap();
4001 let root = temp.path();
4002 std::fs::write(root.join("manifest.json"), "{}").unwrap();
4003 std::fs::write(root.join("profile"), "profile-bytes").unwrap();
4004 std::fs::write(root.join("top.pb"), "pb").unwrap();
4005 std::fs::create_dir(root.join("objects")).unwrap();
4006 std::fs::write(root.join("objects").join("obj.pb"), "pb").unwrap();
4007
4008 let paths = pb_import_paths(root).unwrap();
4009 assert!(paths.iter().any(|p| Path::new(p).ends_with("objects")));
4010 assert!(paths.iter().any(|p| Path::new(p).ends_with("top.pb")));
4011 assert!(
4012 !paths
4013 .iter()
4014 .any(|p| Path::new(p).ends_with("manifest.json"))
4015 );
4016 }
4017
4018 #[test]
4019 fn pb_import_paths_skips_empty_directories() {
4020 let temp = tempfile::tempdir().unwrap();
4021 let root = temp.path();
4022 std::fs::create_dir(root.join("empty")).unwrap();
4023 std::fs::create_dir(root.join("objects")).unwrap();
4024 std::fs::write(root.join("objects").join("a.pb"), "pb").unwrap();
4025
4026 let paths = pb_import_paths(root).unwrap();
4027 assert!(paths.iter().any(|p| Path::new(p).ends_with("objects")));
4028 assert!(!paths.iter().any(|p| Path::new(p).ends_with("empty")));
4029 }
4030
4031 #[test]
4032 fn archive_basename_uses_file_name() {
4033 assert_eq!(
4034 archive_basename(Path::new("/tmp/foo/archive-one.zip")),
4035 "archive-one.zip"
4036 );
4037 }
4038
4039 #[test]
4040 fn format_import_api_error_includes_known_hint() {
4041 let message = format_import_api_error("import failed", 11);
4042 assert!(message.contains("code 11"));
4043 assert!(message.contains("valid Anyblock format"));
4044 }
4045
4046 #[test]
4047 fn format_import_api_error_unknown_code_has_no_hint() {
4048 let message = format_import_api_error("import failed", 12345);
4049 assert_eq!(message, "import failed (code 12345)");
4050 }
4051}