Skip to main content

loonfs_api/v0/
reads.rs

1//! Authoritative read-result shapes for the v0 HTTP API: the stat/list
2//! entry, the directory-listing envelope, and the file-bytes read result.
3//! The mutating operation shapes live in [`super::operations`].
4
5use crate::{
6    AbsolutePath, ChangeSeq, ContentRef, DisplayName, InodeId, InodeKind, NameKey, NamespaceId,
7    RevisionNo,
8};
9use serde::{Deserialize, Serialize};
10
11/// Authoritative metadata for one visible path.
12///
13/// This is the result shape for stat/list style reads. File entries include
14/// revision and content summary fields; directory entries leave those empty.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
17pub struct AuthoritativePathEntry {
18    /// Namespace that was read.
19    pub namespace_id: NamespaceId,
20    /// Absolute path as rendered from stored display names.
21    pub absolute_path: AbsolutePath,
22    /// Stable inode identity for this item.
23    pub inode_id: InodeId,
24    /// Whether the item is a file or directory.
25    pub inode_kind: InodeKind,
26    /// Namespace head sequence this answer was read from.
27    pub head_seq: ChangeSeq,
28    /// Parent directory inode, or `None` for the root.
29    pub parent_inode_id: Option<InodeId>,
30    /// Stored display name for this path component, absent for the nameless root.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub display_name: Option<DisplayName>,
33    /// Current file revision number, for files.
34    pub revision_no: Option<RevisionNo>,
35    /// Current file size in bytes, for files.
36    pub size_bytes: Option<u64>,
37    /// Current content reference, for files.
38    pub content_ref: Option<ContentRef>,
39    /// Wall-clock stamp of the commit that created the current revision,
40    /// for files; directories carry no modification time in v0.
41    /// Observational: `head_seq` and revision sequences are the order.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub committed_at_ms: Option<u64>,
44}
45
46/// One directory listing and the namespace head it was answered at.
47///
48/// The envelope names the listing target and head so an empty directory
49/// still tells the caller which state it observed, and so the response can
50/// grow without reshaping `entries`.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53pub struct ListPathEntriesResponse {
54    /// Namespace that was read.
55    pub namespace_id: NamespaceId,
56    /// Absolute path of the listed directory.
57    pub absolute_path: AbsolutePath,
58    /// Namespace head sequence this listing was read from.
59    pub head_seq: ChangeSeq,
60    /// Directory entries for this page.
61    ///
62    /// Entries are returned in canonical name-key order. Higher-level display
63    /// surfaces may sort entries separately for presentation.
64    pub entries: Vec<AuthoritativePathEntry>,
65    /// Cursor for the next page, if more entries remain.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub next_cursor: Option<String>,
68}
69
70/// File bytes plus the metadata entry they came from.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
73pub struct AuthoritativeFileBytes {
74    /// Authoritative metadata for the file that was read.
75    pub entry: AuthoritativePathEntry,
76    /// Validated file bytes.
77    pub bytes: Vec<u8>,
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn entry(
85        path: &str,
86        parent_inode_id: Option<InodeId>,
87        display_name: Option<&str>,
88    ) -> AuthoritativePathEntry {
89        AuthoritativePathEntry {
90            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
91            absolute_path: AbsolutePath::parse(path).expect("absolute path"),
92            inode_id: InodeId(if parent_inode_id.is_some() { 2 } else { 1 }),
93            inode_kind: InodeKind::Directory,
94            head_seq: ChangeSeq(3),
95            parent_inode_id,
96            display_name: display_name.map(|name| DisplayName::parse(name).expect("display name")),
97            revision_no: None,
98            size_bytes: None,
99            content_ref: None,
100            committed_at_ms: None,
101        }
102    }
103
104    #[test]
105    fn authoritative_entry_paths_keep_the_plain_string_wire_shape() {
106        let named = entry("/docs", Some(InodeId(1)), Some("docs"));
107        assert_eq!(
108            serde_json::to_value(&named).expect("serialize named entry"),
109            serde_json::json!({
110                "namespace_id": "demo",
111                "absolute_path": "/docs",
112                "inode_id": 2,
113                "inode_kind": "dir",
114                "head_seq": 3,
115                "parent_inode_id": 1,
116                "display_name": "docs",
117                "revision_no": null,
118                "size_bytes": null,
119                "content_ref": null
120            })
121        );
122
123        let response = ListPathEntriesResponse {
124            namespace_id: NamespaceId::parse("demo").expect("namespace id"),
125            absolute_path: AbsolutePath::parse("/").expect("absolute path"),
126            head_seq: ChangeSeq(3),
127            entries: vec![named],
128            next_cursor: None,
129        };
130        assert_eq!(
131            serde_json::to_value(response).expect("serialize listing"),
132            serde_json::json!({
133                "namespace_id": "demo",
134                "absolute_path": "/",
135                "head_seq": 3,
136                "entries": [{
137                    "namespace_id": "demo",
138                    "absolute_path": "/docs",
139                    "inode_id": 2,
140                    "inode_kind": "dir",
141                    "head_seq": 3,
142                    "parent_inode_id": 1,
143                    "display_name": "docs",
144                    "revision_no": null,
145                    "size_bytes": null,
146                    "content_ref": null
147                }]
148            })
149        );
150    }
151
152    #[test]
153    fn nameless_root_omits_display_name_while_named_entries_carry_it() {
154        let root_json = serde_json::to_value(entry("/", None, None)).expect("serialize root");
155        assert!(root_json.get("display_name").is_none());
156
157        let named_json = serde_json::to_value(entry("/docs", Some(InodeId(1)), Some("docs")))
158            .expect("serialize named entry");
159        assert_eq!(named_json["display_name"], "docs");
160    }
161}
162
163/// One recoverable deletion: an active subtree tombstone plus the identity
164/// of the binding it deleted, when the delete recorded one. Entries with no
165/// recorded name predate the enriched tombstone rows; their inode and
166/// sequence still form a complete `undelete` handle.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169pub struct TrashEntry {
170    /// Root inode the deletion hid; half of the recovery handle.
171    pub root_inode_id: InodeId,
172    /// Commit sequence of the deletion; the other half of the handle.
173    pub deleted_at_seq: ChangeSeq,
174    /// Wall-clock stamp of the deleting commit. Observational.
175    pub deleted_at_ms: u64,
176    /// Directory that held the deleted binding, when recorded.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub parent_inode_id: Option<InodeId>,
179    /// Canonical key of the deleted binding, when recorded.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub name_key: Option<NameKey>,
182    /// User-facing spelling of the deleted binding, when recorded.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub display_name: Option<DisplayName>,
185}
186
187/// One trash listing page: the namespace's recoverable deletions.
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
190pub struct ListTrashResponse {
191    /// Namespace that was read.
192    pub namespace_id: NamespaceId,
193    /// Head sequence this page was evaluated at.
194    pub head_seq: ChangeSeq,
195    /// Recoverable deletions, oldest deletion first.
196    pub entries: Vec<TrashEntry>,
197    /// Present when another page follows.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub next_cursor: Option<String>,
200}