feat(memory): système de mémoire projet model-agnostic (L14, LOT A+B+C)
Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.
LOT A — étage 1 (.md, source de vérité)
- domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
- port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
- application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
ResolveLinks), From<MemoryError> for AppError
- app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
- suppression de la variante morte DomainError::MalformedFrontmatter
LOT B — rappel adaptatif (étage 1)
- port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
au budget de tokens, court-circuit budget-0), use case RecallMemory
LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
- port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
EmbedderStrategy (embedder.json)
- VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
- AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
- HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)
Tests: 57 binaires verts, build + clippy --workspace sans warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -6,7 +6,8 @@
|
||||
//! with one error shape when building its `ErrorDTO`.
|
||||
|
||||
use domain::ports::{
|
||||
FsError, GitError, ProcessError, PtyError, RemoteError, RuntimeError, StoreError,
|
||||
EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError,
|
||||
RuntimeError, StoreError,
|
||||
};
|
||||
|
||||
/// Errors surfaced by application use cases.
|
||||
@ -84,6 +85,26 @@ impl From<StoreError> for AppError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MemoryError> for AppError {
|
||||
fn from(e: MemoryError) -> Self {
|
||||
match e {
|
||||
MemoryError::NotFound => Self::NotFound("memory note".to_owned()),
|
||||
MemoryError::Frontmatter(m) => Self::Invalid(m),
|
||||
other => Self::Store(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EmbedderError> for AppError {
|
||||
/// Maps to [`AppError::Store`] — an embedder is a *derived* recall detail; its
|
||||
/// failure must degrade the recall (fallback to naïve), never fail hard. This
|
||||
/// mapping exists for completeness; recall adapters degrade *before* an
|
||||
/// embedder error ever reaches a use case.
|
||||
fn from(e: EmbedderError) -> Self {
|
||||
Self::Store(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PtyError> for AppError {
|
||||
fn from(e: PtyError) -> Self {
|
||||
Self::Process(e.to_string())
|
||||
|
||||
@ -16,6 +16,7 @@ pub mod error;
|
||||
pub mod git;
|
||||
pub mod health;
|
||||
pub mod layout;
|
||||
pub mod memory;
|
||||
pub mod orchestrator;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
@ -53,6 +54,13 @@ pub use layout::{
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents,
|
||||
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
};
|
||||
pub use memory::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput,
|
||||
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
|
||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
|
||||
23
crates/application/src/memory/mod.rs
Normal file
23
crates/application/src/memory/mod.rs
Normal file
@ -0,0 +1,23 @@
|
||||
//! Memory use cases (ARCHITECTURE §14.5.1; LOT A, étage 1: `.md`).
|
||||
//!
|
||||
//! The memory module owns the CRUD of a project's persistent knowledge base —
|
||||
//! one Markdown note per [`domain::memory::Memory`], stored under
|
||||
//! `.ideai/memory/<slug>.md` with a derived `MEMORY.md` index. On top of the
|
||||
//! CRUD it exposes two read-only navigation helpers driving the graphical memory
|
||||
//! view: [`ReadMemoryIndex`] (the structured index) and [`ResolveMemoryLinks`]
|
||||
//! (a note's resolved `[[slug]]` outgoing links, broken links dropped).
|
||||
//!
|
||||
//! Every use case talks only to ports ([`domain::ports::MemoryStore`],
|
||||
//! [`domain::ports::EventBus`]). Mutating use cases ([`CreateMemory`],
|
||||
//! [`UpdateMemory`], [`DeleteMemory`]) announce a [`domain::DomainEvent`]; the
|
||||
//! read use cases emit nothing.
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput,
|
||||
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
|
||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
414
crates/application/src/memory/usecases.rs
Normal file
414
crates/application/src/memory/usecases.rs
Normal file
@ -0,0 +1,414 @@
|
||||
//! Memory use cases (ARCHITECTURE §14.5.1).
|
||||
//!
|
||||
//! - **CRUD**: [`CreateMemory`], [`UpdateMemory`], [`GetMemory`],
|
||||
//! [`ListMemories`], [`DeleteMemory`].
|
||||
//! - **Navigation** (read-only): [`ReadMemoryIndex`] (structured `MEMORY.md`
|
||||
//! rows) and [`ResolveMemoryLinks`] (a note's outgoing `[[slug]]` links).
|
||||
//!
|
||||
//! Mutating use cases announce a [`DomainEvent`]; the read use cases emit none.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, MemoryQuery, MemoryRecall, MemoryStore};
|
||||
use domain::{
|
||||
DomainEvent, MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug,
|
||||
MemoryType, ProjectPath,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CreateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateMemoryInput {
|
||||
/// Active project root (the note is stored under its `.ideai/memory/`).
|
||||
pub project_root: ProjectPath,
|
||||
/// Raw slug for the new note (validated into a [`MemorySlug`]).
|
||||
pub name: String,
|
||||
/// Human-readable one-line description (the index hook). Non-empty.
|
||||
pub description: String,
|
||||
/// The note's kind.
|
||||
pub r#type: MemoryType,
|
||||
/// Markdown body of the note.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Output of [`CreateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateMemoryOutput {
|
||||
/// The created note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Creates a memory note in the project's store and upserts the index.
|
||||
///
|
||||
/// Announces [`DomainEvent::MemorySaved`] on success.
|
||||
pub struct CreateMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl CreateMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Executes creation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if `name` is not a valid kebab-case slug or the
|
||||
/// note's invariants are violated (empty description/body),
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: CreateMemoryInput) -> Result<CreateMemoryOutput, AppError> {
|
||||
let slug = MemorySlug::new(input.name).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: input.description,
|
||||
r#type: input.r#type,
|
||||
};
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memories.save(&input.project_root, &memory).await?;
|
||||
self.events.publish(DomainEvent::MemorySaved { slug });
|
||||
Ok(CreateMemoryOutput { memory })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`UpdateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to replace.
|
||||
pub slug: MemorySlug,
|
||||
/// New description (the index hook). Non-empty.
|
||||
pub description: String,
|
||||
/// New kind.
|
||||
pub r#type: MemoryType,
|
||||
/// New Markdown body.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Output of [`UpdateMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateMemoryOutput {
|
||||
/// The updated note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Replaces a memory note's content (re-validating its invariants).
|
||||
///
|
||||
/// Announces [`DomainEvent::MemorySaved`] on success. The input carries the full
|
||||
/// note, so no prior `get` is needed — this is replace semantics.
|
||||
pub struct UpdateMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl UpdateMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the note's invariants are violated (empty
|
||||
/// description/body),
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: UpdateMemoryInput) -> Result<UpdateMemoryOutput, AppError> {
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: input.slug.clone(),
|
||||
description: input.description,
|
||||
r#type: input.r#type,
|
||||
};
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memories.save(&input.project_root, &memory).await?;
|
||||
self.events.publish(DomainEvent::MemorySaved { slug: input.slug });
|
||||
Ok(UpdateMemoryOutput { memory })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListMemories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ListMemories::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListMemoriesInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`ListMemories::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListMemoriesOutput {
|
||||
/// All notes in the project.
|
||||
pub memories: Vec<Memory>,
|
||||
}
|
||||
|
||||
/// Lists the memory notes of a project.
|
||||
pub struct ListMemories {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ListMemories {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Lists the project's notes.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if a note's frontmatter is malformed,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: ListMemoriesInput) -> Result<ListMemoriesOutput, AppError> {
|
||||
Ok(ListMemoriesOutput {
|
||||
memories: self.memories.list(&input.project_root).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`GetMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to read.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Output of [`GetMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GetMemoryOutput {
|
||||
/// The requested note.
|
||||
pub memory: Memory,
|
||||
}
|
||||
|
||||
/// Reads one memory note by slug.
|
||||
pub struct GetMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl GetMemory {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Reads the note.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if no note carries that slug,
|
||||
/// - [`AppError::Invalid`] if the note's frontmatter is malformed,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: GetMemoryInput) -> Result<GetMemoryOutput, AppError> {
|
||||
Ok(GetMemoryOutput {
|
||||
memory: self.memories.get(&input.project_root, &input.slug).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the note to delete.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Deletes a memory note (and removes its index row).
|
||||
///
|
||||
/// Announces [`DomainEvent::MemoryDeleted`] on success.
|
||||
pub struct DeleteMemory {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
events: Arc<dyn EventBus>,
|
||||
}
|
||||
|
||||
impl DeleteMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self { memories, events }
|
||||
}
|
||||
|
||||
/// Deletes the note.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if no note carries that slug,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> {
|
||||
self.memories.delete(&input.project_root, &input.slug).await?;
|
||||
self.events.publish(DomainEvent::MemoryDeleted { slug: input.slug });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadMemoryIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ReadMemoryIndex::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadMemoryIndexInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`ReadMemoryIndex::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadMemoryIndexOutput {
|
||||
/// One row per note in the aggregated `MEMORY.md` index.
|
||||
pub entries: Vec<MemoryIndexEntry>,
|
||||
}
|
||||
|
||||
/// Reads the structured `MEMORY.md` index (drives the graphical memory view).
|
||||
pub struct ReadMemoryIndex {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ReadMemoryIndex {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Reads the index rows.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Store`] on an I/O failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReadMemoryIndexInput,
|
||||
) -> Result<ReadMemoryIndexOutput, AppError> {
|
||||
Ok(ReadMemoryIndexOutput {
|
||||
entries: self.memories.read_index(&input.project_root).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecallMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`RecallMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecallMemoryInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// The recall query (often the agent's current working context).
|
||||
pub text: String,
|
||||
/// Approximate token budget bounding the returned entries (`0` ⇒ empty).
|
||||
pub token_budget: usize,
|
||||
}
|
||||
|
||||
/// Output of [`RecallMemory::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecallMemoryOutput {
|
||||
/// The recalled index entries, in relevance order, fitting the budget.
|
||||
pub entries: Vec<MemoryIndexEntry>,
|
||||
}
|
||||
|
||||
/// Recalls the most relevant memory entries for a query within a token budget
|
||||
/// (LOT B, étage 1). Read-only — emits no event.
|
||||
pub struct RecallMemory {
|
||||
recall: Arc<dyn MemoryRecall>,
|
||||
}
|
||||
|
||||
impl RecallMemory {
|
||||
/// Builds the use case from the [`MemoryRecall`] port.
|
||||
#[must_use]
|
||||
pub fn new(recall: Arc<dyn MemoryRecall>) -> Self {
|
||||
Self { recall }
|
||||
}
|
||||
|
||||
/// Executes recall. Best-effort: an empty or absent memory yields an empty
|
||||
/// list, and a budget of `0` yields an empty list.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Store`] on an unexpected I/O failure of the underlying store.
|
||||
pub async fn execute(&self, input: RecallMemoryInput) -> Result<RecallMemoryOutput, AppError> {
|
||||
let query = MemoryQuery {
|
||||
text: input.text,
|
||||
token_budget: input.token_budget,
|
||||
};
|
||||
Ok(RecallMemoryOutput {
|
||||
entries: self.recall.recall(&input.project_root, &query).await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResolveMemoryLinks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`ResolveMemoryLinks::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveMemoryLinksInput {
|
||||
/// Active project root.
|
||||
pub project_root: ProjectPath,
|
||||
/// Slug of the source note whose outgoing links are resolved.
|
||||
pub slug: MemorySlug,
|
||||
}
|
||||
|
||||
/// Output of [`ResolveMemoryLinks::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolveMemoryLinksOutput {
|
||||
/// The note's resolved outgoing `[[slug]]` links (broken links dropped).
|
||||
pub links: Vec<MemoryLink>,
|
||||
}
|
||||
|
||||
/// Resolves a note's outgoing `[[slug]]` links (drives link navigation).
|
||||
pub struct ResolveMemoryLinks {
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
impl ResolveMemoryLinks {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(memories: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { memories }
|
||||
}
|
||||
|
||||
/// Resolves the outgoing links.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] if the source note does not exist,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ResolveMemoryLinksInput,
|
||||
) -> Result<ResolveMemoryLinksOutput, AppError> {
|
||||
Ok(ResolveMemoryLinksOutput {
|
||||
links: self
|
||||
.memories
|
||||
.resolve_links(&input.project_root, &input.slug)
|
||||
.await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,9 @@
|
||||
//! relies on, and the per-port `From` mappings.
|
||||
|
||||
use application::AppError;
|
||||
use domain::ports::{FsError, GitError, ProcessError, PtyError, RemoteError, StoreError};
|
||||
use domain::ports::{
|
||||
EmbedderError, FsError, GitError, MemoryError, ProcessError, PtyError, RemoteError, StoreError,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn codes_are_stable() {
|
||||
@ -55,3 +57,43 @@ fn git_and_remote_map_through() {
|
||||
"REMOTE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_errors_map_per_variant() {
|
||||
// NotFound → NotFound.
|
||||
assert_eq!(AppError::from(MemoryError::NotFound).code(), "NOT_FOUND");
|
||||
// Frontmatter → Invalid (a malformed note is an invariant violation, not I/O).
|
||||
let invalid = AppError::from(MemoryError::Frontmatter("bad yaml".into()));
|
||||
assert_eq!(invalid.code(), "INVALID");
|
||||
assert_eq!(invalid, AppError::Invalid("bad yaml".to_owned()));
|
||||
// Io → Store.
|
||||
assert_eq!(AppError::from(MemoryError::Io("disk full".into())).code(), "STORE");
|
||||
// Serialization → Store (the catch-all arm).
|
||||
assert_eq!(
|
||||
AppError::from(MemoryError::Serialization("bad index".into())).code(),
|
||||
"STORE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedder_errors_all_map_to_store() {
|
||||
// An embedder is a *derived* recall detail: every variant maps to STORE (its
|
||||
// failure must degrade the recall, never fail hard — see the From impl doc).
|
||||
assert_eq!(
|
||||
AppError::from(EmbedderError::Unavailable("no model".into())).code(),
|
||||
"STORE"
|
||||
);
|
||||
assert_eq!(
|
||||
AppError::from(EmbedderError::Unsupported("localOnnx".into())).code(),
|
||||
"STORE"
|
||||
);
|
||||
assert_eq!(
|
||||
AppError::from(EmbedderError::Io("read failed".into())).code(),
|
||||
"STORE"
|
||||
);
|
||||
// The message is carried through.
|
||||
assert_eq!(
|
||||
AppError::from(EmbedderError::Unsupported("api".into())),
|
||||
AppError::Store("embedder strategy unsupported: api".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
632
crates/application/tests/memory_usecases.rs
Normal file
632
crates/application/tests/memory_usecases.rs
Normal file
@ -0,0 +1,632 @@
|
||||
//! L12 tests for the memory use cases (ARCHITECTURE §14.5.1) with in-memory
|
||||
//! port fakes (no real store/FS): CRUD (`CreateMemory`, `UpdateMemory`,
|
||||
//! `GetMemory`, `ListMemories`, `DeleteMemory`) asserting the `MemorySaved` /
|
||||
//! `MemoryDeleted` events, and the read-only navigation helpers
|
||||
//! (`ReadMemoryIndex`, `ResolveMemoryLinks`) asserting they emit nothing.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::{EventBus, EventStream, MemoryError, MemoryQuery, MemoryRecall, MemoryStore};
|
||||
use domain::{
|
||||
MarkdownDoc, Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
||||
ProjectPath,
|
||||
};
|
||||
|
||||
use application::{
|
||||
CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput,
|
||||
ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory,
|
||||
RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, UpdateMemoryInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// In-memory memory store keyed by slug. Index and links are derived from the
|
||||
/// stored notes (mirroring the real adapter), so navigation reads stay honest.
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeMemories(Arc<Mutex<Vec<Memory>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryStore for FakeMemories {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m.slug() == slug)
|
||||
.cloned()
|
||||
.ok_or(MemoryError::NotFound)
|
||||
}
|
||||
|
||||
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
if let Some(slot) = v.iter_mut().find(|m| m.slug() == memory.slug()) {
|
||||
*slot = memory.clone();
|
||||
} else {
|
||||
v.push(memory.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
let before = v.len();
|
||||
v.retain(|m| m.slug() != slug);
|
||||
if v.len() == before {
|
||||
return Err(MemoryError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_index(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(Memory::index_entry)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Vec<MemoryLink>, MemoryError> {
|
||||
let v = self.0.lock().unwrap();
|
||||
let source = v
|
||||
.iter()
|
||||
.find(|m| m.slug() == slug)
|
||||
.ok_or(MemoryError::NotFound)?;
|
||||
// Mirror the adapter: drop links whose target note does not exist.
|
||||
Ok(source
|
||||
.outgoing_links()
|
||||
.into_iter()
|
||||
.filter(|l| v.iter().any(|m| m.slug() == &l.target))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
||||
impl SpyBus {
|
||||
fn events(&self) -> Vec<DomainEvent> {
|
||||
self.0.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
impl EventBus for SpyBus {
|
||||
fn publish(&self, event: DomainEvent) {
|
||||
self.0.lock().unwrap().push(event);
|
||||
}
|
||||
fn subscribe(&self) -> EventStream {
|
||||
Box::new(std::iter::empty())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn root() -> ProjectPath {
|
||||
ProjectPath::new("/home/me/demo").unwrap()
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> MemorySlug {
|
||||
MemorySlug::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// Builds and persists a note directly through the store (bypassing use cases).
|
||||
async fn seed(store: &FakeMemories, name: &str, body: &str) {
|
||||
let memory = Memory::new(
|
||||
MemoryFrontmatter {
|
||||
name: slug(name),
|
||||
description: format!("{name} hook"),
|
||||
r#type: MemoryType::Project,
|
||||
},
|
||||
MarkdownDoc::new(body),
|
||||
)
|
||||
.unwrap();
|
||||
store.save(&root(), &memory).await.unwrap();
|
||||
}
|
||||
|
||||
fn saved_slugs(events: &[DomainEvent]) -> Vec<MemorySlug> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
DomainEvent::MemorySaved { slug } => Some(slug.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn deleted_slugs(events: &[DomainEvent]) -> Vec<MemorySlug> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
DomainEvent::MemoryDeleted { slug } => Some(slug.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_persists_note_and_emits_memory_saved_with_slug() {
|
||||
let store = FakeMemories::default();
|
||||
let bus = SpyBus::default();
|
||||
let out = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
||||
.execute(CreateMemoryInput {
|
||||
project_root: root(),
|
||||
name: "design-decision".to_owned(),
|
||||
description: "why we chose hexagonal".to_owned(),
|
||||
r#type: MemoryType::Project,
|
||||
content: "# body".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.memory.slug(), &slug("design-decision"));
|
||||
// Persisted.
|
||||
let listed = store.list(&root()).await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].slug(), &slug("design-decision"));
|
||||
// Emits exactly one MemorySaved with the right slug.
|
||||
assert_eq!(saved_slugs(&bus.events()), vec![slug("design-decision")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_non_kebab_name_as_invalid() {
|
||||
let store = FakeMemories::default();
|
||||
let bus = SpyBus::default();
|
||||
let err = CreateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
||||
.execute(CreateMemoryInput {
|
||||
project_root: root(),
|
||||
name: "Not Kebab".to_owned(),
|
||||
description: "x".to_owned(),
|
||||
r#type: MemoryType::User,
|
||||
content: "# body".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
// Nothing persisted, nothing announced.
|
||||
assert!(store.list(&root()).await.unwrap().is_empty());
|
||||
assert!(bus.events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_empty_description() {
|
||||
let store = FakeMemories::default();
|
||||
let bus = SpyBus::default();
|
||||
let err = CreateMemory::new(Arc::new(store), Arc::new(bus))
|
||||
.execute(CreateMemoryInput {
|
||||
project_root: root(),
|
||||
name: "ok-slug".to_owned(),
|
||||
description: String::new(),
|
||||
r#type: MemoryType::User,
|
||||
content: "# body".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_empty_content() {
|
||||
let store = FakeMemories::default();
|
||||
let bus = SpyBus::default();
|
||||
let err = CreateMemory::new(Arc::new(store), Arc::new(bus))
|
||||
.execute(CreateMemoryInput {
|
||||
project_root: root(),
|
||||
name: "ok-slug".to_owned(),
|
||||
description: "a hook".to_owned(),
|
||||
r#type: MemoryType::User,
|
||||
content: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UpdateMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_replaces_content_and_emits_memory_saved() {
|
||||
let store = FakeMemories::default();
|
||||
seed(&store, "note-a", "v1").await;
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let out = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
||||
.execute(UpdateMemoryInput {
|
||||
project_root: root(),
|
||||
slug: slug("note-a"),
|
||||
description: "updated hook".to_owned(),
|
||||
r#type: MemoryType::Reference,
|
||||
content: "v2".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.memory.body.as_str(), "v2");
|
||||
assert_eq!(out.memory.frontmatter.description, "updated hook");
|
||||
assert_eq!(out.memory.frontmatter.r#type, MemoryType::Reference);
|
||||
// Replace semantics: still a single note.
|
||||
let listed = store.list(&root()).await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].body.as_str(), "v2");
|
||||
assert_eq!(saved_slugs(&bus.events()), vec![slug("note-a")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_revalidates_invariants_and_rejects_empty_content() {
|
||||
let store = FakeMemories::default();
|
||||
seed(&store, "note-a", "v1").await;
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let err = UpdateMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
||||
.execute(UpdateMemoryInput {
|
||||
project_root: root(),
|
||||
slug: slug("note-a"),
|
||||
description: "still here".to_owned(),
|
||||
r#type: MemoryType::Project,
|
||||
content: String::new(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
// Original note untouched, no event.
|
||||
assert_eq!(store.get(&root(), &slug("note-a")).await.unwrap().body.as_str(), "v1");
|
||||
assert!(bus.events().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_note_and_emits_memory_deleted() {
|
||||
let store = FakeMemories::default();
|
||||
seed(&store, "note-a", "body").await;
|
||||
let bus = SpyBus::default();
|
||||
|
||||
DeleteMemory::new(Arc::new(store.clone()), Arc::new(bus.clone()))
|
||||
.execute(DeleteMemoryInput {
|
||||
project_root: root(),
|
||||
slug: slug("note-a"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store.list(&root()).await.unwrap().is_empty());
|
||||
assert_eq!(deleted_slugs(&bus.events()), vec![slug("note-a")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_slug_is_not_found_and_emits_nothing() {
|
||||
let store = FakeMemories::default();
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let err = DeleteMemory::new(Arc::new(store), Arc::new(bus.clone()))
|
||||
.execute(DeleteMemoryInput {
|
||||
project_root: root(),
|
||||
slug: slug("ghost"),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "NOT_FOUND");
|
||||
assert!(bus.events().is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GetMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_returns_the_note() {
|
||||
let store = FakeMemories::default();
|
||||
seed(&store, "note-a", "hello").await;
|
||||
|
||||
let out = GetMemory::new(Arc::new(store))
|
||||
.execute(GetMemoryInput {
|
||||
project_root: root(),
|
||||
slug: slug("note-a"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.memory.slug(), &slug("note-a"));
|
||||
assert_eq!(out.memory.body.as_str(), "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_unknown_slug_is_not_found() {
|
||||
let store = FakeMemories::default();
|
||||
let err = GetMemory::new(Arc::new(store))
|
||||
.execute(GetMemoryInput {
|
||||
project_root: root(),
|
||||
slug: slug("ghost"),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListMemories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_returns_all_notes() {
|
||||
let store = FakeMemories::default();
|
||||
seed(&store, "note-a", "a").await;
|
||||
seed(&store, "note-b", "b").await;
|
||||
|
||||
let out = ListMemories::new(Arc::new(store))
|
||||
.execute(ListMemoriesInput {
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut slugs: Vec<_> = out.memories.iter().map(|m| m.slug().clone()).collect();
|
||||
slugs.sort();
|
||||
assert_eq!(slugs, vec![slug("note-a"), slug("note-b")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_is_empty_when_no_notes() {
|
||||
let store = FakeMemories::default();
|
||||
let out = ListMemories::new(Arc::new(store))
|
||||
.execute(ListMemoriesInput {
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.memories.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReadMemoryIndex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_index_returns_one_row_per_note() {
|
||||
let store = FakeMemories::default();
|
||||
seed(&store, "note-a", "a").await;
|
||||
seed(&store, "note-b", "b").await;
|
||||
|
||||
let out = ReadMemoryIndex::new(Arc::new(store))
|
||||
.execute(ReadMemoryIndexInput {
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.entries.len(), 2);
|
||||
let mut rows: Vec<_> = out
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| (e.slug.clone(), e.hook.clone()))
|
||||
.collect();
|
||||
rows.sort();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
(slug("note-a"), "note-a hook".to_owned()),
|
||||
(slug("note-b"), "note-b hook".to_owned()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResolveMemoryLinks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_links_returns_outgoing_links_dropping_broken_ones() {
|
||||
let store = FakeMemories::default();
|
||||
// note-a links to note-b (exists) and to ghost (does not).
|
||||
seed(&store, "note-a", "see [[note-b]] and [[ghost]]").await;
|
||||
seed(&store, "note-b", "target body").await;
|
||||
|
||||
let out = ResolveMemoryLinks::new(Arc::new(store))
|
||||
.execute(ResolveMemoryLinksInput {
|
||||
project_root: root(),
|
||||
slug: slug("note-a"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Only the resolvable link survives; the broken one is dropped.
|
||||
assert_eq!(
|
||||
out.links,
|
||||
vec![MemoryLink {
|
||||
target: slug("note-b")
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_links_for_unknown_source_is_not_found() {
|
||||
let store = FakeMemories::default();
|
||||
let err = ResolveMemoryLinks::new(Arc::new(store))
|
||||
.execute(ResolveMemoryLinksInput {
|
||||
project_root: root(),
|
||||
slug: slug("ghost"),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RecallMemory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// In-memory [`MemoryRecall`] fake: records the queries it received and returns a
|
||||
/// canned set of entries. Lets us assert the use case delegates faithfully (same
|
||||
/// `root`, the input `text`/`token_budget` forwarded) and surfaces the result.
|
||||
#[derive(Clone, Default)]
|
||||
struct FakeRecall {
|
||||
entries: Arc<Vec<MemoryIndexEntry>>,
|
||||
seen: Arc<Mutex<Vec<MemoryQuery>>>,
|
||||
}
|
||||
|
||||
impl FakeRecall {
|
||||
fn returning(entries: Vec<MemoryIndexEntry>) -> Self {
|
||||
Self {
|
||||
entries: Arc::new(entries),
|
||||
seen: Arc::default(),
|
||||
}
|
||||
}
|
||||
fn queries(&self) -> Vec<MemoryQuery> {
|
||||
self.seen.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryRecall for FakeRecall {
|
||||
async fn recall(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
query: &MemoryQuery,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
self.seen.lock().unwrap().push(query.clone());
|
||||
Ok((*self.entries).clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(slug_str: &str, hook: &str) -> MemoryIndexEntry {
|
||||
MemoryIndexEntry {
|
||||
slug: slug(slug_str),
|
||||
title: slug_str.to_owned(),
|
||||
hook: hook.to_owned(),
|
||||
r#type: MemoryType::Reference,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_delegates_to_port_and_returns_entries() {
|
||||
let recalled = vec![entry("alpha", "first"), entry("beta", "second")];
|
||||
let recall = FakeRecall::returning(recalled.clone());
|
||||
|
||||
let out = RecallMemory::new(Arc::new(recall.clone()))
|
||||
.execute(RecallMemoryInput {
|
||||
project_root: root(),
|
||||
text: "current context".to_owned(),
|
||||
token_budget: 42,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Returns exactly what the port produced, in order.
|
||||
assert_eq!(out.entries, recalled);
|
||||
// The query was forwarded verbatim (text + budget).
|
||||
assert_eq!(
|
||||
recall.queries(),
|
||||
vec![MemoryQuery {
|
||||
text: "current context".to_owned(),
|
||||
token_budget: 42,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_empty_result_is_empty_not_error() {
|
||||
let recall = FakeRecall::returning(Vec::new());
|
||||
let out = RecallMemory::new(Arc::new(recall))
|
||||
.execute(RecallMemoryInput {
|
||||
project_root: root(),
|
||||
text: String::new(),
|
||||
token_budget: 0,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.entries.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recall_publishes_no_events() {
|
||||
// RecallMemory takes no EventBus; assert a shared spy stays empty when other
|
||||
// (mutating) use cases on the same bus are NOT exercised — i.e. recall is
|
||||
// read-only by construction.
|
||||
let recall = FakeRecall::returning(vec![entry("alpha", "first")]);
|
||||
let bus = SpyBus::default();
|
||||
|
||||
RecallMemory::new(Arc::new(recall))
|
||||
.execute(RecallMemoryInput {
|
||||
project_root: root(),
|
||||
text: "ctx".to_owned(),
|
||||
token_budget: 10,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
bus.events().is_empty(),
|
||||
"recall must not publish any DomainEvent"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read use cases emit no events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_use_cases_publish_no_events() {
|
||||
let store = FakeMemories::default();
|
||||
seed(&store, "note-a", "see [[note-a]]").await;
|
||||
let bus = SpyBus::default();
|
||||
|
||||
// None of the read use cases take an EventBus — assert the shared spy stays
|
||||
// empty after exercising every read path on the same store.
|
||||
GetMemory::new(Arc::new(store.clone()))
|
||||
.execute(GetMemoryInput {
|
||||
project_root: root(),
|
||||
slug: slug("note-a"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
ListMemories::new(Arc::new(store.clone()))
|
||||
.execute(ListMemoriesInput {
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
ReadMemoryIndex::new(Arc::new(store.clone()))
|
||||
.execute(ReadMemoryIndexInput {
|
||||
project_root: root(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
ResolveMemoryLinks::new(Arc::new(store))
|
||||
.execute(ResolveMemoryLinksInput {
|
||||
project_root: root(),
|
||||
slug: slug("note-a"),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
bus.events().is_empty(),
|
||||
"read use cases must not publish any DomainEvent"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user