Skip to main content

icechunk_format/
repo_info.rs

1//! Version info, branches, and tags for a repository.
2
3use itertools::Itertools as _;
4use serde::{Deserialize, Serialize};
5use std::{
6    borrow::Cow,
7    collections::{BTreeSet, HashMap},
8};
9use tracing::trace;
10
11use crate::{
12    IcechunkFormatError, IcechunkFormatErrorKind, IcechunkResult, SnapshotId,
13    flatbuffers::generated,
14    format_constants::SpecVersionBin,
15    lookup_index_by_key,
16    snapshot::{SnapshotInfo, SnapshotProperties},
17};
18
19use chrono::{DateTime, Utc};
20use flatbuffers::{UnionWIPOffset, VerifierOptions, WIPOffset};
21use icechunk_types::ICResultExt as _;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum RepoAvailability {
25    Online,
26    ReadOnly,
27    // Offline is defined in the flatbuffers, but we won't support it
28    // before better specs on how we want to use it
29}
30
31impl From<generated::RepoAvailability> for RepoAvailability {
32    fn from(value: generated::RepoAvailability) -> Self {
33        match value {
34            generated::RepoAvailability::Online => RepoAvailability::Online,
35            generated::RepoAvailability::ReadOnly => RepoAvailability::ReadOnly,
36            _ => RepoAvailability::Online,
37        }
38    }
39}
40
41impl From<RepoAvailability> for generated::RepoAvailability {
42    fn from(value: RepoAvailability) -> Self {
43        match value {
44            RepoAvailability::Online => generated::RepoAvailability::Online,
45            RepoAvailability::ReadOnly => generated::RepoAvailability::ReadOnly,
46        }
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct RepoStatus {
52    pub availability: RepoAvailability,
53    pub set_at: DateTime<Utc>,
54    pub limited_availability_reason: Option<String>,
55}
56
57impl TryFrom<generated::RepoStatus<'_>> for RepoStatus {
58    type Error = IcechunkFormatError;
59
60    fn try_from(fb_status: generated::RepoStatus<'_>) -> Result<Self, Self::Error> {
61        let ts: i64 = fb_status
62            .set_at()
63            .try_into()
64            .map_err(|_| IcechunkFormatErrorKind::InvalidTimestamp)
65            .capture()?;
66        let set_at = DateTime::from_timestamp_micros(ts)
67            .ok_or(IcechunkFormatErrorKind::InvalidTimestamp)
68            .capture()?;
69        Ok(RepoStatus {
70            availability: fb_status.availability().into(),
71            set_at,
72            limited_availability_reason: fb_status
73                .limited_availability_reason()
74                .map(|s| s.to_string()),
75        })
76    }
77}
78
79impl RepoStatus {
80    pub fn error_msg(&self) -> String {
81        format!(
82            "Repo status is {0:?}, set at {1}, reason: {2:?}",
83            self.availability, self.set_at, self.limited_availability_reason
84        )
85    }
86}
87
88// TODO: should we not implement serialize and let the session fetch the repo info?
89#[derive(PartialEq, Serialize, Deserialize)]
90#[serde(try_from = "RepoInfoDeserializer")]
91pub struct RepoInfo {
92    buffer: Vec<u8>,
93}
94
95/// Deserialization goes through [`RepoInfo::from_buffer`] so that a `RepoInfo`
96/// can never hold an unverified buffer, which the unchecked accessors rely on.
97#[derive(Deserialize)]
98struct RepoInfoDeserializer {
99    buffer: Vec<u8>,
100}
101
102impl TryFrom<RepoInfoDeserializer> for RepoInfo {
103    type Error = IcechunkFormatError;
104
105    fn try_from(value: RepoInfoDeserializer) -> Result<Self, Self::Error> {
106        RepoInfo::from_buffer(value.buffer)
107    }
108}
109
110impl std::fmt::Debug for RepoInfo {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        let tags = self.tags().map(Vec::from_iter).unwrap_or_default();
113        let tags =
114            tags.into_iter().map(|(name, snap)| format!("{name} -> {snap}")).join(", ");
115        let branches = self.branches().map(Vec::from_iter).unwrap_or_default();
116        let branches = branches
117            .into_iter()
118            .map(|(name, snap)| format!("{name} -> {snap}"))
119            .join(", ");
120        let snaps =
121            self.snapshot_and_parent_ids().map(Vec::from_iter).unwrap_or_default();
122        let snaps = snaps
123            .into_iter()
124            .map(|(id, parent_id)| {
125                format!(
126                    "{} -> {}",
127                    id,
128                    parent_id.map(|s| s.to_string()).unwrap_or_default()
129                )
130            })
131            .join(", ");
132        // FIXME: add other fields
133        f.debug_struct("RepoInfo")
134            .field("tags", &tags)
135            .field("branches", &branches)
136            .field("snapshots", &snaps)
137            .finish_non_exhaustive()
138    }
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum UpdateType {
143    RepoInitializedUpdate,
144    RepoMigratedUpdate {
145        from_version: SpecVersionBin,
146        to_version: SpecVersionBin,
147    },
148    RepoStatusChangedUpdate {
149        status: RepoStatus,
150    },
151    ConfigChangedUpdate,
152    MetadataChangedUpdate,
153    TagCreatedUpdate {
154        name: String,
155    },
156    TagDeletedUpdate {
157        name: String,
158        previous_snap_id: SnapshotId,
159    },
160    BranchCreatedUpdate {
161        name: String,
162    },
163    BranchDeletedUpdate {
164        name: String,
165        previous_snap_id: SnapshotId,
166    },
167    BranchResetUpdate {
168        name: String,
169        previous_snap_id: SnapshotId,
170    },
171    NewCommitUpdate {
172        branch: String,
173        new_snap_id: SnapshotId,
174    },
175    CommitAmendedUpdate {
176        branch: String,
177        previous_snap_id: SnapshotId,
178        new_snap_id: SnapshotId,
179    },
180    NewDetachedSnapshotUpdate {
181        new_snap_id: SnapshotId,
182    },
183    GCRanUpdate,
184    ExpirationRanUpdate,
185    FeatureFlagChanged {
186        id: u16,
187        new_value: Option<bool>,
188    },
189}
190
191static ROOT_OPTIONS: VerifierOptions = VerifierOptions {
192    max_depth: 10,
193    max_tables: 50_000_000,
194    // Large enough that any buffer the builder can produce is readable: the
195    // builder refuses to grow past FLATBUFFERS_MAX_BUFFER_SIZE, and the
196    // verifier's counter runs at most MAX_APPARENT_SIZE_INFLATION times the
197    // buffer it walks. Saturating because the product doesn't fit a 32 bit
198    // usize (wasm32), where it lands on usize::MAX instead.
199    max_apparent_size: crate::serializers::MAX_APPARENT_SIZE_INFLATION
200        .saturating_mul(flatbuffers::FLATBUFFERS_MAX_BUFFER_SIZE),
201    ignore_missing_null_terminator: true,
202};
203
204/// The verifier limits applied to every repo info buffer we read.
205#[cfg(test)]
206pub(crate) fn root_options() -> &'static VerifierOptions {
207    &ROOT_OPTIONS
208}
209
210#[derive(Debug, Clone)]
211pub struct UpdateInfo<I> {
212    pub update_type: UpdateType,
213    pub update_time: DateTime<Utc>,
214    pub previous_updates: I,
215}
216
217pub(crate) type UpdateTuple<'a> =
218    IcechunkResult<(UpdateType, DateTime<Utc>, Option<&'a str>)>;
219
220type LatestUpdatesResult<'bldr> = IcechunkResult<(
221    WIPOffset<
222        flatbuffers::Vector<
223            'bldr,
224            flatbuffers::ForwardsUOffset<generated::Update<'bldr>>,
225        >,
226    >,
227    Option<WIPOffset<&'bldr str>>,
228)>;
229
230impl RepoInfo {
231    #[expect(clippy::too_many_arguments)]
232    pub fn new<
233        'a,
234        I: IntoIterator<Item = IcechunkResult<(UpdateType, DateTime<Utc>, Option<&'a str>)>>,
235        EFFIt: DoubleEndedIterator<Item = u16> + ExactSizeIterator,
236        DFFIt: DoubleEndedIterator<Item = u16> + ExactSizeIterator,
237    >(
238        spec_version: SpecVersionBin,
239        tags: impl IntoIterator<Item = (&'a str, SnapshotId)>,
240        branches: impl IntoIterator<Item = (&'a str, SnapshotId)>,
241        deleted_tags: impl IntoIterator<Item = &'a str>,
242        snapshots: impl IntoIterator<Item = SnapshotInfo>,
243        metadata: &SnapshotProperties,
244        update: UpdateInfo<I>,
245        backup_path: Option<&'a str>,
246        num_updates_per_file: u16,
247        previous_info: Option<&'a str>,
248        config_bytes: Option<&[u8]>,
249        sorted_enabled_feature_flags: Option<EFFIt>,
250        sorted_disabled_feature_flags: Option<DFFIt>,
251        status: &RepoStatus,
252    ) -> IcechunkResult<Self> {
253        let mut snapshots: Vec<_> = snapshots.into_iter().collect();
254        snapshots.sort_by_key(|a| a.id.0);
255        let tags = resolve_ref_iter(&snapshots, tags)?;
256        let branches = resolve_ref_iter(&snapshots, branches)?;
257        let mut deleted_tags: Vec<_> = deleted_tags.into_iter().collect();
258        deleted_tags.sort();
259        Self::from_parts(
260            spec_version,
261            tags,
262            branches,
263            deleted_tags,
264            snapshots,
265            metadata,
266            |builder| {
267                Self::mk_latest_updates(
268                    builder,
269                    update,
270                    backup_path,
271                    num_updates_per_file,
272                    previous_info,
273                )
274            },
275            config_bytes,
276            sorted_enabled_feature_flags,
277            sorted_disabled_feature_flags,
278            status,
279        )
280    }
281
282    #[expect(clippy::too_many_arguments)]
283    fn from_parts<
284        'a,
285        F: for<'bldr> FnOnce(
286            &mut flatbuffers::FlatBufferBuilder<'bldr>,
287        ) -> LatestUpdatesResult<'bldr>,
288        EFFIt: DoubleEndedIterator<Item = u16> + ExactSizeIterator,
289        DFFIt: DoubleEndedIterator<Item = u16> + ExactSizeIterator,
290    >(
291        spec_version: SpecVersionBin,
292        sorted_tags: impl IntoIterator<Item = (&'a str, u32)>,
293        sorted_branches: impl IntoIterator<Item = (&'a str, u32)>,
294        sorted_deleted_tags: impl IntoIterator<Item = &'a str>,
295        sorted_snapshots: impl IntoIterator<Item = SnapshotInfo>,
296        metadata: &SnapshotProperties,
297        build_updates: F,
298        config_bytes: Option<&[u8]>,
299        sorted_enabled_feature_flags: Option<EFFIt>,
300        sorted_disabled_feature_flags: Option<DFFIt>,
301        status: &RepoStatus,
302    ) -> IcechunkResult<Self> {
303        trace!("Creating new repo info from parts");
304        let mut builder = flatbuffers::FlatBufferBuilder::with_capacity(4_096);
305        let tags = sorted_tags
306            .into_iter()
307            .map(|(name, offset)| {
308                let args = generated::RefArgs {
309                    name: Some(builder.create_string(name)),
310                    snapshot_index: offset,
311                };
312                generated::Ref::create(&mut builder, &args)
313            })
314            .collect::<Vec<_>>();
315        let tags = builder.create_vector(&tags);
316
317        let mut main_found = false;
318        let branches = sorted_branches
319            .into_iter()
320            .map(|(name, offset)| {
321                if name == icechunk_types::DEFAULT_BRANCH {
322                    main_found = true;
323                }
324                let args = generated::RefArgs {
325                    name: Some(builder.create_string(name)),
326                    snapshot_index: offset,
327                };
328                generated::Ref::create(&mut builder, &args)
329            })
330            .collect::<Vec<_>>();
331        if !main_found {
332            return Err(IcechunkFormatErrorKind::BranchNotFound {
333                branch: icechunk_types::DEFAULT_BRANCH.to_string(),
334            })
335            .capture();
336        }
337
338        let branches = builder.create_vector(&branches);
339
340        let deleted_tags = sorted_deleted_tags
341            .into_iter()
342            .map(|name| builder.create_string(name))
343            .collect::<Vec<_>>();
344        let deleted_tags = builder.create_vector(&deleted_tags);
345
346        let snapshots: Vec<_> = sorted_snapshots.into_iter().collect();
347        debug_assert!(snapshots.is_sorted_by(|a, b| a.id.0 <= b.id.0));
348
349        let snapshot_index: HashMap<_, _> =
350            snapshots.iter().enumerate().map(|(ix, sn)| (sn.id.clone(), ix)).collect();
351
352        // TODO: we should check no loops
353        let snapshots: Vec<_> = snapshots
354            .iter()
355            .map(|snap| {
356                let id = &snap.id.0;
357                let id = generated::ObjectId12::new(id);
358                let parent_offset = match snap.parent_id.as_ref() {
359                    Some(parent_id) => {
360                        let index = snapshot_index
361                            .get(parent_id)
362                            .ok_or_else(|| IcechunkFormatErrorKind::SnapshotIdNotFound {
363                                snapshot_id: snap.id.clone(),
364                            })
365                            .capture()?;
366                        Ok(*index as i32)
367                    }
368                    None => Ok::<_, IcechunkFormatError>(-1),
369                }?;
370
371                let metadata_items: Vec<_> = snap
372                    .metadata
373                    .iter()
374                    .map(|(k, v)| {
375                        let name = builder.create_shared_string(k.as_str());
376                        let serialized =
377                            flexbuffers::to_vec(v).map_err(Box::new).capture()?;
378                        let value = builder.create_vector(serialized.as_slice());
379                        let item = generated::MetadataItem::create(
380                            &mut builder,
381                            &generated::MetadataItemArgs {
382                                name: Some(name),
383                                value: Some(value),
384                            },
385                        );
386                        Ok::<_, IcechunkFormatError>(item)
387                    })
388                    .try_collect()?;
389
390                let metadata = builder.create_vector(metadata_items.as_slice());
391
392                // Absent vector when empty, so unexpired snapshots stay
393                // byte-identical to the pre-feature encoding.
394                let pruned_ancestor_tx_logs = if snap.pruned_ancestor_tx_logs.is_empty() {
395                    None
396                } else {
397                    let ids: Vec<_> = snap
398                        .pruned_ancestor_tx_logs
399                        .iter()
400                        .map(|id| generated::ObjectId12::new(&id.0))
401                        .collect();
402                    Some(builder.create_vector(ids.as_slice()))
403                };
404                debug_assert_eq!(
405                    pruned_ancestor_tx_logs.is_none(),
406                    snap.pruned_ancestor_tx_logs.is_empty(),
407                    "empty pruned_ancestor_tx_logs must serialize as a null vector, \
408                     not a present empty one"
409                );
410
411                let args = generated::SnapshotInfoArgs {
412                    id: Some(&id),
413                    parent_offset,
414                    flushed_at: snap.flushed_at.timestamp_micros() as u64,
415                    message: Some(builder.create_string(snap.message.as_str())),
416                    metadata: Some(metadata),
417                    pruned_ancestor_tx_logs,
418                };
419                Ok::<_, IcechunkFormatError>(generated::SnapshotInfo::create(
420                    &mut builder,
421                    &args,
422                ))
423            })
424            .try_collect()?;
425        let snapshots = builder.create_vector(&snapshots);
426
427        let limited_reason = status
428            .limited_availability_reason
429            .as_deref()
430            .map(|s| builder.create_string(s));
431        let status = generated::RepoStatus::create(
432            &mut builder,
433            &generated::RepoStatusArgs {
434                availability: status.availability.into(),
435                set_at: status.set_at.timestamp_micros() as u64,
436                limited_availability_reason: limited_reason,
437            },
438        );
439
440        let metadata_items: Vec<_> = metadata
441            .iter()
442            .map(|(k, v)| {
443                let name = builder.create_shared_string(k.as_str());
444                let serialized = flexbuffers::to_vec(v).map_err(Box::new).capture()?;
445                let value = builder.create_vector(serialized.as_slice());
446                let item = generated::MetadataItem::create(
447                    &mut builder,
448                    &generated::MetadataItemArgs { name: Some(name), value: Some(value) },
449                );
450                Ok::<_, IcechunkFormatError>(item)
451            })
452            .try_collect()?;
453
454        let metadata = builder.create_vector(metadata_items.as_slice());
455
456        let enabled_feature_flags =
457            sorted_enabled_feature_flags.map(|it| builder.create_vector_from_iter(it));
458        let disabled_feature_flags =
459            sorted_disabled_feature_flags.map(|it| builder.create_vector_from_iter(it));
460
461        let (latest_updates, repo_before_updates) = build_updates(&mut builder)?;
462
463        let config = config_bytes.map(|bytes| builder.create_vector(bytes));
464
465        // TODO: provide accessors for last_updated_at, status, metadata, etc.
466        let repo_args = generated::RepoArgs {
467            tags: Some(tags),
468            branches: Some(branches),
469            deleted_tags: Some(deleted_tags),
470            snapshots: Some(snapshots),
471            spec_version: spec_version as u8,
472            status: Some(status),
473            metadata: Some(metadata),
474            latest_updates: Some(latest_updates),
475            repo_before_updates,
476            config,
477            enabled_feature_flags,
478            disabled_feature_flags,
479            ..Default::default()
480        };
481        let repo = generated::Repo::create(&mut builder, &repo_args);
482        builder.finish(repo, Some("Ichk"));
483        let (mut buffer, offset) = builder.collapse();
484        buffer.drain(0..offset);
485        buffer.shrink_to_fit();
486        Ok(Self { buffer })
487    }
488
489    fn mk_latest_updates<
490        'bldr,
491        'a,
492        I: IntoIterator<Item = IcechunkResult<(UpdateType, DateTime<Utc>, Option<&'a str>)>>,
493    >(
494        builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
495        update: UpdateInfo<I>,
496        backup_path: Option<&'a str>,
497        num_updates_per_file: u16,
498        previous_info: Option<&'a str>,
499    ) -> LatestUpdatesResult<'bldr> {
500        // replace the backup path in the last update, that must be None, by the new backup path
501        let mut previous_updates = update.previous_updates.into_iter();
502        let last_update = previous_updates.next().map(|maybe_data| {
503            maybe_data.map(|(ut, dt, path)| {
504                assert!(
505                    path.is_none(),
506                    "Invalid latest update iterator, last element has backup path"
507                );
508                (ut, dt, backup_path)
509            })
510        });
511        // A backup_path points to the previous repo info file. It is only meaningful
512        // when there are previous updates (you can't back up what doesn't exist).
513        // However, previous updates CAN exist without a backup_path: during migration,
514        // synthetic ops log entries are generated with no prior repo info file to
515        // reference. (This differs from RepoInitializedUpdate, which has neither
516        // previous updates nor a backup path.)
517        assert!(
518            backup_path.is_none() || last_update.is_some(),
519            "A backup path must not be provided without previous updates"
520        );
521
522        // Reject updates whose timestamp is not strictly newer than the top of the ops log
523        if let Some(Ok((_, latest_time, _))) = &last_update
524            && update.update_time <= *latest_time
525        {
526            return Err(IcechunkFormatErrorKind::InvalidUpdateTimestamp {
527                latest_time: *latest_time,
528                new_time: update.update_time,
529            })
530            .capture();
531        }
532
533        let new_updates: Box<dyn Iterator<Item = _>> =
534            if let Some(last_update) = last_update {
535                Box::new(
536                    [Ok((update.update_type, update.update_time, None)), last_update]
537                        .into_iter(),
538                )
539            } else {
540                Box::new([Ok((update.update_type, update.update_time, None))].into_iter())
541            };
542
543        let all_updates = new_updates.into_iter().chain(previous_updates);
544        // If we didn't overflow (all previous updates fit in the new file),
545        // preserve the old file's chain pointer so older history remains reachable.
546        let mut repo_before_updates = previous_info;
547
548        let num_updates = num_updates_per_file as usize;
549        let mut updates = Vec::new();
550        for maybe_data in all_updates {
551            let (u_type, u_time, file) = maybe_data?;
552
553            // Once we've reached the target file size, look for a valid overflow
554            // point: an entry whose backup_path we can use as the chain pointer.
555            // Entries without a backup_path (e.g. synthetic migration entries)
556            // can't serve as overflow — keep them in the file instead.
557            if updates.len() >= num_updates
558                && let Some(bp) = file
559            {
560                repo_before_updates = Some(bp);
561                break;
562            }
563
564            let (update_type_type, update_type) = update_type_to_fb(builder, &u_type)?;
565            let file = file.map(|file| builder.create_string(file));
566            updates.push(generated::Update::create(
567                builder,
568                &generated::UpdateArgs {
569                    update_type_type,
570                    update_type: Some(update_type),
571                    updated_at: u_time.timestamp_micros() as u64,
572                    backup_path: file,
573                },
574            ));
575        }
576
577        debug_assert!(!updates.is_empty(), "Must have at least one update in repo file");
578
579        let updates = builder.create_vector(&updates);
580        let repo_before_updates = repo_before_updates.map(|s| builder.create_string(s));
581        Ok((updates, repo_before_updates))
582    }
583
584    fn mk_latest_updates_passthrough<'bldr, 'a>(
585        builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
586        updates: impl IntoIterator<Item = UpdateTuple<'a>>,
587        repo_before_updates: Option<&'a str>,
588    ) -> LatestUpdatesResult<'bldr> {
589        let mut fb_updates = Vec::new();
590        for maybe_data in updates {
591            let (u_type, u_time, file) = maybe_data?;
592            let (update_type_type, update_type) = update_type_to_fb(builder, &u_type)?;
593            let file = file.map(|file| builder.create_string(file));
594            fb_updates.push(generated::Update::create(
595                builder,
596                &generated::UpdateArgs {
597                    update_type_type,
598                    update_type: Some(update_type),
599                    updated_at: u_time.timestamp_micros() as u64,
600                    backup_path: file,
601                },
602            ));
603        }
604        debug_assert!(
605            !fb_updates.is_empty(),
606            "Must have at least one update in repo file"
607        );
608        let updates = builder.create_vector(&fb_updates);
609        let repo_before_updates = repo_before_updates.map(|s| builder.create_string(s));
610        Ok((updates, repo_before_updates))
611    }
612
613    pub fn initial<C: Serialize>(
614        spec_version: SpecVersionBin,
615        snapshot: SnapshotInfo,
616        num_updates_per_file: u16,
617        config: Option<&C>,
618        update_time: Option<DateTime<Utc>>,
619    ) -> Self {
620        #[expect(clippy::expect_used)]
621        let config_bytes =
622            config.map(|c| flexbuffers::to_vec(c).expect("Cannot serialize config"));
623        // This method is basically constant, so it's OK to unwrap in it
624        #[expect(clippy::expect_used)]
625        Self::from_parts(
626            spec_version,
627            [],
628            [("main", 0)],
629            [],
630            [snapshot],
631            &Default::default(),
632            |builder| {
633                Self::mk_latest_updates(
634                    builder,
635                    UpdateInfo {
636                        update_type: UpdateType::RepoInitializedUpdate,
637                        update_time: update_time.unwrap_or_else(Utc::now),
638                        previous_updates: [],
639                    },
640                    None,
641                    num_updates_per_file,
642                    None,
643                )
644            },
645            config_bytes.as_deref(),
646            None::<std::iter::Empty<u16>>,
647            None::<std::iter::Empty<u16>>,
648            &RepoStatus {
649                availability: RepoAvailability::Online,
650                set_at: update_time.unwrap_or_else(Utc::now),
651                limited_availability_reason: None,
652            },
653        )
654        .expect("Cannot generate initial snapshot")
655    }
656
657    /// Read the raw config bytes from the `FlatBuffer` (for pass-through in mutations).
658    pub fn config_bytes_raw(&self) -> IcechunkResult<Option<Vec<u8>>> {
659        Ok(self.root()?.config().map(|v| v.bytes().to_vec()))
660    }
661
662    /// Read the repository configuration from the repo info.
663    /// Returns `None` for repos created before config was embedded,
664    /// or for repos using the default configuration.
665    pub fn config<C: serde::de::DeserializeOwned>(&self) -> IcechunkResult<Option<C>> {
666        match self.root()?.config() {
667            None => Ok(None),
668            Some(config_fb) => {
669                let config: C = flexbuffers::from_slice(config_fb.bytes())
670                    .map_err(Box::new)
671                    .capture()?;
672                Ok(Some(config))
673            }
674        }
675    }
676
677    pub fn metadata(&self) -> IcechunkResult<SnapshotProperties> {
678        self.root()?
679            .metadata()
680            .unwrap_or_default()
681            .iter()
682            .map(|item| {
683                let key = item.name().to_string();
684                let value = flexbuffers::from_slice(item.value().bytes())
685                    .map_err(Box::new)
686                    .capture()?;
687                Ok((key, value))
688            })
689            .try_collect()
690    }
691
692    pub fn status(&self) -> IcechunkResult<RepoStatus> {
693        let root = self.root()?;
694        let fb_status = root.status();
695        fb_status.try_into()
696    }
697
698    pub fn enabled_feature_flags(
699        &self,
700    ) -> IcechunkResult<Option<impl DoubleEndedIterator<Item = u16> + ExactSizeIterator>>
701    {
702        Ok(self.root()?.enabled_feature_flags().map(|v| v.iter()))
703    }
704
705    pub fn disabled_feature_flags(
706        &self,
707    ) -> IcechunkResult<Option<impl DoubleEndedIterator<Item = u16> + ExactSizeIterator>>
708    {
709        Ok(self.root()?.disabled_feature_flags().map(|v| v.iter()))
710    }
711
712    /// None means not set, use the default value
713    /// Some(true) means enabled
714    /// Some(false) means disabled
715    pub fn feature_flag_enabled(&self, id: u16) -> IcechunkResult<Option<bool>> {
716        let root = self.root()?;
717        if root
718            .enabled_feature_flags()
719            .and_then(|v| v.lookup_by_key(id, |this, key| this.cmp(key)))
720            .is_some()
721        {
722            return Ok(Some(true));
723        }
724        if root
725            .disabled_feature_flags()
726            .and_then(|v| v.lookup_by_key(id, |this, key| this.cmp(key)))
727            .is_some()
728        {
729            return Ok(Some(false));
730        }
731        Ok(None)
732    }
733
734    fn all_tags(&self) -> IcechunkResult<impl Iterator<Item = (&str, u32)>> {
735        Ok(self.root()?.tags().iter().map(|r| (r.name(), r.snapshot_index())))
736    }
737
738    fn all_branches(&self) -> IcechunkResult<impl Iterator<Item = (&str, u32)>> {
739        Ok(self.root()?.branches().iter().map(|r| (r.name(), r.snapshot_index())))
740    }
741
742    pub fn deleted_tags(&self) -> IcechunkResult<impl Iterator<Item = &str>> {
743        Ok(self.root()?.deleted_tags().iter())
744    }
745
746    pub fn all_snapshots(
747        &self,
748    ) -> IcechunkResult<impl Iterator<Item = IcechunkResult<SnapshotInfo>>> {
749        let root = self.root()?;
750        Ok(root.snapshots().iter().map(move |snap| mk_snapshot_info(&root, &snap)))
751    }
752
753    /// Reads the ids from the flatbuffer. It does not deserialize snapshot metadata.
754    fn snapshot_and_parent_ids(
755        &self,
756    ) -> IcechunkResult<impl Iterator<Item = (SnapshotId, Option<SnapshotId>)>> {
757        let root = self.root()?;
758        Ok(root.snapshots().iter().map(move |snap| {
759            let parent_id = if snap.parent_offset() >= 0 {
760                let parent = root.snapshots().get(snap.parent_offset() as usize).id();
761                Some(SnapshotId::new(parent.0))
762            } else {
763                None
764            };
765            (SnapshotId::new(snap.id().0), parent_id)
766        }))
767    }
768
769    /// Doesn't check the validity of `flag_id`
770    pub fn update_feature_flag(
771        &self,
772        spec_version: SpecVersionBin,
773        flag_id: u16,
774        enabled: Option<bool>,
775        previous_file: &str,
776        num_updates_per_file: u16,
777    ) -> IcechunkResult<Self> {
778        let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
779        let (eff, dff): (Option<Vec<_>>, Option<Vec<_>>) = match enabled {
780            Some(false) => {
781                let e = self
782                    .enabled_feature_flags()?
783                    .map(|it| it.filter(|x| *x != flag_id).collect());
784
785                let mut d: BTreeSet<_> = self
786                    .disabled_feature_flags()?
787                    .map(|it| it.collect())
788                    .unwrap_or_default();
789                d.insert(flag_id);
790                let d = d.into_iter().collect();
791                (e, Some(d))
792            }
793            Some(true) => {
794                let d = self
795                    .disabled_feature_flags()?
796                    .map(|it| it.filter(|x| *x != flag_id).collect());
797
798                let mut e: BTreeSet<_> = self
799                    .enabled_feature_flags()?
800                    .map(|it| it.collect())
801                    .unwrap_or_default();
802                e.insert(flag_id);
803                let e = e.into_iter().collect();
804                (Some(e), d)
805            }
806            None => {
807                let e = self
808                    .enabled_feature_flags()?
809                    .map(|it| it.filter(|x| *x != flag_id).collect());
810                let d = self
811                    .disabled_feature_flags()?
812                    .map(|it| it.filter(|x| *x != flag_id).collect());
813                (e, d)
814            }
815        };
816
817        let latest_updates = self.latest_updates()?;
818        let rbu = self.repo_before_updates()?;
819        Self::from_parts(
820            spec_version,
821            self.all_tags()?,
822            self.all_branches()?,
823            self.deleted_tags()?,
824            snaps,
825            &self.metadata()?,
826            |builder| {
827                Self::mk_latest_updates(
828                    builder,
829                    UpdateInfo {
830                        update_type: UpdateType::FeatureFlagChanged {
831                            id: flag_id,
832                            new_value: enabled,
833                        },
834                        update_time: Utc::now(),
835                        previous_updates: latest_updates,
836                    },
837                    Some(previous_file),
838                    num_updates_per_file,
839                    rbu,
840                )
841            },
842            self.config_bytes_raw()?.as_deref(),
843            eff.map(|it| it.into_iter()),
844            dff.map(|it| it.into_iter()),
845            &self.status()?,
846        )
847    }
848
849    #[expect(clippy::too_many_arguments)]
850    pub fn add_snapshot(
851        &self,
852        spec_version: SpecVersionBin,
853        snap: SnapshotInfo,
854        branch: Option<&str>,
855        update_type: UpdateType,
856        update_time: Option<DateTime<Utc>>, // for testing
857        previous_file: &str,
858        num_updates_per_file: u16,
859    ) -> IcechunkResult<Self> {
860        let mut snapshots: Vec<_> = self.all_snapshots()?.try_collect()?;
861        let new_index = match snapshots.binary_search_by_key(&&snap.id, |snap| &snap.id) {
862            Ok(_) => Err(IcechunkFormatErrorKind::DuplicateSnapshotId {
863                snapshot_id: snap.id.clone(),
864            })
865            .capture(),
866            Err(idx) => Ok(idx),
867        }?;
868
869        snapshots.insert(new_index, snap);
870
871        let tags = self.all_tags()?.map(|(name, idx)| {
872            if idx as usize >= new_index { (name, idx + 1) } else { (name, idx) }
873        });
874        let branches = self.all_branches()?.map(|(name, idx)| {
875            if Some(name) == branch {
876                (name, new_index as u32)
877            } else if idx as usize >= new_index {
878                (name, idx + 1)
879            } else {
880                (name, idx)
881            }
882        });
883
884        let latest_updates = self.latest_updates()?;
885        let rbu = self.repo_before_updates()?;
886        let res = Self::from_parts(
887            spec_version,
888            tags,
889            branches,
890            self.deleted_tags()?,
891            snapshots,
892            &self.metadata()?,
893            |builder| {
894                Self::mk_latest_updates(
895                    builder,
896                    UpdateInfo {
897                        update_type,
898                        update_time: update_time.unwrap_or_else(Utc::now),
899                        previous_updates: latest_updates,
900                    },
901                    Some(previous_file),
902                    num_updates_per_file,
903                    rbu,
904                )
905            },
906            self.config_bytes_raw()?.as_deref(),
907            self.enabled_feature_flags()?,
908            self.disabled_feature_flags()?,
909            &self.status()?,
910        )?;
911        Ok(res)
912    }
913
914    pub fn add_branch(
915        &self,
916        spec_version: SpecVersionBin,
917        name: &str,
918        snap: &SnapshotId,
919        previous_file: &str,
920        num_updates_per_file: u16,
921    ) -> IcechunkResult<Self> {
922        if let Ok(snapshot_id) = self.resolve_branch(name) {
923            return Err(IcechunkFormatErrorKind::BranchAlreadyExists {
924                branch: name.to_string(),
925                snapshot_id,
926            })
927            .capture();
928        }
929
930        match self.resolve_snapshot_index(snap)? {
931            Some(snap_idx) => {
932                let mut branches: Vec<_> = self.all_branches()?.collect();
933                branches.push((name, snap_idx as u32));
934                branches.sort_by_key(|(name1, _)| *name1);
935                let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
936                let latest_updates = self.latest_updates()?;
937                let rbu = self.repo_before_updates()?;
938                Ok(Self::from_parts(
939                    spec_version,
940                    self.all_tags()?,
941                    branches,
942                    self.deleted_tags()?,
943                    snaps,
944                    &self.metadata()?,
945                    |builder| {
946                        Self::mk_latest_updates(
947                            builder,
948                            UpdateInfo {
949                                update_type: UpdateType::BranchCreatedUpdate {
950                                    name: name.to_string(),
951                                },
952                                update_time: Utc::now(),
953                                previous_updates: latest_updates,
954                            },
955                            Some(previous_file),
956                            num_updates_per_file,
957                            rbu,
958                        )
959                    },
960                    self.config_bytes_raw()?.as_deref(),
961                    self.enabled_feature_flags()?,
962                    self.disabled_feature_flags()?,
963                    &self.status()?,
964                )?)
965            }
966            None => Err(IcechunkFormatErrorKind::SnapshotIdNotFound {
967                snapshot_id: snap.clone(),
968            })
969            .capture(),
970        }
971    }
972
973    pub fn delete_branch(
974        &self,
975        spec_version: SpecVersionBin,
976        name: &str,
977        previous_file: &str,
978        num_updates_per_file: u16,
979    ) -> IcechunkResult<Self> {
980        match self.resolve_branch(name) {
981            Ok(previous_snap_id) => {
982                let mut branches: Vec<_> = self.all_branches()?.collect();
983                // retain preserves order
984                branches.retain(|(n, _)| n != &name);
985                let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
986                let latest_updates = self.latest_updates()?;
987                let rbu = self.repo_before_updates()?;
988                Self::from_parts(
989                    spec_version,
990                    self.all_tags()?,
991                    branches,
992                    self.deleted_tags()?,
993                    snaps,
994                    &self.metadata()?,
995                    |builder| {
996                        Self::mk_latest_updates(
997                            builder,
998                            UpdateInfo {
999                                update_type: UpdateType::BranchDeletedUpdate {
1000                                    name: name.to_string(),
1001                                    previous_snap_id,
1002                                },
1003                                update_time: Utc::now(),
1004                                previous_updates: latest_updates,
1005                            },
1006                            Some(previous_file),
1007                            num_updates_per_file,
1008                            rbu,
1009                        )
1010                    },
1011                    self.config_bytes_raw()?.as_deref(),
1012                    self.enabled_feature_flags()?,
1013                    self.disabled_feature_flags()?,
1014                    &self.status()?,
1015                )
1016            }
1017            Err(IcechunkFormatError {
1018                kind: IcechunkFormatErrorKind::BranchNotFound { .. },
1019                ..
1020            }) => {
1021                Err(IcechunkFormatErrorKind::BranchNotFound { branch: name.to_string() })
1022                    .capture()
1023            }
1024            Err(err) => Err(err),
1025        }
1026    }
1027
1028    pub fn update_branch(
1029        &self,
1030        spec_version: SpecVersionBin,
1031        name: &str,
1032        new_snap: &SnapshotId,
1033        previous_file: &str,
1034        num_updates_per_file: u16,
1035    ) -> IcechunkResult<Self> {
1036        let previous_snap_id = self.resolve_branch(name)?;
1037        match self.resolve_snapshot_index(new_snap)? {
1038            Some(snap_idx) => {
1039                let branches = self.all_branches()?.map(|(br, idx)| {
1040                    if br == name { (br, snap_idx as u32) } else { (br, idx) }
1041                });
1042                let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
1043                let latest_updates = self.latest_updates()?;
1044                let rbu = self.repo_before_updates()?;
1045                Ok(Self::from_parts(
1046                    spec_version,
1047                    self.all_tags()?,
1048                    branches,
1049                    self.deleted_tags()?,
1050                    snaps,
1051                    &self.metadata()?,
1052                    |builder| {
1053                        Self::mk_latest_updates(
1054                            builder,
1055                            UpdateInfo {
1056                                update_type: UpdateType::BranchResetUpdate {
1057                                    name: name.to_string(),
1058                                    previous_snap_id,
1059                                },
1060                                update_time: Utc::now(),
1061                                previous_updates: latest_updates,
1062                            },
1063                            Some(previous_file),
1064                            num_updates_per_file,
1065                            rbu,
1066                        )
1067                    },
1068                    self.config_bytes_raw()?.as_deref(),
1069                    self.enabled_feature_flags()?,
1070                    self.disabled_feature_flags()?,
1071                    &self.status()?,
1072                )?)
1073            }
1074            None => Err(IcechunkFormatErrorKind::SnapshotIdNotFound {
1075                snapshot_id: new_snap.clone(),
1076            })
1077            .capture(),
1078        }
1079    }
1080
1081    pub fn add_tag(
1082        &self,
1083        spec_version: SpecVersionBin,
1084        name: &str,
1085        snap: &SnapshotId,
1086        previous_file: &str,
1087        num_updates_per_file: u16,
1088    ) -> IcechunkResult<Self> {
1089        if self.resolve_tag(name).is_ok() {
1090            return Err(IcechunkFormatErrorKind::TagAlreadyExists {
1091                tag: name.to_string(),
1092            })
1093            .capture();
1094        }
1095        if self.tag_was_deleted(name)? {
1096            return Err(IcechunkFormatErrorKind::TagPreviouslyDeleted {
1097                tag: name.to_string(),
1098            })
1099            .capture();
1100        }
1101
1102        match self.resolve_snapshot_index(snap)? {
1103            Some(snap_idx) => {
1104                let mut tags: Vec<_> = self.all_tags()?.collect();
1105                tags.push((name, snap_idx as u32));
1106                tags.sort_by_key(|(name1, _)| *name1);
1107                let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
1108                let latest_updates = self.latest_updates()?;
1109                let rbu = self.repo_before_updates()?;
1110                Ok(Self::from_parts(
1111                    spec_version,
1112                    tags,
1113                    self.all_branches()?,
1114                    self.deleted_tags()?,
1115                    snaps,
1116                    &self.metadata()?,
1117                    |builder| {
1118                        Self::mk_latest_updates(
1119                            builder,
1120                            UpdateInfo {
1121                                update_type: UpdateType::TagCreatedUpdate {
1122                                    name: name.to_string(),
1123                                },
1124                                update_time: Utc::now(),
1125                                previous_updates: latest_updates,
1126                            },
1127                            Some(previous_file),
1128                            num_updates_per_file,
1129                            rbu,
1130                        )
1131                    },
1132                    self.config_bytes_raw()?.as_deref(),
1133                    self.enabled_feature_flags()?,
1134                    self.disabled_feature_flags()?,
1135                    &self.status()?,
1136                )?)
1137            }
1138            None => Err(IcechunkFormatErrorKind::SnapshotIdNotFound {
1139                snapshot_id: snap.clone(),
1140            })
1141            .capture(),
1142        }
1143    }
1144
1145    pub fn delete_tag(
1146        &self,
1147        spec_version: SpecVersionBin,
1148        name: &str,
1149        previous_file: &str,
1150        num_updates_per_file: u16,
1151    ) -> IcechunkResult<Self> {
1152        match self.resolve_tag(name) {
1153            Ok(previous_snap_id) => {
1154                let mut tags: Vec<_> = self.all_tags()?.collect();
1155                // retain preserves order
1156                tags.retain(|(n, _)| n != &name);
1157
1158                let mut deleted_tags: BTreeSet<_> = self.deleted_tags()?.collect();
1159                debug_assert!(!deleted_tags.contains(name));
1160                deleted_tags.insert(name);
1161
1162                let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
1163                let latest_updates = self.latest_updates()?;
1164                let rbu = self.repo_before_updates()?;
1165                Self::from_parts(
1166                    spec_version,
1167                    tags,
1168                    self.all_branches()?,
1169                    deleted_tags,
1170                    snaps,
1171                    &self.metadata()?,
1172                    |builder| {
1173                        Self::mk_latest_updates(
1174                            builder,
1175                            UpdateInfo {
1176                                update_type: UpdateType::TagDeletedUpdate {
1177                                    name: name.to_string(),
1178                                    previous_snap_id,
1179                                },
1180                                update_time: Utc::now(),
1181                                previous_updates: latest_updates,
1182                            },
1183                            Some(previous_file),
1184                            num_updates_per_file,
1185                            rbu,
1186                        )
1187                    },
1188                    self.config_bytes_raw()?.as_deref(),
1189                    self.enabled_feature_flags()?,
1190                    self.disabled_feature_flags()?,
1191                    &self.status()?,
1192                )
1193            }
1194            Err(IcechunkFormatError {
1195                kind: IcechunkFormatErrorKind::TagNotFound { .. },
1196                ..
1197            }) => Err(IcechunkFormatErrorKind::TagNotFound { tag: name.to_string() })
1198                .capture(),
1199            Err(err) => Err(err),
1200        }
1201    }
1202
1203    pub fn set_metadata(
1204        &self,
1205        spec_version: SpecVersionBin,
1206        metadata: &SnapshotProperties,
1207        previous_file: &str,
1208        num_updates_per_file: u16,
1209    ) -> IcechunkResult<Self> {
1210        let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
1211        let latest_updates = self.latest_updates()?;
1212        let rbu = self.repo_before_updates()?;
1213        Self::from_parts(
1214            spec_version,
1215            self.all_tags()?,
1216            self.all_branches()?,
1217            self.deleted_tags()?,
1218            snaps,
1219            metadata,
1220            |builder| {
1221                Self::mk_latest_updates(
1222                    builder,
1223                    UpdateInfo {
1224                        update_type: UpdateType::MetadataChangedUpdate,
1225                        update_time: Utc::now(),
1226                        previous_updates: latest_updates,
1227                    },
1228                    Some(previous_file),
1229                    num_updates_per_file,
1230                    rbu,
1231                )
1232            },
1233            self.config_bytes_raw()?.as_deref(),
1234            self.enabled_feature_flags()?,
1235            self.disabled_feature_flags()?,
1236            &self.status()?,
1237        )
1238    }
1239
1240    /// Update the embedded configuration and record a `ConfigChangedUpdate` in the op log.
1241    pub fn set_config<C: Serialize>(
1242        &self,
1243        spec_version: SpecVersionBin,
1244        config: &C,
1245        previous_file: &str,
1246        num_updates_per_file: u16,
1247    ) -> IcechunkResult<Self> {
1248        let config_bytes = flexbuffers::to_vec(config).map_err(Box::new).capture()?;
1249        let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
1250        let latest_updates = self.latest_updates()?;
1251        let rbu = self.repo_before_updates()?;
1252        Self::from_parts(
1253            spec_version,
1254            self.all_tags()?,
1255            self.all_branches()?,
1256            self.deleted_tags()?,
1257            snaps,
1258            &self.metadata()?,
1259            |builder| {
1260                Self::mk_latest_updates(
1261                    builder,
1262                    UpdateInfo {
1263                        update_type: UpdateType::ConfigChangedUpdate,
1264                        update_time: Utc::now(),
1265                        previous_updates: latest_updates,
1266                    },
1267                    Some(previous_file),
1268                    num_updates_per_file,
1269                    rbu,
1270                )
1271            },
1272            Some(config_bytes.as_slice()),
1273            self.enabled_feature_flags()?,
1274            self.disabled_feature_flags()?,
1275            &self.status()?,
1276        )
1277    }
1278
1279    /// Set the repository status.
1280    ///
1281    /// When `update_config` is `Some((previous_file, num_updates_per_file))`, a
1282    /// `RepoStatusChangedUpdate` entry is appended to the ops log (normal behavior).
1283    ///
1284    /// When `update_config` is `None`, the status is changed silently: the existing
1285    /// ops log entries are preserved as-is with no new entry added.
1286    pub fn set_status(
1287        &self,
1288        spec_version: SpecVersionBin,
1289        status: &RepoStatus,
1290        update_config: Option<(&str, u16)>,
1291    ) -> IcechunkResult<Self> {
1292        let snaps: Vec<_> = self.all_snapshots()?.try_collect()?;
1293        let latest_updates = self.latest_updates()?;
1294        let rbu = self.repo_before_updates()?;
1295        if let Some((previous_file, num_updates_per_file)) = update_config {
1296            let status_update = status.clone();
1297            Self::from_parts(
1298                spec_version,
1299                self.all_tags()?,
1300                self.all_branches()?,
1301                self.deleted_tags()?,
1302                snaps,
1303                &self.metadata()?,
1304                |builder| {
1305                    Self::mk_latest_updates(
1306                        builder,
1307                        UpdateInfo {
1308                            update_type: UpdateType::RepoStatusChangedUpdate {
1309                                status: status_update,
1310                            },
1311                            update_time: Utc::now(),
1312                            previous_updates: latest_updates,
1313                        },
1314                        Some(previous_file),
1315                        num_updates_per_file,
1316                        rbu,
1317                    )
1318                },
1319                self.config_bytes_raw()?.as_deref(),
1320                self.enabled_feature_flags()?,
1321                self.disabled_feature_flags()?,
1322                status,
1323            )
1324        } else {
1325            Self::from_parts(
1326                spec_version,
1327                self.all_tags()?,
1328                self.all_branches()?,
1329                self.deleted_tags()?,
1330                snaps,
1331                &self.metadata()?,
1332                |builder| {
1333                    Self::mk_latest_updates_passthrough(builder, latest_updates, rbu)
1334                },
1335                self.config_bytes_raw()?.as_deref(),
1336                self.enabled_feature_flags()?,
1337                self.disabled_feature_flags()?,
1338                status,
1339            )
1340        }
1341    }
1342
1343    /// Check that `buffer` passes every check [`RepoInfo::from_buffer`] applies.
1344    pub fn verify_buffer(buffer: &[u8]) -> IcechunkResult<()> {
1345        let _ = flatbuffers::root_with_opts::<generated::Repo<'_>>(&ROOT_OPTIONS, buffer)
1346            .capture()?;
1347        Ok(())
1348    }
1349
1350    pub fn from_buffer(buffer: Vec<u8>) -> IcechunkResult<RepoInfo> {
1351        Self::verify_buffer(buffer.as_slice())?;
1352        Ok(RepoInfo { buffer })
1353    }
1354
1355    pub fn bytes(&self) -> &[u8] {
1356        self.buffer.as_slice()
1357    }
1358
1359    #[expect(unsafe_code)]
1360    fn root(&self) -> IcechunkResult<generated::Repo<'_>> {
1361        // SAFETY: every RepoInfo holds a verified buffer: `from_buffer` and the
1362        // `Deserialize` impl verify, and `from_parts` builds with our own
1363        // serialization code. We skip validation here because these accessors are
1364        // called many times per repo info, and each check is O(file).
1365        Ok(unsafe { flatbuffers::root_unchecked::<generated::Repo<'_>>(&self.buffer) })
1366    }
1367
1368    pub fn tag_names(&self) -> IcechunkResult<impl Iterator<Item = &str>> {
1369        Ok(self.root()?.tags().iter().map(|r| r.name()))
1370    }
1371
1372    pub fn branch_names(&self) -> IcechunkResult<impl Iterator<Item = &str>> {
1373        Ok(self.root()?.branches().iter().map(|r| r.name()))
1374    }
1375
1376    pub fn tags(&self) -> IcechunkResult<impl Iterator<Item = (&str, SnapshotId)>> {
1377        let root = self.root()?;
1378        Ok(self.all_tags()?.map(move |(name, idx)| {
1379            (name, SnapshotId::new(root.snapshots().get(idx as usize).id().0))
1380        }))
1381    }
1382
1383    pub fn branches(&self) -> IcechunkResult<impl Iterator<Item = (&str, SnapshotId)>> {
1384        let root = self.root()?;
1385        Ok(self.all_branches()?.map(move |(name, idx)| {
1386            (name, SnapshotId::new(root.snapshots().get(idx as usize).id().0))
1387        }))
1388    }
1389
1390    pub fn resolve_tag(&self, name: &str) -> IcechunkResult<SnapshotId> {
1391        let root = self.root()?;
1392        let res = root
1393            .tags()
1394            .lookup_by_key(name, |r, key| r.name().cmp(key))
1395            .map(|r| {
1396                let index = r.snapshot_index();
1397                SnapshotId::new(root.snapshots().get(index as usize).id().0)
1398            })
1399            .ok_or_else(|| IcechunkFormatErrorKind::TagNotFound { tag: name.to_string() })
1400            .capture()?;
1401
1402        Ok(res)
1403    }
1404
1405    pub fn tag_was_deleted(&self, name: &str) -> IcechunkResult<bool> {
1406        let root = self.root()?;
1407        let res = root.deleted_tags().lookup_by_key(name, |name, key| name.cmp(key));
1408        Ok(res.is_some())
1409    }
1410
1411    pub fn resolve_branch(&self, name: &str) -> IcechunkResult<SnapshotId> {
1412        let root = self.root()?;
1413        let res = root
1414            .branches()
1415            .lookup_by_key(name, |r, key| r.name().cmp(key))
1416            .map(|r| {
1417                let index = r.snapshot_index();
1418                SnapshotId::new(root.snapshots().get(index as usize).id().0)
1419            })
1420            .ok_or_else(|| IcechunkFormatErrorKind::BranchNotFound {
1421                branch: name.to_string(),
1422            })
1423            .capture()?;
1424
1425        Ok(res)
1426    }
1427
1428    pub fn spec_version(&self) -> IcechunkResult<SpecVersionBin> {
1429        let raw = self.root()?.spec_version();
1430        raw.try_into()
1431            .map_err(|_| IcechunkFormatErrorKind::InvalidSpecVersion {
1432                found: raw,
1433                max_supported: SpecVersionBin::current() as u8,
1434            })
1435            .capture()
1436    }
1437
1438    pub fn latest_updates(
1439        &self,
1440    ) -> IcechunkResult<impl Iterator<Item = UpdateTuple<'_>>> {
1441        let res = self.root()?.latest_updates().iter().map(|up| self.update_to_tuple(up));
1442        Ok(res)
1443    }
1444
1445    fn update_to_tuple<'a>(
1446        &'a self,
1447        update: generated::Update<'a>,
1448    ) -> IcechunkResult<(UpdateType, DateTime<Utc>, Option<&'a str>)> {
1449        let ty = self.mk_update_type(&update)?;
1450        let ts = timestamp_to_timestamp(update.updated_at())?;
1451        let bp = update.backup_path();
1452        Ok((ty, ts, bp))
1453    }
1454
1455    fn mk_update_type(
1456        &self,
1457        update: &generated::Update<'_>,
1458    ) -> IcechunkResult<UpdateType> {
1459        #[expect(clippy::unwrap_used)]
1460        match update.update_type_type() {
1461            generated::UpdateType::RepoInitializedUpdate => {
1462                Ok(UpdateType::RepoInitializedUpdate)
1463            }
1464            generated::UpdateType::RepoMigratedUpdate => {
1465                let up = update.update_type_as_repo_migrated_update().unwrap();
1466                let from_raw = up.from_version();
1467                let to_raw = up.to_version();
1468                Ok(UpdateType::RepoMigratedUpdate {
1469                    from_version: from_raw
1470                        .try_into()
1471                        .map_err(|_| IcechunkFormatErrorKind::InvalidSpecVersion {
1472                            found: from_raw,
1473                            max_supported: SpecVersionBin::current() as u8,
1474                        })
1475                        .capture()?,
1476                    to_version: to_raw
1477                        .try_into()
1478                        .map_err(|_| IcechunkFormatErrorKind::InvalidSpecVersion {
1479                            found: to_raw,
1480                            max_supported: SpecVersionBin::current() as u8,
1481                        })
1482                        .capture()?,
1483                })
1484            }
1485            generated::UpdateType::RepoStatusChangedUpdate => {
1486                let up = update.update_type_as_repo_status_changed_update().unwrap();
1487                let fb_status = up.status().unwrap();
1488                let status = fb_status.try_into()?;
1489
1490                Ok(UpdateType::RepoStatusChangedUpdate { status })
1491            }
1492            generated::UpdateType::ConfigChangedUpdate => {
1493                Ok(UpdateType::ConfigChangedUpdate)
1494            }
1495            generated::UpdateType::MetadataChangedUpdate => {
1496                Ok(UpdateType::MetadataChangedUpdate)
1497            }
1498            generated::UpdateType::TagCreatedUpdate => {
1499                let up = update.update_type_as_tag_created_update().unwrap();
1500                Ok(UpdateType::TagCreatedUpdate { name: up.name().to_string() })
1501            }
1502            generated::UpdateType::TagDeletedUpdate => {
1503                let up = update.update_type_as_tag_deleted_update().unwrap();
1504                let previous_snap_id = SnapshotId::new(up.previous_snap_id().0);
1505                Ok(UpdateType::TagDeletedUpdate {
1506                    name: up.name().to_string(),
1507                    previous_snap_id,
1508                })
1509            }
1510            generated::UpdateType::BranchCreatedUpdate => {
1511                let up = update.update_type_as_branch_created_update().unwrap();
1512                Ok(UpdateType::BranchCreatedUpdate { name: up.name().to_string() })
1513            }
1514            generated::UpdateType::BranchDeletedUpdate => {
1515                let up = update.update_type_as_branch_deleted_update().unwrap();
1516                let previous_snap_id = SnapshotId::new(up.previous_snap_id().0);
1517                Ok(UpdateType::BranchDeletedUpdate {
1518                    name: up.name().to_string(),
1519                    previous_snap_id,
1520                })
1521            }
1522            generated::UpdateType::BranchResetUpdate => {
1523                let up = update.update_type_as_branch_reset_update().unwrap();
1524                let previous_snap_id = SnapshotId::new(up.previous_snap_id().0);
1525                Ok(UpdateType::BranchResetUpdate {
1526                    name: up.name().to_string(),
1527                    previous_snap_id,
1528                })
1529            }
1530            generated::UpdateType::NewCommitUpdate => {
1531                let up = update.update_type_as_new_commit_update().unwrap();
1532                let new_snap_id = SnapshotId::new(up.new_snap_id().0);
1533                Ok(UpdateType::NewCommitUpdate {
1534                    branch: up.branch().to_string(),
1535                    new_snap_id,
1536                })
1537            }
1538            generated::UpdateType::CommitAmendedUpdate => {
1539                let up = update.update_type_as_commit_amended_update().unwrap();
1540                let previous_snap_id = SnapshotId::new(up.previous_snap_id().0);
1541                let new_snap_id = SnapshotId::new(up.new_snap_id().0);
1542                Ok(UpdateType::CommitAmendedUpdate {
1543                    branch: up.branch().to_string(),
1544                    previous_snap_id,
1545                    new_snap_id,
1546                })
1547            }
1548            generated::UpdateType::NewDetachedSnapshotUpdate => {
1549                let up = update.update_type_as_new_detached_snapshot_update().unwrap();
1550                let new_snap_id = SnapshotId::new(up.new_snap_id().0);
1551                Ok(UpdateType::NewDetachedSnapshotUpdate { new_snap_id })
1552            }
1553            generated::UpdateType::GCRanUpdate => Ok(UpdateType::GCRanUpdate),
1554            generated::UpdateType::ExpirationRanUpdate => {
1555                Ok(UpdateType::ExpirationRanUpdate)
1556            }
1557            generated::UpdateType::FeatureFlagChangedUpdate => {
1558                let up = update.update_type_as_feature_flag_changed_update().unwrap();
1559                Ok(UpdateType::FeatureFlagChanged {
1560                    id: up.id(),
1561                    new_value: if up.is_set() { Some(up.new_value()) } else { None },
1562                })
1563            }
1564            _ => Err(IcechunkFormatErrorKind::InvalidFlatBuffer(
1565                flatbuffers::InvalidFlatbuffer::InconsistentUnion {
1566                    field: Cow::Borrowed("latest_update_type"),
1567                    field_type: Cow::Borrowed("UpdateType"),
1568                    error_trace: Default::default(),
1569                },
1570            ))
1571            .capture(),
1572        }
1573    }
1574
1575    pub fn ancestry<'a>(
1576        &'a self,
1577        snapshot: &SnapshotId,
1578    ) -> IcechunkResult<impl Iterator<Item = IcechunkResult<SnapshotInfo>> + Send + use<'a>>
1579    {
1580        let root = self.root()?;
1581        if let Some(start) = self.resolve_snapshot_index(snapshot)? {
1582            let mut index = Some(start as i32);
1583            let iter = std::iter::from_fn(move || {
1584                if let Some(ix) = index {
1585                    if ix >= 0 {
1586                        let snap = root.snapshots().get(ix as usize);
1587                        index = Some(snap.parent_offset());
1588                        Some(mk_snapshot_info(&root, &snap))
1589                    } else {
1590                        index = None;
1591                        None
1592                    }
1593                } else {
1594                    None
1595                }
1596            });
1597            Ok(iter)
1598        } else {
1599            Err(IcechunkFormatErrorKind::SnapshotIdNotFound {
1600                snapshot_id: snapshot.clone(),
1601            })
1602            .capture()
1603        }
1604    }
1605
1606    pub fn find_snapshot(&self, id: &SnapshotId) -> IcechunkResult<SnapshotInfo> {
1607        let mut anc = self.ancestry(id)?;
1608        #[expect(clippy::panic)]
1609        match anc.next() {
1610            Some(snap) => snap,
1611            // It's OK to panic here because ancestry already found the snapshot, and
1612            // it's always the first element of the ancestry
1613            None => panic!("Ancestry head snapshot not found"),
1614        }
1615    }
1616
1617    pub fn repo_before_updates(&self) -> IcechunkResult<Option<&str>> {
1618        Ok(self.root()?.repo_before_updates())
1619    }
1620
1621    fn resolve_snapshot_index(&self, id: &SnapshotId) -> IcechunkResult<Option<usize>> {
1622        Ok(lookup_index_by_key(self.root()?.snapshots(), &id.0, |snap, key| {
1623            snap.id().0.cmp(key)
1624        }))
1625    }
1626}
1627
1628fn resolve_ref_iter<'a>(
1629    sorted_snapshots: &[SnapshotInfo],
1630    it: impl IntoIterator<Item = (&'a str, SnapshotId)>,
1631) -> IcechunkResult<Vec<(&'a str, u32)>> {
1632    let mut res: Vec<_> = it
1633        .into_iter()
1634        .map(|(name, id)| {
1635            let idx = sorted_snapshots
1636                .binary_search_by_key(&&id.0, |snap| &snap.id.0)
1637                .map_err(|_| IcechunkFormatErrorKind::SnapshotIdNotFound {
1638                    snapshot_id: id.clone(),
1639                })
1640                .capture()? as u32;
1641            Ok::<_, IcechunkFormatError>((name, idx))
1642        })
1643        .try_collect()?;
1644    res.sort_by_key(|(name1, _)| *name1);
1645    Ok(res)
1646}
1647
1648fn timestamp_to_timestamp(ts: u64) -> IcechunkResult<DateTime<Utc>> {
1649    let ts: i64 =
1650        ts.try_into().map_err(|_| IcechunkFormatErrorKind::InvalidTimestamp).capture()?;
1651    DateTime::from_timestamp_micros(ts)
1652        .ok_or(IcechunkFormatErrorKind::InvalidTimestamp)
1653        .capture()
1654}
1655
1656fn mk_snapshot_info(
1657    repo: &generated::Repo<'_>,
1658    snap: &generated::SnapshotInfo<'_>,
1659) -> IcechunkResult<SnapshotInfo> {
1660    let flushed_at = timestamp_to_timestamp(snap.flushed_at())?;
1661    let parent_id = if snap.parent_offset() >= 0 {
1662        let parent = repo.snapshots().get(snap.parent_offset() as usize).id();
1663        Some(parent)
1664    } else {
1665        None
1666    };
1667    let metadata = snap
1668        .metadata()
1669        .map(|items| {
1670            let items = items
1671                .iter()
1672                .map(|item| {
1673                    let name = item.name().to_string();
1674                    let value = flexbuffers::from_slice(item.value().bytes())
1675                        .map_err(Box::new)
1676                        .capture()?;
1677                    Ok::<_, IcechunkFormatError>((name, value))
1678                })
1679                .try_collect()?;
1680            Ok::<_, IcechunkFormatError>(items)
1681        })
1682        .transpose()?
1683        .unwrap_or_default();
1684
1685    let pruned_ancestor_tx_logs = snap
1686        .pruned_ancestor_tx_logs()
1687        .map(|ids| ids.iter().map(|oid| SnapshotId::new(oid.0)).collect())
1688        .unwrap_or_default();
1689
1690    Ok(SnapshotInfo {
1691        id: SnapshotId::new(snap.id().0),
1692        flushed_at,
1693        message: snap.message().to_string(),
1694        metadata,
1695        parent_id: parent_id.map(|buf| SnapshotId::new(buf.0)),
1696        pruned_ancestor_tx_logs,
1697    })
1698}
1699
1700fn update_type_to_fb<'bldr>(
1701    builder: &mut flatbuffers::FlatBufferBuilder<'bldr>,
1702    update: &UpdateType,
1703) -> IcechunkResult<(generated::UpdateType, WIPOffset<UnionWIPOffset>)> {
1704    match update {
1705        UpdateType::RepoInitializedUpdate => Ok((
1706            generated::UpdateType::RepoInitializedUpdate,
1707            generated::RepoInitializedUpdate::create(
1708                builder,
1709                &generated::RepoInitializedUpdateArgs {},
1710            )
1711            .as_union_value(),
1712        )),
1713        UpdateType::RepoMigratedUpdate { from_version, to_version } => Ok((
1714            generated::UpdateType::RepoMigratedUpdate,
1715            generated::RepoMigratedUpdate::create(
1716                builder,
1717                &generated::RepoMigratedUpdateArgs {
1718                    from_version: *from_version as u8,
1719                    to_version: *to_version as u8,
1720                },
1721            )
1722            .as_union_value(),
1723        )),
1724        UpdateType::RepoStatusChangedUpdate { status } => {
1725            let limited_availability_reason = status
1726                .limited_availability_reason
1727                .as_ref()
1728                .map(|r| builder.create_string(r));
1729            let status = generated::RepoStatus::create(
1730                builder,
1731                &generated::RepoStatusArgs {
1732                    availability: status.availability.into(),
1733                    set_at: status.set_at.timestamp_micros() as u64,
1734                    limited_availability_reason,
1735                },
1736            );
1737
1738            Ok((
1739                generated::UpdateType::RepoStatusChangedUpdate,
1740                generated::RepoStatusChangedUpdate::create(
1741                    builder,
1742                    &generated::RepoStatusChangedUpdateArgs { status: Some(status) },
1743                )
1744                .as_union_value(),
1745            ))
1746        }
1747        UpdateType::ConfigChangedUpdate => Ok((
1748            generated::UpdateType::ConfigChangedUpdate,
1749            generated::ConfigChangedUpdate::create(
1750                builder,
1751                &generated::ConfigChangedUpdateArgs {},
1752            )
1753            .as_union_value(),
1754        )),
1755        UpdateType::MetadataChangedUpdate => Ok((
1756            generated::UpdateType::MetadataChangedUpdate,
1757            generated::MetadataChangedUpdate::create(
1758                builder,
1759                &generated::MetadataChangedUpdateArgs {},
1760            )
1761            .as_union_value(),
1762        )),
1763        UpdateType::TagCreatedUpdate { name } => {
1764            let name = Some(builder.create_string(name));
1765            Ok((
1766                generated::UpdateType::TagCreatedUpdate,
1767                generated::TagCreatedUpdate::create(
1768                    builder,
1769                    &generated::TagCreatedUpdateArgs { name },
1770                )
1771                .as_union_value(),
1772            ))
1773        }
1774        UpdateType::TagDeletedUpdate { name, previous_snap_id } => {
1775            let name = Some(builder.create_string(name));
1776            let object_id12 = generated::ObjectId12::new(&previous_snap_id.0);
1777            let previous_snap_id = Some(&object_id12);
1778            Ok((
1779                generated::UpdateType::TagDeletedUpdate,
1780                generated::TagDeletedUpdate::create(
1781                    builder,
1782                    &generated::TagDeletedUpdateArgs { name, previous_snap_id },
1783                )
1784                .as_union_value(),
1785            ))
1786        }
1787        UpdateType::BranchCreatedUpdate { name } => {
1788            let name = Some(builder.create_string(name));
1789            Ok((
1790                generated::UpdateType::BranchCreatedUpdate,
1791                generated::BranchCreatedUpdate::create(
1792                    builder,
1793                    &generated::BranchCreatedUpdateArgs { name },
1794                )
1795                .as_union_value(),
1796            ))
1797        }
1798        UpdateType::BranchDeletedUpdate { name, previous_snap_id } => {
1799            let name = Some(builder.create_string(name));
1800            let object_id12 = generated::ObjectId12::new(&previous_snap_id.0);
1801            let previous_snap_id = Some(&object_id12);
1802            Ok((
1803                generated::UpdateType::BranchDeletedUpdate,
1804                generated::BranchDeletedUpdate::create(
1805                    builder,
1806                    &generated::BranchDeletedUpdateArgs { name, previous_snap_id },
1807                )
1808                .as_union_value(),
1809            ))
1810        }
1811        UpdateType::BranchResetUpdate { name, previous_snap_id } => {
1812            let name = Some(builder.create_string(name));
1813            let object_id12 = generated::ObjectId12::new(&previous_snap_id.0);
1814            let previous_snap_id = Some(&object_id12);
1815            Ok((
1816                generated::UpdateType::BranchResetUpdate,
1817                generated::BranchResetUpdate::create(
1818                    builder,
1819                    &generated::BranchResetUpdateArgs { name, previous_snap_id },
1820                )
1821                .as_union_value(),
1822            ))
1823        }
1824        UpdateType::NewCommitUpdate { branch, new_snap_id } => {
1825            let branch = Some(builder.create_string(branch));
1826            let object_id12 = generated::ObjectId12::new(&new_snap_id.0);
1827            let new_snap_id = Some(&object_id12);
1828            Ok((
1829                generated::UpdateType::NewCommitUpdate,
1830                generated::NewCommitUpdate::create(
1831                    builder,
1832                    &generated::NewCommitUpdateArgs { branch, new_snap_id },
1833                )
1834                .as_union_value(),
1835            ))
1836        }
1837        UpdateType::CommitAmendedUpdate { branch, previous_snap_id, new_snap_id } => {
1838            let branch = Some(builder.create_string(branch));
1839            let object_id12 = generated::ObjectId12::new(&previous_snap_id.0);
1840            let previous_snap_id = Some(&object_id12);
1841            let object_id12 = generated::ObjectId12::new(&new_snap_id.0);
1842            let new_snap_id = Some(&object_id12);
1843            Ok((
1844                generated::UpdateType::CommitAmendedUpdate,
1845                generated::CommitAmendedUpdate::create(
1846                    builder,
1847                    &generated::CommitAmendedUpdateArgs {
1848                        branch,
1849                        previous_snap_id,
1850                        new_snap_id,
1851                    },
1852                )
1853                .as_union_value(),
1854            ))
1855        }
1856        UpdateType::NewDetachedSnapshotUpdate { new_snap_id } => {
1857            let object_id12 = generated::ObjectId12::new(&new_snap_id.0);
1858            let new_snap_id = Some(&object_id12);
1859            Ok((
1860                generated::UpdateType::NewDetachedSnapshotUpdate,
1861                generated::NewDetachedSnapshotUpdate::create(
1862                    builder,
1863                    &generated::NewDetachedSnapshotUpdateArgs { new_snap_id },
1864                )
1865                .as_union_value(),
1866            ))
1867        }
1868        UpdateType::GCRanUpdate => Ok((
1869            generated::UpdateType::GCRanUpdate,
1870            generated::GCRanUpdate::create(builder, &generated::GCRanUpdateArgs {})
1871                .as_union_value(),
1872        )),
1873        UpdateType::ExpirationRanUpdate => Ok((
1874            generated::UpdateType::ExpirationRanUpdate,
1875            generated::ExpirationRanUpdate::create(
1876                builder,
1877                &generated::ExpirationRanUpdateArgs {},
1878            )
1879            .as_union_value(),
1880        )),
1881        UpdateType::FeatureFlagChanged { id, new_value } => Ok((
1882            generated::UpdateType::FeatureFlagChangedUpdate,
1883            generated::FeatureFlagChangedUpdate::create(
1884                builder,
1885                &generated::FeatureFlagChangedUpdateArgs {
1886                    id: *id,
1887                    new_value: new_value.unwrap_or_default(),
1888                    is_set: new_value.is_some(),
1889                },
1890            )
1891            .as_union_value(),
1892        )),
1893    }
1894}
1895
1896#[cfg(test)]
1897mod tests {
1898
1899    use super::*;
1900    use crate::roundtrip_serialization_tests;
1901    use proptest::prelude::*;
1902    use std::collections::HashSet;
1903
1904    fn a_snapshot_info(message: &str) -> SnapshotInfo {
1905        SnapshotInfo {
1906            id: SnapshotId::random(),
1907            parent_id: None,
1908            flushed_at: DateTime::from_timestamp_micros(1_000_000).unwrap(),
1909            message: message.to_string(),
1910            metadata: Default::default(),
1911            pruned_ancestor_tx_logs: vec![],
1912        }
1913    }
1914
1915    // Every RepoInfo holds a buffer that was verified, either on read or when we
1916    // built it, which is what makes the unchecked accessors sound.
1917    fn valid_repo_info() -> impl Strategy<Value = RepoInfo> {
1918        proptest::collection::vec(any::<String>(), 1..5).prop_map(|messages| {
1919            RepoInfo::initial(
1920                SpecVersionBin::current(),
1921                a_snapshot_info(messages.first().map_or("initial", |m| m.as_str())),
1922                100,
1923                None::<&()>,
1924                None,
1925            )
1926        })
1927    }
1928
1929    roundtrip_serialization_tests!(serialize_and_deserialize_repo_info - valid_repo_info);
1930
1931    #[test]
1932    fn deserializing_an_unverified_buffer_fails() {
1933        // a RepoInfo whose buffer is not a repo file at all
1934        let bytes = rmp_serde::to_vec(&RepoInfo { buffer: vec![0u8; 64] }).unwrap();
1935        assert!(
1936            rmp_serde::from_slice::<RepoInfo>(bytes.as_slice()).is_err(),
1937            "deserialization must not produce a RepoInfo with an unverified buffer"
1938        );
1939    }
1940
1941    #[test]
1942    fn repo_info_with_more_than_ten_million_snapshots_is_usable()
1943    -> Result<(), Box<dyn std::error::Error>> {
1944        // flatbuffers' default verifier options cap a buffer at 1M tables, and
1945        // every snapshot is one table. Repos this size must still be readable.
1946        let n = 10_000_001;
1947        let snapshots: Vec<_> = (0..n).map(|_| a_snapshot_info("")).collect();
1948        let branch_target = snapshots[0].id.clone();
1949        let info = RepoInfo::new(
1950            SpecVersionBin::current(),
1951            [],
1952            [("main", branch_target)],
1953            [],
1954            snapshots,
1955            &Default::default(),
1956            UpdateInfo {
1957                update_type: UpdateType::RepoInitializedUpdate,
1958                update_time: Utc::now(),
1959                previous_updates: Vec::<UpdateTuple<'_>>::new(),
1960            },
1961            None,
1962            10,
1963            None,
1964            None,
1965            None::<std::iter::Empty<u16>>,
1966            None::<std::iter::Empty<u16>>,
1967            &RepoStatus {
1968                availability: RepoAvailability::Online,
1969                set_at: Utc::now(),
1970                limited_availability_reason: None,
1971            },
1972        )?;
1973
1974        let info = RepoInfo::from_buffer(info.bytes().to_vec())?;
1975        assert_eq!(info.all_snapshots()?.count(), n);
1976        assert!(info.branch_names()?.any(|name| name == "main"));
1977        Ok(())
1978    }
1979
1980    #[test]
1981    fn test_add_snapshot() -> Result<(), Box<dyn std::error::Error>> {
1982        let id1 = SnapshotId::random();
1983        let snap1 = SnapshotInfo {
1984            id: id1.clone(),
1985            parent_id: None,
1986            // needs to be micro second rounded
1987            flushed_at: DateTime::from_timestamp_micros(1_000_000).unwrap(),
1988            message: "snap 1".to_string(),
1989            metadata: Default::default(),
1990            pruned_ancestor_tx_logs: vec![],
1991        };
1992        let repo = RepoInfo::initial(
1993            SpecVersionBin::current(),
1994            snap1.clone(),
1995            100,
1996            None::<&()>,
1997            None,
1998        );
1999        assert_eq!(repo.all_snapshots()?.next().unwrap().unwrap(), snap1);
2000
2001        let id2 = SnapshotId::random();
2002        let snap2 = SnapshotInfo {
2003            id: id2.clone(),
2004            parent_id: Some(id1.clone()),
2005            flushed_at: DateTime::from_timestamp_micros(2_000_000).unwrap(),
2006            message: "snap 2".to_string(),
2007            ..snap1.clone()
2008        };
2009        let repo = repo.add_snapshot(
2010            SpecVersionBin::current(),
2011            snap2.clone(),
2012            Some("main"),
2013            UpdateType::NewCommitUpdate {
2014                branch: "main".to_string(),
2015                new_snap_id: snap2.id.clone(),
2016            },
2017            None,
2018            "foo/bar",
2019            100,
2020        )?;
2021        assert_eq!(&repo.resolve_branch("main")?, &snap2.id);
2022        assert_eq!(repo.repo_before_updates()?, None);
2023
2024        let all: HashSet<_> = repo.all_snapshots()?.try_collect()?;
2025        assert_eq!(all, HashSet::from_iter([snap1.clone(), snap2.clone()]));
2026
2027        let anc: Vec<_> = repo.ancestry(&id1)?.try_collect()?;
2028        assert_eq!(anc, std::slice::from_ref(&snap1));
2029
2030        let anc: Vec<_> = repo.ancestry(&id2)?.try_collect()?;
2031        assert_eq!(anc, [snap2.clone(), snap1.clone()]);
2032
2033        assert!(repo.ancestry(&SnapshotId::random()).is_err());
2034
2035        let id3 = SnapshotId::random();
2036        let snap3 = SnapshotInfo {
2037            id: id3.clone(),
2038            parent_id: Some(id2.clone()),
2039            flushed_at: DateTime::from_timestamp_micros(3_000_000).unwrap(),
2040            message: "snap 3".to_string(),
2041            ..snap2.clone()
2042        };
2043        let repo = repo.add_snapshot(
2044            SpecVersionBin::current(),
2045            snap3.clone(),
2046            Some("main"),
2047            UpdateType::NewCommitUpdate {
2048                branch: "main".to_string(),
2049                new_snap_id: snap3.id.clone(),
2050            },
2051            None,
2052            "foo",
2053            100,
2054        )?;
2055        assert_eq!(&repo.resolve_branch("main")?, &snap3.id);
2056        let all: HashSet<_> = repo.all_snapshots()?.try_collect()?;
2057        assert_eq!(
2058            all,
2059            HashSet::from_iter([snap1.clone(), snap2.clone(), snap3.clone()])
2060        );
2061
2062        let all: HashSet<_> = repo.all_snapshots()?.try_collect()?;
2063        assert_eq!(
2064            all,
2065            HashSet::from_iter([snap1.clone(), snap2.clone(), snap3.clone()])
2066        );
2067
2068        let anc: Vec<_> = repo.ancestry(&id3)?.try_collect()?;
2069        assert_eq!(anc, [snap3.clone(), snap2.clone(), snap1.clone()]);
2070        Ok(())
2071    }
2072
2073    #[test]
2074    fn test_pruned_ancestor_tx_logs_roundtrip() -> Result<(), Box<dyn std::error::Error>>
2075    {
2076        let root_id = SnapshotId::random();
2077        let root = SnapshotInfo {
2078            id: root_id.clone(),
2079            parent_id: None,
2080            flushed_at: DateTime::from_timestamp_micros(1_000_000).unwrap(),
2081            message: "root".to_string(),
2082            metadata: Default::default(),
2083            pruned_ancestor_tx_logs: vec![],
2084        };
2085        let repo = RepoInfo::initial(
2086            SpecVersionBin::current(),
2087            root.clone(),
2088            100,
2089            None::<&()>,
2090            None,
2091        );
2092
2093        // an edited snapshot carrying a non-empty list
2094        let pruned =
2095            vec![SnapshotId::random(), SnapshotId::random(), SnapshotId::random()];
2096        let tip_id = SnapshotId::random();
2097        let tip = SnapshotInfo {
2098            id: tip_id.clone(),
2099            parent_id: Some(root_id.clone()),
2100            flushed_at: DateTime::from_timestamp_micros(2_000_000).unwrap(),
2101            message: "tip".to_string(),
2102            metadata: Default::default(),
2103            pruned_ancestor_tx_logs: pruned.clone(),
2104        };
2105        let repo = repo.add_snapshot(
2106            SpecVersionBin::current(),
2107            tip.clone(),
2108            Some("main"),
2109            UpdateType::NewCommitUpdate {
2110                branch: "main".to_string(),
2111                new_snap_id: tip_id.clone(),
2112            },
2113            None,
2114            "foo",
2115            100,
2116        )?;
2117
2118        // the list survives serialization verbatim, order preserved
2119        let read_tip = repo.find_snapshot(&tip_id)?;
2120        assert_eq!(read_tip.pruned_ancestor_tx_logs, pruned);
2121
2122        // an unedited snapshot reads back as empty (absent vector)
2123        let read_root = repo.find_snapshot(&root_id)?;
2124        assert!(read_root.pruned_ancestor_tx_logs.is_empty());
2125        Ok(())
2126    }
2127
2128    #[test]
2129    fn test_tags_and_branches() -> Result<(), Box<dyn std::error::Error>> {
2130        let id1 = SnapshotId::random();
2131        let snap1 = SnapshotInfo {
2132            id: id1.clone(),
2133            parent_id: None,
2134            // needs to be micro second rounded
2135            flushed_at: DateTime::from_timestamp_micros(1_000_000).unwrap(),
2136            message: "snap 1".to_string(),
2137            metadata: Default::default(),
2138            pruned_ancestor_tx_logs: vec![],
2139        };
2140        let repo = RepoInfo::initial(
2141            SpecVersionBin::current(),
2142            snap1.clone(),
2143            100,
2144            None::<&()>,
2145            None,
2146        );
2147        let repo = repo.add_branch(SpecVersionBin::current(), "foo", &id1, "foo", 100)?;
2148        let repo = repo.add_branch(SpecVersionBin::current(), "bar", &id1, "bar", 100)?;
2149        assert!(matches!(
2150            repo.add_branch(
2151                SpecVersionBin::current(),
2152                "bad-snap",
2153                &SnapshotId::random(),
2154                "bad",
2155                100
2156            ),
2157            Err(IcechunkFormatError {
2158                kind: IcechunkFormatErrorKind::SnapshotIdNotFound { .. },
2159                ..
2160            })
2161        ));
2162        // cannot add existing
2163        assert!(matches!(
2164            repo.add_branch(SpecVersionBin::current(), "bar", &id1, "/foo/bar", 100),
2165            Err(IcechunkFormatError {
2166                kind: IcechunkFormatErrorKind::BranchAlreadyExists { .. },
2167                ..
2168            })
2169        ));
2170
2171        assert_eq!(
2172            repo.all_branches()?.collect::<HashSet<_>>(),
2173            [("main", 0), ("foo", 0), ("bar", 0)].into()
2174        );
2175
2176        let id2 = SnapshotId::random();
2177        let snap2 = SnapshotInfo {
2178            id: id2.clone(),
2179            parent_id: Some(id1.clone()),
2180            flushed_at: Utc::now(),
2181            message: "snap 2".to_string(),
2182            ..snap1.clone()
2183        };
2184        let repo = repo.add_snapshot(
2185            SpecVersionBin::current(),
2186            snap2.clone(),
2187            Some("main"),
2188            UpdateType::NewCommitUpdate {
2189                branch: "main".to_string(),
2190                new_snap_id: snap2.id.clone(),
2191            },
2192            None,
2193            "foo",
2194            100,
2195        )?;
2196        let repo =
2197            repo.add_branch(SpecVersionBin::current(), "baz", &id2, "/foo/bar", 100)?;
2198        assert_eq!(repo.resolve_branch("main")?, id2.clone());
2199        assert_eq!(repo.resolve_branch("foo")?, id1.clone());
2200        assert_eq!(repo.resolve_branch("bar")?, id1.clone());
2201        assert_eq!(repo.resolve_branch("baz")?, id2.clone());
2202
2203        let repo = repo.delete_branch(SpecVersionBin::current(), "bar", "bar", 100)?;
2204        assert!(repo.resolve_branch("bar").is_err());
2205        assert_eq!(
2206            repo.all_branches()?.map(|(n, _)| n).collect::<HashSet<_>>(),
2207            ["main", "foo", "baz"].into()
2208        );
2209
2210        assert!(
2211            repo.delete_branch(SpecVersionBin::current(), "bad-branch", "bad", 100)
2212                .is_err()
2213        );
2214
2215        // tags
2216        let repo = repo.add_tag(SpecVersionBin::current(), "tag1", &id1, "tag1", 100)?;
2217        let repo = repo.add_tag(SpecVersionBin::current(), "tag2", &id2, "tag2", 100)?;
2218        assert!(
2219            repo.add_tag(
2220                SpecVersionBin::current(),
2221                "bad-snap",
2222                &SnapshotId::random(),
2223                "bad",
2224                100
2225            )
2226            .is_err()
2227        );
2228        assert!(
2229            repo.add_tag(SpecVersionBin::current(), "tag1", &id1, "tag1-again", 100)
2230                .is_err()
2231        );
2232        assert_eq!(repo.resolve_tag("tag1")?, id1.clone());
2233        assert_eq!(repo.resolve_tag("tag2")?, id2.clone());
2234        assert_eq!(
2235            repo.all_tags()?.map(|(n, _)| n).collect::<HashSet<_>>(),
2236            ["tag1", "tag2"].into()
2237        );
2238
2239        // delete tags
2240        let repo = repo.add_tag(SpecVersionBin::current(), "tag3", &id1, "tag3", 100)?;
2241        let repo =
2242            repo.delete_tag(SpecVersionBin::current(), "tag3", "delete-tag3", 100)?;
2243        assert_eq!(
2244            repo.all_tags()?.map(|(n, _)| n).collect::<HashSet<_>>(),
2245            ["tag1", "tag2"].into()
2246        );
2247        // cannot add deleted
2248        assert!(
2249            repo.add_tag(SpecVersionBin::current(), "tag3", &id1, "tag3-again", 100)
2250                .is_err()
2251        );
2252        // cannot delete deleted
2253        assert!(
2254            repo.delete_tag(SpecVersionBin::current(), "tag3", "delete-tag3-again", 100)
2255                .is_err()
2256        );
2257        assert_eq!(
2258            repo.all_tags()?.map(|(n, _)| n).collect::<HashSet<_>>(),
2259            ["tag1", "tag2"].into()
2260        );
2261        Ok(())
2262    }
2263
2264    #[test]
2265    fn test_repo_info_updates() -> Result<(), Box<dyn std::error::Error>> {
2266        let id1 = SnapshotId::random();
2267        let snap1 = SnapshotInfo {
2268            id: id1.clone(),
2269            parent_id: None,
2270            // needs to be micro second rounded
2271            flushed_at: DateTime::from_timestamp_micros(1_000_000).unwrap(),
2272            message: "snap 1".to_string(),
2273            metadata: Default::default(),
2274            pruned_ancestor_tx_logs: vec![],
2275        };
2276
2277        let num_updates_per_file: u16 = 10;
2278        let n = num_updates_per_file as usize;
2279
2280        // check updates for a new repo
2281        let mut repo = RepoInfo::initial(
2282            SpecVersionBin::current(),
2283            snap1,
2284            num_updates_per_file,
2285            None::<&()>,
2286            None,
2287        );
2288        assert_eq!(repo.latest_updates()?.count(), 1);
2289        let (last_update, _, file) = repo.latest_updates()?.next().unwrap()?;
2290        assert!(file.is_none());
2291        assert_eq!(last_update, UpdateType::RepoInitializedUpdate);
2292        assert!(repo.repo_before_updates()?.is_none());
2293        // check updates after num_updates_per_file changes
2294        // fill the first page of updates by adding branches
2295        for i in 1..=(n - 1) {
2296            repo = repo.add_branch(
2297                SpecVersionBin::current(),
2298                i.to_string().as_str(),
2299                &id1,
2300                (i - 1).to_string().as_str(),
2301                num_updates_per_file,
2302            )?;
2303        }
2304
2305        assert_eq!(repo.latest_updates()?.count(), n);
2306        let updates = repo.latest_updates()?;
2307
2308        // check all other updates
2309        for (idx, update) in updates.enumerate() {
2310            let (update, _, file) = update?;
2311            if idx == n - 1 {
2312                assert_eq!(update, UpdateType::RepoInitializedUpdate);
2313                assert_eq!(file, Some("0"));
2314            } else {
2315                assert_eq!(
2316                    update,
2317                    UpdateType::BranchCreatedUpdate { name: (n - 1 - idx).to_string() }
2318                );
2319                if idx == 0 {
2320                    assert!(file.is_none());
2321                } else {
2322                    assert_eq!(file, Some((n - 1 - idx).to_string().as_str()));
2323                }
2324            }
2325        }
2326        assert!(repo.repo_before_updates()?.is_none());
2327
2328        // Now, if we add another change, it won't fit in the first "page" of repo updates
2329        repo = repo.add_tag(
2330            SpecVersionBin::current(),
2331            "tag",
2332            &id1,
2333            "first-branches",
2334            num_updates_per_file,
2335        )?;
2336        // the file only contains the first "page" worth of updates
2337        assert_eq!(repo.latest_updates()?.count(), n);
2338        // next file is the oldest change
2339        assert_eq!(repo.repo_before_updates()?, Some("0"));
2340        let mut updates = repo.latest_updates()?;
2341        // last change is the tag creation
2342        let (last_update, _, file) = updates.next().unwrap()?;
2343        assert_eq!(last_update, UpdateType::TagCreatedUpdate { name: "tag".to_string() });
2344        assert!(file.is_none());
2345
2346        // next change is a branch creation backed up to first-branches
2347        let (last_update, _, file) = updates.next().unwrap()?;
2348        assert_eq!(
2349            last_update,
2350            UpdateType::BranchCreatedUpdate { name: (n - 1).to_string() }
2351        );
2352        assert_eq!(file, Some("first-branches"));
2353
2354        // all other changes are branch creation (repo creation is in the next page)
2355        for (idx, update) in updates.enumerate() {
2356            let (update, _, file) = update?;
2357            assert_eq!(file, Some((n - 2 - idx).to_string().as_str()));
2358            assert_eq!(
2359                update,
2360                UpdateType::BranchCreatedUpdate { name: (n - 2 - idx).to_string() }
2361            );
2362        }
2363
2364        Ok(())
2365    }
2366
2367    #[test]
2368    fn test_update_timestamp_ordering_rejected() -> Result<(), Box<dyn std::error::Error>>
2369    {
2370        let flushed_at = DateTime::from_timestamp_micros(1_000_000).unwrap();
2371        let id1 = SnapshotId::random();
2372        let snap1 = SnapshotInfo {
2373            id: id1.clone(),
2374            parent_id: None,
2375            flushed_at,
2376            message: "snap 1".to_string(),
2377            metadata: Default::default(),
2378            pruned_ancestor_tx_logs: vec![],
2379        };
2380        let repo = RepoInfo::initial(
2381            SpecVersionBin::current(),
2382            snap1,
2383            100,
2384            None::<&()>,
2385            Some(flushed_at),
2386        );
2387
2388        // Attempting add_snapshot with a timestamp equal to the top of the ops log
2389        // should fail
2390        let id2 = SnapshotId::random();
2391        let snap2 = SnapshotInfo {
2392            id: id2.clone(),
2393            parent_id: Some(id1.clone()),
2394            flushed_at: DateTime::from_timestamp_micros(1_000_000).unwrap(),
2395            message: "snap 2".to_string(),
2396            metadata: Default::default(),
2397            pruned_ancestor_tx_logs: vec![],
2398        };
2399        let result = repo.add_snapshot(
2400            SpecVersionBin::current(),
2401            snap2,
2402            Some("main"),
2403            UpdateType::NewCommitUpdate {
2404                branch: "main".to_string(),
2405                new_snap_id: id2.clone(),
2406            },
2407            Some(flushed_at),
2408            "backup",
2409            100,
2410        );
2411        assert!(matches!(
2412            result,
2413            Err(IcechunkFormatError {
2414                kind: IcechunkFormatErrorKind::InvalidUpdateTimestamp { .. },
2415                ..
2416            })
2417        ));
2418
2419        // Attempting add_snapshot with a timestamp older than the top of the ops log
2420        // should also fail
2421        let flushed_at = DateTime::from_timestamp_micros(500_000).unwrap();
2422        let id3 = SnapshotId::random();
2423        let snap3 = SnapshotInfo {
2424            id: id3.clone(),
2425            parent_id: Some(id1.clone()),
2426            flushed_at,
2427            message: "snap 3".to_string(),
2428            metadata: Default::default(),
2429            pruned_ancestor_tx_logs: vec![],
2430        };
2431        let result = repo.add_snapshot(
2432            SpecVersionBin::current(),
2433            snap3,
2434            Some("main"),
2435            UpdateType::NewCommitUpdate {
2436                branch: "main".to_string(),
2437                new_snap_id: id3.clone(),
2438            },
2439            Some(flushed_at),
2440            "backup",
2441            100,
2442        );
2443        assert!(matches!(
2444            result,
2445            Err(IcechunkFormatError {
2446                kind: IcechunkFormatErrorKind::InvalidUpdateTimestamp { .. },
2447                ..
2448            })
2449        ));
2450
2451        // Attempting add_snapshot with a strictly newer timestamp should succeed
2452        let flushed_at = DateTime::from_timestamp_micros(2_000_000).unwrap();
2453        let id4 = SnapshotId::random();
2454        let snap4 = SnapshotInfo {
2455            id: id4.clone(),
2456            parent_id: Some(id1.clone()),
2457            flushed_at,
2458            message: "snap 4".to_string(),
2459            metadata: Default::default(),
2460            pruned_ancestor_tx_logs: vec![],
2461        };
2462        let result = repo.add_snapshot(
2463            SpecVersionBin::current(),
2464            snap4,
2465            Some("main"),
2466            UpdateType::NewCommitUpdate {
2467                branch: "main".to_string(),
2468                new_snap_id: id4.clone(),
2469            },
2470            Some(flushed_at),
2471            "backup",
2472            100,
2473        );
2474        assert!(result.is_ok());
2475
2476        Ok(())
2477    }
2478
2479    /// Verify that `set_status` with `update_config = None` (silent mode)
2480    /// changes the repo status without appending an entry to the ops log.
2481    #[test]
2482    fn test_set_status_silent() -> Result<(), Box<dyn std::error::Error>> {
2483        let id1 = SnapshotId::random();
2484        let snap1 = SnapshotInfo {
2485            id: id1.clone(),
2486            parent_id: None,
2487            flushed_at: DateTime::from_timestamp_micros(1_000_000).unwrap(),
2488            message: "snap 1".to_string(),
2489            metadata: Default::default(),
2490            pruned_ancestor_tx_logs: vec![],
2491        };
2492
2493        let repo =
2494            RepoInfo::initial(SpecVersionBin::current(), snap1, 100, None::<&()>, None);
2495
2496        // Add a branch to have more than one ops log entry
2497        let repo =
2498            repo.add_branch(SpecVersionBin::current(), "dev", &id1, "backup0", 100)?;
2499
2500        let updates_before: Vec<_> =
2501            repo.latest_updates()?.collect::<Result<Vec<_>, _>>()?;
2502        let rbu_before = repo.repo_before_updates()?;
2503        let num_updates_before = updates_before.len();
2504
2505        // Normal set_status: should add a RepoStatusChangedUpdate entry
2506        let new_status = RepoStatus {
2507            availability: RepoAvailability::ReadOnly,
2508            set_at: Utc::now(),
2509            limited_availability_reason: Some("maintenance".to_string()),
2510        };
2511        let repo_normal = repo.set_status(
2512            SpecVersionBin::current(),
2513            &new_status,
2514            Some(("backup1", 100)),
2515        )?;
2516        let updates_after_normal: Vec<_> =
2517            repo_normal.latest_updates()?.collect::<Result<Vec<_>, _>>()?;
2518        assert_eq!(updates_after_normal.len(), num_updates_before + 1);
2519        assert!(matches!(
2520            &updates_after_normal[0].0,
2521            UpdateType::RepoStatusChangedUpdate { .. }
2522        ));
2523        let normal_status = repo_normal.status()?;
2524        assert_eq!(normal_status.availability, RepoAvailability::ReadOnly);
2525        assert_eq!(
2526            normal_status.limited_availability_reason.as_deref(),
2527            Some("maintenance")
2528        );
2529
2530        // Silent set_status: should NOT add an ops log entry
2531        let silent_status = RepoStatus {
2532            availability: RepoAvailability::ReadOnly,
2533            set_at: Utc::now(),
2534            limited_availability_reason: None,
2535        };
2536        let repo_silent =
2537            repo.set_status(SpecVersionBin::current(), &silent_status, None)?;
2538
2539        let updates_after_silent: Vec<_> =
2540            repo_silent.latest_updates()?.collect::<Result<Vec<_>, _>>()?;
2541        // Ops log unchanged: same number of entries, same content
2542        assert_eq!(updates_after_silent.len(), num_updates_before);
2543        for (before, after) in updates_before.iter().zip(updates_after_silent.iter()) {
2544            assert_eq!(before.0, after.0);
2545            assert_eq!(before.1, after.1);
2546            assert_eq!(before.2, after.2);
2547        }
2548        assert_eq!(repo_silent.repo_before_updates()?, rbu_before);
2549
2550        // But the status itself IS changed
2551        let silent_result_status = repo_silent.status()?;
2552        assert_eq!(silent_result_status.availability, RepoAvailability::ReadOnly);
2553        assert!(silent_result_status.limited_availability_reason.is_none());
2554
2555        Ok(())
2556    }
2557}