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:
2026-06-08 08:47:23 +02:00
parent 3ed0f6b45f
commit 98a8b7292a
30 changed files with 4978 additions and 14 deletions

View File

@ -65,6 +65,13 @@ pub enum DomainError {
reason: String,
},
/// A memory slug was not valid kebab-case (`[a-z0-9-]`, non-empty).
#[error("`{value}` is not a valid kebab-case slug")]
InvalidSlug {
/// The offending value.
value: String,
},
/// A generic invariant violation with an explanatory message.
#[error("invariant violated: {0}")]
Invariant(String),

View File

@ -2,6 +2,7 @@
//! presentation layer (ARCHITECTURE §3.2).
use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId};
use crate::memory::MemorySlug;
use crate::template::TemplateVersion;
/// Events emitted by the domain/application as state changes occur.
@ -90,6 +91,21 @@ pub enum DomainEvent {
/// Whether IdeA handled it successfully.
ok: bool,
},
/// A memory note was created or updated (`.md` written, index upserted).
MemorySaved {
/// The saved note's slug.
slug: MemorySlug,
},
/// A memory note was deleted.
MemoryDeleted {
/// The deleted note's slug.
slug: MemorySlug,
},
/// The aggregated `MEMORY.md` index was rebuilt for a project.
MemoryIndexRebuilt {
/// The project whose index was rebuilt.
project_id: ProjectId,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.

View File

@ -37,6 +37,7 @@ pub mod git;
pub mod ids;
pub mod layout;
pub mod markdown;
pub mod memory;
pub mod orchestrator;
pub mod ports;
pub mod profile;
@ -67,10 +68,16 @@ pub use skill::{Skill, SkillRef, SkillScope};
pub use template::{AgentTemplate, TemplateVersion};
pub use profile::{AgentProfile, ContextInjection, SessionStrategy};
pub use profile::{
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
};
pub use markdown::MarkdownDoc;
pub use memory::{
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
pub use remote::{RemoteKind, RemoteRef, SshAuth};
pub use terminal::{PtySize, SessionKind, SessionStatus, TerminalSession};
@ -87,9 +94,11 @@ pub use events::DomainEvent;
pub use orchestrator::{OrchestratorCommand, OrchestratorError, OrchestratorRequest};
pub use ports::{
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, EventBus, EventStream,
AgentContextStore, AgentRuntime, Clock, ContextInjectionPlan, DirEntry, Embedder,
EmbedderError, EventBus, EventStream,
ExitStatus, FileSystem, FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit,
IdGenerator, Output, OutputStream, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
SpawnSpec, StoreError, TemplateStore,
IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream,
PreparedContext, ProcessError,
ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError,
RemoteHost, RemotePath, RuntimeError, SpawnSpec, StoreError, TemplateStore,
};

194
crates/domain/src/memory.rs Normal file
View File

@ -0,0 +1,194 @@
//! Memory entity — the persistent, model-agnostic knowledge base of a project
//! (LOT A, étage 1: `.md` files as the single source of truth).
//!
//! A [`Memory`] is one Markdown note stored under `.ideai/memory/<slug>.md`. Its
//! frontmatter carries the structured metadata (a kebab-case [`MemorySlug`], a
//! human-readable description, and a [`MemoryType`]); its body is opaque
//! [`MarkdownDoc`] text. Notes cross-reference one another via `[[slug]]` wiki
//! links, scanned by [`Memory::outgoing_links`].
//!
//! The aggregated `.ideai/memory/MEMORY.md` index (one [`MemoryIndexEntry`] line
//! per note) is derived data: [`Memory::index_entry`] produces a note's row. The
//! adapter (`FsMemoryStore`) owns the on-disk YAML frontmatter and index file
//! formats; the domain stays I/O-free and format-neutral.
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::markdown::MarkdownDoc;
/// A kebab-case identifier for a memory note (`[a-z0-9]` plus `-`), used both as
/// the on-disk file stem (`<slug>.md`) and as the `[[slug]]` link target.
///
/// Invariants enforced by [`MemorySlug::new`]:
/// - non-empty,
/// - only lowercase ASCII letters, ASCII digits, and `-`,
/// - therefore no uppercase, no whitespace, and no `.` (so no `..` traversal).
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct MemorySlug(String);
impl MemorySlug {
/// Builds a validated kebab-case slug.
///
/// # Errors
/// [`DomainError::InvalidSlug`] if `raw` is empty or contains any character
/// outside `[a-z0-9-]`.
pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
let raw = raw.into();
let invalid = || DomainError::InvalidSlug {
value: raw.clone(),
};
if raw.is_empty() {
return Err(invalid());
}
if raw
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
Ok(Self(raw))
} else {
Err(invalid())
}
}
/// Returns the slug as a string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for MemorySlug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// The kind of a memory note, driving how it is surfaced and prioritised.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MemoryType {
/// A user-authored preference or instruction.
User,
/// Feedback captured from a prior interaction.
Feedback,
/// A project-level fact or decision.
Project,
/// A reference / external knowledge note.
Reference,
}
/// The structured frontmatter of a memory note.
///
/// Serialised with `type` (not `kind`) as the discriminator field name, matching
/// the on-disk YAML `metadata.type` (the adapter maps the nesting). Invariant:
/// `description` is non-empty (enforced at [`Memory::new`]).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryFrontmatter {
/// Stable kebab-case identifier (also the file stem).
pub name: MemorySlug,
/// Human-readable one-line description (the index hook). Non-empty.
pub description: String,
/// The note's kind.
#[serde(rename = "type")]
pub r#type: MemoryType,
}
/// A `[[slug]]` wiki link found in a note's body, pointing at another note.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct MemoryLink {
/// The linked note's slug.
pub target: MemorySlug,
}
/// One row of the aggregated `MEMORY.md` index: the `- [Title](slug.md) — hook`
/// line, decomposed into its parts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryIndexEntry {
/// The note's slug.
pub slug: MemorySlug,
/// The note's display title (currently the slug; titles are derived later).
pub title: String,
/// The one-line hook (the frontmatter description).
pub hook: String,
/// The note's kind.
pub r#type: MemoryType,
}
/// A memory note: validated frontmatter plus an opaque Markdown body.
///
/// Invariants enforced by [`Memory::new`]:
/// - `frontmatter.description` non-empty,
/// - `body` non-empty (an empty note carries no knowledge).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Memory {
/// Structured metadata.
pub frontmatter: MemoryFrontmatter,
/// Markdown body of the note.
pub body: MarkdownDoc,
}
impl Memory {
/// Builds a validated memory note.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `frontmatter.description` is empty,
/// - [`DomainError::EmptyField`] if `body` is empty.
pub fn new(frontmatter: MemoryFrontmatter, body: MarkdownDoc) -> Result<Self, DomainError> {
crate::validation::non_empty(&frontmatter.description, "memory.description")?;
if body.is_empty() {
return Err(DomainError::EmptyField {
field: "memory.body",
});
}
Ok(Self { frontmatter, body })
}
/// Returns the note's slug (its identity).
#[must_use]
pub fn slug(&self) -> &MemorySlug {
&self.frontmatter.name
}
/// Scans the body for `[[slug]]` wiki links, in order of appearance.
///
/// Tokens whose inner text is not a valid [`MemorySlug`] are skipped (a
/// malformed link is not a hard error here). Duplicates are preserved — the
/// caller dedups if it wants a unique link set. No regex: a small linear scan.
#[must_use]
pub fn outgoing_links(&self) -> Vec<MemoryLink> {
let text = self.body.as_str();
let bytes = text.as_bytes();
let mut links = Vec::new();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
// Find the closing `]]`.
if let Some(close) = text[i + 2..].find("]]") {
let inner = &text[i + 2..i + 2 + close];
if let Ok(target) = MemorySlug::new(inner) {
links.push(MemoryLink { target });
}
i = i + 2 + close + 2;
continue;
}
// No closing token: stop scanning further `[[`.
break;
}
i += 1;
}
links
}
/// Produces this note's row in the aggregated `MEMORY.md` index.
#[must_use]
pub fn index_entry(&self) -> MemoryIndexEntry {
MemoryIndexEntry {
slug: self.frontmatter.name.clone(),
title: self.frontmatter.name.as_str().to_string(),
hook: self.frontmatter.description.clone(),
r#type: self.frontmatter.r#type,
}
}
}

