1use crate::domain::{MemoryLifecycleState, MemoryScope};
15use crate::lifecycle_store::{LedgerEntry, LifecycleStore, latest_state_entries};
16use anyhow::{Context, Result};
17use std::collections::BTreeMap;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21pub const INDEX_FILE_NAME: &str = "INDEX.md";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum IndexWriteStatus {
25 Created,
26 Updated,
27 Unchanged,
28}
29
30#[derive(Debug, Clone)]
31pub struct IndexWriteResult {
32 pub path: PathBuf,
33 pub status: IndexWriteStatus,
34 pub user_entries: usize,
35 pub project_entries: usize,
36}
37
38pub fn index_path(vault_root: &Path) -> PathBuf {
39 vault_root.join(INDEX_FILE_NAME)
40}
41
42pub fn render_index(entries: &[LedgerEntry]) -> String {
55 let mut user_entries: Vec<&LedgerEntry> = Vec::new();
56 let mut project_entries: BTreeMap<String, Vec<&LedgerEntry>> = BTreeMap::new();
57 let mut other_entries: Vec<&LedgerEntry> = Vec::new();
58
59 for entry in entries {
60 if !matches!(
61 entry.record.state,
62 MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
63 ) {
64 continue;
65 }
66 match &entry.record.scope {
67 MemoryScope::User => user_entries.push(entry),
68 MemoryScope::Project => {
69 let project_id = entry
70 .record
71 .project_id
72 .clone()
73 .unwrap_or_else(|| "unknown".to_string());
74 project_entries.entry(project_id).or_default().push(entry);
75 }
76 MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {
77 other_entries.push(entry);
78 }
79 }
80 }
81
82 sort_desc_by_recorded_at(&mut user_entries);
83 for entries in project_entries.values_mut() {
84 sort_desc_by_recorded_at(entries);
85 }
86 sort_desc_by_recorded_at(&mut other_entries);
87
88 let mut out = String::new();
89 out.push_str("# Spool Knowledge Index\n\n");
90 out.push_str(
91 "> 自动生成的知识导航。仅列 accepted / canonical 记录。由 spool lifecycle \
92 writes 自动刷新,不要手动编辑。\n\n",
93 );
94
95 out.push_str("## User-Level (always loaded)\n\n");
96 if user_entries.is_empty() {
97 out.push_str("*(尚无用户级记忆)*\n\n");
98 } else {
99 for entry in &user_entries {
100 out.push_str(&render_entry_line(entry));
101 }
102 out.push('\n');
103 }
104
105 if project_entries.is_empty() {
106 out.push_str("## Projects\n\n*(尚无 project-scoped 记忆)*\n\n");
107 } else {
108 for (project_id, entries) in &project_entries {
109 out.push_str(&format!("## Project: {}\n\n", project_id));
110 for entry in entries {
111 out.push_str(&render_entry_line(entry));
112 }
113 out.push('\n');
114 }
115 }
116
117 if !other_entries.is_empty() {
118 out.push_str("## Shared (workspace / team / agent)\n\n");
119 for entry in &other_entries {
120 out.push_str(&render_entry_line(entry));
121 }
122 out.push('\n');
123 }
124
125 out
126}
127
128fn sort_desc_by_recorded_at(entries: &mut [&LedgerEntry]) {
129 entries.sort_by(|a, b| b.recorded_at.cmp(&a.recorded_at));
130}
131
132fn render_entry_line(entry: &LedgerEntry) -> String {
133 let title = if entry.record.title.trim().is_empty() {
134 "(untitled)"
135 } else {
136 entry.record.title.as_str()
137 };
138 let memory_type = &entry.record.memory_type;
139 let state_marker = match entry.record.state {
140 MemoryLifecycleState::Canonical => "★",
141 _ => "·",
142 };
143 format!(
144 "- {} [{}] {} — `{}`\n",
145 state_marker, memory_type, title, entry.record_id
146 )
147}
148
149pub fn write_index(vault_root: &Path, entries: &[LedgerEntry]) -> Result<IndexWriteResult> {
151 let path = index_path(vault_root);
152 let desired = render_index(entries);
153
154 let (status, _) = match fs::read_to_string(&path) {
155 Ok(existing) if existing == desired => (IndexWriteStatus::Unchanged, existing),
156 Ok(existing) => (IndexWriteStatus::Updated, existing),
157 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
158 (IndexWriteStatus::Created, String::new())
159 }
160 Err(err) => {
161 return Err(anyhow::Error::new(err).context(format!(
162 "failed to read existing INDEX.md at {}",
163 path.display()
164 )));
165 }
166 };
167
168 if !matches!(status, IndexWriteStatus::Unchanged) {
169 if let Some(parent) = path.parent() {
170 fs::create_dir_all(parent).with_context(|| {
171 format!("failed to create INDEX.md parent dir {}", parent.display())
172 })?;
173 }
174 fs::write(&path, &desired)
175 .with_context(|| format!("failed to write INDEX.md at {}", path.display()))?;
176 }
177
178 let (user_entries, project_entries) = count_active_entries(entries);
179 Ok(IndexWriteResult {
180 path,
181 status,
182 user_entries,
183 project_entries,
184 })
185}
186
187fn count_active_entries(entries: &[LedgerEntry]) -> (usize, usize) {
188 let mut user_count = 0;
189 let mut project_count = 0;
190 for entry in entries {
191 if !matches!(
192 entry.record.state,
193 MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
194 ) {
195 continue;
196 }
197 match &entry.record.scope {
198 MemoryScope::User => user_count += 1,
199 MemoryScope::Project => project_count += 1,
200 MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {}
201 }
202 }
203 (user_count, project_count)
204}
205
206pub fn refresh_index_from_config(config_path: &Path) -> Option<IndexWriteResult> {
210 match refresh_index_inner(config_path) {
211 Ok(result) => Some(result),
212 Err(error) => {
213 eprintln!("[spool] wiki index refresh failed: {error:#}");
214 None
215 }
216 }
217}
218
219fn refresh_index_inner(config_path: &Path) -> Result<IndexWriteResult> {
220 let config = crate::app::load(config_path)
221 .with_context(|| format!("failed to load config {}", config_path.display()))?;
222 let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
223 .context("failed to resolve vault root")?;
224 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
225 let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
226 let store = LifecycleStore::new(&lifecycle_root);
227 let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
228 write_index(&vault_root, &entries)
229}
230
231pub fn refresh_index_result(config_path: &Path) -> Result<IndexWriteResult> {
234 refresh_index_inner(config_path)
235}
236
237pub fn render_index_from_config(config_path: &Path) -> Result<String> {
239 let config = crate::app::load(config_path)
240 .with_context(|| format!("failed to load config {}", config_path.display()))?;
241 let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
242 .context("failed to resolve vault root")?;
243 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
244 let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
245 let store = LifecycleStore::new(&lifecycle_root);
246 let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
247 let index_path = index_path(&vault_root);
248 let desired = render_index(&entries);
249 let status_hint = match fs::read_to_string(&index_path) {
250 Ok(existing) if existing == desired => "unchanged",
251 Ok(_) => "would update",
252 Err(_) => "would create",
253 };
254 Ok(format!(
255 "{desired}\n---\nTarget: {}\nStatus: {status_hint}\n",
256 index_path.display()
257 ))
258}
259
260pub fn load_index_section(vault_root: &Path, current_project_id: Option<&str>) -> Option<String> {
268 let path = index_path(vault_root);
269 let raw = fs::read_to_string(&path).ok()?;
270 let filtered = filter_index_sections(&raw, current_project_id);
271 if filtered.trim().is_empty() {
272 None
273 } else {
274 Some(filtered)
275 }
276}
277
278pub fn filter_index_sections(raw: &str, current_project_id: Option<&str>) -> String {
281 let wanted_project_heading = current_project_id.map(|id| format!("## Project: {}", id.trim()));
282
283 let mut out = String::new();
284 let mut include = false;
285 let mut first_line = true;
286
287 for line in raw.lines() {
288 if first_line && line.starts_with("# ") {
289 out.push_str(line);
290 out.push('\n');
291 first_line = false;
292 continue;
293 }
294 first_line = false;
295
296 if let Some(stripped) = line.strip_prefix("## ") {
297 include = stripped.starts_with("User-Level")
299 || wanted_project_heading
300 .as_deref()
301 .is_some_and(|want| line == want);
302 if include {
303 out.push_str(line);
304 out.push('\n');
305 }
306 continue;
307 }
308
309 if include {
310 out.push_str(line);
311 out.push('\n');
312 }
313 }
314
315 while out.ends_with("\n\n") {
317 out.pop();
318 }
319 out
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::domain::{
326 MemoryLedgerAction, MemoryLifecycleState, MemoryOrigin, MemoryRecord, MemoryScope,
327 MemorySourceKind,
328 };
329 use crate::lifecycle_store::TransitionMetadata;
330 use tempfile::tempdir;
331
332 fn make_entry(
333 record_id: &str,
334 title: &str,
335 memory_type: &str,
336 state: MemoryLifecycleState,
337 scope: MemoryScope,
338 project_id: Option<&str>,
339 recorded_at: &str,
340 ) -> LedgerEntry {
341 LedgerEntry {
342 schema_version: "memory-ledger.v1".to_string(),
343 recorded_at: recorded_at.to_string(),
344 record_id: record_id.to_string(),
345 scope_key: match &scope {
346 MemoryScope::User => "user:long".to_string(),
347 MemoryScope::Project => {
348 format!("project:{}", project_id.unwrap_or("unknown"))
349 }
350 MemoryScope::Workspace => "workspace:shared".to_string(),
351 MemoryScope::Team => "team:shared".to_string(),
352 MemoryScope::Agent => "agent:shared".to_string(),
353 },
354 action: MemoryLedgerAction::RecordManual,
355 source_kind: MemorySourceKind::Manual,
356 metadata: TransitionMetadata::default(),
357 record: MemoryRecord {
358 title: title.to_string(),
359 summary: "summary".to_string(),
360 memory_type: memory_type.to_string(),
361 scope,
362 state,
363 origin: MemoryOrigin {
364 source_kind: MemorySourceKind::Manual,
365 source_ref: "manual:test".to_string(),
366 },
367 project_id: project_id.map(ToString::to_string),
368 user_id: Some("long".to_string()),
369 sensitivity: None,
370 entities: Vec::new(),
371 tags: Vec::new(),
372 triggers: Vec::new(),
373 related_files: Vec::new(),
374 related_records: Vec::new(),
375 supersedes: None,
376 applies_to: Vec::new(),
377 valid_until: None,
378 },
379 }
380 }
381
382 #[test]
383 fn render_index_should_group_by_scope_and_skip_non_active_states() {
384 let entries = vec![
385 make_entry(
386 "mem-a",
387 "User preference",
388 "preference",
389 MemoryLifecycleState::Accepted,
390 MemoryScope::User,
391 None,
392 "2026-05-10T00:00:00Z",
393 ),
394 make_entry(
395 "mem-b",
396 "Project decision",
397 "decision",
398 MemoryLifecycleState::Canonical,
399 MemoryScope::Project,
400 Some("spool"),
401 "2026-05-11T00:00:00Z",
402 ),
403 make_entry(
404 "mem-c",
405 "Candidate to skip",
406 "workflow",
407 MemoryLifecycleState::Candidate,
408 MemoryScope::User,
409 None,
410 "2026-05-12T00:00:00Z",
411 ),
412 make_entry(
413 "mem-d",
414 "Archived to skip",
415 "incident",
416 MemoryLifecycleState::Archived,
417 MemoryScope::Project,
418 Some("spool"),
419 "2026-05-09T00:00:00Z",
420 ),
421 ];
422
423 let out = render_index(&entries);
424
425 assert!(out.contains("User-Level"));
426 assert!(out.contains("Project: spool"));
427 assert!(out.contains("User preference"));
428 assert!(out.contains("Project decision"));
429 assert!(!out.contains("Candidate to skip"));
430 assert!(!out.contains("Archived to skip"));
431 assert!(out.contains("★ [decision] Project decision"));
433 assert!(out.contains("· [preference] User preference"));
435 }
436
437 #[test]
438 fn render_index_should_sort_desc_by_recorded_at_within_scope() {
439 let entries = vec![
440 make_entry(
441 "mem-old",
442 "Old entry",
443 "preference",
444 MemoryLifecycleState::Accepted,
445 MemoryScope::User,
446 None,
447 "2026-05-01T00:00:00Z",
448 ),
449 make_entry(
450 "mem-new",
451 "New entry",
452 "preference",
453 MemoryLifecycleState::Accepted,
454 MemoryScope::User,
455 None,
456 "2026-05-10T00:00:00Z",
457 ),
458 ];
459 let out = render_index(&entries);
460 let new_pos = out.find("New entry").unwrap();
461 let old_pos = out.find("Old entry").unwrap();
462 assert!(new_pos < old_pos, "newer entries must appear first");
463 }
464
465 #[test]
466 fn render_index_should_show_placeholders_when_scope_empty() {
467 let entries: Vec<LedgerEntry> = Vec::new();
468 let out = render_index(&entries);
469 assert!(out.contains("尚无用户级记忆"));
470 assert!(out.contains("尚无 project-scoped 记忆"));
471 }
472
473 #[test]
474 fn write_index_should_create_file_and_be_idempotent() {
475 let dir = tempdir().unwrap();
476 let entries = vec![make_entry(
477 "mem-a",
478 "User preference",
479 "preference",
480 MemoryLifecycleState::Accepted,
481 MemoryScope::User,
482 None,
483 "2026-05-10T00:00:00Z",
484 )];
485
486 let first = write_index(dir.path(), &entries).unwrap();
487 assert_eq!(first.status, IndexWriteStatus::Created);
488 assert_eq!(first.user_entries, 1);
489 assert_eq!(first.project_entries, 0);
490 assert!(first.path.exists());
491
492 let second = write_index(dir.path(), &entries).unwrap();
493 assert_eq!(second.status, IndexWriteStatus::Unchanged);
494 }
495
496 #[test]
497 fn write_index_should_mark_updated_when_content_changes() {
498 let dir = tempdir().unwrap();
499 let entries_v1 = vec![make_entry(
500 "mem-a",
501 "Original title",
502 "preference",
503 MemoryLifecycleState::Accepted,
504 MemoryScope::User,
505 None,
506 "2026-05-10T00:00:00Z",
507 )];
508 let entries_v2 = vec![make_entry(
509 "mem-a",
510 "Updated title",
511 "preference",
512 MemoryLifecycleState::Accepted,
513 MemoryScope::User,
514 None,
515 "2026-05-10T00:00:00Z",
516 )];
517
518 write_index(dir.path(), &entries_v1).unwrap();
519 let second = write_index(dir.path(), &entries_v2).unwrap();
520 assert_eq!(second.status, IndexWriteStatus::Updated);
521 let body = fs::read_to_string(&second.path).unwrap();
522 assert!(body.contains("Updated title"));
523 assert!(!body.contains("Original title"));
524 }
525
526 #[test]
527 fn filter_index_sections_should_keep_user_level_and_requested_project() {
528 let raw = "# Spool Knowledge Index\n\n\
529 > preamble\n\n\
530 ## User-Level (always loaded)\n\n\
531 - · [preference] stuff — `mem-u1`\n\n\
532 ## Project: spool\n\n\
533 - · [decision] chose X — `mem-p1`\n\n\
534 ## Project: other\n\n\
535 - · [note] irrelevant — `mem-o1`\n";
536 let filtered = filter_index_sections(raw, Some("spool"));
537 assert!(filtered.contains("# Spool Knowledge Index"));
538 assert!(filtered.contains("User-Level"));
539 assert!(filtered.contains("mem-u1"));
540 assert!(filtered.contains("## Project: spool"));
541 assert!(filtered.contains("mem-p1"));
542 assert!(!filtered.contains("Project: other"));
543 assert!(!filtered.contains("mem-o1"));
544 assert!(!filtered.contains("preamble"));
546 }
547
548 #[test]
549 fn filter_index_sections_should_keep_only_user_level_when_no_project() {
550 let raw = "# Spool Knowledge Index\n\n\
551 ## User-Level (always loaded)\n\n\
552 - · [preference] stuff — `mem-u1`\n\n\
553 ## Project: spool\n\n\
554 - · [decision] chose X — `mem-p1`\n";
555 let filtered = filter_index_sections(raw, None);
556 assert!(filtered.contains("User-Level"));
557 assert!(filtered.contains("mem-u1"));
558 assert!(!filtered.contains("Project: spool"));
559 assert!(!filtered.contains("mem-p1"));
560 }
561
562 #[test]
563 fn load_index_section_should_return_none_when_file_missing() {
564 let dir = tempdir().unwrap();
565 assert!(load_index_section(dir.path(), Some("spool")).is_none());
566 }
567
568 #[test]
569 fn load_index_section_should_load_and_filter_written_index() {
570 let dir = tempdir().unwrap();
571 let entries = vec![
572 make_entry(
573 "mem-u",
574 "User pref",
575 "preference",
576 MemoryLifecycleState::Accepted,
577 MemoryScope::User,
578 None,
579 "2026-05-10T00:00:00Z",
580 ),
581 make_entry(
582 "mem-p",
583 "Project decision",
584 "decision",
585 MemoryLifecycleState::Accepted,
586 MemoryScope::Project,
587 Some("spool"),
588 "2026-05-11T00:00:00Z",
589 ),
590 make_entry(
591 "mem-x",
592 "Other project",
593 "note",
594 MemoryLifecycleState::Accepted,
595 MemoryScope::Project,
596 Some("other"),
597 "2026-05-12T00:00:00Z",
598 ),
599 ];
600 write_index(dir.path(), &entries).unwrap();
601
602 let filtered = load_index_section(dir.path(), Some("spool"))
603 .expect("index file exists and should filter cleanly");
604 assert!(filtered.contains("User pref"));
605 assert!(filtered.contains("Project decision"));
606 assert!(!filtered.contains("Other project"));
607 }
608}