Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
// End-to-end integration tests for the memory system (TC-8).
|
||||
//
|
||||
// These tests exercise the full memory lifecycle across multiple modules,
|
||||
// verifying that all components work together correctly.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use nomi_memory::index;
|
||||
use nomi_memory::paths;
|
||||
use nomi_memory::prompt::build_memory_prompt;
|
||||
use nomi_memory::store;
|
||||
use nomi_memory::types::{MemoryEntry, MemoryType};
|
||||
|
||||
// ===========================================================================
|
||||
// TC-8.1: Complete memory lifecycle
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_8_1_complete_memory_lifecycle() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
|
||||
// 1. Ensure memory directory exists
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
assert!(mem_dir.is_dir());
|
||||
|
||||
// 2. Write a feedback-type memory
|
||||
let entry = MemoryEntry::build(
|
||||
"test policy",
|
||||
"integration tests must hit real DB",
|
||||
MemoryType::Feedback,
|
||||
"Never mock the database in integration tests.\n\n\
|
||||
**Why:** mocked tests once passed but prod migration failed.\n\n\
|
||||
**How to apply:** use testcontainers for all DB tests.",
|
||||
);
|
||||
let written_path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
assert!(written_path.exists());
|
||||
|
||||
// 3. Append index entry to MEMORY.md
|
||||
let index_path = paths::memory_entrypoint(&mem_dir);
|
||||
let filename = written_path.file_name().unwrap().to_str().unwrap();
|
||||
index::append_index_entry(
|
||||
&index_path,
|
||||
"Test Policy",
|
||||
filename,
|
||||
"integration tests must hit real DB",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// 4. Scan directory — the memory should appear
|
||||
let headers = store::scan_memory_files(&mem_dir).unwrap();
|
||||
assert_eq!(
|
||||
headers.len(),
|
||||
1,
|
||||
"should find exactly 1 memory file (MEMORY.md excluded)"
|
||||
);
|
||||
assert_eq!(headers[0].filename, filename);
|
||||
assert_eq!(headers[0].memory_type, Some(MemoryType::Feedback));
|
||||
assert_eq!(
|
||||
headers[0].description.as_deref(),
|
||||
Some("integration tests must hit real DB")
|
||||
);
|
||||
|
||||
// 5. Build prompt — should include MEMORY.md content
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
assert!(
|
||||
prompt.contains(filename),
|
||||
"prompt should reference the memory file"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("integration tests must hit real DB"),
|
||||
"prompt should contain the index summary"
|
||||
);
|
||||
|
||||
// 6. Read back the memory file — verify content integrity
|
||||
let read_back = store::read_memory(&written_path).unwrap();
|
||||
assert_eq!(read_back.frontmatter.name.as_deref(), Some("test policy"));
|
||||
assert_eq!(
|
||||
read_back.frontmatter.memory_type,
|
||||
Some(MemoryType::Feedback)
|
||||
);
|
||||
assert!(read_back.content.contains("testcontainers"));
|
||||
|
||||
// 7. Delete the memory file
|
||||
store::delete_memory(&written_path).unwrap();
|
||||
assert!(!written_path.exists());
|
||||
|
||||
// 8. Re-scan — should be empty
|
||||
let headers_after = store::scan_memory_files(&mem_dir).unwrap();
|
||||
assert!(
|
||||
headers_after.is_empty(),
|
||||
"should find no memory files after deletion"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-8.2: Chinese content memory
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_8_2_chinese_content_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
|
||||
let entry = MemoryEntry::build(
|
||||
"用户角色",
|
||||
"资深后端工程师",
|
||||
MemoryType::User,
|
||||
"用户是一位有十年经验的后端工程师,熟悉 Rust 和 Go。\n\
|
||||
偏好函数式编程风格,不喜欢过度抽象。",
|
||||
);
|
||||
|
||||
// Write
|
||||
let path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
// Read back — Chinese content should be intact
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.frontmatter.name.as_deref(), Some("用户角色"));
|
||||
assert_eq!(
|
||||
read_back.frontmatter.description.as_deref(),
|
||||
Some("资深后端工程师")
|
||||
);
|
||||
assert_eq!(read_back.frontmatter.memory_type, Some(MemoryType::User));
|
||||
assert!(read_back.content.contains("十年经验"));
|
||||
assert!(read_back.content.contains("函数式编程"));
|
||||
|
||||
// Scan — header should preserve Chinese description
|
||||
let headers = store::scan_memory_files(&mem_dir).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].description.as_deref(), Some("资深后端工程师"));
|
||||
|
||||
// Index — append Chinese title and verify
|
||||
let index_path = paths::memory_entrypoint(&mem_dir);
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
index::append_index_entry(&index_path, "用户角色", filename, "资深后端工程师").unwrap();
|
||||
|
||||
let index_content = fs::read_to_string(&index_path).unwrap();
|
||||
assert!(index_content.contains("用户角色"));
|
||||
assert!(index_content.contains("资深后端工程师"));
|
||||
|
||||
// Prompt — should include Chinese index content
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
assert!(prompt.contains("用户角色"));
|
||||
assert!(prompt.contains("资深后端工程师"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-8.3: Special character handling
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_8_3_special_characters_in_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
|
||||
// Name with special characters: spaces, slashes, colons, emoji
|
||||
let entry = MemoryEntry::build(
|
||||
"My Role / Senior: 🚀",
|
||||
"role with special chars",
|
||||
MemoryType::User,
|
||||
"Body with special chars: <tag>, \"quotes\", 'apostrophes' & ampersands",
|
||||
);
|
||||
|
||||
let path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
|
||||
// Filename should be safe (no slashes, colons, etc.)
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
assert!(
|
||||
!filename.contains('/'),
|
||||
"filename should not contain slash: {filename}"
|
||||
);
|
||||
assert!(
|
||||
!filename.contains(':'),
|
||||
"filename should not contain colon: {filename}"
|
||||
);
|
||||
assert!(
|
||||
filename.ends_with(".md"),
|
||||
"filename should end with .md: {filename}"
|
||||
);
|
||||
|
||||
// Content should round-trip correctly
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
assert!(read_back.content.contains("<tag>"));
|
||||
assert!(read_back.content.contains("\"quotes\""));
|
||||
assert!(read_back.content.contains("& ampersands"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_8_3_name_with_only_special_chars() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
paths::ensure_memory_dir(&mem_dir).unwrap();
|
||||
|
||||
// Edge case: name is entirely special characters / non-ASCII
|
||||
let entry = MemoryEntry::build(
|
||||
"🔥💡✨",
|
||||
"emoji only name",
|
||||
MemoryType::Feedback,
|
||||
"Some body",
|
||||
);
|
||||
|
||||
let path = store::write_memory(&mem_dir, &entry).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
// Should still produce a valid filename (hash fallback)
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
assert!(filename.ends_with(".md"));
|
||||
|
||||
// Content should round-trip
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
assert_eq!(read_back.content, "Some body");
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
// Integration tests for the MEMORY.md index management.
|
||||
//
|
||||
// These tests target functional requirements from test-plan.md TC-5,
|
||||
// treating the public API as a black box.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use nomi_memory::index;
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.1: Truncation — under limits, no truncation
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_1_no_truncation_under_limits() {
|
||||
let content = (0..100)
|
||||
.map(|i| format!("- [Memory {i}](mem_{i}.md) \u{2014} summary {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 100);
|
||||
assert!(result.byte_count > 0);
|
||||
// Content should be the same as input (trimmed)
|
||||
assert_eq!(result.content, content.trim());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.2: Truncation — exceeds line limit
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_2_line_truncation() {
|
||||
let content = (0..250)
|
||||
.map(|i| format!("- [Memory {i}](mem_{i}.md) \u{2014} summary {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 250);
|
||||
// Warning should mention line count
|
||||
assert!(result.content.contains("250 lines"));
|
||||
assert!(result.content.contains("WARNING"));
|
||||
|
||||
// Only first 200 lines should be present (before warning)
|
||||
let before_warning = result.content.split("\n\n> WARNING:").next().unwrap();
|
||||
let output_lines: Vec<&str> = before_warning.lines().collect();
|
||||
assert_eq!(output_lines.len(), 200);
|
||||
assert!(output_lines[0].contains("Memory 0"));
|
||||
assert!(output_lines[199].contains("Memory 199"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.3: Truncation — exceeds byte limit (lines within limit)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_3_byte_truncation() {
|
||||
// 100 lines of 300 chars each = 30000 bytes > 25000, but 100 < 200 lines
|
||||
let content = (0..100)
|
||||
.map(|i| format!("{i:03}: {}", "x".repeat(296)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 100);
|
||||
// Warning should mention byte size and "too long"
|
||||
assert!(result.content.contains("index entries are too long"));
|
||||
assert!(result.content.contains("KB"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.4: Truncation — both line and byte limits exceeded
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_4_both_limits() {
|
||||
// 300 lines of 200 bytes each = 60000 bytes; both limits exceeded
|
||||
let content = (0..300)
|
||||
.map(|i| format!("{i:03}: {}", "y".repeat(196)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(result.was_truncated);
|
||||
assert_eq!(result.line_count, 300);
|
||||
// Warning should mention both
|
||||
assert!(result.content.contains("300 lines"));
|
||||
assert!(result.content.contains("KB"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.5: Truncation — empty content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_5_empty_content() {
|
||||
let result = index::truncate_index("");
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 0);
|
||||
assert_eq!(result.byte_count, 0);
|
||||
assert_eq!(result.content, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.6: Truncation — whitespace-only content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_6_whitespace_only() {
|
||||
let result = index::truncate_index(" \n \n ");
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.content, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.7: Truncation — exactly at line boundary (200 lines)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_7_exactly_200_lines() {
|
||||
let content = (0..200)
|
||||
.map(|i| format!("- line {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
assert_eq!(result.line_count, 200);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.8: Truncation — exactly at byte boundary (25000 bytes)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_8_exactly_25000_bytes() {
|
||||
// 100 lines under 200 limit, totalling exactly 25000 bytes
|
||||
let per_line = (index::MAX_INDEX_BYTES - 99) / 100;
|
||||
let remainder = index::MAX_INDEX_BYTES - 99 - per_line * 100;
|
||||
let mut lines: Vec<String> = (0..100).map(|_| "x".repeat(per_line)).collect();
|
||||
if remainder > 0 {
|
||||
lines.last_mut().unwrap().push_str(&"x".repeat(remainder));
|
||||
}
|
||||
let content = lines.join("\n");
|
||||
assert_eq!(content.len(), index::MAX_INDEX_BYTES);
|
||||
|
||||
let result = index::truncate_index(&content);
|
||||
assert!(!result.was_truncated);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.9: Truncation — single long line (no newline to cut at)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_9_single_long_line() {
|
||||
let content = "z".repeat(30_000);
|
||||
let result = index::truncate_index(&content);
|
||||
|
||||
assert!(result.was_truncated);
|
||||
// Should truncate at MAX_INDEX_BYTES since there's no newline
|
||||
let before_warning = result.content.split("\n\n> WARNING:").next().unwrap();
|
||||
assert_eq!(before_warning.len(), index::MAX_INDEX_BYTES);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.10: Read index — file doesn't exist
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_10_read_nonexistent() {
|
||||
let result = index::read_index(Path::new("/nonexistent/MEMORY.md"));
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.11: Read index — file exists with content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_11_read_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
let content = "# Index\n- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n";
|
||||
fs::write(&path, content).unwrap();
|
||||
|
||||
let result = index::read_index(&path);
|
||||
assert_eq!(result, content);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.12: Read index — empty file
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_12_read_empty_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(&path, "").unwrap();
|
||||
|
||||
let result = index::read_index(&path);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.13: Append entry — to existing content
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_13_append_to_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
index::append_index_entry(&path, "My Memory", "my_memory.md", "a test").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert_eq!(lines[2], "- [My Memory](my_memory.md) \u{2014} a test");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.14: Append entry — file doesn't exist (auto-create)
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_14_append_auto_create() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
assert!(!path.exists());
|
||||
|
||||
index::append_index_entry(&path, "First", "first.md", "the first entry").unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, "- [First](first.md) \u{2014} the first entry\n");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.15: Append multiple entries sequentially
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_15_append_multiple() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
|
||||
index::append_index_entry(&path, "A", "a.md", "first").unwrap();
|
||||
index::append_index_entry(&path, "B", "b.md", "second").unwrap();
|
||||
index::append_index_entry(&path, "C", "c.md", "third").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
assert!(lines[0].contains("[A]"));
|
||||
assert!(lines[1].contains("[B]"));
|
||||
assert!(lines[2].contains("[C]"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.16: Remove entry — by filename
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_16_remove_by_filename() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"- [A](a.md) \u{2014} first\n- [B](old_memory.md) \u{2014} second\n- [C](c.md) \u{2014} third\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
index::remove_index_entry(&path, "old_memory.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines[0].contains("[A](a.md)"));
|
||||
assert!(lines[1].contains("[C](c.md)"));
|
||||
// Removed entry should not be present
|
||||
assert!(!content.contains("old_memory.md"));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.17: Remove entry — target not found
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_17_remove_not_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("MEMORY.md");
|
||||
let original =
|
||||
"- [A](a.md) \u{2014} first\n- [B](b.md) \u{2014} second\n- [C](c.md) \u{2014} third\n";
|
||||
fs::write(&path, original).unwrap();
|
||||
|
||||
index::remove_index_entry(&path, "nonexistent.md").unwrap();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(content, original);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-5.18: Remove entry — file doesn't exist
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn tc_5_18_remove_from_nonexistent() {
|
||||
let path = Path::new("/nonexistent/MEMORY.md");
|
||||
// Should not error — idempotent
|
||||
index::remove_index_entry(path, "anything.md").unwrap();
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// Integration tests for the memory path system.
|
||||
//
|
||||
// These tests target the functional requirements from test-plan.md TC-2,
|
||||
// treating the public API as a black box.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nomi_memory::paths;
|
||||
use serial_test::serial;
|
||||
|
||||
// -- TC-2.1: Default memory base directory ------------------------------------
|
||||
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_1_default_base_dir_uses_platform_config() {
|
||||
// Ensure env override is NOT set
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::remove_var(env_key()) };
|
||||
|
||||
let base = paths::memory_base_dir();
|
||||
// Should return Some (platform provides a config dir in CI/test envs)
|
||||
assert!(
|
||||
base.is_some(),
|
||||
"memory_base_dir should return Some on this platform"
|
||||
);
|
||||
let base = base.unwrap();
|
||||
// Should end with "nomi" (the brand, not "claude")
|
||||
assert!(
|
||||
base.to_string_lossy().contains("nomi"),
|
||||
"base dir should use nomi brand: {base:?}"
|
||||
);
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
// -- TC-2.2: Environment variable overrides base directory --------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_2_env_var_overrides_base_dir() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "/custom/memory/path") };
|
||||
|
||||
let base = paths::memory_base_dir();
|
||||
assert_eq!(base, Some(PathBuf::from("/custom/memory/path")));
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_2_env_var_overrides_base_dir() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "C:\\custom\\memory\\path") };
|
||||
|
||||
let base = paths::memory_base_dir();
|
||||
assert_eq!(base, Some(PathBuf::from("C:\\custom\\memory\\path")));
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
// -- TC-2.3: Project memory directory path ------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_3_auto_memory_dir_structure() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "/base") };
|
||||
|
||||
let dir = paths::auto_memory_dir(Path::new("/home/user/my-project"));
|
||||
assert!(dir.is_some());
|
||||
let dir = dir.unwrap();
|
||||
|
||||
// Should have the structure: <base>/projects/<sanitized>/memory
|
||||
let dir_str = dir.to_string_lossy();
|
||||
assert!(
|
||||
dir_str.starts_with("/base/projects/"),
|
||||
"wrong prefix: {dir_str}"
|
||||
);
|
||||
assert!(
|
||||
dir_str.ends_with("/memory"),
|
||||
"should end with /memory: {dir_str}"
|
||||
);
|
||||
|
||||
// Sanitized name should not contain `/` (the original separator)
|
||||
let sanitized = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
|
||||
assert!(
|
||||
!sanitized.contains('/'),
|
||||
"sanitized name should not contain /: {sanitized}"
|
||||
);
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[serial(env)]
|
||||
fn tc_2_3_auto_memory_dir_structure() {
|
||||
let saved = std::env::var(env_key()).ok();
|
||||
// SAFETY: #[serial(env)] ensures no concurrent env mutation.
|
||||
unsafe { std::env::set_var(env_key(), "C:\\base") };
|
||||
|
||||
let dir = paths::auto_memory_dir(Path::new("C:\\Users\\user\\my-project"));
|
||||
assert!(dir.is_some());
|
||||
let dir = dir.unwrap();
|
||||
|
||||
let dir_str = dir.to_string_lossy();
|
||||
assert!(
|
||||
dir_str.starts_with("C:\\base\\projects\\"),
|
||||
"wrong prefix: {dir_str}"
|
||||
);
|
||||
assert!(
|
||||
dir_str.ends_with("\\memory"),
|
||||
"should end with \\memory: {dir_str}"
|
||||
);
|
||||
|
||||
let sanitized = dir.parent().unwrap().file_name().unwrap().to_string_lossy();
|
||||
assert!(
|
||||
!sanitized.contains('\\'),
|
||||
"sanitized name should not contain \\: {sanitized}"
|
||||
);
|
||||
|
||||
restore_env(saved);
|
||||
}
|
||||
|
||||
// -- TC-2.4: Reject relative path ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_4_reject_relative_path() {
|
||||
let result = paths::validate_memory_path(Path::new("relative/path"));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("absolute"),
|
||||
"error should mention 'absolute': {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.5: Reject null byte -------------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn tc_2_5_reject_null_byte() {
|
||||
let bad_path = PathBuf::from("/tmp/test\0evil");
|
||||
let result = paths::validate_memory_path(&bad_path);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("null"),
|
||||
"error should mention null: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tc_2_5_reject_null_byte() {
|
||||
let bad_path = PathBuf::from("C:\\tmp\\test\0evil");
|
||||
let result = paths::validate_memory_path(&bad_path);
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("null"),
|
||||
"error should mention null: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.6: Reject path traversal --------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn tc_2_6_reject_traversal() {
|
||||
let result = paths::validate_memory_path(Path::new("/tmp/../../../etc/passwd"));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("traversal"),
|
||||
"error should mention traversal: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tc_2_6_reject_traversal() {
|
||||
let result = paths::validate_memory_path(Path::new("C:\\tmp\\..\\..\\..\\etc\\passwd"));
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err_msg.contains("traversal"),
|
||||
"error should mention traversal: {err_msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.7: Memory entrypoint path -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_7_entrypoint_path() {
|
||||
// memory_entrypoint just appends MEMORY.md — no absolute path requirement,
|
||||
// so a platform-neutral relative path works fine here.
|
||||
let dir = Path::new("path").join("to").join("memory");
|
||||
let ep = paths::memory_entrypoint(&dir);
|
||||
assert_eq!(ep, dir.join("MEMORY.md"));
|
||||
}
|
||||
|
||||
// -- TC-2.8: Path membership positive -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_8_is_memory_path_inside() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
let file = mem_dir.join("user_role.md");
|
||||
fs::write(&file, "test").unwrap();
|
||||
|
||||
assert!(
|
||||
paths::is_memory_path(&file, &mem_dir),
|
||||
"file inside memory dir should be recognized"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.9: Path membership negative -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_9_is_memory_path_outside() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
let outside = tmp.path().join("other_file.md");
|
||||
fs::write(&outside, "test").unwrap();
|
||||
|
||||
assert!(
|
||||
!paths::is_memory_path(&outside, &mem_dir),
|
||||
"file outside memory dir should not be recognized"
|
||||
);
|
||||
}
|
||||
|
||||
// -- TC-2.10: Ensure directory exists -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_2_10_ensure_dir_creates_and_is_idempotent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let deep = tmp.path().join("a").join("b").join("c").join("memory");
|
||||
|
||||
// Does not exist yet
|
||||
assert!(!deep.exists());
|
||||
|
||||
// First call creates it
|
||||
paths::ensure_memory_dir(&deep).unwrap();
|
||||
assert!(deep.is_dir());
|
||||
|
||||
// Second call is idempotent
|
||||
paths::ensure_memory_dir(&deep).unwrap();
|
||||
assert!(deep.is_dir());
|
||||
}
|
||||
|
||||
// -- Additional edge cases from test-plan TC-2 --------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn validate_accepts_valid_absolute_path() {
|
||||
let result = paths::validate_memory_path(Path::new("/tmp/memory/test.md"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn validate_accepts_valid_absolute_path() {
|
||||
let result = paths::validate_memory_path(Path::new("C:\\tmp\\memory\\test.md"));
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn validate_rejects_root_path() {
|
||||
let result = paths::validate_memory_path(Path::new("/"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn validate_rejects_root_path() {
|
||||
let result = paths::validate_memory_path(Path::new("C:\\"));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_produces_deterministic_results() {
|
||||
let path = "/home/user/workspace/project";
|
||||
assert_eq!(paths::sanitize_path(path), paths::sanitize_path(path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_different_paths_produce_different_results() {
|
||||
let a = paths::sanitize_path("/home/alice/project");
|
||||
let b = paths::sanitize_path("/home/bob/project");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entrypoint_name_constant_is_memory_md() {
|
||||
assert_eq!(paths::ENTRYPOINT_NAME, "MEMORY.md");
|
||||
}
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
fn env_key() -> &'static str {
|
||||
"NOMI_MEMORY_DIR"
|
||||
}
|
||||
|
||||
fn restore_env(saved: Option<String>) {
|
||||
// SAFETY: only called from #[serial(env)] tests.
|
||||
unsafe {
|
||||
match saved {
|
||||
Some(v) => std::env::set_var(env_key(), v),
|
||||
None => std::env::remove_var(env_key()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
// Integration tests for memory system prompt construction (TC-6).
|
||||
//
|
||||
// These are black-box tests that exercise the public API of the prompt
|
||||
// module against the functional requirements in test-plan.md.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use nomi_memory::prompt::{
|
||||
build_memory_instructions, build_memory_prompt, memory_type_descriptions,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.1: Complete prompt contains all required sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_1_prompt_contains_all_required_parts() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
// Memory system introduction
|
||||
assert!(
|
||||
prompt.contains("persistent, file-based memory system"),
|
||||
"should contain memory system introduction"
|
||||
);
|
||||
|
||||
// 4 type definitions
|
||||
for ty in ["user", "feedback", "project", "reference"] {
|
||||
assert!(
|
||||
prompt.contains(&format!("<name>{ty}</name>")),
|
||||
"should contain type definition for: {ty}"
|
||||
);
|
||||
}
|
||||
|
||||
// What not to save
|
||||
assert!(
|
||||
prompt.contains("What NOT to save"),
|
||||
"should contain what-not-to-save section"
|
||||
);
|
||||
|
||||
// Save steps
|
||||
assert!(
|
||||
prompt.contains("How to save memories"),
|
||||
"should contain save instructions"
|
||||
);
|
||||
|
||||
// When to access
|
||||
assert!(
|
||||
prompt.contains("When to access memories"),
|
||||
"should contain access guidance"
|
||||
);
|
||||
|
||||
// MEMORY.md content or empty-state message
|
||||
assert!(
|
||||
prompt.contains("MEMORY.md"),
|
||||
"should reference MEMORY.md entrypoint"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.2: Prompt includes the memory directory path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_2_prompt_includes_memory_dir_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("my_project_memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains(&mem_dir.display().to_string()),
|
||||
"prompt should contain the memory directory path"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.3: With MEMORY.md present, prompt includes its content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_3_prompt_includes_memory_md_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
let index_content = "- [User Role](user_role.md) \u{2014} senior engineer\n\
|
||||
- [Test Policy](feedback_tests.md) \u{2014} always use real DB\n";
|
||||
fs::write(mem_dir.join("MEMORY.md"), index_content).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("user_role.md"),
|
||||
"prompt should contain index entry references"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("senior engineer"),
|
||||
"prompt should contain index entry summaries"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("feedback_tests.md"),
|
||||
"prompt should contain all index entries"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.4: Without MEMORY.md, prompt shows empty-state message
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_4_no_memory_md_shows_empty_message() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
// No MEMORY.md file created
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("currently empty"),
|
||||
"should indicate MEMORY.md is empty when file doesn't exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_4_empty_memory_md_shows_empty_message() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(mem_dir.join("MEMORY.md"), "").unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("currently empty"),
|
||||
"should indicate MEMORY.md is empty when file is blank"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_4_whitespace_only_memory_md_shows_empty_message() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(mem_dir.join("MEMORY.md"), " \n\n ").unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("currently empty"),
|
||||
"should indicate MEMORY.md is empty when file is whitespace-only"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.5: No bb brand identifiers in prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_5_no_bb_brand_in_prompt() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(
|
||||
mem_dir.join("MEMORY.md"),
|
||||
"- [Test](test.md) \u{2014} entry\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
!prompt.contains("~/.claude"),
|
||||
"prompt must not contain bb brand path ~/.claude"
|
||||
);
|
||||
assert!(
|
||||
!prompt.contains("CLAUDE.md"),
|
||||
"prompt must not reference CLAUDE.md"
|
||||
);
|
||||
// Allow "claude" in lowercase only in non-brand contexts (e.g. general English).
|
||||
// The key check is no bb-specific identifiers.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_5_no_bb_brand_in_instructions() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
|
||||
assert!(!joined.contains("~/.claude"));
|
||||
assert!(!joined.contains("CLAUDE.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_6_5_no_bb_brand_in_type_descriptions() {
|
||||
let desc = memory_type_descriptions();
|
||||
|
||||
assert!(!desc.contains("~/.claude"));
|
||||
assert!(!desc.contains("CLAUDE.md"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TC-6.6: Paths use nomi brand, not hardcoded platform paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_6_6_no_hardcoded_platform_paths() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
let joined = lines.join("\n");
|
||||
|
||||
// Should not contain hardcoded Unix-specific config paths
|
||||
assert!(
|
||||
!joined.contains("~/.config/nomi"),
|
||||
"should not hardcode platform-specific config path"
|
||||
);
|
||||
|
||||
// Path should come from the memory_dir argument, not hardcoded
|
||||
assert!(
|
||||
joined.contains("/test/memory"),
|
||||
"should use the provided memory_dir path"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional integration tests beyond TC-6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn instructions_are_well_structured_vec() {
|
||||
let lines = build_memory_instructions(Path::new("/test/memory"));
|
||||
|
||||
// Should be a non-empty vec
|
||||
assert!(!lines.is_empty());
|
||||
|
||||
// First line should be the title
|
||||
assert!(lines[0].starts_with("# "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_with_large_index_includes_truncation_warning() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
|
||||
// Create 250-line index (exceeds 200-line limit)
|
||||
let content: String = (0..250)
|
||||
.map(|i| format!("- [Item {i}](item_{i}.md) \u{2014} summary for item {i}\n"))
|
||||
.collect();
|
||||
fs::write(mem_dir.join("MEMORY.md"), &content).unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
assert!(
|
||||
prompt.contains("WARNING"),
|
||||
"should include truncation warning for large index"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("250 lines"),
|
||||
"warning should mention original line count"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_descriptions_standalone() {
|
||||
let desc = memory_type_descriptions();
|
||||
|
||||
assert!(desc.contains("<types>"));
|
||||
assert!(desc.contains("</types>"));
|
||||
|
||||
// All four types present
|
||||
for ty in ["user", "feedback", "project", "reference"] {
|
||||
assert!(
|
||||
desc.contains(&format!("<name>{ty}</name>")),
|
||||
"type_descriptions should include: {ty}"
|
||||
);
|
||||
}
|
||||
|
||||
// Each type has description and examples
|
||||
assert!(desc.contains("<description>"));
|
||||
assert!(desc.contains("<examples>"));
|
||||
assert!(desc.contains("<when_to_save>"));
|
||||
assert!(desc.contains("<how_to_use>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_nonexistent_dir_succeeds() {
|
||||
// build_memory_prompt should not panic even if the directory doesn't exist
|
||||
// (read_index returns empty string for missing files)
|
||||
let result = build_memory_prompt(Path::new("/nonexistent/path/memory"));
|
||||
assert!(result.contains("currently empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_sections_appear_in_correct_order() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mem_dir = tmp.path().join("memory");
|
||||
fs::create_dir_all(&mem_dir).unwrap();
|
||||
fs::write(mem_dir.join("MEMORY.md"), "- [A](a.md) \u{2014} test\n").unwrap();
|
||||
|
||||
let prompt = build_memory_prompt(&mem_dir);
|
||||
|
||||
// Verify section ordering
|
||||
let positions = [
|
||||
("# auto memory", prompt.find("# auto memory")),
|
||||
("## Types of memory", prompt.find("## Types of memory")),
|
||||
("## What NOT to save", prompt.find("## What NOT to save")),
|
||||
("## How to save", prompt.find("## How to save")),
|
||||
("## When to access", prompt.find("## When to access")),
|
||||
(
|
||||
"## Before recommending",
|
||||
prompt.find("## Before recommending"),
|
||||
),
|
||||
(
|
||||
"## Memory and other forms",
|
||||
prompt.find("## Memory and other forms"),
|
||||
),
|
||||
("## MEMORY.md", prompt.find("## MEMORY.md")),
|
||||
];
|
||||
|
||||
for (name, pos) in &positions {
|
||||
assert!(pos.is_some(), "section missing: {name}");
|
||||
}
|
||||
|
||||
// Verify monotonically increasing positions
|
||||
let nums: Vec<usize> = positions.iter().map(|(_, p)| p.unwrap()).collect();
|
||||
for i in 1..nums.len() {
|
||||
assert!(
|
||||
nums[i] > nums[i - 1],
|
||||
"section '{}' should appear after '{}', but positions are {} vs {}",
|
||||
positions[i].0,
|
||||
positions[i - 1].0,
|
||||
nums[i],
|
||||
nums[i - 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// Integration tests for the memory store.
|
||||
//
|
||||
// These tests target functional requirements from test-plan.md TC-3 and TC-4,
|
||||
// treating the public API as a black box.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use nomi_memory::store;
|
||||
use nomi_memory::types::{MemoryEntry, MemoryFrontmatter, MemoryType};
|
||||
|
||||
// ===========================================================================
|
||||
// TC-3: Memory file read/write
|
||||
// ===========================================================================
|
||||
|
||||
// -- TC-3.1: Write then read full memory ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_1_write_then_read_full_memory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build(
|
||||
"test memory",
|
||||
"a test description",
|
||||
MemoryType::User,
|
||||
"Body content here",
|
||||
);
|
||||
|
||||
let path = store::write_memory(tmp.path(), &entry).unwrap();
|
||||
let read_back = store::read_memory(&path).unwrap();
|
||||
|
||||
assert_eq!(read_back.frontmatter.name, entry.frontmatter.name);
|
||||
assert_eq!(
|
||||
read_back.frontmatter.description,
|
||||
entry.frontmatter.description
|
||||
);
|
||||
assert_eq!(
|
||||
read_back.frontmatter.memory_type,
|
||||
entry.frontmatter.memory_type
|
||||
);
|
||||
assert_eq!(read_back.content, entry.content);
|
||||
}
|
||||
|
||||
// -- TC-3.2: Read file with frontmatter -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_2_read_with_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("test.md");
|
||||
fs::write(
|
||||
&path,
|
||||
"---\nname: test memory\ndescription: a test\ntype: feedback\n---\nBody content here",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
assert_eq!(entry.frontmatter.name.as_deref(), Some("test memory"));
|
||||
assert_eq!(entry.frontmatter.description.as_deref(), Some("a test"));
|
||||
assert_eq!(entry.frontmatter.memory_type, Some(MemoryType::Feedback));
|
||||
assert_eq!(entry.content, "Body content here");
|
||||
}
|
||||
|
||||
// -- TC-3.3: Read file without frontmatter ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_3_read_without_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("plain.md");
|
||||
fs::write(&path, "Just plain text").unwrap();
|
||||
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
assert_eq!(entry.frontmatter.name, None);
|
||||
assert_eq!(entry.frontmatter.description, None);
|
||||
assert_eq!(entry.frontmatter.memory_type, None);
|
||||
assert_eq!(entry.content, "Just plain text");
|
||||
}
|
||||
|
||||
// -- TC-3.4: Read empty file ------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_4_read_empty_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("empty.md");
|
||||
fs::write(&path, "").unwrap();
|
||||
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
assert_eq!(entry.frontmatter, MemoryFrontmatter::default());
|
||||
assert_eq!(entry.content, "");
|
||||
}
|
||||
|
||||
// -- TC-3.5: Read incomplete frontmatter ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_5_read_incomplete_frontmatter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("incomplete.md");
|
||||
fs::write(&path, "---\nname: orphan\nno closing delimiter").unwrap();
|
||||
|
||||
// Should not panic, should degrade gracefully
|
||||
let entry = store::read_memory(&path).unwrap();
|
||||
// Entire content treated as body since frontmatter is incomplete
|
||||
assert_eq!(entry.frontmatter, MemoryFrontmatter::default());
|
||||
assert!(entry.content.contains("orphan"));
|
||||
}
|
||||
|
||||
// -- TC-3.6: Delete existing memory file ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_6_delete_existing_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build("to delete", "desc", MemoryType::Feedback, "content");
|
||||
let path = store::write_memory(tmp.path(), &entry).unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
store::delete_memory(&path).unwrap();
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
// -- TC-3.7: Delete non-existent file returns error -------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_7_delete_nonexistent_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("nonexistent.md");
|
||||
|
||||
let result = store::delete_memory(&path);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// -- TC-3.8: Written filename format ----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_3_8_filename_format() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let entry = MemoryEntry::build("My Role", "desc", MemoryType::User, "content");
|
||||
|
||||
let path = store::write_memory(tmp.path(), &entry).unwrap();
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
|
||||
// Should be lowercase, safe characters
|
||||
assert_eq!(filename, "user_my_role.md");
|
||||
assert!(
|
||||
filename
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// TC-4: Directory scanning
|
||||
// ===========================================================================
|
||||
|
||||
// -- TC-4.1: Scan directory with multiple memory files ----------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_1_scan_multiple_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Create 3 memory files + MEMORY.md
|
||||
fs::write(
|
||||
dir.join("user_role.md"),
|
||||
"---\ntype: user\ndescription: role\n---\nBody",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("feedback_testing.md"),
|
||||
"---\ntype: feedback\n---\nBody",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.join("project_status.md"),
|
||||
"---\ntype: project\n---\nBody",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(dir.join("MEMORY.md"), "# Index\n- [role](user_role.md)").unwrap();
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
|
||||
// MEMORY.md should be excluded
|
||||
assert_eq!(headers.len(), 3);
|
||||
let filenames: Vec<&str> = headers.iter().map(|h| h.filename.as_str()).collect();
|
||||
assert!(!filenames.contains(&"MEMORY.md"));
|
||||
}
|
||||
|
||||
// -- TC-4.2: Scan empty directory -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_2_scan_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let headers = store::scan_memory_files(tmp.path()).unwrap();
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
// -- TC-4.3: Scan non-existent directory ------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_3_scan_nonexistent_dir() {
|
||||
let headers = store::scan_memory_files(Path::new("/nonexistent/dir")).unwrap();
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
// -- TC-4.4: Sort by modification time (newest first) -----------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_4_sort_by_mtime() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Write files with small delays to ensure different mtimes
|
||||
fs::write(dir.join("old.md"), "---\nname: old\n---\nOld").unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
fs::write(dir.join("mid.md"), "---\nname: mid\n---\nMid").unwrap();
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
fs::write(dir.join("new.md"), "---\nname: new\n---\nNew").unwrap();
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
assert_eq!(headers.len(), 3);
|
||||
|
||||
// Newest first
|
||||
assert_eq!(headers[0].filename, "new.md");
|
||||
assert_eq!(headers[2].filename, "old.md");
|
||||
}
|
||||
|
||||
// -- TC-4.5: File count cap at 200 -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_5_file_count_cap() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
// Create 210 files
|
||||
for i in 0..210 {
|
||||
fs::write(
|
||||
dir.join(format!("mem_{i:03}.md")),
|
||||
format!("---\nname: mem{i}\n---\nBody {i}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
assert_eq!(headers.len(), 200);
|
||||
}
|
||||
|
||||
// -- TC-4.6: Non-.md files are ignored --------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_6_non_md_ignored() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
|
||||
fs::write(dir.join("memory.md"), "---\nname: valid\n---\nBody").unwrap();
|
||||
fs::write(dir.join("notes.txt"), "text file").unwrap();
|
||||
fs::write(dir.join("data.json"), "{}").unwrap();
|
||||
fs::write(dir.join("script.py"), "pass").unwrap();
|
||||
|
||||
let headers = store::scan_memory_files(dir).unwrap();
|
||||
assert_eq!(headers.len(), 1);
|
||||
assert_eq!(headers[0].filename, "memory.md");
|
||||
}
|
||||
|
||||
// -- TC-4.7: Format memory manifest -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn tc_4_7_format_manifest() {
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
let headers = vec![
|
||||
nomi_memory::types::MemoryHeader {
|
||||
filename: "user_role.md".into(),
|
||||
file_path: "/mem/user_role.md".into(),
|
||||
mtime: Utc.with_ymd_and_hms(2026, 4, 10, 12, 0, 0).unwrap(),
|
||||
description: Some("User role info".into()),
|
||||
memory_type: Some(MemoryType::User),
|
||||
},
|
||||
nomi_memory::types::MemoryHeader {
|
||||
filename: "notes.md".into(),
|
||||
file_path: "/mem/notes.md".into(),
|
||||
mtime: Utc.with_ymd_and_hms(2026, 4, 9, 8, 0, 0).unwrap(),
|
||||
description: None,
|
||||
memory_type: None,
|
||||
},
|
||||
];
|
||||
|
||||
let manifest = store::format_memory_manifest(&headers);
|
||||
let lines: Vec<&str> = manifest.lines().collect();
|
||||
|
||||
assert_eq!(lines.len(), 2);
|
||||
// First: has type and description
|
||||
assert!(lines[0].contains("[user]"));
|
||||
assert!(lines[0].contains("user_role.md"));
|
||||
assert!(lines[0].contains("User role info"));
|
||||
// Second: no type, no description
|
||||
assert!(!lines[1].contains("["));
|
||||
assert!(lines[1].contains("notes.md"));
|
||||
}
|
||||
Reference in New Issue
Block a user