View File

@ -31,6 +31,7 @@ use crate::agent::AgentManifest;
use crate::events::DomainEvent;
use crate::ids::{AgentId, SessionId};
use crate::markdown::MarkdownDoc;
use crate::memory::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use crate::profile::AgentProfile;
use crate::project::{Project, ProjectPath};
use crate::remote::RemoteKind;
@ -257,6 +258,46 @@ pub enum StoreError {
Io(String),
}
/// Errors from the [`MemoryStore`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum MemoryError {
/// The requested note was not found.
#[error("memory not found")]
NotFound,
/// A note's frontmatter could not be parsed or validated.
#[error("memory frontmatter error: {0}")]
Frontmatter(String),
/// Underlying I/O error.
#[error("memory io failed: {0}")]
Io(String),
/// (De)serialisation of the index or another structured part failed.
#[error("memory serialization failed: {0}")]
Serialization(String),
}
/// Errors from an [`Embedder`] (LOT C, étage 2 vectoriel).
///
/// Best-effort by contract: a [`MemoryRecall`] that composes an embedder must
/// **degrade**, never fail hard, on any of these — an unavailable engine or an
/// unimplemented strategy maps to a fallback on the naïve recall (it must never
/// surface as a hard recall error). See [`Embedder`] and the `VectorMemoryRecall`
/// / `AdaptiveMemoryRecall` adapters.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EmbedderError {
/// The embedding engine is not installed / not reachable (e.g. a local ONNX
/// model file is missing, or a remote embedding server / API is unreachable).
#[error("embedder unavailable: {0}")]
Unavailable(String),
/// The requested strategy is not implemented yet (the concrete `localOnnx` /
/// `localServer` / `api` backends ship as documented stubs returning this —
/// never a panic). Real ONNX/HTTP integration is an explicit follow-up.
#[error("embedder strategy unsupported: {0}")]
Unsupported(String),
/// An I/O failure while producing embeddings.
#[error("embedder io failed: {0}")]
Io(String),
}
/// Errors from [`RemoteHost`].
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RemoteError {
@ -561,6 +602,130 @@ pub trait SkillStore: Send + Sync {
) -> Result<(), StoreError>;
}
/// CRUD for project [`Memory`] notes — the `.md` knowledge base under
/// `.ideai/memory/` (LOT A, étage 1). Notes are the single source of truth; the
/// aggregated `MEMORY.md` index is derived and kept in sync on every write.
///
/// `root` identifies the project whose `.ideai/memory/` to use; it is supplied
/// **per call** (mirroring [`SkillStore`]) so a single store instance serves
/// every open project.
#[async_trait]
pub trait MemoryStore: Send + Sync {
/// Lists all memory notes for `root`'s project.
///
/// # Errors
/// [`MemoryError`] on failure (e.g. a malformed note's frontmatter).
async fn list(&self, root: &ProjectPath) -> Result<Vec<Memory>, MemoryError>;
/// Gets a memory note by slug.
///
/// # Errors
/// [`MemoryError::NotFound`] if absent; [`MemoryError::Frontmatter`] if its
/// frontmatter is malformed.
async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError>;
/// Saves (creates or replaces by slug) a note: writes `<slug>.md` and upserts
/// its line in `MEMORY.md` idempotently.
///
/// # Errors
/// [`MemoryError`] on failure.
async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError>;
/// Deletes a note by slug, removing its line from `MEMORY.md`.
///
/// # Errors
/// [`MemoryError::NotFound`] if absent.
async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError>;
/// Reads the aggregated `MEMORY.md` index as structured entries (empty if the
/// index does not exist yet).
///
/// # Errors
/// [`MemoryError`] on an I/O failure.
async fn read_index(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
/// Resolves the `[[slug]]` links emanating from `slug`'s note, **ignoring
/// broken links** (targets that do not resolve to an existing note).
///
/// # Errors
/// [`MemoryError::NotFound`] if `slug` itself does not exist.
async fn resolve_links(
&self,
root: &ProjectPath,
slug: &MemorySlug,
) -> Result<Vec<MemoryLink>, MemoryError>;
}
/// A recall request: the query text plus the token budget bounding the result
/// (LOT B, étage 1). `text` is typically the agent's current context; the naïve
/// adapter ignores it, but a semantic [`MemoryRecall`] (LOT C) ranks against it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoryQuery {
/// The recall query (often the agent's current working context).
pub text: String,
/// Approximate token budget the returned entries must fit within. A budget of
/// `0` yields an empty result.
pub token_budget: usize,
}
/// Adaptive recall of the most relevant subset of a project's memory index for a
/// query, bounded by a token budget (LOT B, étage 1).
///
/// Contract (best-effort, never blocking):
/// - an empty or absent memory yields an empty list, **never** an error;
/// - a `token_budget` of `0` yields an empty list;
/// - the naïve adapter ignores semantic relevance and returns the index entries
/// in order, truncated to fit the budget.
///
/// **Liskov**: every implementation (`NaiveMemoryRecall`, the future
/// `VectorMemoryRecall`) is substitutable — same emptiness/budget guarantees, only
/// the relevance strategy differs.
#[async_trait]
pub trait MemoryRecall: Send + Sync {
/// Returns the entries most relevant to `query` for `root`'s project, capped
/// at `query.token_budget`.
///
/// # Errors
/// [`MemoryError`] only on an unexpected I/O failure of the underlying store;
/// an empty or missing memory is **not** an error (returns an empty list).
async fn recall(
&self,
root: &ProjectPath,
query: &MemoryQuery,
) -> Result<Vec<MemoryIndexEntry>, MemoryError>;
}
/// Produces embedding vectors for texts, driven by a declarative
/// [`crate::profile::EmbedderProfile`] (façon §9 — adding an engine is *data*,
/// not code: Open/Closed). The étage-2 vector recall (LOT C) composes this port
/// to rank memory notes semantically.
///
/// Contract:
/// - [`embed`](Self::embed) returns **one vector per input text**, in the same
/// order, each of length [`dimension`](Self::dimension);
/// - it is **best-effort from the caller's standpoint**: any failure is an
/// [`EmbedderError`], on which a composing [`MemoryRecall`] degrades to the
/// naïve recall — it must never bubble up as a hard recall error;
/// - implementations are **substitutable** (Liskov): a deterministic test
/// embedder and a real ONNX/HTTP one differ only in vector quality, not in the
/// shape of their guarantees.
#[async_trait]
pub trait Embedder: Send + Sync {
/// Stable identifier of this embedder (e.g. `"local-onnx-minilm"`).
fn id(&self) -> &str;
/// Embeds each text into a `dimension()`-length vector, preserving order.
///
/// # Errors
/// - [`EmbedderError::Unavailable`] if the engine is not installed/reachable,
/// - [`EmbedderError::Unsupported`] if the strategy is not implemented yet,
/// - [`EmbedderError::Io`] on an I/O failure.
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError>;
/// The length of every vector produced by [`embed`](Self::embed).
fn dimension(&self) -> usize;
}
/// Persistence of the known-projects registry and the workspace.
#[async_trait]
pub trait ProjectStore: Send + Sync {

View File

@ -150,6 +150,107 @@ pub struct AgentProfile {
pub session: Option<SessionStrategy>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
///
/// Declarative, Open/Closed: a new engine family is a new variant *only* if it
/// changes the adapter's dispatch; otherwise it is pure data on the profile
/// (`model`, `endpoint`, …). The default product posture is [`None`](Self::None):
/// no embedding ⇒ no heavy dependency, recall stays the naïve étage 1.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EmbedderStrategy {
/// A local ONNX model run in-process (real integration is a follow-up).
LocalOnnx,
/// A local embedding server reached over HTTP (real integration is a follow-up).
LocalServer,
/// A remote embedding API (real integration is a follow-up).
Api,
/// No embedding engine: recall stays the dependency-free naïve étage 1.
None,
}
/// Declarative configuration for one embedding engine (LOT C, étage 2).
///
/// Stored in the global IDE store as `embedder.json`, mirroring `profiles.json`
/// for [`AgentProfile`]s: adding an engine is **data, not code** (Open/Closed).
///
/// Invariants:
/// - `id` and `name` non-empty,
/// - `dimension` is non-zero (an embedder always produces fixed-length vectors).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EmbedderProfile {
/// Stable identifier (e.g. `"local-onnx-minilm"`).
pub id: String,
/// Display name.
pub name: String,
/// Embedding strategy driving which concrete adapter is used.
pub strategy: EmbedderStrategy,
/// Model identifier (e.g. an ONNX model name), when the strategy needs one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Endpoint URL for a server/API strategy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
/// Name of the environment variable carrying the API key (never the key
/// itself), for an `api` strategy.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
/// Length of the vectors this engine produces.
pub dimension: usize,
}
impl EmbedderProfile {
/// Builds a validated embedder profile.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `id` or `name` is empty,
/// - [`DomainError::EmptyField`] (`"embedder.dimension"`) if `dimension` is `0`.
pub fn new(
id: impl Into<String>,
name: impl Into<String>,
strategy: EmbedderStrategy,
model: Option<String>,
endpoint: Option<String>,
api_key_env: Option<String>,
dimension: usize,
) -> Result<Self, DomainError> {
let id = id.into();
let name = name.into();
crate::validation::non_empty(&id, "embedder.id")?;
crate::validation::non_empty(&name, "embedder.name")?;
if dimension == 0 {
return Err(DomainError::EmptyField {
field: "embedder.dimension",
});
}
Ok(Self {
id,
name,
strategy,
model,
endpoint,
api_key_env,
dimension,
})
}
/// A dependency-free default profile: strategy [`EmbedderStrategy::None`].
/// Recall stays the naïve étage 1 — nothing is imposed.
#[must_use]
pub fn none() -> Self {
Self {
id: "none".to_owned(),
name: "None".to_owned(),
strategy: EmbedderStrategy::None,
model: None,
endpoint: None,
api_key_env: None,
dimension: 1,
}
}
}
impl AgentProfile {
/// Builds a validated profile.
///

View File

@ -0,0 +1,183 @@
//! Tests for the declarative [`EmbedderProfile`] / [`EmbedderStrategy`] (LOT C,
//! étage 2 vectoriel): serde camelCase round-trip, the validated `new`
//! constructor, and the dependency-free `none()` default.
use domain::{DomainError, EmbedderProfile, EmbedderStrategy};
fn roundtrip<T>(value: &T) -> T
where
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
{
let json = serde_json::to_string(value).expect("serialize");
let back: T = serde_json::from_str(&json).expect("deserialize");
assert_eq!(&back, value, "round-trip mismatch via {json}");
back
}
// ---------------------------------------------------------------------------
// EmbedderStrategy: camelCase wire form (`localOnnx` / `localServer` / `api` /
// `none`).
// ---------------------------------------------------------------------------
#[test]
fn strategy_serializes_camel_case() {
assert_eq!(
serde_json::to_string(&EmbedderStrategy::LocalOnnx).unwrap(),
"\"localOnnx\""
);
assert_eq!(
serde_json::to_string(&EmbedderStrategy::LocalServer).unwrap(),
"\"localServer\""
);
assert_eq!(
serde_json::to_string(&EmbedderStrategy::Api).unwrap(),
"\"api\""
);
assert_eq!(
serde_json::to_string(&EmbedderStrategy::None).unwrap(),
"\"none\""
);
}
#[test]
fn strategy_deserializes_camel_case() {
assert_eq!(
serde_json::from_str::<EmbedderStrategy>("\"localOnnx\"").unwrap(),
EmbedderStrategy::LocalOnnx
);
assert_eq!(
serde_json::from_str::<EmbedderStrategy>("\"localServer\"").unwrap(),
EmbedderStrategy::LocalServer
);
assert_eq!(
serde_json::from_str::<EmbedderStrategy>("\"api\"").unwrap(),
EmbedderStrategy::Api
);
assert_eq!(
serde_json::from_str::<EmbedderStrategy>("\"none\"").unwrap(),
EmbedderStrategy::None
);
}
#[test]
fn strategy_roundtrips() {
for s in [
EmbedderStrategy::LocalOnnx,
EmbedderStrategy::LocalServer,
EmbedderStrategy::Api,
EmbedderStrategy::None,
] {
roundtrip(&s);
}
}
// ---------------------------------------------------------------------------
// EmbedderProfile: camelCase fields and optional-field skipping.
// ---------------------------------------------------------------------------
#[test]
fn profile_roundtrips_with_all_fields() {
let p = EmbedderProfile::new(
"local-onnx-minilm",
"Local MiniLM",
EmbedderStrategy::LocalOnnx,
Some("all-MiniLM-L6-v2".to_owned()),
Some("http://localhost:1234".to_owned()),
Some("OPENAI_API_KEY".to_owned()),
384,
)
.unwrap();
let back = roundtrip(&p);
assert_eq!(back.dimension, 384);
assert_eq!(back.strategy, EmbedderStrategy::LocalOnnx);
}
#[test]
fn profile_uses_camel_case_keys_and_skips_none_options() {
let p = EmbedderProfile::new(
"id-1",
"Name",
EmbedderStrategy::Api,
None,
Some("https://api.example/embed".to_owned()),
Some("MY_KEY".to_owned()),
16,
)
.unwrap();
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"apiKeyEnv\":\"MY_KEY\""), "camelCase apiKeyEnv: {json}");
assert!(json.contains("\"strategy\":\"api\""), "camelCase strategy: {json}");
// `model` is None ⇒ skipped from the wire form.
assert!(!json.contains("\"model\""), "None model must be skipped: {json}");
}
#[test]
fn profile_deserializes_from_camel_case_document() {
let json = r#"{
"id": "srv",
"name": "Server",
"strategy": "localServer",
"endpoint": "http://127.0.0.1:9000",
"dimension": 512
}"#;
let p: EmbedderProfile = serde_json::from_str(json).unwrap();
assert_eq!(p.id, "srv");
assert_eq!(p.strategy, EmbedderStrategy::LocalServer);
assert_eq!(p.endpoint.as_deref(), Some("http://127.0.0.1:9000"));
assert_eq!(p.dimension, 512);
assert!(p.model.is_none());
assert!(p.api_key_env.is_none());
}
// ---------------------------------------------------------------------------
// none(): the dependency-free default.
// ---------------------------------------------------------------------------
#[test]
fn none_profile_has_none_strategy() {
let p = EmbedderProfile::none();
assert_eq!(p.strategy, EmbedderStrategy::None);
assert_eq!(p.id, "none");
assert!(p.dimension > 0, "even the none profile keeps a non-zero dimension");
// And it round-trips like any other profile.
roundtrip(&p);
}
// ---------------------------------------------------------------------------
// new(): validation.
// ---------------------------------------------------------------------------
#[test]
fn new_rejects_empty_id() {
assert!(matches!(
EmbedderProfile::new("", "Name", EmbedderStrategy::None, None, None, None, 8),
Err(DomainError::EmptyField { field: "embedder.id" })
));
}
#[test]
fn new_rejects_empty_name() {
assert!(matches!(
EmbedderProfile::new("id", "", EmbedderStrategy::None, None, None, None, 8),
Err(DomainError::EmptyField {
field: "embedder.name"
})
));
}
#[test]
fn new_rejects_zero_dimension() {
assert!(matches!(
EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 0),
Err(DomainError::EmptyField {
field: "embedder.dimension"
})
));
}
#[test]
fn new_accepts_valid_minimal_profile() {
let p = EmbedderProfile::new("id", "Name", EmbedderStrategy::None, None, None, None, 1).unwrap();
assert_eq!(p.dimension, 1);
assert_eq!(p.strategy, EmbedderStrategy::None);
}

