1use crate::{
6 AbsolutePath, ActorRef, AttributeRevisionNo, Attributes, ChangeSeq, ContentRef, DisplayName,
7 InodeId, InodeKind, NameKey, NamespaceId, RevisionNo,
8};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23pub struct AuthoritativePathEntry {
24 pub namespace_id: NamespaceId,
26 pub path: AbsolutePath,
28 #[serde(with = "crate::public_inode_id")]
30 #[cfg_attr(
31 feature = "openapi",
32 schema(schema_with = crate::public_inode_id::schema)
33 )]
34 pub inode_id: InodeId,
35 pub created_by: ActorRef,
37 pub created_at_ms: u64,
40 #[serde(flatten)]
42 pub kind: AuthoritativePathEntryKind,
43 pub head_seq: ChangeSeq,
45 #[serde(
47 default,
48 skip_serializing_if = "Option::is_none",
49 with = "crate::public_inode_id::option"
50 )]
51 #[cfg_attr(
52 feature = "openapi",
53 schema(schema_with = crate::public_inode_id::optional_schema)
54 )]
55 pub parent_inode_id: Option<InodeId>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub display_name: Option<DisplayName>,
59 #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
61 pub attributes: Option<AttributesProjection>,
62}
63
64impl AuthoritativePathEntry {
65 pub const fn inode_kind(&self) -> InodeKind {
67 self.kind.inode_kind()
68 }
69
70 pub const fn revision_no(&self) -> Option<RevisionNo> {
72 match &self.kind {
73 AuthoritativePathEntryKind::Directory {} => None,
74 AuthoritativePathEntryKind::File { revision_no, .. } => Some(*revision_no),
75 }
76 }
77
78 pub const fn size_bytes(&self) -> Option<u64> {
80 match &self.kind {
81 AuthoritativePathEntryKind::Directory {} => None,
82 AuthoritativePathEntryKind::File { size_bytes, .. } => Some(*size_bytes),
83 }
84 }
85
86 pub const fn content_ref(&self) -> Option<&ContentRef> {
88 match &self.kind {
89 AuthoritativePathEntryKind::Directory {} => None,
90 AuthoritativePathEntryKind::File { content_ref, .. } => Some(content_ref),
91 }
92 }
93
94 pub const fn committed_at_ms(&self) -> Option<u64> {
96 match &self.kind {
97 AuthoritativePathEntryKind::Directory {} => None,
98 AuthoritativePathEntryKind::File {
99 committed_at_ms, ..
100 } => Some(*committed_at_ms),
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
108#[serde(tag = "inode_kind", rename_all = "snake_case")]
109pub enum AuthoritativePathEntryKind {
110 #[serde(rename = "dir")]
114 #[cfg_attr(feature = "openapi", schema(title = "AuthoritativePathEntryDirectory"))]
115 Directory {},
116 #[cfg_attr(feature = "openapi", schema(title = "AuthoritativePathEntryFile"))]
118 File {
119 revision_no: RevisionNo,
121 size_bytes: u64,
126 content_ref: ContentRef,
128 revision_actor: ActorRef,
130 committed_at_ms: u64,
133 },
134}
135
136impl AuthoritativePathEntryKind {
137 pub const fn inode_kind(&self) -> InodeKind {
139 match self {
140 Self::Directory {} => InodeKind::Directory,
141 Self::File { .. } => InodeKind::File,
142 }
143 }
144
145 pub const fn revision_actor(&self) -> Option<&ActorRef> {
148 match self {
149 Self::Directory {} => None,
150 Self::File { revision_actor, .. } => Some(revision_actor),
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
158pub struct AttributesProjection {
159 pub attributes_revision_no: AttributeRevisionNo,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub attributes_updated_by: Option<ActorRef>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub attributes_updated_at_ms: Option<u64>,
169 pub attributes: Attributes,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
183pub struct ListPathEntriesResponse {
184 pub namespace_id: NamespaceId,
186 pub path: AbsolutePath,
188 pub head_seq: ChangeSeq,
190 pub entries: Vec<AuthoritativePathEntry>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub next_cursor: Option<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
202#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
203pub struct AuthoritativeFileBytes {
204 pub entry: AuthoritativePathEntry,
206 pub bytes: Vec<u8>,
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 fn entry(
215 path: &str,
216 parent_inode_id: Option<InodeId>,
217 display_name: Option<&str>,
218 ) -> AuthoritativePathEntry {
219 AuthoritativePathEntry {
220 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
221 path: AbsolutePath::parse(path).expect("absolute path"),
222 inode_id: InodeId(if parent_inode_id.is_some() { 2 } else { 1 }),
223 created_by: ActorRef::loonfs_system(),
224 created_at_ms: 1_752_624_000_000,
225 kind: AuthoritativePathEntryKind::Directory {},
226 head_seq: ChangeSeq(3),
227 parent_inode_id,
228 display_name: display_name.map(|name| DisplayName::parse(name).expect("display name")),
229 attributes: None,
230 }
231 }
232
233 #[test]
234 fn authoritative_entry_paths_keep_the_plain_string_wire_shape() {
235 let named = entry("/docs", Some(InodeId(1)), Some("docs"));
236 assert_eq!(
237 serde_json::to_value(&named).expect("serialize named entry"),
238 serde_json::json!({
239 "namespace_id": "demo",
240 "path": "/docs",
241 "inode_id": "ino_2",
242 "created_by": { "kind": "system", "id": "loonfs" },
243 "created_at_ms": 1_752_624_000_000_u64,
244 "inode_kind": "dir",
245 "head_seq": 3,
246 "parent_inode_id": "ino_1",
247 "display_name": "docs"
248 })
249 );
250
251 let response = ListPathEntriesResponse {
252 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
253 path: AbsolutePath::parse("/").expect("absolute path"),
254 head_seq: ChangeSeq(3),
255 entries: vec![named],
256 next_cursor: None,
257 };
258 assert_eq!(
259 serde_json::to_value(response).expect("serialize listing"),
260 serde_json::json!({
261 "namespace_id": "demo",
262 "path": "/",
263 "head_seq": 3,
264 "entries": [{
265 "namespace_id": "demo",
266 "path": "/docs",
267 "inode_id": "ino_2",
268 "created_by": { "kind": "system", "id": "loonfs" },
269 "created_at_ms": 1_752_624_000_000_u64,
270 "inode_kind": "dir",
271 "head_seq": 3,
272 "parent_inode_id": "ino_1",
273 "display_name": "docs"
274 }]
275 })
276 );
277 }
278
279 #[test]
280 fn a_file_entry_serializes_its_required_payload_with_the_kind() {
281 let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
282 let mut file = entry("/report.txt", Some(InodeId(1)), Some("report.txt"));
283 file.kind = AuthoritativePathEntryKind::File {
284 revision_no: RevisionNo(7),
285 size_bytes: 5,
286 content_ref: content_ref.clone(),
287 revision_actor: ActorRef::loonfs_system(),
288 committed_at_ms: 1_752_624_000_000,
289 };
290
291 assert_eq!(
292 serde_json::to_value(file).expect("serialize file entry"),
293 serde_json::json!({
294 "namespace_id": "demo",
295 "path": "/report.txt",
296 "inode_id": "ino_2",
297 "created_by": { "kind": "system", "id": "loonfs" },
298 "created_at_ms": 1_752_624_000_000_u64,
299 "inode_kind": "file",
300 "revision_no": 7,
301 "size_bytes": 5,
302 "content_ref": content_ref,
303 "revision_actor": { "kind": "system", "id": "loonfs" },
304 "committed_at_ms": 1_752_624_000_000_u64,
305 "head_seq": 3,
306 "parent_inode_id": "ino_1",
307 "display_name": "report.txt"
308 })
309 );
310 }
311
312 #[test]
313 fn nameless_root_omits_parent_inode_id_and_display_name() {
314 let root_json = serde_json::to_value(entry("/", None, None)).expect("serialize root");
315 assert!(root_json.get("parent_inode_id").is_none());
316 assert!(root_json.get("display_name").is_none());
317
318 let decoded: AuthoritativePathEntry =
319 serde_json::from_value(root_json).expect("decode root without optional fields");
320 assert_eq!(decoded.parent_inode_id, None);
321 assert_eq!(decoded.display_name, None);
322
323 let named_json = serde_json::to_value(entry("/docs", Some(InodeId(1)), Some("docs")))
324 .expect("serialize named entry");
325 assert_eq!(named_json["parent_inode_id"], "ino_1");
326 assert_eq!(named_json["display_name"], "docs");
327 }
328
329 #[test]
330 fn authoritative_entry_kinds_share_inode_kind_wire_values() {
331 let directory = AuthoritativePathEntryKind::Directory {};
332 assert_eq!(
333 serde_json::to_value(directory).expect("serialize directory entry kind")["inode_kind"],
334 serde_json::to_value(InodeKind::Directory).expect("serialize directory inode kind")
335 );
336
337 let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
338 let file = AuthoritativePathEntryKind::File {
339 revision_no: RevisionNo(1),
340 size_bytes: 5,
341 content_ref,
342 revision_actor: ActorRef::loonfs_system(),
343 committed_at_ms: 1,
344 };
345 assert_eq!(
346 serde_json::to_value(file).expect("serialize file entry kind")["inode_kind"],
347 serde_json::to_value(InodeKind::File).expect("serialize file inode kind")
348 );
349 }
350
351 #[test]
352 fn requested_attributes_serialize_as_flat_prefixed_siblings() {
353 let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
354 projected.attributes = Some(AttributesProjection {
355 attributes_revision_no: crate::AttributeRevisionNo(7),
356 attributes_updated_by: Some(ActorRef::loonfs_system()),
357 attributes_updated_at_ms: Some(1_752_624_000_000),
358 attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
359 crate::AttributeKey::parse("owner").expect("attribute key"),
360 crate::AttributeValue::parse("finance").expect("attribute value"),
361 )]))
362 .expect("attributes"),
363 });
364
365 let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
366 assert_eq!(projected_json["attributes_revision_no"], 7);
367 assert_eq!(
368 projected_json["attributes"],
369 serde_json::json!({ "owner": "finance" })
370 );
371 assert_eq!(
372 projected_json["attributes_updated_by"],
373 serde_json::json!({ "kind": "system", "id": "loonfs" })
374 );
375 assert_eq!(
376 projected_json["attributes_updated_at_ms"],
377 1_752_624_000_000_u64
378 );
379
380 let decoded: AuthoritativePathEntry =
381 serde_json::from_value(projected_json).expect("decode projected entry");
382 let projection = decoded.attributes.expect("projected attributes");
383 assert_eq!(
384 projection.attributes_revision_no,
385 crate::AttributeRevisionNo(7)
386 );
387 }
388
389 #[test]
390 fn unrequested_attributes_omit_both_wire_keys() {
391 let unprojected = entry("/docs", Some(InodeId(1)), Some("docs"));
392 let unprojected_json =
393 serde_json::to_value(&unprojected).expect("serialize unprojected entry");
394 assert!(unprojected_json.get("attributes").is_none());
395 assert!(unprojected_json.get("attributes_revision_no").is_none());
396
397 let decoded: AuthoritativePathEntry =
398 serde_json::from_value(unprojected_json).expect("decode unprojected entry");
399 assert!(decoded.attributes.is_none());
400 }
401
402 #[test]
403 fn never_written_attributes_serialize_as_revision_zero_and_empty_map() {
404 let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
405 projected.attributes = Some(AttributesProjection {
406 attributes_revision_no: crate::AttributeRevisionNo(0),
407 attributes_updated_by: None,
408 attributes_updated_at_ms: None,
409 attributes: crate::Attributes::default(),
410 });
411 let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
412 assert_eq!(projected_json["attributes_revision_no"], 0);
413 assert_eq!(projected_json["attributes"], serde_json::json!({}));
414 assert!(projected_json.get("attributes_updated_by").is_none());
415 assert!(projected_json.get("attributes_updated_at_ms").is_none());
416 }
417
418 #[test]
419 fn serialized_entries_never_nest_attributes_inside_attributes() {
420 let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
421 projected.attributes = Some(AttributesProjection {
422 attributes_revision_no: crate::AttributeRevisionNo(1),
423 attributes_updated_by: None,
424 attributes_updated_at_ms: None,
425 attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
426 crate::AttributeKey::parse("owner").expect("attribute key"),
427 crate::AttributeValue::parse("finance").expect("attribute value"),
428 )]))
429 .expect("attributes"),
430 });
431
432 let projected_json = serde_json::to_value(projected).expect("serialize projected entry");
433 assert!(projected_json.pointer("/attributes/attributes").is_none());
434 }
435
436 #[test]
437 fn trash_handle_copies_directly_into_an_undelete_operation() {
438 let trash = TrashEntry {
439 inode_id: InodeId(42),
440 deletion_seq: ChangeSeq(417),
441 deleted_at_ms: 1_752_625_000_000,
442 deleted_by: ActorRef::loonfs_system(),
443 parent_inode_id: Some(InodeId(7)),
444 name_key: Some(NameKey::parse("report.txt").expect("name key")),
445 display_name: Some(DisplayName::parse("Report.txt").expect("display name")),
446 };
447 let trash_json = serde_json::to_value(trash).expect("serialize trash entry");
448 assert_eq!(trash_json["inode_id"], serde_json::json!("ino_42"));
449 assert_eq!(trash_json["deletion_seq"], serde_json::json!(417));
450 assert!(trash_json.get("root_inode_id").is_none());
451 assert!(trash_json.get("deleted_at_seq").is_none());
452
453 let operation_json = serde_json::json!({
454 "kind": "undelete",
455 "inode_id": trash_json["inode_id"].clone(),
456 "deletion_seq": trash_json["deletion_seq"].clone()
457 });
458 let operation: crate::v0::FilesystemOperation =
459 serde_json::from_value(operation_json).expect("decode copied trash handle");
460 assert!(matches!(
461 operation,
462 crate::v0::FilesystemOperation::Undelete {
463 inode_id: InodeId(42),
464 deletion_seq: ChangeSeq(417),
465 path: None,
466 }
467 ));
468
469 assert!(
470 serde_json::from_value::<crate::v0::FilesystemOperation>(serde_json::json!({
471 "kind": "undelete",
472 "inode_id": 42,
473 "deleted_at_seq": 417
474 }))
475 .is_err(),
476 "the retired deletion handle must not decode"
477 );
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
486#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
487pub struct TrashEntry {
488 #[serde(with = "crate::public_inode_id")]
490 #[cfg_attr(
491 feature = "openapi",
492 schema(schema_with = crate::public_inode_id::schema)
493 )]
494 pub inode_id: InodeId,
495 pub deletion_seq: ChangeSeq,
497 pub deleted_at_ms: u64,
499 pub deleted_by: ActorRef,
501 #[serde(
503 default,
504 skip_serializing_if = "Option::is_none",
505 with = "crate::public_inode_id::option"
506 )]
507 #[cfg_attr(
508 feature = "openapi",
509 schema(schema_with = crate::public_inode_id::optional_schema)
510 )]
511 pub parent_inode_id: Option<InodeId>,
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub name_key: Option<NameKey>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub display_name: Option<DisplayName>,
518}
519
520#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
522#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
523pub struct ListTrashResponse {
524 pub namespace_id: NamespaceId,
526 pub head_seq: ChangeSeq,
528 pub entries: Vec<TrashEntry>,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
532 pub next_cursor: Option<String>,
533}