2026-07-12 11:28:39 +07:00
|
|
|
//! Long-term agent memory: markdown files with YAML-ish frontmatter storing
|
|
|
|
|
//! lessons/references, plus slugified filenames and export/import helpers.
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// A single memory entry (lesson, reference, etc.) with frontmatter
|
|
|
|
|
/// metadata and free-form markdown content.
|
2026-07-11 13:16:10 +07:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct Memory {
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub description: String,
|
|
|
|
|
pub content: String,
|
|
|
|
|
pub kind: String,
|
|
|
|
|
pub created_at: i64,
|
|
|
|
|
pub updated_at: i64,
|
|
|
|
|
pub outcome: Option<String>,
|
|
|
|
|
pub lifecycle: String,
|
2026-07-11 21:06:22 +07:00
|
|
|
pub scope: Option<String>,
|
|
|
|
|
pub before_snippet: Option<String>,
|
|
|
|
|
pub after_snippet: Option<String>,
|
|
|
|
|
pub provenances: Vec<String>,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Memory {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Convert an arbitrary string into a filesystem-safe slug.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: lowercase → replace non-alphanumeric chars with `-` →
|
|
|
|
|
/// collapse/trim repeated `-` by splitting on it and rejoining
|
|
|
|
|
/// non-empty parts.
|
|
|
|
|
///
|
|
|
|
|
/// Why: rejects empty or overly long (>80 char) results so callers
|
|
|
|
|
/// don't write memories with degenerate or unwieldy filenames.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `Some(slug)` on success, `None` if the input slugifies to
|
|
|
|
|
/// empty or exceeds 80 characters.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn slugify(s: &str) -> Option<String> {
|
|
|
|
|
let slug: String = s
|
|
|
|
|
.to_lowercase()
|
|
|
|
|
.chars()
|
|
|
|
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
|
|
|
|
.collect();
|
|
|
|
|
let slug: String = slug
|
|
|
|
|
.split('-')
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("-");
|
|
|
|
|
if slug.is_empty() || slug.len() > 80 {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(slug)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Compute the on-disk path for a memory of the given name.
|
|
|
|
|
///
|
|
|
|
|
/// Why: falls back to a fixed `"memory"` slug when `name` slugifies
|
|
|
|
|
/// to nothing, so a path is always produced.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
|
|
|
|
|
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
|
|
|
|
|
slug_path(memory_dir, &format!("{}.md", slug))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Serialize this memory to markdown-with-frontmatter and write it
|
|
|
|
|
/// atomically to disk.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: build the frontmatter block (name/description/kind/timestamps/
|
|
|
|
|
/// lifecycle/optional fields) → concatenate with body content → write
|
|
|
|
|
/// to a temp file → rename into place.
|
|
|
|
|
///
|
|
|
|
|
/// Why: write-then-rename avoids leaving a half-written memory file if
|
|
|
|
|
/// the process is interrupted mid-write.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `Ok(())` on success, or an `io::Error` from directory
|
|
|
|
|
/// creation, the temp write, or the rename.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
|
|
|
|
|
let path = Self::path(memory_dir, &self.name);
|
|
|
|
|
let parent = path.parent().unwrap();
|
|
|
|
|
std::fs::create_dir_all(parent)?;
|
|
|
|
|
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
|
2026-07-11 21:06:22 +07:00
|
|
|
let scope_line = self.scope.as_ref().map(|s| format!("scope: {}", s)).unwrap_or_default();
|
|
|
|
|
let before_line = self.before_snippet.as_ref().map(|s| format!("before: {}", s)).unwrap_or_default();
|
|
|
|
|
let after_line = self.after_snippet.as_ref().map(|s| format!("after: {}", s)).unwrap_or_default();
|
|
|
|
|
let prov_line = if self.provenances.is_empty() {
|
|
|
|
|
String::new()
|
|
|
|
|
} else {
|
|
|
|
|
format!("provenances: {}", self.provenances.join(", "))
|
|
|
|
|
};
|
2026-07-11 13:16:10 +07:00
|
|
|
let content = format!(
|
2026-07-11 21:06:22 +07:00
|
|
|
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n{}\n{}\n{}\n{}\n---\n\n{}",
|
|
|
|
|
self.name, self.description, self.kind, self.created_at, self.updated_at,
|
|
|
|
|
self.lifecycle, outcome_line, scope_line, before_line, after_line, prov_line,
|
|
|
|
|
self.content
|
2026-07-11 13:16:10 +07:00
|
|
|
);
|
2026-07-12 11:45:28 +07:00
|
|
|
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
|
2026-07-11 13:16:10 +07:00
|
|
|
std::fs::write(&tmp, &content)?;
|
|
|
|
|
std::fs::rename(&tmp, path)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Read and parse a memory file by name.
|
|
|
|
|
///
|
|
|
|
|
/// Return: the parsed `Memory`, or an `io::Error` if the file is
|
|
|
|
|
/// missing or its frontmatter is malformed (see `parse`).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn read(memory_dir: &Path, name: &str) -> std::io::Result<Self> {
|
|
|
|
|
let path = Self::path(memory_dir, name);
|
|
|
|
|
let content = std::fs::read_to_string(&path)?;
|
|
|
|
|
Self::parse(&content)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: strip leading `---\n` → split on the first `\n---\n` into
|
|
|
|
|
/// frontmatter and body → parse frontmatter lines as `key: value`
|
|
|
|
|
/// pairs into a map → build `Memory` fields from the map with
|
|
|
|
|
/// sensible defaults for missing keys.
|
|
|
|
|
///
|
|
|
|
|
/// Why: unknown/missing frontmatter keys degrade to defaults (e.g.
|
|
|
|
|
/// `kind` → "reference", `lifecycle` → "new") rather than failing,
|
|
|
|
|
/// so older or hand-edited memory files still parse.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `Err(InvalidData)` only if the `---` frontmatter delimiter
|
|
|
|
|
/// itself is missing; otherwise `Ok(Memory)`.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn parse(content: &str) -> std::io::Result<Self> {
|
2026-07-11 22:10:17 +07:00
|
|
|
let content = content.strip_prefix("---\n").unwrap_or(content);
|
|
|
|
|
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
|
2026-07-11 13:16:10 +07:00
|
|
|
if parts.len() < 2 {
|
|
|
|
|
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter"));
|
|
|
|
|
}
|
|
|
|
|
let front: std::collections::HashMap<String, String> = parts[0]
|
|
|
|
|
.lines()
|
|
|
|
|
.filter_map(|l| {
|
|
|
|
|
let mut it = l.splitn(2, ':');
|
|
|
|
|
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string()))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
let body = parts.get(1).unwrap_or(&"").trim().to_string();
|
|
|
|
|
Ok(Memory {
|
|
|
|
|
name: front.get("name").cloned().unwrap_or_default(),
|
|
|
|
|
description: front.get("description").cloned().unwrap_or_default(),
|
|
|
|
|
content: body,
|
|
|
|
|
kind: front.get("kind").cloned().unwrap_or_else(|| "reference".to_string()),
|
|
|
|
|
created_at: front.get("created_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
|
|
|
|
updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
|
|
|
|
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
|
|
|
|
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
|
2026-07-11 21:06:22 +07:00
|
|
|
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
|
|
|
|
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
|
|
|
|
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
|
|
|
|
provenances: front.get("provenances").cloned()
|
|
|
|
|
.map(|s| s.split(", ").map(|p| p.to_string()).collect())
|
|
|
|
|
.unwrap_or_default(),
|
2026-07-11 13:16:10 +07:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Delete a memory file by name, if it exists.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `Ok(())` whether or not the file existed.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> {
|
|
|
|
|
let path = Self::path(memory_dir, name);
|
|
|
|
|
if path.exists() {
|
|
|
|
|
std::fs::remove_file(path)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// List the slugs of all memory files in a directory.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: read the directory → keep entries ending in `.md` → exclude
|
|
|
|
|
/// the special `MEMORY.md` summary file → strip the `.md` suffix.
|
|
|
|
|
///
|
|
|
|
|
/// Return: slugs (without extension); empty `Vec` if the directory
|
|
|
|
|
/// can't be read.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn list(memory_dir: &Path) -> Vec<String> {
|
|
|
|
|
let entries = match std::fs::read_dir(memory_dir) {
|
|
|
|
|
Ok(e) => e,
|
|
|
|
|
Err(_) => return Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
entries
|
|
|
|
|
.filter_map(|e| e.ok())
|
|
|
|
|
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
|
|
|
|
|
.filter_map(|e| {
|
|
|
|
|
let name = e.file_name().to_string_lossy().to_string();
|
|
|
|
|
if name == "MEMORY.md" { return None; }
|
|
|
|
|
let slug = name.strip_suffix(".md")?.to_string();
|
|
|
|
|
Some(slug)
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Sanitize a raw filename into a safe path under `memory_dir`.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: replace any char that isn't alphanumeric, `.`, or `-` with `-` →
|
|
|
|
|
/// strip leading dots (prevents dotfiles / path traversal via `..`) →
|
|
|
|
|
/// join to `memory_dir`, falling back to `"memory.md"` if empty.
|
|
|
|
|
///
|
|
|
|
|
/// Why: leading-dot stripping specifically blocks accidental hidden
|
|
|
|
|
/// files and `..`-style traversal attempts embedded in `raw`.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
|
|
|
|
let clean: String = raw.chars()
|
|
|
|
|
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' })
|
|
|
|
|
.collect();
|
|
|
|
|
let clean = clean.trim_start_matches('.').to_string();
|
|
|
|
|
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Export all memories in `memory_dir` to a single JSON file.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: list memory slugs → read+parse each into a `Memory` (skipping
|
|
|
|
|
/// any that fail) → serialize the collected `Vec<Memory>` to pretty JSON
|
|
|
|
|
/// → write to `output`.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `Ok(())` on success, or an `io::Error` from serialization or
|
|
|
|
|
/// the write.
|
2026-07-11 18:23:01 +07:00
|
|
|
pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
|
|
|
|
let names = Memory::list(memory_dir);
|
|
|
|
|
let lessons: Vec<Memory> = names.iter()
|
|
|
|
|
.filter_map(|n| Memory::read(memory_dir, n).ok())
|
|
|
|
|
.collect();
|
|
|
|
|
let data = serde_json::to_string_pretty(&lessons)
|
|
|
|
|
.map_err(std::io::Error::other)?;
|
|
|
|
|
std::fs::write(output, data)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Import memories from a JSON export file into `memory_dir`, skipping
|
|
|
|
|
/// duplicates.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: read+JSON-decode `input` into `Vec<Memory>` → build a set of
|
|
|
|
|
/// existing slugs in `memory_dir` → for each lesson not already present
|
|
|
|
|
/// (by slug), write it to disk and count it.
|
|
|
|
|
///
|
|
|
|
|
/// Why: slug-based dedup makes repeated imports idempotent — re-running
|
|
|
|
|
/// import on the same file won't overwrite or duplicate existing memories.
|
|
|
|
|
///
|
|
|
|
|
/// Return: the number of memories actually imported (skips existing ones).
|
2026-07-11 18:23:01 +07:00
|
|
|
pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
|
|
|
|
|
let data = std::fs::read_to_string(input)?;
|
|
|
|
|
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
|
|
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
|
|
|
let existing: std::collections::HashSet<String> = Memory::list(memory_dir).into_iter().collect();
|
|
|
|
|
let mut imported = 0;
|
|
|
|
|
for lesson in &lessons {
|
|
|
|
|
let slug = Memory::slugify(&lesson.name).unwrap_or_default();
|
|
|
|
|
if !existing.contains(&slug) {
|
|
|
|
|
lesson.write(memory_dir)?;
|
|
|
|
|
imported += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(imported)
|
|
|
|
|
}
|
2026-07-11 21:06:22 +07:00
|
|
|
|
2026-07-11 22:10:17 +07:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_slugify_empty() {
|
|
|
|
|
assert_eq!(Memory::slugify(""), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_slugify_basic() {
|
|
|
|
|
assert_eq!(Memory::slugify("Hello World"), Some("hello-world".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_slugify_special_chars() {
|
|
|
|
|
assert_eq!(Memory::slugify("Use & Avoid! @#$"), Some("use-avoid".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_slugify_too_long() {
|
|
|
|
|
let long = "a".repeat(100);
|
|
|
|
|
assert_eq!(Memory::slugify(&long), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_slugify_numeric() {
|
|
|
|
|
assert_eq!(Memory::slugify("123"), Some("123".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_memory_parse_basic() {
|
|
|
|
|
let md = "---\nname: test-memory\ndescription: A test memory\nkind: lesson\ncreated_at: 1000\nupdated_at: 2000\n---\n\nThis is the body.";
|
|
|
|
|
let mem = Memory::parse(md).unwrap();
|
|
|
|
|
assert_eq!(mem.name, "test-memory");
|
|
|
|
|
assert_eq!(mem.description, "A test memory");
|
|
|
|
|
assert_eq!(mem.kind, "lesson");
|
|
|
|
|
assert_eq!(mem.created_at, 1000);
|
|
|
|
|
assert_eq!(mem.updated_at, 2000);
|
|
|
|
|
assert_eq!(mem.content, "This is the body.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_memory_parse_with_optional_fields() {
|
|
|
|
|
let md = "---\nname: full-memory\ndescription: Full fields\ntype: reference\ncreated_at: 100\nupdated_at: 200\nlifecycle: active\nscope: project\n---\n\nBody content here.";
|
|
|
|
|
let mem = Memory::parse(md).unwrap();
|
|
|
|
|
assert_eq!(mem.name, "full-memory");
|
|
|
|
|
assert_eq!(mem.lifecycle, "active");
|
|
|
|
|
assert_eq!(mem.scope, Some("project".to_string()));
|
|
|
|
|
assert_eq!(mem.content, "Body content here.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_memory_parse_missing_frontmatter() {
|
|
|
|
|
let md = "No frontmatter here";
|
|
|
|
|
assert!(Memory::parse(md).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_memory_write_and_read() {
|
|
|
|
|
let dir = std::env::temp_dir().join("memory_test_write_read");
|
|
|
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
|
|
|
let mem = Memory {
|
|
|
|
|
name: "my-test".to_string(),
|
|
|
|
|
description: "Test".to_string(),
|
|
|
|
|
content: "Some content".to_string(),
|
|
|
|
|
kind: "reference".to_string(),
|
|
|
|
|
created_at: 42,
|
|
|
|
|
updated_at: 43,
|
|
|
|
|
outcome: None,
|
|
|
|
|
lifecycle: "new".to_string(),
|
|
|
|
|
scope: None,
|
|
|
|
|
before_snippet: None,
|
|
|
|
|
after_snippet: None,
|
|
|
|
|
provenances: vec![],
|
|
|
|
|
};
|
|
|
|
|
mem.write(&dir).unwrap();
|
|
|
|
|
let read = Memory::read(&dir, "my-test").unwrap();
|
|
|
|
|
assert_eq!(read.name, "my-test");
|
|
|
|
|
assert_eq!(read.content, "Some content");
|
|
|
|
|
assert_eq!(read.created_at, 42);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_memory_list() {
|
|
|
|
|
let dir = std::env::temp_dir().join("memory_test_list");
|
|
|
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
|
|
|
let mem = Memory {
|
|
|
|
|
name: "alpha".to_string(),
|
|
|
|
|
description: "A".to_string(),
|
|
|
|
|
content: "a".to_string(),
|
|
|
|
|
kind: "lesson".to_string(),
|
|
|
|
|
created_at: 1,
|
|
|
|
|
updated_at: 1,
|
|
|
|
|
outcome: None,
|
|
|
|
|
lifecycle: "new".to_string(),
|
|
|
|
|
scope: None,
|
|
|
|
|
before_snippet: None,
|
|
|
|
|
after_snippet: None,
|
|
|
|
|
provenances: vec![],
|
|
|
|
|
};
|
|
|
|
|
mem.write(&dir).unwrap();
|
|
|
|
|
let names = Memory::list(&dir);
|
|
|
|
|
assert!(names.contains(&"alpha".to_string()), "list should contain 'alpha', got: {:?}", names);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_memory_remove() {
|
|
|
|
|
let dir = std::env::temp_dir().join("memory_test_remove");
|
|
|
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
|
|
|
let mem = Memory {
|
|
|
|
|
name: "remove-me".to_string(),
|
|
|
|
|
description: "R".to_string(),
|
|
|
|
|
content: "r".to_string(),
|
|
|
|
|
kind: "lesson".to_string(),
|
|
|
|
|
created_at: 1,
|
|
|
|
|
updated_at: 1,
|
|
|
|
|
outcome: None,
|
|
|
|
|
lifecycle: "new".to_string(),
|
|
|
|
|
scope: None,
|
|
|
|
|
before_snippet: None,
|
|
|
|
|
after_snippet: None,
|
|
|
|
|
provenances: vec![],
|
|
|
|
|
};
|
|
|
|
|
mem.write(&dir).unwrap();
|
|
|
|
|
assert!(Memory::read(&dir, "remove-me").is_ok());
|
|
|
|
|
Memory::remove(&dir, "remove-me").unwrap();
|
|
|
|
|
assert!(Memory::read(&dir, "remove-me").is_err());
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_export_import_lessons() {
|
|
|
|
|
let dir = std::env::temp_dir().join("memory_test_export");
|
|
|
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
|
|
|
let mem = Memory {
|
|
|
|
|
name: "export-me".to_string(),
|
|
|
|
|
description: "Exported".to_string(),
|
|
|
|
|
content: "content".to_string(),
|
|
|
|
|
kind: "lesson".to_string(),
|
|
|
|
|
created_at: 10,
|
|
|
|
|
updated_at: 10,
|
|
|
|
|
outcome: None,
|
|
|
|
|
lifecycle: "active".to_string(),
|
|
|
|
|
scope: Some("project".to_string()),
|
|
|
|
|
before_snippet: None,
|
|
|
|
|
after_snippet: None,
|
|
|
|
|
provenances: vec![],
|
|
|
|
|
};
|
|
|
|
|
mem.write(&dir).unwrap();
|
|
|
|
|
|
|
|
|
|
let export_path = std::env::temp_dir().join("memory_test_export_lessons.json");
|
|
|
|
|
export_lessons(&dir, &export_path).unwrap();
|
|
|
|
|
assert!(export_path.exists());
|
|
|
|
|
|
|
|
|
|
let dest_dir = std::env::temp_dir().join("memory_test_import_dest");
|
|
|
|
|
let _ = std::fs::create_dir_all(&dest_dir);
|
|
|
|
|
let imported = import_lessons(&dest_dir, &export_path).unwrap();
|
|
|
|
|
assert_eq!(imported, 1);
|
|
|
|
|
assert!(Memory::read(&dest_dir, "export-me").is_ok());
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
|
let _ = std::fs::remove_dir_all(&dest_dir);
|
|
|
|
|
let _ = std::fs::remove_file(&export_path);
|
|
|
|
|
}
|
|
|
|
|
}
|