215 lines
7.0 KiB
Rust
215 lines
7.0 KiB
Rust
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<Vec<(String, Value)>> {
|
|
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<Option<Value>> {
|
|
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<Value> {
|
|
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<Vec<(String, Value)>> {
|
|
self.list_impl(collection, locale).await
|
|
}
|
|
|
|
async fn get(&self, collection: &str, slug: &str, locale: Option<&str>) -> Result<Option<Value>> {
|
|
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
|
|
}
|
|
}
|