View File

@ -0,0 +1,187 @@
//! Domain tests for the [`Memory`] entity and its value objects (LOT A, étage 1):
//! [`MemorySlug`] validation, `[[slug]]` link scanning, frontmatter serde
//! round-trip, and the derived index entry.
use domain::{
DomainError, MarkdownDoc, Memory, MemoryFrontmatter, MemorySlug, MemoryType,
};
fn fm(slug: &str, description: &str, ty: MemoryType) -> MemoryFrontmatter {
MemoryFrontmatter {
name: MemorySlug::new(slug).unwrap(),
description: description.to_string(),
r#type: ty,
}
}
fn note(slug: &str, body: &str) -> Memory {
Memory::new(
fm(slug, "a hook", MemoryType::Project),
MarkdownDoc::new(body),
)
.unwrap()
}
// ---------------------------------------------------------------------------
// MemorySlug
// ---------------------------------------------------------------------------
#[test]
fn slug_accepts_kebab_case() {
assert_eq!(
MemorySlug::new("my-note-42").unwrap().as_str(),
"my-note-42"
);
assert!(MemorySlug::new("abc").is_ok());
assert!(MemorySlug::new("a-b-c-1-2-3").is_ok());
}
#[test]
fn slug_rejects_uppercase() {
assert!(matches!(
MemorySlug::new("MyNote").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_spaces() {
assert!(matches!(
MemorySlug::new("my note").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_dot_and_traversal() {
assert!(matches!(
MemorySlug::new("..").unwrap_err(),
DomainError::InvalidSlug { .. }
));
assert!(matches!(
MemorySlug::new("a.b").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_empty() {
assert!(matches!(
MemorySlug::new("").unwrap_err(),
DomainError::InvalidSlug { .. }
));
}
#[test]
fn slug_rejects_underscore_and_slash() {
assert!(MemorySlug::new("a_b").is_err());
assert!(MemorySlug::new("a/b").is_err());
}
// ---------------------------------------------------------------------------
// Memory invariants
// ---------------------------------------------------------------------------
#[test]
fn memory_rejects_empty_description() {
let r = Memory::new(
fm("ok-slug", "", MemoryType::User),
MarkdownDoc::new("body"),
);
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
}
#[test]
fn memory_rejects_empty_body() {
let r = Memory::new(fm("ok-slug", "hook", MemoryType::User), MarkdownDoc::new(""));
assert!(matches!(r.unwrap_err(), DomainError::EmptyField { .. }));
}
// ---------------------------------------------------------------------------
// outgoing_links
// ---------------------------------------------------------------------------
#[test]
fn outgoing_links_none() {
assert!(note("n", "plain body, no links").outgoing_links().is_empty());
}
#[test]
fn outgoing_links_multiple() {
let m = note("n", "see [[alpha]] and also [[beta-2]] here");
let targets: Vec<_> = m
.outgoing_links()
.into_iter()
.map(|l| l.target.as_str().to_string())
.collect();
assert_eq!(targets, vec!["alpha".to_string(), "beta-2".to_string()]);
}
#[test]
fn outgoing_links_preserves_duplicates() {
let m = note("n", "[[alpha]] then again [[alpha]]");
assert_eq!(m.outgoing_links().len(), 2);
}
#[test]
fn outgoing_links_skips_invalid_targets() {
// `[[Not Valid]]` is not a kebab-case slug -> skipped.
let m = note("n", "[[Not Valid]] but [[good-one]]");
let targets: Vec<_> = m
.outgoing_links()
.into_iter()
.map(|l| l.target.as_str().to_string())
.collect();
assert_eq!(targets, vec!["good-one".to_string()]);
}
#[test]
fn outgoing_links_ignores_unterminated() {
let m = note("n", "dangling [[oops without close");
assert!(m.outgoing_links().is_empty());
}
// ---------------------------------------------------------------------------
// index_entry
// ---------------------------------------------------------------------------
#[test]
fn index_entry_carries_slug_hook_and_type() {
let m = Memory::new(
fm("topic-x", "a useful hook", MemoryType::Feedback),
MarkdownDoc::new("body"),
)
.unwrap();
let e = m.index_entry();
assert_eq!(e.slug.as_str(), "topic-x");
assert_eq!(e.hook, "a useful hook");
assert_eq!(e.title, "topic-x");
assert_eq!(e.r#type, MemoryType::Feedback);
}
// ---------------------------------------------------------------------------
// Frontmatter serde round-trip (the `type` field name + camelCase variants).
// ---------------------------------------------------------------------------
#[test]
fn frontmatter_serde_roundtrip() {
let original = fm("round-trip", "desc", MemoryType::Reference);
let json = serde_json::to_string(&original).unwrap();
// `type` field name (not `kind`), camelCase enum value.
assert!(json.contains("\"type\":\"reference\""));
assert!(json.contains("\"name\":\"round-trip\""));
let back: MemoryFrontmatter = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
}
#[test]
fn memory_type_serde_is_camel_case() {
assert_eq!(
serde_json::to_string(&MemoryType::User).unwrap(),
"\"user\""
);
assert_eq!(
serde_json::to_string(&MemoryType::Project).unwrap(),
"\"project\""
);
}