Files
IdeaSDK/crates/infrastructure/src/store/template.rs
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

226 lines
7.9 KiB
Rust

//! [`FsTemplateStore`] — file implementation of the [`TemplateStore`] port
//! (ARCHITECTURE §5, §9.2).
//!
//! Templates live in the **global IDE store** (machine-local app-data dir, *not*
//! inside any project): the Markdown content travels as a diffable `.md`, with a
//! small JSON index carrying the metadata (version, hash) needed to list and
//! version templates without parsing every `.md`:
//!
//! ```text
//! <app_data_dir>/templates/
//! ├── index.json # { version, templates: [{ id, name, version, contentHash, defaultProfileId }] }
//! └── md/
//! └── <id>.md # a template's Markdown content
//! ```
//!
//! `contentHash` is a stable digest of the `.md` content, recorded so a future
//! spike can detect **out-of-app edits** (ARCHITECTURE §13.9); it is not part of
//! the [`AgentTemplate`] domain entity. Like the other stores, all I/O goes
//! through the [`FileSystem`] port, so the adapter is Tauri-agnostic.
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use domain::ids::{ProfileId, TemplateId};
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, StoreError, TemplateStore};
use domain::template::{AgentTemplate, TemplateVersion};
/// Directory (under app-data) holding the templates store.
const TEMPLATES_DIR: &str = "templates";
/// Index file name inside the templates dir.
const INDEX_FILE: &str = "index.json";
/// Current schema version of the index file.
const INDEX_VERSION: u32 = 1;
/// One metadata row in `index.json` (the `.md` content lives separately).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexEntry {
id: TemplateId,
name: String,
version: TemplateVersion,
content_hash: String,
default_profile_id: ProfileId,
}
/// On-disk shape of `templates/index.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
version: u32,
templates: Vec<IndexEntry>,
}
impl Default for IndexDoc {
fn default() -> Self {
Self {
version: INDEX_VERSION,
templates: Vec::new(),
}
}
}
/// A stable, dependency-free digest of Markdown content for out-of-app edit
/// detection. `DefaultHasher::new()` uses fixed keys, so this is deterministic
/// across runs and platforms (unlike a `RandomState`-seeded hasher).
fn content_hash(md: &MarkdownDoc) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
md.as_str().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
/// File-backed [`TemplateStore`], composing a [`FileSystem`] port.
#[derive(Clone)]
pub struct FsTemplateStore {
fs: Arc<dyn FileSystem>,
app_data_dir: String,
}
impl FsTemplateStore {
/// Builds the store from an injected [`FileSystem`] and the app-data dir
/// (resolved by the composition root). Directories are created on first write.
#[must_use]
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
Self {
fs,
app_data_dir: app_data_dir.into(),
}
}
/// `<app>/templates`.
fn dir(&self) -> String {
let base = self.app_data_dir.trim_end_matches(['/', '\\']);
format!("{base}/{TEMPLATES_DIR}")
}
/// `<app>/templates/index.json`.
fn index_path(&self) -> RemotePath {
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir()))
}
/// `<app>/templates/md/<id>.md`.
fn md_path(&self, id: TemplateId) -> RemotePath {
RemotePath::new(format!("{}/md/{id}.md", self.dir()))
}
/// Reads the index, returning an empty default if absent.
async fn read_index(&self) -> Result<IndexDoc, StoreError> {
match self.fs.read(&self.index_path()).await {
Ok(bytes) => {
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
}
Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
Err(e) => Err(StoreError::Io(e.to_string())),
}
}
/// Writes the index, ensuring `templates/` exists.
async fn write_index(&self, doc: &IndexDoc) -> Result<(), StoreError> {
self.fs
.create_dir_all(&RemotePath::new(self.dir()))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
let bytes =
serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
self.fs
.write(&self.index_path(), &bytes)
.await
.map_err(|e| StoreError::Io(e.to_string()))
}
/// Reconstructs the [`AgentTemplate`] for an index entry by reading its `.md`.
async fn load(&self, entry: &IndexEntry) -> Result<AgentTemplate, StoreError> {
let bytes = self
.fs
.read(&self.md_path(entry.id))
.await
.map_err(|e| match e {
domain::ports::FsError::NotFound(_) => StoreError::NotFound,
other => StoreError::Io(other.to_string()),
})?;
let content =
String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
// The domain entity carries the authoritative version/name from the index;
// we reconstruct it directly (no public mutator needed) since every field
// is known and already validated when it was first saved.
Ok(AgentTemplate {
id: entry.id,
name: entry.name.clone(),
content_md: MarkdownDoc::new(content),
version: entry.version,
default_profile_id: entry.default_profile_id,
})
}
}
#[async_trait]
impl TemplateStore for FsTemplateStore {
async fn list(&self) -> Result<Vec<AgentTemplate>, StoreError> {
let index = self.read_index().await?;
let mut out = Vec::with_capacity(index.templates.len());
for entry in &index.templates {
out.push(self.load(entry).await?);
}
Ok(out)
}
async fn get(&self, id: TemplateId) -> Result<AgentTemplate, StoreError> {
let index = self.read_index().await?;
let entry = index
.templates
.iter()
.find(|e| e.id == id)
.ok_or(StoreError::NotFound)?;
self.load(entry).await
}
async fn save(&self, template: &AgentTemplate) -> Result<(), StoreError> {
// (1) Write the Markdown content.
self.fs
.create_dir_all(&RemotePath::new(format!("{}/md", self.dir())))
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
self.fs
.write(
&self.md_path(template.id),
template.content_md.as_str().as_bytes(),
)
.await
.map_err(|e| StoreError::Io(e.to_string()))?;
// (2) Upsert the index metadata.
let mut index = self.read_index().await?;
let row = IndexEntry {
id: template.id,
name: template.name.clone(),
version: template.version,
content_hash: content_hash(&template.content_md),
default_profile_id: template.default_profile_id,
};
if let Some(slot) = index.templates.iter_mut().find(|e| e.id == template.id) {
*slot = row;
} else {
index.templates.push(row);
}
self.write_index(&index).await
}
async fn delete(&self, id: TemplateId) -> Result<(), StoreError> {
let mut index = self.read_index().await?;
let before = index.templates.len();
index.templates.retain(|e| e.id != id);
if index.templates.len() == before {
return Err(StoreError::NotFound);
}
// The orphaned `md/<id>.md` is left on disk (the FileSystem port exposes no
// delete); the index no longer references it, so it is effectively gone.
self.write_index(&index).await
}
}