use crate::domain::{MemoryLifecycleState, MemoryScope};
use crate::lifecycle_store::{LedgerEntry, LifecycleStore, latest_state_entries};
use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub const INDEX_FILE_NAME: &str = "INDEX.md";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IndexWriteStatus {
Created,
Updated,
Unchanged,
}
#[derive(Debug, Clone)]
pub struct IndexWriteResult {
pub path: PathBuf,
pub status: IndexWriteStatus,
pub user_entries: usize,
pub project_entries: usize,
}
pub fn index_path(vault_root: &Path) -> PathBuf {
vault_root.join(INDEX_FILE_NAME)
}
pub fn render_index(entries: &[LedgerEntry]) -> String {
let mut user_entries: Vec<&LedgerEntry> = Vec::new();
let mut project_entries: BTreeMap<String, Vec<&LedgerEntry>> = BTreeMap::new();
let mut other_entries: Vec<&LedgerEntry> = Vec::new();
for entry in entries {
if !matches!(
entry.record.state,
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
) {
continue;
}
match &entry.record.scope {
MemoryScope::User => user_entries.push(entry),
MemoryScope::Project => {
let project_id = entry
.record
.project_id
.clone()
.unwrap_or_else(|| "unknown".to_string());
project_entries.entry(project_id).or_default().push(entry);
}
MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {
other_entries.push(entry);
}
}
}
sort_desc_by_recorded_at(&mut user_entries);
for entries in project_entries.values_mut() {
sort_desc_by_recorded_at(entries);
}
sort_desc_by_recorded_at(&mut other_entries);
let mut out = String::new();
out.push_str("# Spool Knowledge Index\n\n");
out.push_str(
"> 自动生成的知识导航。仅列 accepted / canonical 记录。由 spool lifecycle \
writes 自动刷新,不要手动编辑。\n\n",
);
out.push_str("## User-Level (always loaded)\n\n");
if user_entries.is_empty() {
out.push_str("*(尚无用户级记忆)*\n\n");
} else {
for entry in &user_entries {
out.push_str(&render_entry_line(entry));
}
out.push('\n');
}
if project_entries.is_empty() {
out.push_str("## Projects\n\n*(尚无 project-scoped 记忆)*\n\n");
} else {
for (project_id, entries) in &project_entries {
out.push_str(&format!("## Project: {}\n\n", project_id));
for entry in entries {
out.push_str(&render_entry_line(entry));
}
out.push('\n');
}
}
if !other_entries.is_empty() {
out.push_str("## Shared (workspace / team / agent)\n\n");
for entry in &other_entries {
out.push_str(&render_entry_line(entry));
}
out.push('\n');
}
out
}
fn sort_desc_by_recorded_at(entries: &mut [&LedgerEntry]) {
entries.sort_by(|a, b| b.recorded_at.cmp(&a.recorded_at));
}
fn render_entry_line(entry: &LedgerEntry) -> String {
let title = if entry.record.title.trim().is_empty() {
"(untitled)"
} else {
entry.record.title.as_str()
};
let memory_type = &entry.record.memory_type;
let state_marker = match entry.record.state {
MemoryLifecycleState::Canonical => "★",
_ => "·",
};
format!(
"- {} [{}] {} — `{}`\n",
state_marker, memory_type, title, entry.record_id
)
}
pub fn write_index(vault_root: &Path, entries: &[LedgerEntry]) -> Result<IndexWriteResult> {
let path = index_path(vault_root);
let desired = render_index(entries);
let (status, _) = match fs::read_to_string(&path) {
Ok(existing) if existing == desired => (IndexWriteStatus::Unchanged, existing),
Ok(existing) => (IndexWriteStatus::Updated, existing),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
(IndexWriteStatus::Created, String::new())
}
Err(err) => {
return Err(anyhow::Error::new(err).context(format!(
"failed to read existing INDEX.md at {}",
path.display()
)));
}
};
if !matches!(status, IndexWriteStatus::Unchanged) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create INDEX.md parent dir {}", parent.display())
})?;
}
fs::write(&path, &desired)
.with_context(|| format!("failed to write INDEX.md at {}", path.display()))?;
}
let (user_entries, project_entries) = count_active_entries(entries);
Ok(IndexWriteResult {
path,
status,
user_entries,
project_entries,
})
}
fn count_active_entries(entries: &[LedgerEntry]) -> (usize, usize) {
let mut user_count = 0;
let mut project_count = 0;
for entry in entries {
if !matches!(
entry.record.state,
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
) {
continue;
}
match &entry.record.scope {
MemoryScope::User => user_count += 1,
MemoryScope::Project => project_count += 1,
MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {}
}
}
(user_count, project_count)
}
pub fn refresh_index_from_config(config_path: &Path) -> Option<IndexWriteResult> {
match refresh_index_inner(config_path) {
Ok(result) => Some(result),
Err(error) => {
eprintln!("[spool] wiki index refresh failed: {error:#}");
None
}
}
}
fn refresh_index_inner(config_path: &Path) -> Result<IndexWriteResult> {
let config = crate::app::load(config_path)
.with_context(|| format!("failed to load config {}", config_path.display()))?;
let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
.context("failed to resolve vault root")?;
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
let store = LifecycleStore::new(&lifecycle_root);
let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
write_index(&vault_root, &entries)
}
pub fn refresh_index_result(config_path: &Path) -> Result<IndexWriteResult> {
refresh_index_inner(config_path)
}
pub fn render_index_from_config(config_path: &Path) -> Result<String> {
let config = crate::app::load(config_path)
.with_context(|| format!("failed to load config {}", config_path.display()))?;
let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
.context("failed to resolve vault root")?;
let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
let store = LifecycleStore::new(&lifecycle_root);
let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
let index_path = index_path(&vault_root);
let desired = render_index(&entries);
let status_hint = match fs::read_to_string(&index_path) {
Ok(existing) if existing == desired => "unchanged",
Ok(_) => "would update",
Err(_) => "would create",
};
Ok(format!(
"{desired}\n---\nTarget: {}\nStatus: {status_hint}\n",
index_path.display()
))
}
pub fn load_index_section(vault_root: &Path, current_project_id: Option<&str>) -> Option<String> {
let path = index_path(vault_root);
let raw = fs::read_to_string(&path).ok()?;
let filtered = filter_index_sections(&raw, current_project_id);
if filtered.trim().is_empty() {
None
} else {
Some(filtered)
}
}
pub fn filter_index_sections(raw: &str, current_project_id: Option<&str>) -> String {
let wanted_project_heading = current_project_id.map(|id| format!("## Project: {}", id.trim()));
let mut out = String::new();
let mut include = false;
let mut first_line = true;
for line in raw.lines() {
if first_line && line.starts_with("# ") {
out.push_str(line);
out.push('\n');
first_line = false;
continue;
}
first_line = false;
if let Some(stripped) = line.strip_prefix("## ") {
include = stripped.starts_with("User-Level")
|| wanted_project_heading
.as_deref()
.is_some_and(|want| line == want);
if include {
out.push_str(line);
out.push('\n');
}
continue;
}
if include {
out.push_str(line);
out.push('\n');
}
}
while out.ends_with("\n\n") {
out.pop();
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{
MemoryLedgerAction, MemoryLifecycleState, MemoryOrigin, MemoryRecord, MemoryScope,
MemorySourceKind,
};
use crate::lifecycle_store::TransitionMetadata;
use tempfile::tempdir;
fn make_entry(
record_id: &str,
title: &str,
memory_type: &str,
state: MemoryLifecycleState,
scope: MemoryScope,
project_id: Option<&str>,
recorded_at: &str,
) -> LedgerEntry {
LedgerEntry {
schema_version: "memory-ledger.v1".to_string(),
recorded_at: recorded_at.to_string(),
record_id: record_id.to_string(),
scope_key: match &scope {
MemoryScope::User => "user:long".to_string(),
MemoryScope::Project => {
format!("project:{}", project_id.unwrap_or("unknown"))
}
MemoryScope::Workspace => "workspace:shared".to_string(),
MemoryScope::Team => "team:shared".to_string(),
MemoryScope::Agent => "agent:shared".to_string(),
},
action: MemoryLedgerAction::RecordManual,
source_kind: MemorySourceKind::Manual,
metadata: TransitionMetadata::default(),
record: MemoryRecord {
title: title.to_string(),
summary: "summary".to_string(),
memory_type: memory_type.to_string(),
scope,
state,
origin: MemoryOrigin {
source_kind: MemorySourceKind::Manual,
source_ref: "manual:test".to_string(),
},
project_id: project_id.map(ToString::to_string),
user_id: Some("long".to_string()),
sensitivity: None,
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
}
}
#[test]
fn render_index_should_group_by_scope_and_skip_non_active_states() {
let entries = vec![
make_entry(
"mem-a",
"User preference",
"preference",
MemoryLifecycleState::Accepted,
MemoryScope::User,
None,
"2026-05-10T00:00:00Z",
),
make_entry(
"mem-b",
"Project decision",
"decision",
MemoryLifecycleState::Canonical,
MemoryScope::Project,
Some("spool"),
"2026-05-11T00:00:00Z",
),
make_entry(
"mem-c",
"Candidate to skip",
"workflow",
MemoryLifecycleState::Candidate,
MemoryScope::User,
None,
"2026-05-12T00:00:00Z",
),
make_entry(
"mem-d",
"Archived to skip",
"incident",
MemoryLifecycleState::Archived,
MemoryScope::Project,
Some("spool"),
"2026-05-09T00:00:00Z",
),
];
let out = render_index(&entries);
assert!(out.contains("User-Level"));
assert!(out.contains("Project: spool"));
assert!(out.contains("User preference"));
assert!(out.contains("Project decision"));
assert!(!out.contains("Candidate to skip"));
assert!(!out.contains("Archived to skip"));
assert!(out.contains("★ [decision] Project decision"));
assert!(out.contains("· [preference] User preference"));
}
#[test]
fn render_index_should_sort_desc_by_recorded_at_within_scope() {
let entries = vec![
make_entry(
"mem-old",
"Old entry",
"preference",
MemoryLifecycleState::Accepted,
MemoryScope::User,
None,
"2026-05-01T00:00:00Z",
),
make_entry(
"mem-new",
"New entry",
"preference",
MemoryLifecycleState::Accepted,
MemoryScope::User,
None,
"2026-05-10T00:00:00Z",
),
];
let out = render_index(&entries);
let new_pos = out.find("New entry").unwrap();
let old_pos = out.find("Old entry").unwrap();
assert!(new_pos < old_pos, "newer entries must appear first");
}
#[test]
fn render_index_should_show_placeholders_when_scope_empty() {
let entries: Vec<LedgerEntry> = Vec::new();
let out = render_index(&entries);
assert!(out.contains("尚无用户级记忆"));
assert!(out.contains("尚无 project-scoped 记忆"));
}
#[test]
fn write_index_should_create_file_and_be_idempotent() {
let dir = tempdir().unwrap();
let entries = vec![make_entry(
"mem-a",
"User preference",
"preference",
MemoryLifecycleState::Accepted,
MemoryScope::User,
None,
"2026-05-10T00:00:00Z",
)];
let first = write_index(dir.path(), &entries).unwrap();
assert_eq!(first.status, IndexWriteStatus::Created);
assert_eq!(first.user_entries, 1);
assert_eq!(first.project_entries, 0);
assert!(first.path.exists());
let second = write_index(dir.path(), &entries).unwrap();
assert_eq!(second.status, IndexWriteStatus::Unchanged);
}
#[test]
fn write_index_should_mark_updated_when_content_changes() {
let dir = tempdir().unwrap();
let entries_v1 = vec![make_entry(
"mem-a",
"Original title",
"preference",
MemoryLifecycleState::Accepted,
MemoryScope::User,
None,
"2026-05-10T00:00:00Z",
)];
let entries_v2 = vec![make_entry(
"mem-a",
"Updated title",
"preference",
MemoryLifecycleState::Accepted,
MemoryScope::User,
None,
"2026-05-10T00:00:00Z",
)];
write_index(dir.path(), &entries_v1).unwrap();
let second = write_index(dir.path(), &entries_v2).unwrap();
assert_eq!(second.status, IndexWriteStatus::Updated);
let body = fs::read_to_string(&second.path).unwrap();
assert!(body.contains("Updated title"));
assert!(!body.contains("Original title"));
}
#[test]
fn filter_index_sections_should_keep_user_level_and_requested_project() {
let raw = "# Spool Knowledge Index\n\n\
> preamble\n\n\
## User-Level (always loaded)\n\n\
- · [preference] stuff — `mem-u1`\n\n\
## Project: spool\n\n\
- · [decision] chose X — `mem-p1`\n\n\
## Project: other\n\n\
- · [note] irrelevant — `mem-o1`\n";
let filtered = filter_index_sections(raw, Some("spool"));
assert!(filtered.contains("# Spool Knowledge Index"));
assert!(filtered.contains("User-Level"));
assert!(filtered.contains("mem-u1"));
assert!(filtered.contains("## Project: spool"));
assert!(filtered.contains("mem-p1"));
assert!(!filtered.contains("Project: other"));
assert!(!filtered.contains("mem-o1"));
assert!(!filtered.contains("preamble"));
}
#[test]
fn filter_index_sections_should_keep_only_user_level_when_no_project() {
let raw = "# Spool Knowledge Index\n\n\
## User-Level (always loaded)\n\n\
- · [preference] stuff — `mem-u1`\n\n\
## Project: spool\n\n\
- · [decision] chose X — `mem-p1`\n";
let filtered = filter_index_sections(raw, None);
assert!(filtered.contains("User-Level"));
assert!(filtered.contains("mem-u1"));
assert!(!filtered.contains("Project: spool"));
assert!(!filtered.contains("mem-p1"));
}
#[test]
fn load_index_section_should_return_none_when_file_missing() {
let dir = tempdir().unwrap();
assert!(load_index_section(dir.path(), Some("spool")).is_none());
}
#[test]
fn load_index_section_should_load_and_filter_written_index() {
let dir = tempdir().unwrap();
let entries = vec![
make_entry(
"mem-u",
"User pref",
"preference",
MemoryLifecycleState::Accepted,
MemoryScope::User,
None,
"2026-05-10T00:00:00Z",
),
make_entry(
"mem-p",
"Project decision",
"decision",
MemoryLifecycleState::Accepted,
MemoryScope::Project,
Some("spool"),
"2026-05-11T00:00:00Z",
),
make_entry(
"mem-x",
"Other project",
"note",
MemoryLifecycleState::Accepted,
MemoryScope::Project,
Some("other"),
"2026-05-12T00:00:00Z",
),
];
write_index(dir.path(), &entries).unwrap();
let filtered = load_index_section(dir.path(), Some("spool"))
.expect("index file exists and should filter cleanly");
assert!(filtered.contains("User pref"));
assert!(filtered.contains("Project decision"));
assert!(!filtered.contains("Other project"));
}
}