Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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"));
}