RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
214
src/store/filesystem.rs
Normal file
214
src/store/filesystem.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user