Skip to main content

anyback_reader/cli/
decode.rs

1use crate::archive::{ArchiveFileEntry, ArchiveReader, infer_object_id_from_snapshot_path};
2use anyhow::{Context, Result, anyhow};
3use anytype_rpc::anytype::SnapshotWithType;
4use chrono::{DateTime, FixedOffset, Utc};
5use prost::Message;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use std::{
10    fmt::Write as _,
11    fs::File,
12    io::{self, Read, Seek},
13    path::{Path, PathBuf},
14};
15
16pub const MANIFEST_NAME: &str = "manifest.json";
17pub const MANIFEST_SIDECAR_SUFFIX: &str = ".manifest.json";
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ObjectDescriptor {
21    pub id: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub new_id: Option<String>,
24    pub name: Option<String>,
25    pub r#type: Option<String>,
26    pub last_modified: Option<String>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Manifest {
31    pub schema_version: u32,
32    pub tool: String,
33    pub created_at: String,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub created_at_display: Option<String>,
36    pub source_space_id: String,
37    pub source_space_name: String,
38    pub format: String,
39    pub object_count: usize,
40    pub objects: Vec<ObjectDescriptor>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub mode: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub since: Option<String>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub since_display: Option<String>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub until: Option<String>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub until_display: Option<String>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub type_ids: Option<Vec<String>>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub archive_size: Option<u64>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub archive_sha256: Option<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ObjectImportError {
61    pub id: String,
62    pub name: Option<String>,
63    pub r#type: Option<String>,
64    pub last_modified: Option<String>,
65    pub error_code: String,
66    pub message: String,
67    pub status: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ImportReport {
72    pub archive: String,
73    pub space_id: String,
74    pub attempted: usize,
75    pub imported: usize,
76    pub failed: usize,
77    pub success: Vec<ObjectDescriptor>,
78    pub errors: Vec<ObjectImportError>,
79    pub summary: Vec<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub event_progress: Option<ImportEventProgressReport>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ImportEventProgressReport {
86    pub processes_started: usize,
87    pub processes_done: usize,
88    pub process_updates: usize,
89    pub import_finish_events: usize,
90    pub import_finish_objects: i64,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub last_process_id: Option<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub last_process_state: Option<String>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub last_progress_done: Option<i64>,
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub last_progress_total: Option<i64>,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub last_progress_message: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub last_process_error: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize)]
106pub struct ManifestSummary {
107    pub schema_version: u32,
108    pub tool: String,
109    pub created_at: String,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub created_at_display: Option<String>,
112    pub source_space_id: String,
113    pub source_space_name: String,
114    pub format: String,
115    pub object_count: usize,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub mode: Option<String>,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub since: Option<String>,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub since_display: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub until: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub until_display: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub type_ids: Option<Vec<String>>,
128}
129
130#[derive(Debug, Clone, Serialize)]
131pub struct ExpandedSnapshotEntry {
132    pub path: String,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub id: Option<String>,
135    pub status: String,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub unreadable_reason: Option<String>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub sb_type: Option<String>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub name: Option<String>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub object_type: Option<Value>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub layout: Option<i64>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub layout_name: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub archived: Option<bool>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub created_date: Option<Value>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub last_modified_date: Option<Value>,
154}
155
156pub fn proto_value_to_json(value: &prost_types::Value) -> Value {
157    use prost_types::value::Kind;
158    match value.kind.as_ref() {
159        None | Some(Kind::NullValue(_)) => Value::Null,
160        Some(Kind::NumberValue(n)) => {
161            serde_json::Number::from_f64(*n).map_or(Value::Null, Value::Number)
162        }
163        Some(Kind::StringValue(s)) => Value::String(s.clone()),
164        Some(Kind::BoolValue(b)) => Value::Bool(*b),
165        Some(Kind::StructValue(s)) => {
166            let mut map = serde_json::Map::<String, Value>::new();
167            for (k, v) in &s.fields {
168                map.insert(k.clone(), proto_value_to_json(v));
169            }
170            Value::Object(map)
171        }
172        Some(Kind::ListValue(list)) => {
173            Value::Array(list.values.iter().map(proto_value_to_json).collect())
174        }
175    }
176}
177
178pub fn normalize_jsonpb_value(value: &Value) -> Value {
179    let Some(obj) = value.as_object() else {
180        return value.clone();
181    };
182    if let Some(v) = obj.get("stringValue").and_then(Value::as_str) {
183        return Value::String(v.to_string());
184    }
185    if let Some(v) = obj.get("numberValue").and_then(Value::as_f64) {
186        return serde_json::Number::from_f64(v).map_or(Value::Null, Value::Number);
187    }
188    if let Some(v) = obj.get("boolValue").and_then(Value::as_bool) {
189        return Value::Bool(v);
190    }
191    if obj.get("nullValue").is_some() {
192        return Value::Null;
193    }
194    if let Some(fields) = obj
195        .get("structValue")
196        .and_then(Value::as_object)
197        .and_then(|o| o.get("fields"))
198        .and_then(Value::as_object)
199    {
200        let mut out = serde_json::Map::<String, Value>::new();
201        for (k, v) in fields {
202            out.insert(k.clone(), normalize_jsonpb_value(v));
203        }
204        return Value::Object(out);
205    }
206    if let Some(values) = obj
207        .get("listValue")
208        .and_then(Value::as_object)
209        .and_then(|o| o.get("values"))
210        .and_then(Value::as_array)
211    {
212        return Value::Array(values.iter().map(normalize_jsonpb_value).collect());
213    }
214    value.clone()
215}
216
217#[allow(clippy::cast_possible_truncation)]
218pub fn value_as_i64(value: &Value) -> Option<i64> {
219    value
220        .as_i64()
221        .or_else(|| value.as_f64().map(|n| n as i64))
222        .or_else(|| value.as_str().and_then(|s| s.parse::<i64>().ok()))
223}
224
225pub fn value_as_bool(value: &Value) -> Option<bool> {
226    value
227        .as_bool()
228        .or_else(|| value.as_i64().map(|n| n != 0))
229        .or_else(|| {
230            value.as_str().and_then(|s| match s {
231                "true" | "1" => Some(true),
232                "false" | "0" => Some(false),
233                _ => None,
234            })
235        })
236}
237
238pub fn derive_layout_name(layout: Option<i64>) -> Option<String> {
239    let layout = layout?;
240    let parsed =
241        anytype_rpc::model::object_type::Layout::try_from(i32::try_from(layout).ok()?).ok()?;
242    Some(parsed.as_str_name().to_string())
243}
244
245pub fn parse_snapshot_details_from_pb(
246    bytes: &[u8],
247) -> Result<(Option<String>, serde_json::Map<String, Value>)> {
248    let snapshot = SnapshotWithType::decode(bytes).context("failed to decode protobuf snapshot")?;
249    let sb_type = anytype_rpc::model::SmartBlockType::try_from(snapshot.sb_type)
250        .ok()
251        .map(|s| s.as_str_name().to_string());
252    let data = snapshot
253        .snapshot
254        .and_then(|s| s.data)
255        .ok_or_else(|| anyhow!("snapshot payload missing data"))?;
256    let details = data
257        .details
258        .ok_or_else(|| anyhow!("snapshot payload missing details"))?;
259    let mut map = serde_json::Map::new();
260    for (k, v) in &details.fields {
261        map.insert(k.clone(), proto_value_to_json(v));
262    }
263    Ok((sb_type, map))
264}
265
266pub fn parse_snapshot_details_from_pb_json(
267    bytes: &[u8],
268) -> Result<(Option<String>, serde_json::Map<String, Value>)> {
269    let root: Value = serde_json::from_slice(bytes).context("invalid pb-json")?;
270    let sb_type = root
271        .get("sbType")
272        .and_then(Value::as_str)
273        .map(ToString::to_string)
274        .or_else(|| {
275            root.get("sbType")
276                .and_then(Value::as_i64)
277                .and_then(|n| i32::try_from(n).ok())
278                .and_then(|n| anytype_rpc::model::SmartBlockType::try_from(n).ok())
279                .map(|s| s.as_str_name().to_string())
280        });
281    let details = root
282        .get("snapshot")
283        .and_then(|v| v.get("data"))
284        .and_then(|v| v.get("details"))
285        .ok_or_else(|| anyhow!("pb-json snapshot missing snapshot.data.details"))?;
286    let fields = if let Some(fields) = details.get("fields").and_then(Value::as_object) {
287        fields
288    } else {
289        details
290            .as_object()
291            .ok_or_else(|| anyhow!("pb-json snapshot details is not an object"))?
292    };
293    let mut map = serde_json::Map::new();
294    for (k, v) in fields {
295        map.insert(k.clone(), normalize_jsonpb_value(v));
296    }
297    Ok((sb_type, map))
298}
299
300pub fn detail_value<'a>(
301    details: &'a serde_json::Map<String, Value>,
302    key: &str,
303) -> Option<&'a Value> {
304    details.get(key)
305}
306
307pub fn build_expanded_entry_from_details(
308    path: &str,
309    id_from_path: Option<String>,
310    sb_type: Option<String>,
311    details: &serde_json::Map<String, Value>,
312) -> ExpandedSnapshotEntry {
313    let id = detail_value(details, "id")
314        .and_then(Value::as_str)
315        .map(ToString::to_string)
316        .or(id_from_path);
317    let name = detail_value(details, "name")
318        .and_then(Value::as_str)
319        .map(ToString::to_string);
320    let object_type = detail_value(details, "type").cloned();
321    let layout = detail_value(details, "layout")
322        .and_then(value_as_i64)
323        .or_else(|| detail_value(details, "resolvedLayout").and_then(value_as_i64));
324    let layout_name = derive_layout_name(layout);
325    let archived = detail_value(details, "isArchived").and_then(value_as_bool);
326    let created_date = detail_value(details, "createdDate").cloned();
327    let last_modified_date = detail_value(details, "lastModifiedDate").cloned();
328    ExpandedSnapshotEntry {
329        path: path.to_string(),
330        id,
331        status: "ok".to_string(),
332        unreadable_reason: None,
333        sb_type,
334        name,
335        object_type,
336        layout,
337        layout_name,
338        archived,
339        created_date,
340        last_modified_date,
341    }
342}
343
344pub fn parse_expanded_entries(
345    reader: &ArchiveReader,
346    files: &[ArchiveFileEntry],
347) -> Vec<ExpandedSnapshotEntry> {
348    let mut out = Vec::new();
349    for file in files {
350        let lower = file.path.to_ascii_lowercase();
351        // lower is already lowercased, so extension checks are case-insensitive
352        #[allow(clippy::case_sensitive_file_extension_comparisons)]
353        if !(lower.ends_with(".pb") || lower.ends_with(".pb.json")) {
354            continue;
355        }
356        let id_from_path = infer_object_id_from_snapshot_path(&file.path);
357        let bytes = match reader.read_bytes(&file.path) {
358            Ok(bytes) => bytes,
359            Err(err) => {
360                out.push(ExpandedSnapshotEntry {
361                    path: file.path.clone(),
362                    id: id_from_path,
363                    status: "unreadable".to_string(),
364                    unreadable_reason: Some(format!("failed to read file: {err}")),
365                    sb_type: None,
366                    name: None,
367                    object_type: None,
368                    layout: None,
369                    layout_name: None,
370                    archived: None,
371                    created_date: None,
372                    last_modified_date: None,
373                });
374                continue;
375            }
376        };
377
378        let parsed = if lower.ends_with(".pb.json") {
379            parse_snapshot_details_from_pb_json(&bytes)
380        } else {
381            parse_snapshot_details_from_pb(&bytes)
382        };
383        match parsed {
384            Ok((sb_type, details)) => {
385                out.push(build_expanded_entry_from_details(
386                    &file.path,
387                    id_from_path,
388                    sb_type,
389                    &details,
390                ));
391            }
392            Err(err) => {
393                out.push(ExpandedSnapshotEntry {
394                    path: file.path.clone(),
395                    id: id_from_path,
396                    status: "unreadable".to_string(),
397                    unreadable_reason: Some(err.to_string()),
398                    sb_type: None,
399                    name: None,
400                    object_type: None,
401                    layout: None,
402                    layout_name: None,
403                    archived: None,
404                    created_date: None,
405                    last_modified_date: None,
406                });
407            }
408        }
409    }
410    out
411}
412
413pub fn read_manifest_from_reader(reader: &ArchiveReader) -> (Option<Manifest>, Option<String>) {
414    let Ok(Some(manifest_bytes)) = reader.read_bytes_if_exists(MANIFEST_NAME) else {
415        return (None, None);
416    };
417    match serde_json::from_slice::<Manifest>(&manifest_bytes) {
418        Ok(manifest) => (Some(manifest), None),
419        Err(err) => (None, Some(format!("invalid manifest json: {err}"))),
420    }
421}
422
423pub fn manifest_sidecar_path(archive_path: &Path) -> PathBuf {
424    let base_name = archive_path
425        .file_name()
426        .and_then(|v| v.to_str())
427        .unwrap_or("archive");
428    let sidecar_name = format!("{base_name}{MANIFEST_SIDECAR_SUFFIX}");
429    archive_path
430        .parent()
431        .unwrap_or_else(|| Path::new("."))
432        .join(sidecar_name)
433}
434
435pub fn read_manifest_from_sidecar(archive_path: &Path) -> (Option<Manifest>, Option<String>) {
436    let sidecar = manifest_sidecar_path(archive_path);
437    let bytes = match std::fs::read(&sidecar) {
438        Ok(bytes) => bytes,
439        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (None, None),
440        Err(err) => {
441            return (
442                None,
443                Some(format!(
444                    "failed to read sidecar manifest {}: {err}",
445                    sidecar.display()
446                )),
447            );
448        }
449    };
450    match serde_json::from_slice::<Manifest>(&bytes) {
451        Ok(manifest) => match validate_archive_binding(archive_path, &manifest) {
452            Ok(()) => (Some(manifest), None),
453            Err(error) => (None, Some(error)),
454        },
455        Err(err) => (
456            None,
457            Some(format!(
458                "invalid sidecar manifest {}: {err}",
459                sidecar.display()
460            )),
461        ),
462    }
463}
464
465pub fn archive_binding(path: &Path) -> io::Result<(u64, String)> {
466    let mut file = File::open(path)?;
467    archive_binding_from_file(&mut file)
468}
469
470pub fn archive_binding_from_file(file: &mut File) -> io::Result<(u64, String)> {
471    file.rewind()?;
472    let mut size = 0_u64;
473    let mut digest = Sha256::new();
474    let mut buffer = vec![0_u8; 64 * 1024];
475    loop {
476        let read = file.read(&mut buffer)?;
477        if read == 0 {
478            break;
479        }
480        let read = u64::try_from(read).map_err(|_| io::Error::other("archive size overflow"))?;
481        size = size
482            .checked_add(read)
483            .ok_or_else(|| io::Error::other("archive size overflow"))?;
484        let read = usize::try_from(read).map_err(|_| io::Error::other("archive size overflow"))?;
485        digest.update(&buffer[..read]);
486    }
487    let mut encoded = String::with_capacity(64);
488    for byte in digest.finalize() {
489        write!(&mut encoded, "{byte:02x}")
490            .map_err(|_| io::Error::other("archive digest encoding failed"))?;
491    }
492    file.rewind()?;
493    Ok((size, encoded))
494}
495
496fn validate_archive_binding(
497    archive_path: &Path,
498    manifest: &Manifest,
499) -> std::result::Result<(), String> {
500    match (&manifest.archive_size, &manifest.archive_sha256) {
501        (None, None) => Ok(()),
502        (Some(expected_size), Some(expected_digest)) => {
503            let (actual_size, actual_digest) = archive_binding(archive_path)
504                .map_err(|_| "sidecar archive binding could not be verified".to_string())?;
505            if actual_size != *expected_size || actual_digest != *expected_digest {
506                return Err(
507                    "sidecar archive binding does not match the selected archive".to_string(),
508                );
509            }
510            Ok(())
511        }
512        _ => Err("sidecar archive binding is incomplete".to_string()),
513    }
514}
515
516pub fn read_manifest_prefer_sidecar(
517    archive_path: &Path,
518    reader: &ArchiveReader,
519) -> (Option<Manifest>, Option<String>) {
520    let (sidecar_manifest, sidecar_error) = read_manifest_from_sidecar(archive_path);
521    if sidecar_manifest.is_some() || sidecar_error.is_some() {
522        return (sidecar_manifest, sidecar_error);
523    }
524    read_manifest_from_reader(reader)
525}
526
527fn offset_label(offset: FixedOffset) -> String {
528    let seconds = offset.local_minus_utc();
529    if seconds == 0 {
530        return "UTC".to_string();
531    }
532    let sign = if seconds >= 0 { '+' } else { '-' };
533    let abs = seconds.unsigned_abs();
534    let hours = abs / 3600;
535    let minutes = (abs % 3600) / 60;
536    format!("{sign}{hours:02}:{minutes:02}")
537}
538
539fn format_datetime_with_tz(dt: DateTime<FixedOffset>) -> String {
540    format!(
541        "{} {}",
542        dt.format("%Y-%m-%d %H:%M:%S"),
543        offset_label(*dt.offset())
544    )
545}
546
547fn format_utc_datetime_with_tz(dt: DateTime<Utc>) -> String {
548    format!("{} UTC", dt.format("%Y-%m-%d %H:%M:%S"))
549}
550
551pub fn format_datetime_display(value: &str) -> Option<String> {
552    DateTime::parse_from_rfc3339(value)
553        .ok()
554        .map(format_datetime_with_tz)
555}
556
557pub fn format_last_modified(value: Option<&Value>) -> Option<String> {
558    let value = value?;
559    if let Some(text) = value.as_str() {
560        if let Ok(parsed) = DateTime::parse_from_rfc3339(text) {
561            return Some(format_datetime_with_tz(parsed));
562        }
563        return Some(text.to_string());
564    }
565    if let Some(raw) = value.as_i64() {
566        let dt = if raw > 2_000_000_000_000 {
567            DateTime::<Utc>::from_timestamp_millis(raw)
568        } else {
569            DateTime::<Utc>::from_timestamp(raw, 0)
570        };
571        return dt.map(format_utc_datetime_with_tz);
572    }
573    #[allow(clippy::cast_possible_truncation)]
574    if let Some(raw) = value.as_f64() {
575        return format_last_modified(Some(&Value::Number(serde_json::Number::from(raw as i64))));
576    }
577    Some(value.to_string())
578}
579
580pub fn manifest_summary(manifest: &Manifest) -> ManifestSummary {
581    ManifestSummary {
582        schema_version: manifest.schema_version,
583        tool: manifest.tool.clone(),
584        created_at: manifest.created_at.clone(),
585        created_at_display: manifest.created_at_display.clone(),
586        source_space_id: manifest.source_space_id.clone(),
587        source_space_name: manifest.source_space_name.clone(),
588        format: manifest.format.clone(),
589        object_count: manifest.object_count,
590        mode: manifest.mode.clone(),
591        since: manifest.since.clone(),
592        since_display: manifest.since_display.clone(),
593        until: manifest.until.clone(),
594        until_display: manifest.until_display.clone(),
595        type_ids: manifest.type_ids.clone(),
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use std::fs;
602
603    use super::*;
604
605    #[test]
606    fn parse_pb_json_accepts_details_fields_wrapper() {
607        let json = serde_json::json!({
608            "sbType": "Page",
609            "snapshot": {
610                "data": {
611                    "details": {
612                        "fields": {
613                            "name": {"stringValue": "Wrapped"},
614                            "layout": {"numberValue": 1.0},
615                            "isArchived": {"boolValue": false}
616                        }
617                    }
618                }
619            }
620        });
621        let bytes = serde_json::to_vec(&json).unwrap();
622        let (sb_type, details) = parse_snapshot_details_from_pb_json(&bytes).unwrap();
623        assert_eq!(sb_type.as_deref(), Some("Page"));
624        assert_eq!(details.get("name").and_then(Value::as_str), Some("Wrapped"));
625        assert_eq!(details.get("layout").and_then(value_as_i64), Some(1));
626        assert_eq!(
627            details.get("isArchived").and_then(value_as_bool),
628            Some(false)
629        );
630    }
631
632    #[test]
633    fn parse_pb_json_accepts_direct_details_object() {
634        let json = serde_json::json!({
635            "sbType": "Page",
636            "snapshot": {
637                "data": {
638                    "details": {
639                        "id": "bafyreitest",
640                        "name": "Templates",
641                        "layout": 0,
642                        "isArchived": false,
643                        "type": "bafyreitype"
644                    }
645                }
646            }
647        });
648        let bytes = serde_json::to_vec(&json).unwrap();
649        let (sb_type, details) = parse_snapshot_details_from_pb_json(&bytes).unwrap();
650        assert_eq!(sb_type.as_deref(), Some("Page"));
651        assert_eq!(
652            details.get("name").and_then(Value::as_str),
653            Some("Templates")
654        );
655        assert_eq!(details.get("layout").and_then(value_as_i64), Some(0));
656        assert_eq!(
657            details.get("isArchived").and_then(value_as_bool),
658            Some(false)
659        );
660        assert_eq!(
661            details.get("type").and_then(Value::as_str),
662            Some("bafyreitype")
663        );
664    }
665
666    #[test]
667    fn manifest_sidecar_path_uses_sibling_name() {
668        let zip_path = Path::new("/tmp/archive.zip");
669        assert_eq!(
670            manifest_sidecar_path(zip_path),
671            Path::new("/tmp/archive.zip.manifest.json")
672        );
673
674        let dir_path = Path::new("/tmp/archive-dir");
675        assert_eq!(
676            manifest_sidecar_path(dir_path),
677            Path::new("/tmp/archive-dir.manifest.json")
678        );
679    }
680
681    #[test]
682    fn read_manifest_prefers_sidecar_over_archive_manifest() {
683        let temp = tempfile::tempdir().unwrap();
684        let archive_dir = temp.path().join("backup-dir");
685        fs::create_dir_all(&archive_dir).unwrap();
686
687        fs::write(archive_dir.join(MANIFEST_NAME), b"{not-json").unwrap();
688
689        let sidecar = manifest_sidecar_path(&archive_dir);
690        let manifest = Manifest {
691            schema_version: 1,
692            tool: "anyback/test".to_string(),
693            created_at: "2026-02-16T00:00:00Z".to_string(),
694            created_at_display: None,
695            source_space_id: "space-id".to_string(),
696            source_space_name: "space-name".to_string(),
697            format: "pb".to_string(),
698            object_count: 0,
699            objects: Vec::new(),
700            mode: Some("full".to_string()),
701            since: None,
702            since_display: None,
703            until: None,
704            until_display: None,
705            type_ids: None,
706            archive_size: None,
707            archive_sha256: None,
708        };
709        fs::write(&sidecar, serde_json::to_vec(&manifest).unwrap()).unwrap();
710
711        let reader = ArchiveReader::from_path(&archive_dir).unwrap();
712        let (resolved, err) = read_manifest_prefer_sidecar(&archive_dir, &reader);
713        assert!(err.is_none());
714        assert_eq!(resolved.unwrap().tool, "anyback/test");
715    }
716}