twine_core/memories/
editing.rs1use super::{MemoryError, MemoryRequest, discovery};
2use crate::{FileSaveOutcome, FileSaveRequest, FileVersion};
3
4pub fn save(
9 request: &MemoryRequest,
10 text: String,
11 expected_version: FileVersion,
12 overwrite: bool,
13) -> Result<FileSaveOutcome, MemoryError> {
14 let id = request
15 .source_id
16 .as_ref()
17 .ok_or(MemoryError::MissingSource)?;
18 let (entries, _) = discovery::discover(request)?;
19 let entry = entries
20 .into_iter()
21 .find(|entry| &entry.source.id == id)
22 .ok_or(MemoryError::MissingSource)?;
23 let discovery::Backing::File { root, path } = entry.backing else {
24 return Err(MemoryError::UnsupportedEdit);
25 };
26 let extension = path
27 .extension()
28 .and_then(|value| value.to_str())
29 .unwrap_or_default();
30 if !extension.eq_ignore_ascii_case("md") && !extension.eq_ignore_ascii_case("markdown") {
31 return Err(MemoryError::UnsupportedEdit);
32 }
33 Ok(crate::files::save(&FileSaveRequest {
34 folder: root,
35 path,
36 text,
37 expected_version,
38 overwrite,
39 })?)
40}
41
42#[cfg(test)]
43mod tests {
44 use super::*;
45 use crate::FileContent;
46 use std::fs;
47
48 #[test]
49 fn markdown_edits_preserve_external_changes_until_explicit_overwrite() {
50 let fixture = tempfile::tempdir().unwrap();
51 let root = fixture.path().join("home/.codex/memories");
52 fs::create_dir_all(&root).unwrap();
53 let path = root.join("MEMORY.md");
54 fs::write(&path, "# Original\n").unwrap();
55 let mut request = MemoryRequest {
56 examples_root: Some(fixture.path().to_owned()),
57 ..MemoryRequest::default()
58 };
59 request.source_id = Some(
60 super::super::catalog(&request)
61 .unwrap()
62 .sources
63 .into_iter()
64 .find(|source| source.location == path.display().to_string())
65 .unwrap()
66 .id,
67 );
68 let original = super::super::read(&request).unwrap().file.unwrap();
69 let FileSaveOutcome::Saved(saved) = save(
70 &request,
71 "# Edited\n".into(),
72 original.version.unwrap(),
73 false,
74 )
75 .unwrap() else {
76 panic!("expected a saved file");
77 };
78 assert_eq!(saved.content, FileContent::Text("# Edited\n".into()));
79 let version = saved.version.unwrap();
80 fs::write(&path, "# Harness update\n").unwrap();
81 assert!(matches!(
82 save(&request, "# My edit\n".into(), version.clone(), false).unwrap(),
83 FileSaveOutcome::Conflict(_)
84 ));
85 assert_eq!(fs::read_to_string(&path).unwrap(), "# Harness update\n");
86 assert!(matches!(
87 save(&request, "# My edit\n".into(), version, true).unwrap(),
88 FileSaveOutcome::Saved(_)
89 ));
90 assert_eq!(fs::read_to_string(&path).unwrap(), "# My edit\n");
91 }
92
93 #[test]
94 fn edits_reject_settings_non_markdown_files_and_replaced_symlinks() {
95 let fixture = tempfile::tempdir().unwrap();
96 let root = fixture.path().join("home/.claude/agent-memory/helper");
97 fs::create_dir_all(&root).unwrap();
98 fs::write(root.join("data.json"), "{}").unwrap();
99 let path = root.join("MEMORY.md");
100 fs::write(&path, "original").unwrap();
101 let mut request = MemoryRequest {
102 examples_root: Some(fixture.path().to_owned()),
103 ..MemoryRequest::default()
104 };
105 let catalog = super::super::catalog(&request).unwrap();
106 for source in catalog.sources.iter().filter(|source| {
107 source.example
108 && (source.title == "data.json"
109 || source.kind == super::super::MemoryKind::Configuration)
110 }) {
111 request.source_id = Some(source.id.clone());
112 assert!(matches!(
113 save(
114 &request,
115 "edit".into(),
116 FileVersion {
117 fingerprint: "unused".into(),
118 utf8_bom: false
119 },
120 true
121 ),
122 Err(MemoryError::UnsupportedEdit)
123 ));
124 }
125 request.source_id = Some(
126 catalog
127 .sources
128 .into_iter()
129 .find(|source| source.location == path.display().to_string())
130 .unwrap()
131 .id,
132 );
133 let version = super::super::read(&request)
134 .unwrap()
135 .file
136 .unwrap()
137 .version
138 .unwrap();
139 let outside = fixture.path().join("outside.md");
140 fs::write(&outside, "untouched").unwrap();
141 fs::remove_file(&path).unwrap();
142 std::os::unix::fs::symlink(&outside, &path).unwrap();
143 assert!(save(&request, "edit".into(), version, true).is_err());
144 assert_eq!(fs::read_to_string(outside).unwrap(), "untouched");
145 }
146}