1use 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
45pub 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 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
176struct 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 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 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
439pub 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
469pub 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
490pub 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")); 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 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 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}