1use anyhow::{Context, Result, bail};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use tokio::sync::RwLock;
22use tracing::{debug, info, warn};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum DiskType {
27 Private,
29 Public,
31 Shared,
33}
34
35impl DiskType {
36 pub fn as_dir_name(&self) -> &'static str {
38 match self {
39 DiskType::Private => "private",
40 DiskType::Public => "public",
41 DiskType::Shared => "shared",
42 }
43 }
44}
45
46impl std::fmt::Display for DiskType {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 DiskType::Private => write!(f, "private"),
50 DiskType::Public => write!(f, "public"),
51 DiskType::Shared => write!(f, "shared"),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FileInfo {
59 pub path: String,
61 pub name: String,
63 pub is_directory: bool,
65 pub size_bytes: u64,
67 pub modified_at: i64,
69 pub content_hash: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DiskStats {
76 pub entity_id: String,
78 pub disk_type: DiskType,
80 pub used_bytes: u64,
82 pub file_count: u32,
84 pub dir_count: u32,
86 pub last_modified: i64,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92struct DiskFileMetadata {
93 pub entity_id: String,
94 pub disk_type: DiskType,
95 pub path: String,
96 pub name: String,
97 pub is_directory: bool,
98 pub size_bytes: u64,
99 pub modified_at: i64,
100 pub content_hash: String,
101 pub file_path: PathBuf, }
103
104#[derive(Debug)]
106pub struct EntityDiskService {
107 root: PathBuf,
109 index: RwLock<HashMap<String, DiskFileMetadata>>,
111}
112
113impl EntityDiskService {
114 pub async fn new<P: AsRef<Path>>(root: P) -> Result<Self> {
116 let root = root.as_ref().to_path_buf();
117
118 tokio::fs::create_dir_all(&root)
120 .await
121 .with_context(|| format!("Failed to create disk root: {}", root.display()))?;
122
123 let service = Self {
124 root,
125 index: RwLock::new(HashMap::new()),
126 };
127
128 service.load_index().await?;
130
131 info!(
132 "EntityDiskService initialized at {}",
133 service.root.display()
134 );
135 Ok(service)
136 }
137
138 fn get_entity_disk_path(&self, entity_id: &str, disk_type: DiskType) -> PathBuf {
140 self.root
141 .join("entities")
142 .join(entity_id)
143 .join(disk_type.as_dir_name())
144 }
145
146 fn get_file_path(&self, entity_id: &str, disk_type: DiskType, path: &str) -> PathBuf {
148 let disk_path = self.get_entity_disk_path(entity_id, disk_type);
149 let clean_path = path.trim_start_matches('/').replace("..", "");
151 disk_path.join(clean_path)
152 }
153
154 fn index_key(entity_id: &str, disk_type: DiskType, path: &str) -> String {
156 format!("{}:{}:{}", entity_id, disk_type, path)
157 }
158
159 pub async fn write_file(
161 &self,
162 entity_id: &str,
163 disk_type: DiskType,
164 path: &str,
165 data: &[u8],
166 ) -> Result<FileInfo> {
167 if path.is_empty() || path == "/" {
169 bail!("Invalid file path: cannot write to root");
170 }
171
172 let file_path = self.get_file_path(entity_id, disk_type, path);
173
174 if let Some(parent) = file_path.parent() {
176 tokio::fs::create_dir_all(parent).await.with_context(|| {
177 format!(
178 "Failed to create parent directories for {}",
179 file_path.display()
180 )
181 })?;
182 }
183
184 tokio::fs::write(&file_path, data)
186 .await
187 .with_context(|| format!("Failed to write file: {}", file_path.display()))?;
188
189 let content_hash = blake3::hash(data).to_string();
191
192 let name = Path::new(path)
194 .file_name()
195 .map(|s| s.to_string_lossy().to_string())
196 .unwrap_or_else(|| path.to_string());
197
198 let now = chrono::Utc::now().timestamp();
199
200 let metadata = DiskFileMetadata {
202 entity_id: entity_id.to_string(),
203 disk_type,
204 path: path.to_string(),
205 name: name.clone(),
206 is_directory: false,
207 size_bytes: data.len() as u64,
208 modified_at: now,
209 content_hash: content_hash.clone(),
210 file_path: file_path.clone(),
211 };
212
213 {
215 let key = Self::index_key(entity_id, disk_type, path);
216 let mut index = self.index.write().await;
217 index.insert(key, metadata);
218 }
219
220 self.save_index().await?;
222
223 debug!(
224 "Wrote file {}:{}{} ({} bytes)",
225 entity_id,
226 disk_type,
227 path,
228 data.len()
229 );
230
231 Ok(FileInfo {
232 path: path.to_string(),
233 name,
234 is_directory: false,
235 size_bytes: data.len() as u64,
236 modified_at: now,
237 content_hash,
238 })
239 }
240
241 pub async fn read_file(
243 &self,
244 entity_id: &str,
245 disk_type: DiskType,
246 path: &str,
247 ) -> Result<Vec<u8>> {
248 let key = Self::index_key(entity_id, disk_type, path);
249
250 let metadata = {
252 let index = self.index.read().await;
253 index.get(&key).cloned()
254 };
255
256 let file_path = match metadata {
257 Some(meta) => {
258 if meta.is_directory {
259 bail!("Cannot read directory as file: {}", path);
260 }
261 meta.file_path
262 }
263 None => {
264 let fp = self.get_file_path(entity_id, disk_type, path);
266 if !fp.exists() {
267 bail!("File not found: {}:{}{}", entity_id, disk_type, path);
268 }
269 fp
270 }
271 };
272
273 let data = tokio::fs::read(&file_path)
275 .await
276 .with_context(|| format!("Failed to read file: {}", file_path.display()))?;
277
278 debug!(
279 "Read file {}:{}{} ({} bytes)",
280 entity_id,
281 disk_type,
282 path,
283 data.len()
284 );
285
286 Ok(data)
287 }
288
289 pub async fn list_files(
291 &self,
292 entity_id: &str,
293 disk_type: DiskType,
294 path: &str,
295 ) -> Result<Vec<FileInfo>> {
296 let dir_path = if path.is_empty() || path == "/" {
297 self.get_entity_disk_path(entity_id, disk_type)
298 } else {
299 self.get_file_path(entity_id, disk_type, path)
300 };
301
302 if !dir_path.exists() {
304 tokio::fs::create_dir_all(&dir_path)
305 .await
306 .with_context(|| format!("Failed to create directory: {}", dir_path.display()))?;
307 }
308
309 if !dir_path.is_dir() {
310 bail!("Path is not a directory: {}", path);
311 }
312
313 let mut entries = Vec::new();
314 let mut read_dir = tokio::fs::read_dir(&dir_path)
315 .await
316 .with_context(|| format!("Failed to read directory: {}", dir_path.display()))?;
317
318 while let Some(entry) = read_dir.next_entry().await? {
319 let entry_path = entry.path();
320 let metadata = entry.metadata().await?;
321
322 let name = entry.file_name().to_string_lossy().to_string();
323
324 if name.starts_with('.') || name == "disk_index.json" {
326 continue;
327 }
328
329 let entry_relative_path = if path.is_empty() || path == "/" {
330 format!("/{}", name)
331 } else {
332 format!("{}/{}", path.trim_end_matches('/'), name)
333 };
334
335 let (content_hash, size_bytes) = if metadata.is_file() {
336 let data = tokio::fs::read(&entry_path).await?;
337 (blake3::hash(&data).to_string(), data.len() as u64)
338 } else {
339 (String::new(), 0)
340 };
341
342 let modified_at = metadata
343 .modified()
344 .ok()
345 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
346 .map(|d| d.as_secs() as i64)
347 .unwrap_or(0);
348
349 entries.push(FileInfo {
350 path: entry_relative_path,
351 name,
352 is_directory: metadata.is_dir(),
353 size_bytes,
354 modified_at,
355 content_hash,
356 });
357 }
358
359 entries.sort_by(|a, b| a.name.cmp(&b.name));
361
362 debug!(
363 "Listed {}:{}{} - {} entries",
364 entity_id,
365 disk_type,
366 path,
367 entries.len()
368 );
369
370 Ok(entries)
371 }
372
373 pub async fn delete_file(
375 &self,
376 entity_id: &str,
377 disk_type: DiskType,
378 path: &str,
379 ) -> Result<()> {
380 if path.is_empty() || path == "/" {
381 bail!("Cannot delete root directory");
382 }
383
384 let file_path = self.get_file_path(entity_id, disk_type, path);
385
386 if !file_path.exists() {
387 bail!("File not found: {}:{}{}", entity_id, disk_type, path);
388 }
389
390 if file_path.is_dir() {
391 tokio::fs::remove_dir_all(&file_path)
392 .await
393 .with_context(|| format!("Failed to delete directory: {}", file_path.display()))?;
394 } else {
395 tokio::fs::remove_file(&file_path)
396 .await
397 .with_context(|| format!("Failed to delete file: {}", file_path.display()))?;
398 }
399
400 {
402 let key = Self::index_key(entity_id, disk_type, path);
403 let mut index = self.index.write().await;
404 index.remove(&key);
405 }
406
407 self.save_index().await?;
409
410 debug!("Deleted {}:{}{}", entity_id, disk_type, path);
411
412 Ok(())
413 }
414
415 pub async fn get_stats(&self, entity_id: &str, disk_type: DiskType) -> Result<DiskStats> {
417 let disk_path = self.get_entity_disk_path(entity_id, disk_type);
418
419 let mut used_bytes: u64 = 0;
420 let mut file_count: u32 = 0;
421 let mut dir_count: u32 = 0;
422 let mut last_modified: i64 = 0;
423
424 if disk_path.exists() {
425 self.calculate_stats_recursive(
426 &disk_path,
427 &mut used_bytes,
428 &mut file_count,
429 &mut dir_count,
430 &mut last_modified,
431 )
432 .await?;
433 }
434
435 Ok(DiskStats {
436 entity_id: entity_id.to_string(),
437 disk_type,
438 used_bytes,
439 file_count,
440 dir_count,
441 last_modified,
442 })
443 }
444
445 async fn calculate_stats_recursive(
447 &self,
448 path: &Path,
449 used_bytes: &mut u64,
450 file_count: &mut u32,
451 dir_count: &mut u32,
452 last_modified: &mut i64,
453 ) -> Result<()> {
454 let mut read_dir = tokio::fs::read_dir(path).await?;
455
456 while let Some(entry) = read_dir.next_entry().await? {
457 let entry_path = entry.path();
458 let metadata = entry.metadata().await?;
459
460 let name = entry.file_name().to_string_lossy().to_string();
462 if name.starts_with('.') || name == "disk_index.json" {
463 continue;
464 }
465
466 let modified = metadata
467 .modified()
468 .ok()
469 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
470 .map(|d| d.as_secs() as i64)
471 .unwrap_or(0);
472
473 if modified > *last_modified {
474 *last_modified = modified;
475 }
476
477 if metadata.is_dir() {
478 *dir_count += 1;
479 Box::pin(self.calculate_stats_recursive(
480 &entry_path,
481 used_bytes,
482 file_count,
483 dir_count,
484 last_modified,
485 ))
486 .await?;
487 } else {
488 *file_count += 1;
489 *used_bytes += metadata.len();
490 }
491 }
492
493 Ok(())
494 }
495
496 pub async fn create_directory(
498 &self,
499 entity_id: &str,
500 disk_type: DiskType,
501 path: &str,
502 ) -> Result<FileInfo> {
503 if path.is_empty() || path == "/" {
504 bail!("Cannot create root directory");
505 }
506
507 let dir_path = self.get_file_path(entity_id, disk_type, path);
508
509 tokio::fs::create_dir_all(&dir_path)
510 .await
511 .with_context(|| format!("Failed to create directory: {}", dir_path.display()))?;
512
513 let name = Path::new(path)
514 .file_name()
515 .map(|s| s.to_string_lossy().to_string())
516 .unwrap_or_else(|| path.to_string());
517
518 let now = chrono::Utc::now().timestamp();
519
520 let metadata = DiskFileMetadata {
522 entity_id: entity_id.to_string(),
523 disk_type,
524 path: path.to_string(),
525 name: name.clone(),
526 is_directory: true,
527 size_bytes: 0,
528 modified_at: now,
529 content_hash: String::new(),
530 file_path: dir_path,
531 };
532
533 {
535 let key = Self::index_key(entity_id, disk_type, path);
536 let mut index = self.index.write().await;
537 index.insert(key, metadata);
538 }
539
540 self.save_index().await?;
542
543 debug!("Created directory {}:{}{}", entity_id, disk_type, path);
544
545 Ok(FileInfo {
546 path: path.to_string(),
547 name,
548 is_directory: true,
549 size_bytes: 0,
550 modified_at: now,
551 content_hash: String::new(),
552 })
553 }
554
555 pub async fn file_exists(&self, entity_id: &str, disk_type: DiskType, path: &str) -> bool {
557 let file_path = self.get_file_path(entity_id, disk_type, path);
558 file_path.exists()
559 }
560
561 pub async fn get_file_info(
563 &self,
564 entity_id: &str,
565 disk_type: DiskType,
566 path: &str,
567 ) -> Result<FileInfo> {
568 let file_path = self.get_file_path(entity_id, disk_type, path);
569
570 if !file_path.exists() {
571 bail!("File not found: {}:{}{}", entity_id, disk_type, path);
572 }
573
574 let metadata = tokio::fs::metadata(&file_path).await?;
575 let is_directory = metadata.is_dir();
576
577 let name = Path::new(path)
578 .file_name()
579 .map(|s| s.to_string_lossy().to_string())
580 .unwrap_or_else(|| path.to_string());
581
582 let modified_at = metadata
583 .modified()
584 .ok()
585 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
586 .map(|d| d.as_secs() as i64)
587 .unwrap_or(0);
588
589 let (size_bytes, content_hash) = if is_directory {
590 (0, String::new())
591 } else {
592 let data = tokio::fs::read(&file_path).await?;
593 (data.len() as u64, blake3::hash(&data).to_string())
594 };
595
596 Ok(FileInfo {
597 path: path.to_string(),
598 name,
599 is_directory,
600 size_bytes,
601 modified_at,
602 content_hash,
603 })
604 }
605
606 async fn load_index(&self) -> Result<()> {
608 let index_path = self.root.join("disk_index.json");
609
610 if !index_path.exists() {
611 debug!("No existing disk index found, starting fresh");
612 return Ok(());
613 }
614
615 match tokio::fs::read_to_string(&index_path).await {
616 Ok(data) => match serde_json::from_str::<HashMap<String, DiskFileMetadata>>(&data) {
617 Ok(stored_index) => {
618 let count = stored_index.len();
619 let mut index = self.index.write().await;
620 *index = stored_index;
621 info!("Loaded {} entries from disk index", count);
622 }
623 Err(e) => {
624 warn!("Failed to parse disk index, starting fresh: {}", e);
625 }
626 },
627 Err(e) => {
628 warn!("Failed to read disk index, starting fresh: {}", e);
629 }
630 }
631
632 Ok(())
633 }
634
635 async fn save_index(&self) -> Result<()> {
637 let index_path = self.root.join("disk_index.json");
638 let temp_path = self.root.join(".disk_index.tmp");
639
640 let data = {
641 let index = self.index.read().await;
642 serde_json::to_string_pretty(&*index).context("Failed to serialize disk index")?
643 };
644
645 tokio::fs::write(&temp_path, &data)
647 .await
648 .context("Failed to write temp index file")?;
649
650 tokio::fs::rename(&temp_path, &index_path)
652 .await
653 .context("Failed to move index file")?;
654
655 Ok(())
656 }
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662 use tempfile::tempdir;
663
664 #[tokio::test]
665 async fn test_write_and_read_file() {
666 let temp = tempdir().unwrap();
667 let service = EntityDiskService::new(temp.path()).await.unwrap();
668
669 let entity_id = "test-entity-one-two";
670 let data = b"Hello, World!";
671
672 let info = service
674 .write_file(entity_id, DiskType::Private, "/docs/test.txt", data)
675 .await
676 .unwrap();
677
678 assert_eq!(info.name, "test.txt");
679 assert_eq!(info.size_bytes, 13);
680 assert!(!info.is_directory);
681
682 let read_data = service
684 .read_file(entity_id, DiskType::Private, "/docs/test.txt")
685 .await
686 .unwrap();
687
688 assert_eq!(read_data, data);
689 }
690
691 #[tokio::test]
692 async fn test_list_files() {
693 let temp = tempdir().unwrap();
694 let service = EntityDiskService::new(temp.path()).await.unwrap();
695
696 let entity_id = "test-entity-one-two";
697
698 service
700 .write_file(entity_id, DiskType::Public, "/file1.txt", b"one")
701 .await
702 .unwrap();
703 service
704 .write_file(entity_id, DiskType::Public, "/file2.txt", b"two")
705 .await
706 .unwrap();
707
708 let files = service
710 .list_files(entity_id, DiskType::Public, "/")
711 .await
712 .unwrap();
713
714 assert_eq!(files.len(), 2);
715 }
716
717 #[tokio::test]
718 async fn test_delete_file() {
719 let temp = tempdir().unwrap();
720 let service = EntityDiskService::new(temp.path()).await.unwrap();
721
722 let entity_id = "test-entity-one-two";
723
724 service
726 .write_file(entity_id, DiskType::Shared, "/to_delete.txt", b"delete me")
727 .await
728 .unwrap();
729
730 assert!(
732 service
733 .file_exists(entity_id, DiskType::Shared, "/to_delete.txt")
734 .await
735 );
736
737 service
739 .delete_file(entity_id, DiskType::Shared, "/to_delete.txt")
740 .await
741 .unwrap();
742
743 assert!(
745 !service
746 .file_exists(entity_id, DiskType::Shared, "/to_delete.txt")
747 .await
748 );
749 }
750
751 #[tokio::test]
752 async fn test_get_stats() {
753 let temp = tempdir().unwrap();
754 let service = EntityDiskService::new(temp.path()).await.unwrap();
755
756 let entity_id = "test-entity-one-two";
757
758 service
760 .write_file(entity_id, DiskType::Private, "/file1.txt", b"hello")
761 .await
762 .unwrap();
763 service
764 .write_file(entity_id, DiskType::Private, "/dir/file2.txt", b"world")
765 .await
766 .unwrap();
767
768 let stats = service
770 .get_stats(entity_id, DiskType::Private)
771 .await
772 .unwrap();
773
774 assert_eq!(stats.file_count, 2);
775 assert_eq!(stats.used_bytes, 10); assert_eq!(stats.dir_count, 1); }
778
779 #[tokio::test]
780 async fn test_create_directory() {
781 let temp = tempdir().unwrap();
782 let service = EntityDiskService::new(temp.path()).await.unwrap();
783
784 let entity_id = "test-entity-one-two";
785
786 let info = service
788 .create_directory(entity_id, DiskType::Public, "/my-folder")
789 .await
790 .unwrap();
791
792 assert_eq!(info.name, "my-folder");
793 assert!(info.is_directory);
794
795 assert!(
797 service
798 .file_exists(entity_id, DiskType::Public, "/my-folder")
799 .await
800 );
801 }
802
803 #[tokio::test]
804 async fn test_disk_types() {
805 let temp = tempdir().unwrap();
806 let service = EntityDiskService::new(temp.path()).await.unwrap();
807
808 let entity_id = "test-entity-one-two";
809
810 service
812 .write_file(entity_id, DiskType::Private, "/private.txt", b"private")
813 .await
814 .unwrap();
815 service
816 .write_file(entity_id, DiskType::Public, "/public.txt", b"public")
817 .await
818 .unwrap();
819 service
820 .write_file(entity_id, DiskType::Shared, "/shared.txt", b"shared")
821 .await
822 .unwrap();
823
824 assert!(
826 service
827 .file_exists(entity_id, DiskType::Private, "/private.txt")
828 .await
829 );
830 assert!(
831 !service
832 .file_exists(entity_id, DiskType::Public, "/private.txt")
833 .await
834 );
835 assert!(
836 !service
837 .file_exists(entity_id, DiskType::Shared, "/private.txt")
838 .await
839 );
840 }
841}