1use super::DirectoryBinding;
4use crate::{
5 AbsolutePath, ActorId, AttributeRevisionNo, Attributes, BindingGeneration, ChangeSeq,
6 ContentRef, DisplayName, InodeId, InodeKind, NamespaceId, RevisionNo,
7};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct PathEntry {
16 pub namespace_id: NamespaceId,
18 pub path: AbsolutePath,
20 #[serde(with = "crate::public_inode_id")]
22 pub inode_id: InodeId,
23 pub created_by: ActorId,
25 pub created_at_ms: u64,
27 #[serde(flatten)]
29 pub kind: PathEntryKind,
30 pub head_seq: ChangeSeq,
32 #[serde(
34 default,
35 skip_serializing_if = "Option::is_none",
36 with = "crate::public_inode_id::option"
37 )]
38 #[cfg_attr(feature = "openapi", schema(nullable = false))]
39 pub parent_inode_id: Option<InodeId>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 #[cfg_attr(feature = "openapi", schema(nullable = false))]
43 pub display_name: Option<DisplayName>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 #[cfg_attr(feature = "openapi", schema(nullable = false))]
47 pub binding_generation: Option<BindingGeneration>,
48 #[serde(flatten, default, skip_serializing_if = "Option::is_none")]
50 #[cfg_attr(feature = "openapi", schema(nullable = false))]
51 pub attributes: Option<AttributesProjection>,
52}
53
54impl PathEntry {
55 pub const fn inode_kind(&self) -> InodeKind {
57 self.kind.inode_kind()
58 }
59
60 pub const fn revision_no(&self) -> Option<RevisionNo> {
62 match &self.kind {
63 PathEntryKind::Directory {} => None,
64 PathEntryKind::File { revision_no, .. } => Some(*revision_no),
65 }
66 }
67
68 pub const fn size_bytes(&self) -> Option<u64> {
70 match &self.kind {
71 PathEntryKind::Directory {} => None,
72 PathEntryKind::File { size_bytes, .. } => Some(*size_bytes),
73 }
74 }
75
76 pub const fn content_ref(&self) -> Option<&ContentRef> {
78 match &self.kind {
79 PathEntryKind::Directory {} => None,
80 PathEntryKind::File { content_ref, .. } => Some(content_ref),
81 }
82 }
83
84 pub const fn revision_committed_at_ms(&self) -> Option<u64> {
86 match &self.kind {
87 PathEntryKind::Directory {} => None,
88 PathEntryKind::File {
89 revision_committed_at_ms,
90 ..
91 } => Some(*revision_committed_at_ms),
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
99#[serde(tag = "inode_kind", rename_all = "snake_case")]
100pub enum PathEntryKind {
101 #[serde(rename = "dir")]
103 #[cfg_attr(feature = "openapi", schema(title = "PathEntryDirectory"))]
104 Directory {},
105 #[cfg_attr(feature = "openapi", schema(title = "PathEntryFile"))]
107 File {
108 revision_no: RevisionNo,
110 size_bytes: u64,
112 content_ref: ContentRef,
114 revision_committed_by: ActorId,
116 revision_committed_at_ms: u64,
118 },
119}
120
121impl PathEntryKind {
122 pub const fn inode_kind(&self) -> InodeKind {
124 match self {
125 Self::Directory {} => InodeKind::Directory,
126 Self::File { .. } => InodeKind::File,
127 }
128 }
129
130 pub const fn revision_committed_by(&self) -> Option<&ActorId> {
133 match self {
134 Self::Directory {} => None,
135 Self::File {
136 revision_committed_by,
137 ..
138 } => Some(revision_committed_by),
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
146pub struct AttributesProjection {
147 #[cfg_attr(feature = "openapi", schema(required = false))]
149 pub attributes_revision_no: AttributeRevisionNo,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 #[cfg_attr(feature = "openapi", schema(nullable = false))]
154 pub attributes_updated_by: Option<ActorId>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 #[cfg_attr(feature = "openapi", schema(nullable = false))]
159 pub attributes_updated_at_ms: Option<u64>,
160 #[cfg_attr(feature = "openapi", schema(required = false))]
163 pub attributes: Attributes,
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
169pub struct ListPathEntriesResponse {
170 pub namespace_id: NamespaceId,
172 pub path: AbsolutePath,
174 pub head_seq: ChangeSeq,
176 pub entries: Vec<PathEntry>,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 #[cfg_attr(feature = "openapi", schema(nullable = false))]
181 pub next_cursor: Option<String>,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
187pub struct ListInodeChildrenResponse {
188 pub namespace_id: NamespaceId,
190 #[serde(with = "crate::public_inode_id")]
192 pub parent_inode_id: InodeId,
193 pub head_seq: ChangeSeq,
195 pub entries: Vec<PathEntry>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 #[cfg_attr(feature = "openapi", schema(nullable = false))]
200 pub next_cursor: Option<String>,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
206pub struct FileBytes {
207 pub entry: PathEntry,
209 pub bytes: Vec<u8>,
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
216pub struct TrashEntry {
217 #[serde(with = "crate::public_inode_id")]
219 pub inode_id: InodeId,
220 pub inode_kind: InodeKind,
222 pub deletion_seq: ChangeSeq,
224 pub deleted_at_ms: u64,
226 pub deleted_by: ActorId,
228 pub deleted_binding: DirectoryBinding,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
235pub struct ListTrashResponse {
236 pub namespace_id: NamespaceId,
238 pub head_seq: ChangeSeq,
240 pub entries: Vec<TrashEntry>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
244 #[cfg_attr(feature = "openapi", schema(nullable = false))]
245 pub next_cursor: Option<String>,
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::NameKey;
252
253 fn binding_generation() -> crate::BindingGeneration {
254 crate::BindingGeneration::parse("abcdef").expect("binding generation")
255 }
256
257 fn entry(
258 path: &str,
259 parent_inode_id: Option<InodeId>,
260 display_name: Option<&str>,
261 ) -> PathEntry {
262 PathEntry {
263 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
264 path: AbsolutePath::parse(path).expect("absolute path"),
265 inode_id: InodeId(if parent_inode_id.is_some() { 2 } else { 1 }),
266 created_by: ActorId::loonfs(),
267 created_at_ms: 1_752_624_000_000,
268 kind: PathEntryKind::Directory {},
269 head_seq: ChangeSeq(3),
270 parent_inode_id,
271 display_name: display_name.map(|name| DisplayName::parse(name).expect("display name")),
272 binding_generation: parent_inode_id.map(|_| binding_generation()),
273 attributes: None,
274 }
275 }
276
277 #[test]
278 fn path_entries_keep_the_plain_string_wire_shape() {
279 let named = entry("/docs", Some(InodeId(1)), Some("docs"));
280 assert_eq!(
281 serde_json::to_value(&named).expect("serialize named entry"),
282 serde_json::json!({
283 "namespace_id": "demo",
284 "path": "/docs",
285 "inode_id": "ino_2",
286 "created_by": "loonfs",
287 "created_at_ms": 1_752_624_000_000_u64,
288 "inode_kind": "dir",
289 "head_seq": 3,
290 "parent_inode_id": "ino_1",
291 "display_name": "docs",
292 "binding_generation": binding_generation()
293 })
294 );
295
296 let response = ListPathEntriesResponse {
297 namespace_id: NamespaceId::parse("demo").expect("namespace id"),
298 path: AbsolutePath::parse("/").expect("absolute path"),
299 head_seq: ChangeSeq(3),
300 entries: vec![named],
301 next_cursor: None,
302 };
303 assert_eq!(
304 serde_json::to_value(response).expect("serialize listing"),
305 serde_json::json!({
306 "namespace_id": "demo",
307 "path": "/",
308 "head_seq": 3,
309 "entries": [{
310 "namespace_id": "demo",
311 "path": "/docs",
312 "inode_id": "ino_2",
313 "created_by": "loonfs",
314 "created_at_ms": 1_752_624_000_000_u64,
315 "inode_kind": "dir",
316 "head_seq": 3,
317 "parent_inode_id": "ino_1",
318 "display_name": "docs",
319 "binding_generation": binding_generation()
320 }]
321 })
322 );
323 }
324
325 #[test]
326 fn a_file_entry_serializes_its_required_payload_with_the_kind() {
327 let content_ref = ContentRef::blob_v1(
328 crate::NamespaceId::parse("demo").expect("namespace id"),
329 crate::ContentId::generate(),
330 b"hello",
331 );
332 let mut file = entry("/report.txt", Some(InodeId(1)), Some("report.txt"));
333 file.kind = PathEntryKind::File {
334 revision_no: RevisionNo(7),
335 size_bytes: 5,
336 content_ref: content_ref.clone(),
337 revision_committed_by: ActorId::loonfs(),
338 revision_committed_at_ms: 1_752_624_000_000,
339 };
340
341 assert_eq!(
342 serde_json::to_value(file).expect("serialize file entry"),
343 serde_json::json!({
344 "namespace_id": "demo",
345 "path": "/report.txt",
346 "inode_id": "ino_2",
347 "created_by": "loonfs",
348 "created_at_ms": 1_752_624_000_000_u64,
349 "inode_kind": "file",
350 "revision_no": 7,
351 "size_bytes": 5,
352 "content_ref": content_ref,
353 "revision_committed_by": "loonfs",
354 "revision_committed_at_ms": 1_752_624_000_000_u64,
355 "head_seq": 3,
356 "parent_inode_id": "ino_1",
357 "display_name": "report.txt",
358 "binding_generation": binding_generation()
359 })
360 );
361 }
362
363 #[test]
364 fn nameless_root_omits_parent_inode_id_and_display_name() {
365 let root_json = serde_json::to_value(entry("/", None, None)).expect("serialize root");
366 assert!(root_json.get("parent_inode_id").is_none());
367 assert!(root_json.get("display_name").is_none());
368 assert!(root_json.get("binding_generation").is_none());
369
370 let decoded: PathEntry =
371 serde_json::from_value(root_json).expect("decode root without optional fields");
372 assert_eq!(decoded.parent_inode_id, None);
373 assert_eq!(decoded.display_name, None);
374 assert_eq!(decoded.binding_generation, None);
375
376 let named_json = serde_json::to_value(entry("/docs", Some(InodeId(1)), Some("docs")))
377 .expect("serialize named entry");
378 assert_eq!(named_json["parent_inode_id"], "ino_1");
379 assert_eq!(named_json["display_name"], "docs");
380 assert_eq!(
381 named_json["binding_generation"],
382 binding_generation().as_str()
383 );
384 }
385
386 #[test]
387 fn path_entry_kinds_share_inode_kind_wire_values() {
388 let directory = PathEntryKind::Directory {};
389 assert_eq!(
390 serde_json::to_value(directory).expect("serialize directory entry kind")["inode_kind"],
391 serde_json::to_value(InodeKind::Directory).expect("serialize directory inode kind")
392 );
393
394 let content_ref = ContentRef::blob_v1(
395 crate::NamespaceId::parse("demo").expect("namespace id"),
396 crate::ContentId::generate(),
397 b"hello",
398 );
399 let file = PathEntryKind::File {
400 revision_no: RevisionNo(1),
401 size_bytes: 5,
402 content_ref,
403 revision_committed_by: ActorId::loonfs(),
404 revision_committed_at_ms: 1,
405 };
406 assert_eq!(
407 serde_json::to_value(file).expect("serialize file entry kind")["inode_kind"],
408 serde_json::to_value(InodeKind::File).expect("serialize file inode kind")
409 );
410 }
411
412 #[test]
413 fn requested_attributes_serialize_as_flat_prefixed_siblings() {
414 let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
415 projected.attributes = Some(AttributesProjection {
416 attributes_revision_no: crate::AttributeRevisionNo(7),
417 attributes_updated_by: Some(ActorId::loonfs()),
418 attributes_updated_at_ms: Some(1_752_624_000_000),
419 attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
420 crate::AttributeKey::parse("owner").expect("attribute key"),
421 crate::AttributeValue::parse("finance").expect("attribute value"),
422 )]))
423 .expect("attributes"),
424 });
425
426 let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
427 assert_eq!(projected_json["attributes_revision_no"], 7);
428 assert_eq!(
429 projected_json["attributes"],
430 serde_json::json!({ "owner": "finance" })
431 );
432 assert_eq!(
433 projected_json["attributes_updated_by"],
434 serde_json::json!("loonfs")
435 );
436 assert_eq!(
437 projected_json["attributes_updated_at_ms"],
438 1_752_624_000_000_u64
439 );
440
441 let decoded: PathEntry =
442 serde_json::from_value(projected_json).expect("decode projected entry");
443 let projection = decoded.attributes.expect("projected attributes");
444 assert_eq!(
445 projection.attributes_revision_no,
446 crate::AttributeRevisionNo(7)
447 );
448 }
449
450 #[test]
451 fn unrequested_attributes_omit_both_wire_keys() {
452 let unprojected = entry("/docs", Some(InodeId(1)), Some("docs"));
453 let unprojected_json =
454 serde_json::to_value(&unprojected).expect("serialize unprojected entry");
455 assert!(unprojected_json.get("attributes").is_none());
456 assert!(unprojected_json.get("attributes_revision_no").is_none());
457
458 let decoded: PathEntry =
459 serde_json::from_value(unprojected_json).expect("decode unprojected entry");
460 assert!(decoded.attributes.is_none());
461 }
462
463 #[test]
464 fn never_written_attributes_serialize_as_revision_zero_and_empty_map() {
465 let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
466 projected.attributes = Some(AttributesProjection {
467 attributes_revision_no: crate::AttributeRevisionNo(0),
468 attributes_updated_by: None,
469 attributes_updated_at_ms: None,
470 attributes: crate::Attributes::default(),
471 });
472 let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
473 assert_eq!(projected_json["attributes_revision_no"], 0);
474 assert_eq!(projected_json["attributes"], serde_json::json!({}));
475 assert!(projected_json.get("attributes_updated_by").is_none());
476 assert!(projected_json.get("attributes_updated_at_ms").is_none());
477 }
478
479 #[test]
480 fn a_trash_entry_nests_the_binding_the_deletion_removed() {
481 let trash = TrashEntry {
482 inode_id: InodeId(42),
483 inode_kind: InodeKind::File,
484 deletion_seq: ChangeSeq(417),
485 deleted_at_ms: 1,
486 deleted_by: ActorId::loonfs(),
487 deleted_binding: DirectoryBinding {
488 parent_inode_id: InodeId(7),
489 name_key: NameKey::parse("report.txt").expect("name key"),
490 display_name: DisplayName::parse("report.txt").expect("display name"),
491 },
492 };
493 assert_eq!(
494 serde_json::to_value(&trash).expect("serialize trash entry"),
495 serde_json::json!({
496 "inode_id": "ino_42",
497 "inode_kind": "file",
498 "deletion_seq": 417,
499 "deleted_at_ms": 1,
500 "deleted_by": "loonfs",
501 "deleted_binding": {
502 "parent_inode_id": "ino_7",
503 "name_key": "report.txt",
504 "display_name": "report.txt"
505 }
506 })
507 );
508 }
509
510 #[test]
511 fn trash_handle_copies_directly_into_an_undelete_operation() {
512 let trash = TrashEntry {
513 inode_id: InodeId(42),
514 inode_kind: InodeKind::File,
515 deletion_seq: ChangeSeq(417),
516 deleted_at_ms: 1_752_625_000_000,
517 deleted_by: ActorId::loonfs(),
518 deleted_binding: DirectoryBinding {
519 parent_inode_id: InodeId(7),
520 name_key: NameKey::parse("report.txt").expect("name key"),
521 display_name: DisplayName::parse("Report.txt").expect("display name"),
522 },
523 };
524 let trash_json = serde_json::to_value(trash).expect("serialize trash entry");
525 assert_eq!(trash_json["inode_id"], serde_json::json!("ino_42"));
526 assert_eq!(trash_json["deletion_seq"], serde_json::json!(417));
527 assert!(trash_json.get("root_inode_id").is_none());
528 assert!(trash_json.get("deleted_at_seq").is_none());
529
530 let operation_json = serde_json::json!({
531 "kind": "undelete",
532 "inode_id": trash_json["inode_id"].clone(),
533 "deletion_seq": trash_json["deletion_seq"].clone()
534 });
535 let operation: crate::v0::FilesystemOperation =
536 serde_json::from_value(operation_json).expect("decode copied trash handle");
537 assert!(matches!(
538 operation,
539 crate::v0::FilesystemOperation::Undelete {
540 inode_id: InodeId(42),
541 deletion_seq: ChangeSeq(417),
542 destination_path: None,
543 }
544 ));
545
546 assert!(
547 serde_json::from_value::<crate::v0::FilesystemOperation>(serde_json::json!({
548 "kind": "undelete",
549 "inode_id": 42,
550 "deleted_at_seq": 417
551 }))
552 .is_err(),
553 "the retired deletion handle must not decode"
554 );
555 }
556}