1use super::DirectoryBinding;
6use crate::{
7 AbsolutePath, ActorRef, AttributeRevisionNo, Attributes, ChangeSeq, ContentRef, DisplayName,
8 InodeId, InodeKind, NamespaceId, RevisionNo,
9};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21pub struct PathEntry {
22 pub namespace_id: NamespaceId,
24 pub path: AbsolutePath,
26 #[serde(with = "crate::public_inode_id")]
28 #[cfg_attr(
29 feature = "openapi",
30 schema(schema_with = crate::public_inode_id::schema)
31 )]
32 pub inode_id: InodeId,
33 pub created_by: ActorRef,
35 pub created_at_ms: u64,
38 #[serde(flatten)]
40 pub kind: PathEntryKind,
41 pub head_seq: ChangeSeq,
43 #[serde(
45 default,
46 skip_serializing_if = "Option::is_none",
47 with = "crate::public_inode_id::option"
48 )]
49 #[cfg_attr(
50 feature = "openapi",
51 schema(schema_with = crate::public_inode_id::schema)
52 )]
53 pub parent_inode_id: Option<InodeId>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 #[cfg_attr(feature = "openapi", schema(nullable = false))]
57 pub display_name: Option<DisplayName>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 #[cfg_attr(feature = "openapi", schema(nullable = false))]
61 pub binding_generation: Option<String>,
62 #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
64 #[cfg_attr(feature = "openapi", schema(nullable = false))]
65 pub attributes: Option<AttributesProjection>,
66}
67
68impl PathEntry {
69 pub const fn inode_kind(&self) -> InodeKind {
71 self.kind.inode_kind()
72 }
73
74 pub const fn revision_no(&self) -> Option<RevisionNo> {
76 match &self.kind {
77 PathEntryKind::Directory {} => None,
78 PathEntryKind::File { revision_no, .. } => Some(*revision_no),
79 }
80 }
81
82 pub const fn size_bytes(&self) -> Option<u64> {
84 match &self.kind {
85 PathEntryKind::Directory {} => None,
86 PathEntryKind::File { size_bytes, .. } => Some(*size_bytes),
87 }
88 }
89
90 pub const fn content_ref(&self) -> Option<&ContentRef> {
92 match &self.kind {
93 PathEntryKind::Directory {} => None,
94 PathEntryKind::File { content_ref, .. } => Some(content_ref),
95 }
96 }
97
98 pub const fn revision_committed_at_ms(&self) -> Option<u64> {
100 match &self.kind {
101 PathEntryKind::Directory {} => None,
102 PathEntryKind::File {
103 revision_committed_at_ms,
104 ..
105 } => Some(*revision_committed_at_ms),
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
113#[serde(tag = "inode_kind", rename_all = "snake_case")]
114pub enum PathEntryKind {
115 #[serde(rename = "dir")]
119 #[cfg_attr(feature = "openapi", schema(title = "PathEntryDirectory"))]
120 Directory {},
121 #[cfg_attr(feature = "openapi", schema(title = "PathEntryFile"))]
123 File {
124 revision_no: RevisionNo,
126 size_bytes: u64,
131 content_ref: ContentRef,
133 revision_committed_by: ActorRef,
135 revision_committed_at_ms: u64,
138 },
139}
140
141impl PathEntryKind {
142 pub const fn inode_kind(&self) -> InodeKind {
144 match self {
145 Self::Directory {} => InodeKind::Directory,
146 Self::File { .. } => InodeKind::File,
147 }
148 }
149
150 pub const fn revision_committed_by(&self) -> Option<&ActorRef> {
153 match self {
154 Self::Directory {} => None,
155 Self::File {
156 revision_committed_by,
157 ..
158 } => Some(revision_committed_by),
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
170pub struct AttributesProjection {
171 #[cfg_attr(feature = "openapi", schema(required = false))]
173 pub attributes_revision_no: AttributeRevisionNo,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 #[cfg_attr(feature = "openapi", schema(nullable = false))]
178 pub attributes_updated_by: Option<ActorRef>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub attributes_updated_at_ms: Option<u64>,
183 #[cfg_attr(feature = "openapi", schema(required = false))]
188 pub attributes: Attributes,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
198pub struct ListPathEntriesResponse {
199 pub namespace_id: NamespaceId,
201 pub path: AbsolutePath,
203 pub head_seq: ChangeSeq,
205 pub entries: Vec<PathEntry>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
212 pub next_cursor: Option<String>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
222#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
223pub struct ListInodeChildrenResponse {
224 pub namespace_id: NamespaceId,
226 #[serde(with = "crate::public_inode_id")]
228 #[cfg_attr(
229 feature = "openapi",
230 schema(schema_with = crate::public_inode_id::schema)
231 )]
232 pub parent_inode_id: InodeId,
233 pub head_seq: ChangeSeq,
235 pub entries: Vec<PathEntry>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub next_cursor: Option<String>,
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
248pub struct FileBytes {
249 pub entry: PathEntry,
251 pub bytes: Vec<u8>,
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
261pub struct TrashEntry {
262 #[serde(with = "crate::public_inode_id")]
264 #[cfg_attr(
265 feature = "openapi",
266 schema(schema_with = crate::public_inode_id::schema)
267 )]
268 pub inode_id: InodeId,
269 pub deletion_seq: ChangeSeq,
271 pub deleted_at_ms: u64,
273 pub deleted_by: ActorRef,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 #[cfg_attr(feature = "openapi", schema(nullable = false))]
278 pub deleted_binding: Option<DirectoryBinding>,
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
284pub struct ListTrashResponse {
285 pub namespace_id: NamespaceId,
287 pub head_seq: ChangeSeq,
289 pub entries: Vec<TrashEntry>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub next_cursor: Option<String>,
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::NameKey;
300
301 fn binding_generation() -> String {
302 "generation".to_owned()
303 }
304
305 fn entry(
306 path: &str,
307 parent_inode_id: Option<InodeId>,
308 display_name: Option<&str>,
309 ) -> PathEntry {
310 PathEntry {
311 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
312 path: AbsolutePath::parse(path).expect("absolute path"),
313 inode_id: InodeId(if parent_inode_id.is_some() { 2 } else { 1 }),
314 created_by: ActorRef::loonfs_system(),
315 created_at_ms: 1_752_624_000_000,
316 kind: PathEntryKind::Directory {},
317 head_seq: ChangeSeq(3),
318 parent_inode_id,
319 display_name: display_name.map(|name| DisplayName::parse(name).expect("display name")),
320 binding_generation: parent_inode_id.map(|_| binding_generation()),
321 attributes: None,
322 }
323 }
324
325 #[test]
326 fn path_entries_keep_the_plain_string_wire_shape() {
327 let named = entry("/docs", Some(InodeId(1)), Some("docs"));
328 assert_eq!(
329 serde_json::to_value(&named).expect("serialize named entry"),
330 serde_json::json!({
331 "namespace_id": "demo",
332 "path": "/docs",
333 "inode_id": "ino_2",
334 "created_by": { "kind": "system", "id": "loonfs" },
335 "created_at_ms": 1_752_624_000_000_u64,
336 "inode_kind": "dir",
337 "head_seq": 3,
338 "parent_inode_id": "ino_1",
339 "display_name": "docs",
340 "binding_generation": binding_generation()
341 })
342 );
343
344 let response = ListPathEntriesResponse {
345 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
346 path: AbsolutePath::parse("/").expect("absolute path"),
347 head_seq: ChangeSeq(3),
348 entries: vec![named],
349 next_cursor: None,
350 };
351 assert_eq!(
352 serde_json::to_value(response).expect("serialize listing"),
353 serde_json::json!({
354 "namespace_id": "demo",
355 "path": "/",
356 "head_seq": 3,
357 "entries": [{
358 "namespace_id": "demo",
359 "path": "/docs",
360 "inode_id": "ino_2",
361 "created_by": { "kind": "system", "id": "loonfs" },
362 "created_at_ms": 1_752_624_000_000_u64,
363 "inode_kind": "dir",
364 "head_seq": 3,
365 "parent_inode_id": "ino_1",
366 "display_name": "docs",
367 "binding_generation": binding_generation()
368 }]
369 })
370 );
371 }
372
373 #[test]
374 fn a_file_entry_serializes_its_required_payload_with_the_kind() {
375 let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
376 let mut file = entry("/report.txt", Some(InodeId(1)), Some("report.txt"));
377 file.kind = PathEntryKind::File {
378 revision_no: RevisionNo(7),
379 size_bytes: 5,
380 content_ref: content_ref.clone(),
381 revision_committed_by: ActorRef::loonfs_system(),
382 revision_committed_at_ms: 1_752_624_000_000,
383 };
384
385 assert_eq!(
386 serde_json::to_value(file).expect("serialize file entry"),
387 serde_json::json!({
388 "namespace_id": "demo",
389 "path": "/report.txt",
390 "inode_id": "ino_2",
391 "created_by": { "kind": "system", "id": "loonfs" },
392 "created_at_ms": 1_752_624_000_000_u64,
393 "inode_kind": "file",
394 "revision_no": 7,
395 "size_bytes": 5,
396 "content_ref": content_ref,
397 "revision_committed_by": { "kind": "system", "id": "loonfs" },
398 "revision_committed_at_ms": 1_752_624_000_000_u64,
399 "head_seq": 3,
400 "parent_inode_id": "ino_1",
401 "display_name": "report.txt",
402 "binding_generation": binding_generation()
403 })
404 );
405 }
406
407 #[test]
408 fn nameless_root_omits_parent_inode_id_and_display_name() {
409 let root_json = serde_json::to_value(entry("/", None, None)).expect("serialize root");
410 assert!(root_json.get("parent_inode_id").is_none());
411 assert!(root_json.get("display_name").is_none());
412 assert!(root_json.get("binding_generation").is_none());
413
414 let decoded: PathEntry =
415 serde_json::from_value(root_json).expect("decode root without optional fields");
416 assert_eq!(decoded.parent_inode_id, None);
417 assert_eq!(decoded.display_name, None);
418 assert_eq!(decoded.binding_generation, None);
419
420 let named_json = serde_json::to_value(entry("/docs", Some(InodeId(1)), Some("docs")))
421 .expect("serialize named entry");
422 assert_eq!(named_json["parent_inode_id"], "ino_1");
423 assert_eq!(named_json["display_name"], "docs");
424 assert_eq!(named_json["binding_generation"], binding_generation());
425 }
426
427 #[test]
428 fn path_entry_kinds_share_inode_kind_wire_values() {
429 let directory = PathEntryKind::Directory {};
430 assert_eq!(
431 serde_json::to_value(directory).expect("serialize directory entry kind")["inode_kind"],
432 serde_json::to_value(InodeKind::Directory).expect("serialize directory inode kind")
433 );
434
435 let content_ref = ContentRef::blob_v1(crate::ContentId::generate(), b"hello");
436 let file = PathEntryKind::File {
437 revision_no: RevisionNo(1),
438 size_bytes: 5,
439 content_ref,
440 revision_committed_by: ActorRef::loonfs_system(),
441 revision_committed_at_ms: 1,
442 };
443 assert_eq!(
444 serde_json::to_value(file).expect("serialize file entry kind")["inode_kind"],
445 serde_json::to_value(InodeKind::File).expect("serialize file inode kind")
446 );
447 }
448
449 #[test]
450 fn requested_attributes_serialize_as_flat_prefixed_siblings() {
451 let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
452 projected.attributes = Some(AttributesProjection {
453 attributes_revision_no: crate::AttributeRevisionNo(7),
454 attributes_updated_by: Some(ActorRef::loonfs_system()),
455 attributes_updated_at_ms: Some(1_752_624_000_000),
456 attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
457 crate::AttributeKey::parse("owner").expect("attribute key"),
458 crate::AttributeValue::parse("finance").expect("attribute value"),
459 )]))
460 .expect("attributes"),
461 });
462
463 let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
464 assert_eq!(projected_json["attributes_revision_no"], 7);
465 assert_eq!(
466 projected_json["attributes"],
467 serde_json::json!({ "owner": "finance" })
468 );
469 assert_eq!(
470 projected_json["attributes_updated_by"],
471 serde_json::json!({ "kind": "system", "id": "loonfs" })
472 );
473 assert_eq!(
474 projected_json["attributes_updated_at_ms"],
475 1_752_624_000_000_u64
476 );
477
478 let decoded: PathEntry =
479 serde_json::from_value(projected_json).expect("decode projected entry");
480 let projection = decoded.attributes.expect("projected attributes");
481 assert_eq!(
482 projection.attributes_revision_no,
483 crate::AttributeRevisionNo(7)
484 );
485 }
486
487 #[test]
488 fn unrequested_attributes_omit_both_wire_keys() {
489 let unprojected = entry("/docs", Some(InodeId(1)), Some("docs"));
490 let unprojected_json =
491 serde_json::to_value(&unprojected).expect("serialize unprojected entry");
492 assert!(unprojected_json.get("attributes").is_none());
493 assert!(unprojected_json.get("attributes_revision_no").is_none());
494
495 let decoded: PathEntry =
496 serde_json::from_value(unprojected_json).expect("decode unprojected entry");
497 assert!(decoded.attributes.is_none());
498 }
499
500 #[test]
501 fn never_written_attributes_serialize_as_revision_zero_and_empty_map() {
502 let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
503 projected.attributes = Some(AttributesProjection {
504 attributes_revision_no: crate::AttributeRevisionNo(0),
505 attributes_updated_by: None,
506 attributes_updated_at_ms: None,
507 attributes: crate::Attributes::default(),
508 });
509 let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
510 assert_eq!(projected_json["attributes_revision_no"], 0);
511 assert_eq!(projected_json["attributes"], serde_json::json!({}));
512 assert!(projected_json.get("attributes_updated_by").is_none());
513 assert!(projected_json.get("attributes_updated_at_ms").is_none());
514 }
515
516 #[test]
517 fn a_trash_entry_nests_the_binding_the_deletion_removed() {
518 let trash = TrashEntry {
519 inode_id: InodeId(42),
520 deletion_seq: ChangeSeq(417),
521 deleted_at_ms: 1,
522 deleted_by: ActorRef::loonfs_system(),
523 deleted_binding: Some(DirectoryBinding {
524 parent_inode_id: InodeId(7),
525 name_key: NameKey::parse("report.txt").expect("name key"),
526 display_name: DisplayName::parse("report.txt").expect("display name"),
527 }),
528 };
529 assert_eq!(
530 serde_json::to_value(&trash).expect("serialize trash entry"),
531 serde_json::json!({
532 "inode_id": "ino_42",
533 "deletion_seq": 417,
534 "deleted_at_ms": 1,
535 "deleted_by": { "kind": "system", "id": "loonfs" },
536 "deleted_binding": {
537 "parent_inode_id": "ino_7",
538 "name_key": "report.txt",
539 "display_name": "report.txt"
540 }
541 })
542 );
543
544 let bindingless = TrashEntry {
546 deleted_binding: None,
547 ..trash
548 };
549 let bindingless_json =
550 serde_json::to_value(bindingless).expect("serialize bindingless entry");
551 assert!(bindingless_json.get("deleted_binding").is_none());
552 }
553
554 #[test]
555 fn trash_handle_copies_directly_into_an_undelete_operation() {
556 let trash = TrashEntry {
557 inode_id: InodeId(42),
558 deletion_seq: ChangeSeq(417),
559 deleted_at_ms: 1_752_625_000_000,
560 deleted_by: ActorRef::loonfs_system(),
561 deleted_binding: Some(DirectoryBinding {
562 parent_inode_id: InodeId(7),
563 name_key: NameKey::parse("report.txt").expect("name key"),
564 display_name: DisplayName::parse("Report.txt").expect("display name"),
565 }),
566 };
567 let trash_json = serde_json::to_value(trash).expect("serialize trash entry");
568 assert_eq!(trash_json["inode_id"], serde_json::json!("ino_42"));
569 assert_eq!(trash_json["deletion_seq"], serde_json::json!(417));
570 assert!(trash_json.get("root_inode_id").is_none());
571 assert!(trash_json.get("deleted_at_seq").is_none());
572
573 let operation_json = serde_json::json!({
574 "kind": "undelete",
575 "inode_id": trash_json["inode_id"].clone(),
576 "deletion_seq": trash_json["deletion_seq"].clone()
577 });
578 let operation: crate::v0::FilesystemOperation =
579 serde_json::from_value(operation_json).expect("decode copied trash handle");
580 assert!(matches!(
581 operation,
582 crate::v0::FilesystemOperation::Undelete {
583 inode_id: InodeId(42),
584 deletion_seq: ChangeSeq(417),
585 path: None,
586 }
587 ));
588
589 assert!(
590 serde_json::from_value::<crate::v0::FilesystemOperation>(serde_json::json!({
591 "kind": "undelete",
592 "inode_id": 42,
593 "deleted_at_seq": 417
594 }))
595 .is_err(),
596 "the retired deletion handle must not decode"
597 );
598 }
599}