Skip to main content

kmp_embedded/
memory_selection.rs

1//! The memory an operator chose on purpose, and kept.
2//!
3//! Automatic selection answers "which memory is in front of me": the nearest
4//! project, or the per-user default. That is the right answer for a checkout
5//! and the wrong one for a workspace that is not a repository, where it
6//! silently lands on whatever the per-user default happens to be. A saved
7//! selection is the operator saying which memory this machine opens when
8//! nothing more local has been said, and it survives every restart because it
9//! lives in the user's config file rather than in an environment.
10//!
11//! It never converts anything. Selecting a store this engine cannot open is
12//! refused with the reason and the repair; the bytes already on disk are not
13//! read, moved, migrated or replaced.
14
15use std::path::{Path, PathBuf};
16
17use crate::memory_selection_refusal::SelectionRefusal;
18use crate::user_config_file;
19
20/// Root config key that owns this setting.
21pub const KEY: &str = "memory_store";
22
23/// The resolution order, said once for every surface that explains it.
24pub 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/// An absolute directory a person chose to be this machine's user memory.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SelectedMemory(PathBuf);
31
32impl SelectedMemory {
33    /// The syntax a saved selection must satisfy before anything looks at
34    /// the disk: a non-empty, already-expanded, absolute path that this
35    /// config file can round-trip.
36    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    /// The line this selection is written as, in the config file's subset.
63    pub fn rendered(&self) -> String {
64        format!("{KEY} = \"{}\"", self.0.display())
65    }
66}
67
68/// Whether this engine could open the chosen directory, without opening it.
69///
70/// A directory that does not exist yet is a valid choice: the store is
71/// created on first write, which is the operator's next explicit act rather
72/// than a side effect of configuring anything.
73pub 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
87/// The selection a written value names, refusing everything that must not
88/// become one. Nothing is written and no store is opened.
89pub fn select_memory(raw: &str) -> Result<SelectedMemory, SelectionRefusal> {
90    let selection = SelectedMemory::parse(raw)?;
91    ensure_openable(&selection)?;
92    Ok(selection)
93}
94
95/// What the user's config file currently selects, if anything.
96pub 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
102/// The selection a config document carries. A value the file cannot mean is
103/// an error rather than a silent fall-through to automatic selection: a
104/// mistyped selection must be visible, not quietly ignored.
105pub 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
114/// Writes the selection into the user's config file, leaving every other
115/// setting in it exactly as it was.
116pub 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
123/// Removes the selection and returns automatic selection to the operator,
124/// touching no store on the way.
125pub 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    /// The refusal this issue exists for: an old store stays exactly where it
175    /// is, byte for byte, and the selection does not happen.
176    #[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}