Skip to main content

twine_core/memories/
editing.rs

1use super::{MemoryError, MemoryRequest, discovery};
2use crate::{FileSaveOutcome, FileSaveRequest, FileVersion};
3
4/// Save an explicitly selected Markdown source using its last-read disk version.
5///
6/// # Errors
7/// Rejects undiscovered sources, non-Markdown resources, and unsafe or failed file writes.
8pub 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}