use super::DirectoryBinding;
use crate::{
AbsolutePath, ActorId, AttributeRevisionNo, Attributes, BindingGeneration, ChangeSeq,
ContentRef, DisplayName, InodeId, InodeKind, NamespaceId, RevisionNo,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct PathEntry {
pub namespace_id: NamespaceId,
pub path: AbsolutePath,
#[serde(with = "crate::public_inode_id")]
pub inode_id: InodeId,
pub created_by: ActorId,
pub created_at_ms: u64,
#[serde(flatten)]
pub kind: PathEntryKind,
pub head_seq: ChangeSeq,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::public_inode_id::option"
)]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub parent_inode_id: Option<InodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub display_name: Option<DisplayName>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub binding_generation: Option<BindingGeneration>,
#[serde(flatten, default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub attributes: Option<AttributesProjection>,
}
impl PathEntry {
pub const fn inode_kind(&self) -> InodeKind {
self.kind.inode_kind()
}
pub const fn revision_no(&self) -> Option<RevisionNo> {
match &self.kind {
PathEntryKind::Directory {} => None,
PathEntryKind::File { revision_no, .. } => Some(*revision_no),
}
}
pub const fn size_bytes(&self) -> Option<u64> {
match &self.kind {
PathEntryKind::Directory {} => None,
PathEntryKind::File { size_bytes, .. } => Some(*size_bytes),
}
}
pub const fn content_ref(&self) -> Option<&ContentRef> {
match &self.kind {
PathEntryKind::Directory {} => None,
PathEntryKind::File { content_ref, .. } => Some(content_ref),
}
}
pub const fn revision_committed_at_ms(&self) -> Option<u64> {
match &self.kind {
PathEntryKind::Directory {} => None,
PathEntryKind::File {
revision_committed_at_ms,
..
} => Some(*revision_committed_at_ms),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(tag = "inode_kind", rename_all = "snake_case")]
pub enum PathEntryKind {
#[serde(rename = "dir")]
#[cfg_attr(feature = "openapi", schema(title = "PathEntryDirectory"))]
Directory {},
#[cfg_attr(feature = "openapi", schema(title = "PathEntryFile"))]
File {
revision_no: RevisionNo,
size_bytes: u64,
content_ref: ContentRef,
revision_committed_by: ActorId,
revision_committed_at_ms: u64,
},
}
impl PathEntryKind {
pub const fn inode_kind(&self) -> InodeKind {
match self {
Self::Directory {} => InodeKind::Directory,
Self::File { .. } => InodeKind::File,
}
}
pub const fn revision_committed_by(&self) -> Option<&ActorId> {
match self {
Self::Directory {} => None,
Self::File {
revision_committed_by,
..
} => Some(revision_committed_by),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct AttributesProjection {
#[cfg_attr(feature = "openapi", schema(required = false))]
pub attributes_revision_no: AttributeRevisionNo,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub attributes_updated_by: Option<ActorId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub attributes_updated_at_ms: Option<u64>,
#[cfg_attr(feature = "openapi", schema(required = false))]
pub attributes: Attributes,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListPathEntriesResponse {
pub namespace_id: NamespaceId,
pub path: AbsolutePath,
pub head_seq: ChangeSeq,
pub entries: Vec<PathEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListInodeChildrenResponse {
pub namespace_id: NamespaceId,
#[serde(with = "crate::public_inode_id")]
pub parent_inode_id: InodeId,
pub head_seq: ChangeSeq,
pub entries: Vec<PathEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct FileBytes {
pub entry: PathEntry,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct TrashEntry {
#[serde(with = "crate::public_inode_id")]
pub inode_id: InodeId,
pub inode_kind: InodeKind,
pub deletion_seq: ChangeSeq,
pub deleted_at_ms: u64,
pub deleted_by: ActorId,
pub deleted_binding: DirectoryBinding,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct ListTrashResponse {
pub namespace_id: NamespaceId,
pub head_seq: ChangeSeq,
pub entries: Vec<TrashEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "openapi", schema(nullable = false))]
pub next_cursor: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NameKey;
fn binding_generation() -> crate::BindingGeneration {
crate::BindingGeneration::parse("abcdef").expect("binding generation")
}
fn entry(
path: &str,
parent_inode_id: Option<InodeId>,
display_name: Option<&str>,
) -> PathEntry {
PathEntry {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
path: AbsolutePath::parse(path).expect("absolute path"),
inode_id: InodeId(if parent_inode_id.is_some() { 2 } else { 1 }),
created_by: ActorId::loonfs(),
created_at_ms: 1_752_624_000_000,
kind: PathEntryKind::Directory {},
head_seq: ChangeSeq(3),
parent_inode_id,
display_name: display_name.map(|name| DisplayName::parse(name).expect("display name")),
binding_generation: parent_inode_id.map(|_| binding_generation()),
attributes: None,
}
}
#[test]
fn path_entries_keep_the_plain_string_wire_shape() {
let named = entry("/docs", Some(InodeId(1)), Some("docs"));
assert_eq!(
serde_json::to_value(&named).expect("serialize named entry"),
serde_json::json!({
"namespace_id": "demo",
"path": "/docs",
"inode_id": "ino_2",
"created_by": "loonfs",
"created_at_ms": 1_752_624_000_000_u64,
"inode_kind": "dir",
"head_seq": 3,
"parent_inode_id": "ino_1",
"display_name": "docs",
"binding_generation": binding_generation()
})
);
let response = ListPathEntriesResponse {
namespace_id: NamespaceId::parse("demo").expect("namespace id"),
path: AbsolutePath::parse("/").expect("absolute path"),
head_seq: ChangeSeq(3),
entries: vec![named],
next_cursor: None,
};
assert_eq!(
serde_json::to_value(response).expect("serialize listing"),
serde_json::json!({
"namespace_id": "demo",
"path": "/",
"head_seq": 3,
"entries": [{
"namespace_id": "demo",
"path": "/docs",
"inode_id": "ino_2",
"created_by": "loonfs",
"created_at_ms": 1_752_624_000_000_u64,
"inode_kind": "dir",
"head_seq": 3,
"parent_inode_id": "ino_1",
"display_name": "docs",
"binding_generation": binding_generation()
}]
})
);
}
#[test]
fn a_file_entry_serializes_its_required_payload_with_the_kind() {
let content_ref = ContentRef::blob_v1(
crate::NamespaceId::parse("demo").expect("namespace id"),
crate::ContentId::generate(),
b"hello",
);
let mut file = entry("/report.txt", Some(InodeId(1)), Some("report.txt"));
file.kind = PathEntryKind::File {
revision_no: RevisionNo(7),
size_bytes: 5,
content_ref: content_ref.clone(),
revision_committed_by: ActorId::loonfs(),
revision_committed_at_ms: 1_752_624_000_000,
};
assert_eq!(
serde_json::to_value(file).expect("serialize file entry"),
serde_json::json!({
"namespace_id": "demo",
"path": "/report.txt",
"inode_id": "ino_2",
"created_by": "loonfs",
"created_at_ms": 1_752_624_000_000_u64,
"inode_kind": "file",
"revision_no": 7,
"size_bytes": 5,
"content_ref": content_ref,
"revision_committed_by": "loonfs",
"revision_committed_at_ms": 1_752_624_000_000_u64,
"head_seq": 3,
"parent_inode_id": "ino_1",
"display_name": "report.txt",
"binding_generation": binding_generation()
})
);
}
#[test]
fn nameless_root_omits_parent_inode_id_and_display_name() {
let root_json = serde_json::to_value(entry("/", None, None)).expect("serialize root");
assert!(root_json.get("parent_inode_id").is_none());
assert!(root_json.get("display_name").is_none());
assert!(root_json.get("binding_generation").is_none());
let decoded: PathEntry =
serde_json::from_value(root_json).expect("decode root without optional fields");
assert_eq!(decoded.parent_inode_id, None);
assert_eq!(decoded.display_name, None);
assert_eq!(decoded.binding_generation, None);
let named_json = serde_json::to_value(entry("/docs", Some(InodeId(1)), Some("docs")))
.expect("serialize named entry");
assert_eq!(named_json["parent_inode_id"], "ino_1");
assert_eq!(named_json["display_name"], "docs");
assert_eq!(
named_json["binding_generation"],
binding_generation().as_str()
);
}
#[test]
fn path_entry_kinds_share_inode_kind_wire_values() {
let directory = PathEntryKind::Directory {};
assert_eq!(
serde_json::to_value(directory).expect("serialize directory entry kind")["inode_kind"],
serde_json::to_value(InodeKind::Directory).expect("serialize directory inode kind")
);
let content_ref = ContentRef::blob_v1(
crate::NamespaceId::parse("demo").expect("namespace id"),
crate::ContentId::generate(),
b"hello",
);
let file = PathEntryKind::File {
revision_no: RevisionNo(1),
size_bytes: 5,
content_ref,
revision_committed_by: ActorId::loonfs(),
revision_committed_at_ms: 1,
};
assert_eq!(
serde_json::to_value(file).expect("serialize file entry kind")["inode_kind"],
serde_json::to_value(InodeKind::File).expect("serialize file inode kind")
);
}
#[test]
fn requested_attributes_serialize_as_flat_prefixed_siblings() {
let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
projected.attributes = Some(AttributesProjection {
attributes_revision_no: crate::AttributeRevisionNo(7),
attributes_updated_by: Some(ActorId::loonfs()),
attributes_updated_at_ms: Some(1_752_624_000_000),
attributes: crate::Attributes::new(std::collections::BTreeMap::from([(
crate::AttributeKey::parse("owner").expect("attribute key"),
crate::AttributeValue::parse("finance").expect("attribute value"),
)]))
.expect("attributes"),
});
let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
assert_eq!(projected_json["attributes_revision_no"], 7);
assert_eq!(
projected_json["attributes"],
serde_json::json!({ "owner": "finance" })
);
assert_eq!(
projected_json["attributes_updated_by"],
serde_json::json!("loonfs")
);
assert_eq!(
projected_json["attributes_updated_at_ms"],
1_752_624_000_000_u64
);
let decoded: PathEntry =
serde_json::from_value(projected_json).expect("decode projected entry");
let projection = decoded.attributes.expect("projected attributes");
assert_eq!(
projection.attributes_revision_no,
crate::AttributeRevisionNo(7)
);
}
#[test]
fn unrequested_attributes_omit_both_wire_keys() {
let unprojected = entry("/docs", Some(InodeId(1)), Some("docs"));
let unprojected_json =
serde_json::to_value(&unprojected).expect("serialize unprojected entry");
assert!(unprojected_json.get("attributes").is_none());
assert!(unprojected_json.get("attributes_revision_no").is_none());
let decoded: PathEntry =
serde_json::from_value(unprojected_json).expect("decode unprojected entry");
assert!(decoded.attributes.is_none());
}
#[test]
fn never_written_attributes_serialize_as_revision_zero_and_empty_map() {
let mut projected = entry("/docs", Some(InodeId(1)), Some("docs"));
projected.attributes = Some(AttributesProjection {
attributes_revision_no: crate::AttributeRevisionNo(0),
attributes_updated_by: None,
attributes_updated_at_ms: None,
attributes: crate::Attributes::default(),
});
let projected_json = serde_json::to_value(&projected).expect("serialize projected entry");
assert_eq!(projected_json["attributes_revision_no"], 0);
assert_eq!(projected_json["attributes"], serde_json::json!({}));
assert!(projected_json.get("attributes_updated_by").is_none());
assert!(projected_json.get("attributes_updated_at_ms").is_none());
}
#[test]
fn a_trash_entry_nests_the_binding_the_deletion_removed() {
let trash = TrashEntry {
inode_id: InodeId(42),
inode_kind: InodeKind::File,
deletion_seq: ChangeSeq(417),
deleted_at_ms: 1,
deleted_by: ActorId::loonfs(),
deleted_binding: DirectoryBinding {
parent_inode_id: InodeId(7),
name_key: NameKey::parse("report.txt").expect("name key"),
display_name: DisplayName::parse("report.txt").expect("display name"),
},
};
assert_eq!(
serde_json::to_value(&trash).expect("serialize trash entry"),
serde_json::json!({
"inode_id": "ino_42",
"inode_kind": "file",
"deletion_seq": 417,
"deleted_at_ms": 1,
"deleted_by": "loonfs",
"deleted_binding": {
"parent_inode_id": "ino_7",
"name_key": "report.txt",
"display_name": "report.txt"
}
})
);
}
#[test]
fn trash_handle_copies_directly_into_an_undelete_operation() {
let trash = TrashEntry {
inode_id: InodeId(42),
inode_kind: InodeKind::File,
deletion_seq: ChangeSeq(417),
deleted_at_ms: 1_752_625_000_000,
deleted_by: ActorId::loonfs(),
deleted_binding: DirectoryBinding {
parent_inode_id: InodeId(7),
name_key: NameKey::parse("report.txt").expect("name key"),
display_name: DisplayName::parse("Report.txt").expect("display name"),
},
};
let trash_json = serde_json::to_value(trash).expect("serialize trash entry");
assert_eq!(trash_json["inode_id"], serde_json::json!("ino_42"));
assert_eq!(trash_json["deletion_seq"], serde_json::json!(417));
assert!(trash_json.get("root_inode_id").is_none());
assert!(trash_json.get("deleted_at_seq").is_none());
let operation_json = serde_json::json!({
"kind": "undelete",
"inode_id": trash_json["inode_id"].clone(),
"deletion_seq": trash_json["deletion_seq"].clone()
});
let operation: crate::v0::FilesystemOperation =
serde_json::from_value(operation_json).expect("decode copied trash handle");
assert!(matches!(
operation,
crate::v0::FilesystemOperation::Undelete {
inode_id: InodeId(42),
deletion_seq: ChangeSeq(417),
destination_path: None,
}
));
assert!(
serde_json::from_value::<crate::v0::FilesystemOperation>(serde_json::json!({
"kind": "undelete",
"inode_id": 42,
"deleted_at_seq": 417
}))
.is_err(),
"the retired deletion handle must not decode"
);
}
}