use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use async_trait::async_trait; use serde_json::Value; use tokio::fs; use tokio::io::AsyncWriteExt; use super::store::ContentStore; /// File-based content store (async I/O via tokio::fs). /// Each collection is a subdirectory, each entry a `.json5` file. pub struct FileStore { content_dir: PathBuf, } impl FileStore { pub fn new(content_dir: &Path) -> Self { Self { content_dir: content_dir.to_path_buf(), } } /// Base path for a collection. When locale is Some, content is under content/{locale}/{collection}/. fn collection_dir(&self, collection: &str, locale: Option<&str>) -> PathBuf { match locale { Some(loc) => self.content_dir.join(loc).join(collection), None => self.content_dir.join(collection), } } fn entry_path(&self, collection: &str, slug: &str, locale: Option<&str>) -> PathBuf { self.collection_dir(collection, locale) .join(format!("{}.json5", slug)) } /// Ensure the collection directory exists (no locale: used at startup for all collections). async fn ensure_collection_dir_impl(&self, collection: &str) -> Result<()> { let dir = self.collection_dir(collection, None); if !dir.exists() { fs::create_dir_all(&dir) .await .with_context(|| format!("Failed to create directory: {}", dir.display()))?; } Ok(()) } /// List all entries in a collection (optionally for a locale). async fn list_impl(&self, collection: &str, locale: Option<&str>) -> Result> { let dir = self.collection_dir(collection, locale); if !dir.exists() { return Ok(Vec::new()); } let mut entries = Vec::new(); let mut read_dir = fs::read_dir(&dir) .await .with_context(|| format!("Failed to read directory: {}", dir.display()))?; let mut dir_entries = Vec::new(); while let Some(entry) = read_dir.next_entry().await? { let path = entry.path(); if path .extension() .map(|ext| ext == "json5" || ext == "json") .unwrap_or(false) { dir_entries.push(path); } } dir_entries.sort(); for path in dir_entries { let slug = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or_default() .to_string(); match self.read_file(&path).await { Ok(mut value) => { if let Some(obj) = value.as_object_mut() { obj.insert("_slug".to_string(), Value::String(slug.clone())); } entries.push((slug, value)); } Err(e) => { tracing::warn!("Skipping {}: {}", path.display(), e); } } } Ok(entries) } /// Get a single entry by slug (optionally for a locale). async fn get_impl(&self, collection: &str, slug: &str, locale: Option<&str>) -> Result> { let path = self.entry_path(collection, slug, locale); if !path.exists() { return Ok(None); } let mut value = self.read_file(&path).await?; if let Some(obj) = value.as_object_mut() { obj.insert("_slug".to_string(), Value::String(slug.to_string())); } Ok(Some(value)) } /// Create a new entry (optionally under a locale path). async fn create_impl(&self, collection: &str, slug: &str, data: &Value, locale: Option<&str>) -> Result<()> { let dir = self.collection_dir(collection, locale); if !dir.exists() { fs::create_dir_all(&dir) .await .with_context(|| format!("Failed to create directory: {}", dir.display()))?; } let path = self.entry_path(collection, slug, locale); if path.exists() { anyhow::bail!( "Entry '{}' already exists in collection '{}'", slug, collection ); } self.write_file(&path, data).await } /// Update an existing entry (optionally under a locale path). async fn update_impl(&self, collection: &str, slug: &str, data: &Value, locale: Option<&str>) -> Result<()> { let path = self.entry_path(collection, slug, locale); if !path.exists() { anyhow::bail!( "Entry '{}' not found in collection '{}'", slug, collection ); } self.write_file(&path, data).await } /// Delete an entry (optionally under a locale path). async fn delete_impl(&self, collection: &str, slug: &str, locale: Option<&str>) -> Result<()> { let path = self.entry_path(collection, slug, locale); if !path.exists() { anyhow::bail!( "Entry '{}' not found in collection '{}'", slug, collection ); } fs::remove_file(&path) .await .with_context(|| format!("Failed to delete {}", path.display()))?; Ok(()) } async fn read_file(&self, path: &Path) -> Result { let content = fs::read_to_string(path) .await .with_context(|| format!("Failed to read {}", path.display()))?; let value: Value = json5::from_str(&content) .with_context(|| format!("Failed to parse {}", path.display()))?; Ok(value) } async fn write_file(&self, path: &Path, data: &Value) -> Result<()> { let content = serde_json::to_string_pretty(data)?; let mut file = fs::File::create(path) .await .with_context(|| format!("Failed to create {}", path.display()))?; file.write_all(content.as_bytes()) .await .with_context(|| format!("Failed to write {}", path.display()))?; Ok(()) } } #[async_trait] impl ContentStore for FileStore { async fn ensure_collection_dir(&self, collection: &str) -> Result<()> { self.ensure_collection_dir_impl(collection).await } async fn list(&self, collection: &str, locale: Option<&str>) -> Result> { self.list_impl(collection, locale).await } async fn get(&self, collection: &str, slug: &str, locale: Option<&str>) -> Result> { self.get_impl(collection, slug, locale).await } async fn create(&self, collection: &str, slug: &str, data: &Value, locale: Option<&str>) -> Result<()> { self.create_impl(collection, slug, data, locale).await } async fn update(&self, collection: &str, slug: &str, data: &Value, locale: Option<&str>) -> Result<()> { self.update_impl(collection, slug, data, locale).await } async fn delete(&self, collection: &str, slug: &str, locale: Option<&str>) -> Result<()> { self.delete_impl(collection, slug, locale).await } }