kmp_embedded/
memory_selection.rs1use std::path::{Path, PathBuf};
16
17use crate::memory_selection_refusal::SelectionRefusal;
18use crate::user_config_file;
19
20pub const KEY: &str = "memory_store";
22
23pub const PRECEDENCE: &str = "precedence: the KMP_MCP_DATA_DIR environment variable, then the \
25 saved selection, then the nearest project root, then the per-user \
26 default";
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SelectedMemory(PathBuf);
31
32impl SelectedMemory {
33 pub fn parse(raw: &str) -> Result<Self, SelectionRefusal> {
37 let value = raw.trim();
38 if value.is_empty() {
39 return Err(SelectionRefusal::Empty);
40 }
41 if value.contains(['"', '\n', '\r']) {
42 return Err(SelectionRefusal::NotQuotable(value.to_string()));
43 }
44 let path = Path::new(value);
45 if path
46 .components()
47 .next()
48 .is_some_and(|component| component.as_os_str() == "~")
49 {
50 return Err(SelectionRefusal::unexpanded("memory selection", value));
51 }
52 if !path.is_absolute() {
53 return Err(SelectionRefusal::Relative(value.to_string()));
54 }
55 Ok(Self(path.to_path_buf()))
56 }
57
58 pub fn path(&self) -> &Path {
59 &self.0
60 }
61
62 pub fn rendered(&self) -> String {
64 format!("{KEY} = \"{}\"", self.0.display())
65 }
66}
67
68pub fn ensure_openable(selection: &SelectedMemory) -> Result<(), SelectionRefusal> {
74 let path = selection.path();
75 if path.exists() && !path.is_dir() {
76 return Err(SelectionRefusal::NotADirectory(path.to_path_buf()));
77 }
78 kmp_adapter_embedded::validate_store_layout(path).map_err(|error| {
79 SelectionRefusal::Incompatible {
80 path: path.to_path_buf(),
81 reason: error.to_string(),
82 }
83 })?;
84 Ok(())
85}
86
87pub fn select_memory(raw: &str) -> Result<SelectedMemory, SelectionRefusal> {
90 let selection = SelectedMemory::parse(raw)?;
91 ensure_openable(&selection)?;
92 Ok(selection)
93}
94
95pub fn saved_selection() -> Result<Option<SelectedMemory>, String> {
97 let path = user_config_file::user_config_path()?;
98 let text = user_config_file::read_text(&path)?;
99 parse_saved_selection(&text).map_err(|error| format!("{}: {error}", path.display()))
100}
101
102pub fn parse_saved_selection(text: &str) -> Result<Option<SelectedMemory>, String> {
106 let Some((line, value)) = user_config_file::quoted_root_setting(text, KEY)? else {
107 return Ok(None);
108 };
109 SelectedMemory::parse(value)
110 .map(Some)
111 .map_err(|refusal| format!("line {line} has invalid {KEY}: {refusal}"))
112}
113
114pub fn save_selection(selection: &SelectedMemory) -> Result<(), String> {
117 let path = user_config_file::user_config_path()?;
118 let existing = user_config_file::read_text(&path)?;
119 let updated = user_config_file::with_root_setting(&existing, KEY, &selection.rendered());
120 user_config_file::write_text(&path, &updated)
121}
122
123pub fn clear_selection() -> Result<(), String> {
126 let path = user_config_file::user_config_path()?;
127 let existing = user_config_file::read_text(&path)?;
128 let updated = user_config_file::without_root_setting(&existing, KEY);
129 user_config_file::write_text(&path, &updated)
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use kmp_adapter_embedded::StorageEngine;
136
137 #[test]
138 fn only_an_absolute_expanded_path_can_be_saved() {
139 let selection = SelectedMemory::parse("/srv/memory/kmp").expect("absolute path");
140 assert_eq!(selection.path(), Path::new("/srv/memory/kmp"));
141 assert_eq!(selection.rendered(), "memory_store = \"/srv/memory/kmp\"");
142
143 assert_eq!(
144 SelectedMemory::parse(" ").expect_err("empty"),
145 SelectionRefusal::Empty
146 );
147 assert_eq!(
148 SelectedMemory::parse("memory/kmp").expect_err("relative"),
149 SelectionRefusal::Relative("memory/kmp".to_string())
150 );
151 assert!(matches!(
152 SelectedMemory::parse("~/memory").expect_err("unexpanded"),
153 SelectionRefusal::Unexpanded { .. }
154 ));
155 assert!(matches!(
156 SelectedMemory::parse("/srv/mem\"ory").expect_err("unquotable"),
157 SelectionRefusal::NotQuotable(_)
158 ));
159 }
160
161 #[test]
162 fn a_directory_that_does_not_exist_yet_is_a_valid_choice() {
163 let temp = tempfile::tempdir().expect("tempdir");
164 let fresh = temp.path().join("not-created-yet");
165 let selection = SelectedMemory::parse(fresh.to_str().expect("path")).expect("absolute");
166
167 ensure_openable(&selection).expect("a fresh directory is selectable");
168 assert!(
169 !fresh.exists(),
170 "checking a selection must not bring the store into being"
171 );
172 }
173
174 #[test]
177 fn an_incompatible_store_is_refused_and_preserved_byte_for_byte() {
178 let temp = tempfile::tempdir().expect("tempdir");
179 let old = temp.path().join("old-store");
180 std::fs::create_dir_all(old.join("store")).expect("store dir");
181 std::fs::write(kmp_adapter_embedded::format_version_path(&old), "2\n").expect("stamp");
182 let artifact = old.join("store").join("retired-layout.bin");
183 std::fs::write(&artifact, b"irreplaceable memory").expect("legacy store");
184
185 let refusal =
186 select_memory(old.to_str().expect("path")).expect_err("an unopenable store is refused");
187 assert!(matches!(refusal, SelectionRefusal::Incompatible { .. }));
188 let message = refusal.to_string();
189 assert!(message.contains("cannot open"), "{message}");
190 assert!(message.contains("nothing was migrated"), "{message}");
191
192 assert_eq!(
193 std::fs::read(&artifact).expect("the old store is still there"),
194 b"irreplaceable memory"
195 );
196 assert_eq!(
197 std::fs::read_to_string(kmp_adapter_embedded::format_version_path(&old))
198 .expect("stamp"),
199 "2\n",
200 "the refusal must not restamp a store it would not open"
201 );
202 }
203
204 #[test]
205 fn a_file_where_a_directory_was_named_is_refused_without_touching_it() {
206 let temp = tempfile::tempdir().expect("tempdir");
207 let file = temp.path().join("not-a-directory");
208 std::fs::write(&file, b"unrelated").expect("write");
209
210 let refusal = select_memory(file.to_str().expect("path")).expect_err("not a directory");
211 assert!(matches!(refusal, SelectionRefusal::NotADirectory(_)));
212 assert_eq!(std::fs::read(&file).expect("still there"), b"unrelated");
213 }
214
215 #[test]
216 fn a_supported_store_is_selectable() {
217 let temp = tempfile::tempdir().expect("tempdir");
218 let supported = temp.path().join("supported");
219 std::fs::create_dir_all(&supported).expect("dir");
220 std::fs::write(
221 kmp_adapter_embedded::format_version_path(&supported),
222 format!("{}\n", StorageEngine::Sqlite.format_version()),
223 )
224 .expect("stamp");
225
226 select_memory(supported.to_str().expect("path")).expect("a stamped store is selectable");
227 }
228
229 #[test]
230 fn a_saved_selection_is_read_back_and_a_broken_one_is_reported() {
231 assert_eq!(parse_saved_selection("").expect("no selection"), None);
232 assert_eq!(
233 parse_saved_selection("memory_store = \"/srv/memory\"\n").expect("selection"),
234 Some(SelectedMemory(PathBuf::from("/srv/memory")))
235 );
236 assert_eq!(
237 parse_saved_selection("[future]\nmemory_store = \"/srv/memory\"\n")
238 .expect("a key inside a table is not this setting"),
239 None
240 );
241
242 let relative = parse_saved_selection("memory_store = \"memory\"\n")
243 .expect_err("a relative selection is visible, not ignored");
244 assert!(
245 relative.contains("line 1 has invalid memory_store"),
246 "{relative}"
247 );
248 assert!(relative.contains("absolute path"), "{relative}");
249
250 let unquoted = parse_saved_selection("memory_store = /srv/memory\n")
251 .expect_err("an unquoted value is not TOML we accept");
252 assert!(unquoted.contains("quoted value"), "{unquoted}");
253 }
254}