Skip to main content

anyback_reader/cli/
mod.rs

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 a backup (requires Anytype CLI server and gRPC credentials)
126    Create(BackupCreateArgs),
127
128    /// Restore objects (CLI server/gRPC credentials required unless --dry-run)
129    Restore(RestoreApplyArgs),
130
131    /// List archive contents
132    List(ListArgs),
133
134    /// Show archive manifest
135    Manifest(ManifestArgs),
136
137    /// Compare two archives
138    Diff(DiffArgs),
139
140    /// Extract one object from an archive
141    Extract(ExtractArgs),
142
143    /// Export objects (requires Anytype CLI server and gRPC credentials)
144    Export(BackupCreateArgs),
145
146    /// Import objects (CLI server/gRPC credentials required unless --dry-run)
147    Import(RestoreApplyArgs),
148
149    /// Interactive archive browser (TUI)
150    #[cfg(feature = "tui")]
151    Inspect(InspectorArgs),
152}
153
154#[cfg(feature = "tui")]
155#[derive(Args, Debug)]
156pub struct InspectorArgs {
157    /// Archive path (directory or .zip)
158    pub archive: PathBuf,
159
160    /// Maximum inspector cache size (default unit: MiB). Examples: 200, 512k, 64mb, 1g
161    #[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    /// Space name or id. Name must be unambiguous.
169    #[arg(long, value_name = "NAME_OR_ID")]
170    pub space: String,
171
172    /// Object IDs source path, or '-' to read from stdin. Omit for full-space backup.
173    #[arg(long, value_name = "FILE|-")]
174    pub objects: Option<String>,
175
176    /// Export format
177    #[arg(long, value_enum, default_value_t = ExportFormatArg::Pb)]
178    pub format: ExportFormatArg,
179
180    /// Backup mode
181    #[arg(long, value_enum, default_value_t = BackupModeArg::Full)]
182    pub mode: BackupModeArg,
183
184    /// Incremental lower bound timestamp.
185    /// Accepts RFC3339 with timezone/offset, or no-timezone local time (assumed local timezone).
186    /// Example UTC values: `2026-01-12T10:11:22Z`, `2026-01-12 10:11:22 UTC`, `2026-01-12T10:11:22+00:00`.
187    #[arg(long, value_name = "RFC3339", required_if_eq("mode", "incremental"))]
188    pub since: Option<String>,
189
190    /// Incremental window mode
191    #[arg(long, value_enum, default_value_t = SinceModeArg::Exclusive)]
192    pub since_mode: SinceModeArg,
193
194    /// Include only these object types (comma-separated keys and/or ids)
195    #[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    /// Parent directory where the archive will be created (default: current directory)
204    #[arg(long, value_name = "DIR", conflicts_with = "dest")]
205    pub dir: Option<PathBuf>,
206
207    /// Output archive path to create
208    #[arg(long, value_name = "PATH", conflicts_with_all = ["dir", "prefix"])]
209    pub dest: Option<PathBuf>,
210
211    /// Archive name prefix used with --dir/default parent; ignored when --dest is used
212    #[arg(long, value_name = "PREFIX")]
213    pub prefix: Option<String>,
214
215    /// Include linked (nested) objects in export payload
216    #[arg(long)]
217    pub include_nested: bool,
218
219    /// Include file objects and file binaries in export payload
220    #[arg(long)]
221    pub include_files: bool,
222
223    /// Include archived objects in backup selection
224    #[arg(long)]
225    pub include_archived: bool,
226
227    /// Include backlinks in export payload
228    #[arg(long)]
229    pub include_backlinks: bool,
230
231    /// Include properties and schema in markdown export output
232    #[arg(long)]
233    pub include_properties: bool,
234}
235
236#[derive(Args, Debug)]
237pub struct RestoreApplyArgs {
238    /// Archive path (directory or .zip)
239    #[arg(value_name = "ARCHIVE")]
240    pub archive: PathBuf,
241
242    /// Optional object IDs source path, or '-' to read from stdin.
243    #[arg(long, value_name = "FILE|-")]
244    pub objects: Option<String>,
245
246    /// Destination space name or id. Space must exist.
247    #[arg(long, value_name = "NAME_OR_ID")]
248    pub space: Option<String>,
249
250    /// Validate restore inputs and selection without importing objects
251    #[arg(long)]
252    pub dry_run: bool,
253
254    /// Write detailed JSON import report to file
255    #[arg(long, value_name = "REPORT_OUTPUT")]
256    pub log: Option<PathBuf>,
257
258    /// Import mode. all-or-nothing stops on first error but does not roll back prior imports.
259    #[arg(long, value_enum, default_value_t = ImportModeArg::IgnoreErrors)]
260    pub import_mode: ImportModeArg,
261
262    /// Replace objects that already exist in the destination space.
263    /// Without this flag, existing objects are left unchanged.
264    #[arg(long)]
265    pub replace: bool,
266}
267
268#[derive(Args, Debug, Clone)]
269pub struct ListArgs {
270    /// Archive path (directory or .zip)
271    pub archive: PathBuf,
272
273    /// Summary only (omit object IDs)
274    #[arg(long, group = "list_mode")]
275    pub brief: bool,
276
277    /// Include per-object expanded metadata
278    #[arg(long, group = "list_mode")]
279    pub expanded: bool,
280
281    /// Include file listing with sizes
282    #[arg(long, group = "list_mode")]
283    pub files: bool,
284}
285
286#[derive(Args, Debug, Clone)]
287pub struct ManifestArgs {
288    /// Archive path (directory or .zip)
289    pub archive: PathBuf,
290}
291
292#[derive(Args, Debug, Clone)]
293pub struct DiffArgs {
294    /// First archive path (directory or .zip)
295    #[arg(value_name = "ARCHIVE1")]
296    pub archive1: PathBuf,
297
298    /// Second archive path (directory or .zip)
299    #[arg(value_name = "ARCHIVE2")]
300    pub archive2: PathBuf,
301}
302
303#[derive(Args, Debug, Clone)]
304pub struct ExtractArgs {
305    /// Archive path (directory or .zip)
306    #[arg(value_name = "ARCHIVE")]
307    pub archive: PathBuf,
308
309    /// Object ID to extract
310    #[arg(value_name = "ID")]
311    pub object_id: String,
312
313    /// Output file path
314    #[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/// Commands that render an interactive terminal UI and therefore cannot be
404/// redirected, formatted, or silenced by the standard output contract.
405#[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/// The command name as spelled on the command line, for diagnostics.
419#[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
435/// Rejects a result path that aliases an input or artifact used by `command`.
436///
437/// Parent CLIs should call this during argument validation for early errors.
438/// [`run_command`] also calls it before dispatch so library users receive the
439/// same protection.
440pub 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
536/// Execute a backup command with a client and output contract configured by
537/// the parent application.
538///
539/// The parent application is responsible for rejecting output combinations the
540/// requested command cannot honor; see [`command_is_interactive`].
541pub 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
557/// Executes a backup command with timeout configuration captured before client construction.
558pub 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        // Intentionally always enabled in CLI wiring; this is not a user-facing flag.
731        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!(&current == 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        &current == 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(&current)
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        // The manifest is already a JSON document; human mode renders it indented.
2467        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
2731/// Records the import outcome on the tracing channel.
2732///
2733/// Kept separate from the result document so that quiet and JSON output still
2734/// produce operator diagnostics on stderr.
2735fn 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
2745/// Renders the human-readable import summary.
2746fn 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                    // Models descheduling after the worker completes but before
2889                    // the caller-owned commit reaches its final boundary.
2890                    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}