Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
226 lines
7.9 KiB
Rust
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
|
|
}
|
|
}
|