use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VirtualPath(String);
impl VirtualPath {
pub fn new(s: impl AsRef<str>) -> Result<Self, crate::error::AgentdirError> {
let raw = s.as_ref();
let normalized_separators = raw.replace('\\', "/");
let s = normalized_separators.as_str();
if s.is_empty() {
return Err(crate::error::AgentdirError::InvalidPath(
"empty path".into(),
));
}
let is_absolute = s.starts_with('/');
let mut components: Vec<String> = Vec::new();
for component in Path::new(s).components() {
use std::path::Component;
match component {
Component::Normal(part) => components.push(part.to_string_lossy().into_owned()),
Component::CurDir => {}
Component::ParentDir => {
if !components.is_empty() {
components.pop();
}
}
Component::RootDir => {}
Component::Prefix(_) => {}
}
}
let normalized = if is_absolute {
match components.is_empty() {
true => "/".to_string(),
false => format!("/{}", components.join("/")),
}
} else {
components.join("/")
};
if normalized.is_empty() {
return Err(crate::error::AgentdirError::InvalidPath(
"path normalizes to empty".into(),
));
}
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn as_path(&self) -> &Path {
Path::new(&self.0)
}
pub fn is_absolute(&self) -> bool {
self.0.starts_with('/')
}
pub fn parent(&self) -> Option<VirtualPath> {
self.as_path()
.parent()
.map(|p| VirtualPath(p.to_string_lossy().into_owned()))
}
pub fn file_name(&self) -> Option<&str> {
self.as_path().file_name().and_then(|n| n.to_str())
}
pub fn starts_with_path(&self, other: &VirtualPath) -> bool {
self.as_path().starts_with(other.as_path())
}
}
impl fmt::Display for VirtualPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<Path> for VirtualPath {
fn as_ref(&self) -> &Path {
self.as_path()
}
}
impl From<PathBuf> for VirtualPath {
fn from(p: PathBuf) -> Self {
VirtualPath::new(p.to_string_lossy()).expect("PathBuf must convert to a valid VirtualPath")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SourcePath(PathBuf);
impl SourcePath {
pub fn new(p: PathBuf) -> Self {
Self(p)
}
pub fn as_path(&self) -> &Path {
&self.0
}
pub fn to_path_buf(&self) -> PathBuf {
self.0.clone()
}
pub fn starts_with(&self, other: &SourcePath) -> bool {
self.0.starts_with(&other.0)
}
}
impl fmt::Display for SourcePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.display())
}
}
impl AsRef<Path> for SourcePath {
fn as_ref(&self) -> &Path {
&self.0
}
}
impl From<PathBuf> for SourcePath {
fn from(p: PathBuf) -> Self {
Self(p)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentHash(pub [u8; 32]);
impl fmt::Display for ContentHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in &self.0 {
write!(f, "{:02x}", byte)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MappingDirection {
SourceToVirtual,
VirtualToSource,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MaterializeStrategy {
#[default]
Reflink,
Symlink,
Virtual,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum EntryType {
File,
Directory,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceMetadata {
pub mtime_ns: u128,
pub size_bytes: u64,
pub entry_type: EntryType,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatalogEntry {
pub virtual_path: VirtualPath,
pub source_path: SourcePath,
pub content_hash: Option<ContentHash>,
pub metadata: SourceMetadata,
pub materialized: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VirtualStat {
pub virtual_path: VirtualPath,
pub source_path: SourcePath,
pub size_bytes: u64,
pub mtime_ns: u128,
pub entry_type: EntryType,
pub materialized: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRoot {
pub source_path: SourcePath,
pub virtual_mount: VirtualPath,
pub recursive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub version: u32,
pub created_at_epoch_secs: u64,
pub updated_at_epoch_secs: u64,
#[serde(default)]
pub strategy: MaterializeStrategy,
pub source_roots: Vec<SourceRoot>,
pub entries: Vec<CatalogEntry>,
}
impl Manifest {
pub fn new() -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
version: 1,
created_at_epoch_secs: now,
updated_at_epoch_secs: now,
strategy: MaterializeStrategy::default(),
source_roots: Vec::new(),
entries: Vec::new(),
}
}
pub fn touch(&mut self) {
self.updated_at_epoch_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
}
}
impl Default for Manifest {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_virtual_path_normalization() {
let p = VirtualPath::new("/foo/bar/").unwrap();
assert_eq!(p.as_str(), "/foo/bar");
let p = VirtualPath::new("/foo/./bar").unwrap();
assert_eq!(p.as_str(), "/foo/bar");
assert!(VirtualPath::new("").is_err());
}
#[test]
fn test_catalog_entry_roundtrip() {
use std::path::PathBuf;
let entry = CatalogEntry {
virtual_path: VirtualPath::new("/docs/readme.md").unwrap(),
source_path: SourcePath::new(PathBuf::from("/home/user/readme.md")),
content_hash: None,
metadata: SourceMetadata {
mtime_ns: 1_000_000_000,
size_bytes: 42,
entry_type: EntryType::File,
},
materialized: false,
};
let json = serde_json::to_string(&entry).unwrap();
let decoded: CatalogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry.virtual_path.as_str(), decoded.virtual_path.as_str());
assert_eq!(entry.metadata.size_bytes, decoded.metadata.size_bytes);
}
#[test]
fn test_manifest_version_field() {
let manifest = Manifest::new();
assert_eq!(manifest.version, 1);
let json = serde_json::to_string(&manifest).unwrap();
assert!(json.contains("\"version\":1"));
}
#[test]
fn test_content_hash_display() {
let hash = ContentHash([0u8; 32]);
let s = format!("{}", hash);
assert_eq!(s.len(), 64);
assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
}
}