RustyCMS: File-based headless CMS with REST API, admin UI, multilingual support

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Peter Meier
2026-02-16 09:30:30 +01:00
commit aad93d145f
224 changed files with 19225 additions and 0 deletions

214
src/store/filesystem.rs Normal file
View 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
}
}