Skip to main content

spool/
vault_writer.rs

1//! Lifecycle → Vault canonical note 回写。
2//!
3//! 负责把 `MemoryRecord` 按 `docs/OBSIDIAN_SCHEMA.md` 的 frontmatter 契约渲染成单个 Markdown
4//! note,落入 `50-Memory-Ledger/Extracted/<record_id>.md`。
5//!
6//! 幂等 + body 保护:frontmatter 里保存上次写入时的 body hash (`spool_body_hash`),
7//! 再次回写时若磁盘上 body 的实际 hash 与 stored 不一致,视为用户手改,保留用户 body
8//! 只重写 frontmatter;一致则按 record 重新渲染 body。archive 不删文件,仅打 archived 标记。
9
10use crate::domain::{MemoryLifecycleState, MemoryRecord, MemoryScope, MemorySourceKind};
11use crate::lifecycle_store::LedgerEntry;
12use anyhow::{Context, Result, bail};
13use std::collections::BTreeMap;
14use std::fs;
15use std::hash::{Hash, Hasher};
16use std::path::{Path, PathBuf};
17
18pub const MEMORY_LEDGER_DIR: &str = "50-Memory-Ledger/Extracted";
19pub const MEMORY_LEDGER_COMPILED_DIR: &str = "50-Memory-Ledger/Compiled";
20pub const NOTE_VERSION: &str = "memory-note.v1";
21pub const BODY_HASH_KEY: &str = "spool_body_hash";
22pub const VERSION_KEY: &str = "spool_version";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum WriteStatus {
26    Created,
27    UpdatedAll,
28    UpdatedPreserveBody,
29    Unchanged,
30}
31
32#[derive(Debug, Clone)]
33pub struct VaultWriteResult {
34    pub path: PathBuf,
35    pub status: WriteStatus,
36    pub body_user_edited: bool,
37}
38
39pub fn memory_note_path(vault_root: &Path, record_id: &str) -> PathBuf {
40    vault_root
41        .join(MEMORY_LEDGER_DIR)
42        .join(format!("{record_id}.md"))
43}
44
45/// 根据 memory_type 分发目标子目录: `knowledge` (Karpathy wiki compiled 页) 走
46/// `Compiled/`,其他碎片记忆走 `Extracted/`。保持 Extracted 为默认以兼容现有
47/// 测试与 MCP 路径引用。
48pub fn memory_note_path_for(vault_root: &Path, record_id: &str, memory_type: &str) -> PathBuf {
49    let dir = if memory_type == "knowledge" {
50        MEMORY_LEDGER_COMPILED_DIR
51    } else {
52        MEMORY_LEDGER_DIR
53    };
54    vault_root.join(dir).join(format!("{record_id}.md"))
55}
56
57pub fn write_memory_note(
58    vault_root: &Path,
59    record_id: &str,
60    record: &MemoryRecord,
61) -> Result<VaultWriteResult> {
62    if record_id.is_empty() {
63        bail!("record_id must not be empty");
64    }
65    let path = memory_note_path_for(vault_root, record_id, &record.memory_type);
66    let existing = read_existing_note(&path)?;
67
68    let desired_body = render_body(record);
69    let (final_body, base_status, body_user_edited) = match &existing {
70        None => (desired_body, WriteStatus::Created, false),
71        Some(existing) => {
72            let current_hash = body_hash(&existing.body);
73            let user_edited = existing
74                .stored_body_hash
75                .as_deref()
76                .map(|stored| stored != current_hash)
77                .unwrap_or(false);
78            if user_edited {
79                (
80                    existing.body.clone(),
81                    WriteStatus::UpdatedPreserveBody,
82                    true,
83                )
84            } else {
85                (desired_body, WriteStatus::UpdatedAll, false)
86            }
87        }
88    };
89
90    let fm = render_frontmatter(record_id, record, &final_body);
91    let desired_content = format_note(&fm, &final_body)?;
92
93    let status = if let Some(existing) = &existing {
94        if existing.raw_content == desired_content {
95            WriteStatus::Unchanged
96        } else {
97            base_status
98        }
99    } else {
100        base_status
101    };
102
103    if status != WriteStatus::Unchanged {
104        if let Some(parent) = path.parent() {
105            fs::create_dir_all(parent).with_context(|| {
106                format!(
107                    "failed to create memory note parent dir {}",
108                    parent.display()
109                )
110            })?;
111        }
112        fs::write(&path, &desired_content)
113            .with_context(|| format!("failed to write memory note {}", path.display()))?;
114    }
115
116    Ok(VaultWriteResult {
117        path,
118        status,
119        body_user_edited,
120    })
121}
122
123pub fn archive_memory_note(vault_root: &Path, record_id: &str) -> Result<Option<VaultWriteResult>> {
124    // 尝试两个目录:先 Extracted (原始碎片),再 Compiled (knowledge 综合页)。
125    // archive 时拿不到 memory_type,用路径 fallback 覆盖两种情况。
126    let extracted = memory_note_path(vault_root, record_id);
127    let compiled = vault_root
128        .join(MEMORY_LEDGER_COMPILED_DIR)
129        .join(format!("{record_id}.md"));
130    let path = if extracted.exists() {
131        extracted
132    } else if compiled.exists() {
133        compiled
134    } else {
135        return Ok(None);
136    };
137    let existing = read_existing_note(&path)?.expect("path.exists guarded");
138    let body = existing.body.clone();
139    let body_hash_value = body_hash(&body);
140    let mut fm = existing.frontmatter.clone();
141    fm.insert("archived".to_string(), serde_yaml::Value::Bool(true));
142    fm.insert(
143        "archived_at".to_string(),
144        serde_yaml::Value::String(current_timestamp()),
145    );
146    fm.insert(
147        "state".to_string(),
148        serde_yaml::Value::String("archived".to_string()),
149    );
150    fm.insert(
151        "source_of_truth".to_string(),
152        serde_yaml::Value::Bool(false),
153    );
154    fm.insert(
155        BODY_HASH_KEY.to_string(),
156        serde_yaml::Value::String(body_hash_value),
157    );
158
159    let content = format_note(&fm, &body)?;
160    if existing.raw_content == content {
161        return Ok(Some(VaultWriteResult {
162            path,
163            status: WriteStatus::Unchanged,
164            body_user_edited: false,
165        }));
166    }
167    fs::write(&path, &content)
168        .with_context(|| format!("failed to archive memory note {}", path.display()))?;
169    Ok(Some(VaultWriteResult {
170        path,
171        status: WriteStatus::UpdatedAll,
172        body_user_edited: false,
173    }))
174}
175
176// ---------- 内部渲染与读取 ----------
177
178struct ExistingNote {
179    frontmatter: BTreeMap<String, serde_yaml::Value>,
180    body: String,
181    stored_body_hash: Option<String>,
182    raw_content: String,
183}
184
185fn read_existing_note(path: &Path) -> Result<Option<ExistingNote>> {
186    if !path.exists() {
187        return Ok(None);
188    }
189    let raw = fs::read_to_string(path)
190        .with_context(|| format!("failed to read memory note {}", path.display()))?;
191    let (fm_text, body) = split_frontmatter_raw(&raw);
192    let frontmatter: BTreeMap<String, serde_yaml::Value> = match fm_text {
193        Some(text) if !text.trim().is_empty() => serde_yaml::from_str(text)
194            .with_context(|| format!("failed to parse frontmatter in {}", path.display()))?,
195        _ => BTreeMap::new(),
196    };
197    let stored_body_hash = frontmatter
198        .get(BODY_HASH_KEY)
199        .and_then(|v| v.as_str())
200        .map(ToString::to_string);
201    Ok(Some(ExistingNote {
202        frontmatter,
203        body,
204        stored_body_hash,
205        raw_content: raw,
206    }))
207}
208
209fn split_frontmatter_raw(raw: &str) -> (Option<&str>, String) {
210    let rest = if let Some(r) = raw.strip_prefix("---\n") {
211        r
212    } else if let Some(r) = raw.strip_prefix("---\r\n") {
213        r
214    } else {
215        return (None, raw.to_string());
216    };
217    if let Some(end) = rest.find("\n---\n") {
218        let fm = &rest[..end];
219        let body_start = end + "\n---\n".len();
220        let body = strip_one_leading_newline(&rest[body_start..]);
221        (Some(fm), body)
222    } else if let Some(end) = rest.find("\n---\r\n") {
223        let fm = &rest[..end];
224        let body_start = end + "\n---\r\n".len();
225        let body = strip_one_leading_newline(&rest[body_start..]);
226        (Some(fm), body)
227    } else if let Some(stripped) = rest.strip_suffix("\n---") {
228        (Some(stripped), String::new())
229    } else {
230        (None, raw.to_string())
231    }
232}
233
234fn strip_one_leading_newline(s: &str) -> String {
235    if let Some(stripped) = s.strip_prefix("\r\n") {
236        stripped.to_string()
237    } else if let Some(stripped) = s.strip_prefix('\n') {
238        stripped.to_string()
239    } else {
240        s.to_string()
241    }
242}
243
244fn render_body(record: &MemoryRecord) -> String {
245    let summary = record.summary.trim();
246
247    if record.memory_type == "knowledge" {
248        // Knowledge pages already have structured sections in summary
249        format!(
250            "# {title}\n\n{summary}\n",
251            title = record.title.trim(),
252            summary = if summary.is_empty() {
253                "_no summary_"
254            } else {
255                summary
256            },
257        )
258    } else {
259        format!(
260            "# {title}\n\n{summary}\n\n## Provenance\n\n- source_kind: {source_kind}\n- source_ref: {source_ref}\n",
261            title = record.title.trim(),
262            summary = if summary.is_empty() {
263                "_no summary_"
264            } else {
265                summary
266            },
267            source_kind = format_source_kind(record.origin.source_kind),
268            source_ref = record.origin.source_ref,
269        )
270    }
271}
272
273fn body_hash(body: &str) -> String {
274    let mut hasher = std::collections::hash_map::DefaultHasher::new();
275    body.hash(&mut hasher);
276    format!("{:016x}", hasher.finish())
277}
278
279fn render_frontmatter(
280    record_id: &str,
281    record: &MemoryRecord,
282    body: &str,
283) -> BTreeMap<String, serde_yaml::Value> {
284    use serde_yaml::Value;
285    let mut fm = BTreeMap::new();
286    fm.insert(
287        VERSION_KEY.to_string(),
288        Value::String(NOTE_VERSION.to_string()),
289    );
290    fm.insert(
291        "record_id".to_string(),
292        Value::String(record_id.to_string()),
293    );
294    fm.insert(
295        "memory_type".to_string(),
296        Value::String(record.memory_type.clone()),
297    );
298    fm.insert(
299        "scope".to_string(),
300        Value::String(map_scope(record.scope).to_string()),
301    );
302    fm.insert(
303        "state".to_string(),
304        Value::String(map_state(record.state).to_string()),
305    );
306    fm.insert(
307        "source_of_truth".to_string(),
308        Value::Bool(matches!(record.state, MemoryLifecycleState::Canonical)),
309    );
310    if let Some(pid) = &record.project_id {
311        fm.insert("project_id".to_string(), Value::String(pid.clone()));
312    }
313    if let Some(uid) = &record.user_id {
314        fm.insert("user_id".to_string(), Value::String(uid.clone()));
315    }
316    if let Some(sens) = &record.sensitivity {
317        fm.insert("sensitivity".to_string(), Value::String(sens.clone()));
318    }
319    fm.insert(
320        "source_kind".to_string(),
321        Value::String(format_source_kind(record.origin.source_kind).to_string()),
322    );
323    fm.insert(
324        "source_ref".to_string(),
325        Value::String(record.origin.source_ref.clone()),
326    );
327    // Structured retrieval signals
328    if !record.entities.is_empty() {
329        fm.insert(
330            "entities".to_string(),
331            Value::Sequence(
332                record
333                    .entities
334                    .iter()
335                    .map(|s| Value::String(s.clone()))
336                    .collect(),
337            ),
338        );
339    }
340    if !record.tags.is_empty() {
341        fm.insert(
342            "tags".to_string(),
343            Value::Sequence(
344                record
345                    .tags
346                    .iter()
347                    .map(|s| Value::String(s.clone()))
348                    .collect(),
349            ),
350        );
351    }
352    if !record.triggers.is_empty() {
353        fm.insert(
354            "triggers".to_string(),
355            Value::Sequence(
356                record
357                    .triggers
358                    .iter()
359                    .map(|s| Value::String(s.clone()))
360                    .collect(),
361            ),
362        );
363    }
364    if !record.related_files.is_empty() {
365        fm.insert(
366            "related_files".to_string(),
367            Value::Sequence(
368                record
369                    .related_files
370                    .iter()
371                    .map(|s| Value::String(s.clone()))
372                    .collect(),
373            ),
374        );
375    }
376    if !record.related_records.is_empty() {
377        fm.insert(
378            "related_memory".to_string(),
379            Value::Sequence(
380                record
381                    .related_records
382                    .iter()
383                    .map(|s| Value::String(format!("[[{s}]]")))
384                    .collect(),
385            ),
386        );
387    }
388    if let Some(supersedes) = &record.supersedes {
389        fm.insert("supersedes".to_string(), Value::String(supersedes.clone()));
390    }
391    fm.insert(BODY_HASH_KEY.to_string(), Value::String(body_hash(body)));
392    fm
393}
394
395fn format_note(fm: &BTreeMap<String, serde_yaml::Value>, body: &str) -> Result<String> {
396    let yaml = serde_yaml::to_string(fm).context("failed to serialize frontmatter as yaml")?;
397    let body_trimmed = body.trim_end_matches('\n');
398    Ok(format!("---\n{yaml}---\n\n{body_trimmed}\n"))
399}
400
401fn map_scope(scope: MemoryScope) -> &'static str {
402    match scope {
403        MemoryScope::User => "personal",
404        MemoryScope::Project => "project",
405        MemoryScope::Workspace => "team",
406        MemoryScope::Team => "team",
407        MemoryScope::Agent => "personal",
408    }
409}
410
411fn map_state(state: MemoryLifecycleState) -> &'static str {
412    match state {
413        MemoryLifecycleState::Draft => "draft",
414        MemoryLifecycleState::Candidate => "candidate",
415        MemoryLifecycleState::Accepted => "accepted",
416        MemoryLifecycleState::Canonical => "canonical",
417        MemoryLifecycleState::Archived => "archived",
418    }
419}
420
421fn format_source_kind(kind: MemorySourceKind) -> &'static str {
422    match kind {
423        MemorySourceKind::Manual => "manual",
424        MemorySourceKind::AiProposal => "ai_proposal",
425        MemorySourceKind::SessionCapture => "session_capture",
426        MemorySourceKind::Distilled => "distilled",
427        MemorySourceKind::Imported => "imported",
428    }
429}
430
431fn current_timestamp() -> String {
432    let seconds = std::time::SystemTime::now()
433        .duration_since(std::time::UNIX_EPOCH)
434        .unwrap_or_default()
435        .as_secs();
436    format!("unix:{seconds}")
437}
438
439/// 根据 `LedgerEntry` 的最新 state 做 vault 回写,错误降级为 stderr warn。
440///
441/// - Accepted / Canonical → 写/更新 note
442/// - Archived → 打 archived 标记
443/// - Draft / Candidate → 不回写 (schema: 审核后才 promote)
444pub fn apply_writeback_for_entry(
445    vault_root: &Path,
446    entry: &LedgerEntry,
447) -> Option<VaultWriteResult> {
448    match entry.record.state {
449        MemoryLifecycleState::Archived => match archive_memory_note(vault_root, &entry.record_id) {
450            Ok(result) => result,
451            Err(error) => {
452                log_writeback_error(&entry.record_id, &error);
453                None
454            }
455        },
456        MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical => {
457            match write_memory_note(vault_root, &entry.record_id, &entry.record) {
458                Ok(result) => Some(result),
459                Err(error) => {
460                    log_writeback_error(&entry.record_id, &error);
461                    None
462                }
463            }
464        }
465        MemoryLifecycleState::Draft | MemoryLifecycleState::Candidate => None,
466    }
467}
468
469/// 从 config_path 加载 AppConfig 并 resolve vault root,再做 writeback。
470/// 任何 config/vault 解析错误都 swallow 为 None (不阻断业务)。
471///
472/// Side effects (都 swallow 为 stderr warn,不影响返回值):
473/// - 刷 `<vault_root>/INDEX.md` (知识导航索引,Karpathy wiki 的 Query 层入口)
474/// - 自动 compile:检测可合并集群,对新集群 auto-propose 为 candidate
475///   (Karpathy wiki 的 Compile 层,只在 accepted/canonical ≥ 3 时才跑)
476pub fn writeback_from_config(config_path: &Path, entry: &LedgerEntry) -> Option<VaultWriteResult> {
477    let vault_root = match resolve_vault_root(config_path) {
478        Ok(root) => root,
479        Err(error) => {
480            log_writeback_error(&entry.record_id, &error);
481            return None;
482        }
483    };
484    let result = apply_writeback_for_entry(&vault_root, entry);
485    let _ = crate::wiki_index::refresh_index_from_config(config_path);
486    let _ = crate::knowledge::auto_compile_from_config(config_path);
487    result
488}
489
490/// Same as [`writeback_from_config`] but skips auto-compile. Used by
491/// MCP handlers that will run LLM-assisted compile separately.
492pub fn writeback_from_config_no_compile(
493    config_path: &Path,
494    entry: &LedgerEntry,
495) -> Option<VaultWriteResult> {
496    let vault_root = match resolve_vault_root(config_path) {
497        Ok(root) => root,
498        Err(error) => {
499            log_writeback_error(&entry.record_id, &error);
500            return None;
501        }
502    };
503    let result = apply_writeback_for_entry(&vault_root, entry);
504    let _ = crate::wiki_index::refresh_index_from_config(config_path);
505    result
506}
507
508fn resolve_vault_root(config_path: &Path) -> Result<PathBuf> {
509    let config = crate::app::load(config_path)
510        .with_context(|| format!("failed to load config {}", config_path.display()))?;
511    crate::app::resolve_override_path(&config.vault.root, config_path)
512}
513
514fn log_writeback_error(record_id: &str, error: &anyhow::Error) {
515    eprintln!("[spool] vault writeback failed for record {record_id}: {error}");
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use crate::domain::{
522        MemoryLifecycleState, MemoryOrigin, MemoryRecord, MemoryScope, MemorySourceKind,
523    };
524    use tempfile::tempdir;
525
526    fn sample_record(state: MemoryLifecycleState) -> MemoryRecord {
527        MemoryRecord {
528            title: "简洁输出".to_string(),
529            summary: "偏好简短直接的回复,不要 trailing 总结".to_string(),
530            memory_type: "preference".to_string(),
531            scope: MemoryScope::User,
532            state,
533            origin: MemoryOrigin {
534                source_kind: MemorySourceKind::Manual,
535                source_ref: "manual:cli".to_string(),
536            },
537            project_id: None,
538            user_id: Some("long".to_string()),
539            sensitivity: Some("internal".to_string()),
540            entities: Vec::new(),
541            tags: Vec::new(),
542            triggers: Vec::new(),
543            related_files: Vec::new(),
544            related_records: Vec::new(),
545            supersedes: None,
546            applies_to: Vec::new(),
547            valid_until: None,
548        }
549    }
550
551    #[test]
552    fn write_memory_note_should_create_new_file_with_frontmatter_and_body() {
553        let temp = tempdir().unwrap();
554        let result = write_memory_note(
555            temp.path(),
556            "rec-001",
557            &sample_record(MemoryLifecycleState::Accepted),
558        )
559        .unwrap();
560
561        assert_eq!(result.status, WriteStatus::Created);
562        assert!(!result.body_user_edited);
563        let content = fs::read_to_string(&result.path).unwrap();
564        assert!(content.starts_with("---\n"));
565        assert!(content.contains("record_id: rec-001"));
566        assert!(content.contains("memory_type: preference"));
567        assert!(content.contains("scope: personal")); // user → personal
568        assert!(content.contains("state: accepted"));
569        assert!(content.contains("source_of_truth: false"));
570        assert!(content.contains("spool_body_hash:"));
571        assert!(content.contains("# 简洁输出"));
572        assert!(content.contains("## Provenance"));
573        assert!(content.contains("source_kind: manual"));
574    }
575
576    #[test]
577    fn write_memory_note_should_mark_canonical_as_source_of_truth() {
578        let temp = tempdir().unwrap();
579        let result = write_memory_note(
580            temp.path(),
581            "rec-002",
582            &sample_record(MemoryLifecycleState::Canonical),
583        )
584        .unwrap();
585        let content = fs::read_to_string(&result.path).unwrap();
586        assert!(content.contains("state: canonical"));
587        assert!(content.contains("source_of_truth: true"));
588    }
589
590    #[test]
591    fn write_memory_note_should_be_idempotent_on_identical_record() {
592        let temp = tempdir().unwrap();
593        let record = sample_record(MemoryLifecycleState::Accepted);
594        let first = write_memory_note(temp.path(), "rec-003", &record).unwrap();
595        assert_eq!(first.status, WriteStatus::Created);
596        let second = write_memory_note(temp.path(), "rec-003", &record).unwrap();
597        assert_eq!(second.status, WriteStatus::Unchanged);
598    }
599
600    #[test]
601    fn write_memory_note_should_update_body_when_summary_changes() {
602        let temp = tempdir().unwrap();
603        let mut record = sample_record(MemoryLifecycleState::Accepted);
604        write_memory_note(temp.path(), "rec-004", &record).unwrap();
605        record.summary = "新的更短摘要".to_string();
606        let result = write_memory_note(temp.path(), "rec-004", &record).unwrap();
607        assert_eq!(result.status, WriteStatus::UpdatedAll);
608        assert!(!result.body_user_edited);
609        let content = fs::read_to_string(&result.path).unwrap();
610        assert!(content.contains("新的更短摘要"));
611    }
612
613    #[test]
614    fn write_memory_note_should_preserve_body_when_user_hand_edited() {
615        let temp = tempdir().unwrap();
616        let record = sample_record(MemoryLifecycleState::Accepted);
617        let first = write_memory_note(temp.path(), "rec-005", &record).unwrap();
618
619        // 模拟用户手改 body
620        let original = fs::read_to_string(&first.path).unwrap();
621        let user_edited =
622            original.replace("# 简洁输出", "# 简洁输出\n\n> NOTE: 用户手动补充的上下文");
623        fs::write(&first.path, user_edited).unwrap();
624
625        // 再次回写,record 不变
626        let result = write_memory_note(temp.path(), "rec-005", &record).unwrap();
627        assert_eq!(result.status, WriteStatus::UpdatedPreserveBody);
628        assert!(result.body_user_edited);
629        let content = fs::read_to_string(&result.path).unwrap();
630        assert!(content.contains("NOTE: 用户手动补充的上下文"));
631    }
632
633    #[test]
634    fn archive_memory_note_should_mark_archived_and_keep_body() {
635        let temp = tempdir().unwrap();
636        let record = sample_record(MemoryLifecycleState::Accepted);
637        write_memory_note(temp.path(), "rec-006", &record).unwrap();
638        let result = archive_memory_note(temp.path(), "rec-006")
639            .unwrap()
640            .expect("archive should return result for existing file");
641        assert_eq!(result.status, WriteStatus::UpdatedAll);
642        let content = fs::read_to_string(&result.path).unwrap();
643        assert!(content.contains("archived: true"));
644        assert!(content.contains("archived_at: unix:"));
645        assert!(content.contains("state: archived"));
646        assert!(content.contains("# 简洁输出"));
647    }
648
649    #[test]
650    fn archive_memory_note_should_return_none_for_missing_file() {
651        let temp = tempdir().unwrap();
652        assert!(
653            archive_memory_note(temp.path(), "missing")
654                .unwrap()
655                .is_none()
656        );
657    }
658
659    #[test]
660    fn write_memory_note_should_reject_empty_record_id() {
661        let temp = tempdir().unwrap();
662        let err = write_memory_note(
663            temp.path(),
664            "",
665            &sample_record(MemoryLifecycleState::Accepted),
666        )
667        .unwrap_err();
668        assert!(err.to_string().contains("record_id"));
669    }
670
671    #[test]
672    fn memory_note_path_should_use_extracted_dir_and_record_id() {
673        let path = memory_note_path(Path::new("/vault"), "abc");
674        assert_eq!(
675            path,
676            PathBuf::from("/vault/50-Memory-Ledger/Extracted/abc.md")
677        );
678    }
679
680    #[test]
681    fn memory_note_path_for_should_route_knowledge_to_compiled_dir() {
682        let compiled = memory_note_path_for(Path::new("/vault"), "wiki-1", "knowledge");
683        assert_eq!(
684            compiled,
685            PathBuf::from("/vault/50-Memory-Ledger/Compiled/wiki-1.md")
686        );
687        let fragment = memory_note_path_for(Path::new("/vault"), "frag-1", "preference");
688        assert_eq!(
689            fragment,
690            PathBuf::from("/vault/50-Memory-Ledger/Extracted/frag-1.md")
691        );
692    }
693
694    #[test]
695    fn write_memory_note_should_place_knowledge_in_compiled_dir() {
696        let temp = tempdir().unwrap();
697        let mut record = sample_record(MemoryLifecycleState::Accepted);
698        record.memory_type = "knowledge".to_string();
699        let result = write_memory_note(temp.path(), "wiki-x", &record).unwrap();
700        assert_eq!(result.status, WriteStatus::Created);
701        assert!(
702            temp.path()
703                .join("50-Memory-Ledger/Compiled/wiki-x.md")
704                .exists()
705        );
706        assert!(
707            !temp
708                .path()
709                .join("50-Memory-Ledger/Extracted/wiki-x.md")
710                .exists()
711        );
712    }
713
714    fn sample_entry(record_id: &str, state: MemoryLifecycleState) -> LedgerEntry {
715        LedgerEntry {
716            schema_version: "memory-ledger.v1".to_string(),
717            recorded_at: "unix:0".to_string(),
718            record_id: record_id.to_string(),
719            scope_key: "user".to_string(),
720            action: crate::domain::MemoryLedgerAction::RecordManual,
721            source_kind: MemorySourceKind::Manual,
722            metadata: Default::default(),
723            record: MemoryRecord {
724                state,
725                ..sample_record(state)
726            },
727        }
728    }
729
730    #[test]
731    fn apply_writeback_should_write_for_accepted_and_canonical() {
732        let temp = tempdir().unwrap();
733        let entry_a = sample_entry("wb-accept", MemoryLifecycleState::Accepted);
734        let entry_c = sample_entry("wb-canon", MemoryLifecycleState::Canonical);
735
736        assert!(apply_writeback_for_entry(temp.path(), &entry_a).is_some());
737        assert!(apply_writeback_for_entry(temp.path(), &entry_c).is_some());
738        assert!(memory_note_path(temp.path(), "wb-accept").exists());
739        assert!(memory_note_path(temp.path(), "wb-canon").exists());
740    }
741
742    #[test]
743    fn apply_writeback_should_skip_draft_and_candidate() {
744        let temp = tempdir().unwrap();
745        let entry_d = sample_entry("wb-draft", MemoryLifecycleState::Draft);
746        let entry_p = sample_entry("wb-cand", MemoryLifecycleState::Candidate);
747        assert!(apply_writeback_for_entry(temp.path(), &entry_d).is_none());
748        assert!(apply_writeback_for_entry(temp.path(), &entry_p).is_none());
749        assert!(!memory_note_path(temp.path(), "wb-draft").exists());
750        assert!(!memory_note_path(temp.path(), "wb-cand").exists());
751    }
752
753    #[test]
754    fn apply_writeback_should_archive_when_state_archived() {
755        let temp = tempdir().unwrap();
756        let entry_a = sample_entry("wb-life", MemoryLifecycleState::Accepted);
757        apply_writeback_for_entry(temp.path(), &entry_a);
758        let archived_entry = sample_entry("wb-life", MemoryLifecycleState::Archived);
759        let result = apply_writeback_for_entry(temp.path(), &archived_entry).unwrap();
760        let content = fs::read_to_string(&result.path).unwrap();
761        assert!(content.contains("archived: true"));
762    }
763}