use std::path::{Path, PathBuf};
use crate::memory_selection_refusal::SelectionRefusal;
use crate::user_config_file;
pub const KEY: &str = "memory_store";
pub const PRECEDENCE: &str = "precedence: the KMP_MCP_DATA_DIR environment variable, then the \
saved selection, then the nearest project root, then the per-user \
default";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectedMemory(PathBuf);
impl SelectedMemory {
pub fn parse(raw: &str) -> Result<Self, SelectionRefusal> {
let value = raw.trim();
if value.is_empty() {
return Err(SelectionRefusal::Empty);
}
if value.contains(['"', '\n', '\r']) {
return Err(SelectionRefusal::NotQuotable(value.to_string()));
}
let path = Path::new(value);
if path
.components()
.next()
.is_some_and(|component| component.as_os_str() == "~")
{
return Err(SelectionRefusal::unexpanded("memory selection", value));
}
if !path.is_absolute() {
return Err(SelectionRefusal::Relative(value.to_string()));
}
Ok(Self(path.to_path_buf()))
}
pub fn path(&self) -> &Path {
&self.0
}
pub fn rendered(&self) -> String {
format!("{KEY} = \"{}\"", self.0.display())
}
}
pub fn ensure_openable(selection: &SelectedMemory) -> Result<(), SelectionRefusal> {
let path = selection.path();
if path.exists() && !path.is_dir() {
return Err(SelectionRefusal::NotADirectory(path.to_path_buf()));
}
kmp_adapter_embedded::validate_store_layout(path).map_err(|error| {
SelectionRefusal::Incompatible {
path: path.to_path_buf(),
reason: error.to_string(),
}
})?;
Ok(())
}
pub fn select_memory(raw: &str) -> Result<SelectedMemory, SelectionRefusal> {
let selection = SelectedMemory::parse(raw)?;
ensure_openable(&selection)?;
Ok(selection)
}
pub fn saved_selection() -> Result<Option<SelectedMemory>, String> {
let path = user_config_file::user_config_path()?;
let text = user_config_file::read_text(&path)?;
parse_saved_selection(&text).map_err(|error| format!("{}: {error}", path.display()))
}
pub fn parse_saved_selection(text: &str) -> Result<Option<SelectedMemory>, String> {
let Some((line, value)) = user_config_file::quoted_root_setting(text, KEY)? else {
return Ok(None);
};
SelectedMemory::parse(value)
.map(Some)
.map_err(|refusal| format!("line {line} has invalid {KEY}: {refusal}"))
}
pub fn save_selection(selection: &SelectedMemory) -> Result<(), String> {
let path = user_config_file::user_config_path()?;
let existing = user_config_file::read_text(&path)?;
let updated = user_config_file::with_root_setting(&existing, KEY, &selection.rendered());
user_config_file::write_text(&path, &updated)
}
pub fn clear_selection() -> Result<(), String> {
let path = user_config_file::user_config_path()?;
let existing = user_config_file::read_text(&path)?;
let updated = user_config_file::without_root_setting(&existing, KEY);
user_config_file::write_text(&path, &updated)
}
#[cfg(test)]
mod tests {
use super::*;
use kmp_adapter_embedded::StorageEngine;
#[test]
fn only_an_absolute_expanded_path_can_be_saved() {
let selection = SelectedMemory::parse("/srv/memory/kmp").expect("absolute path");
assert_eq!(selection.path(), Path::new("/srv/memory/kmp"));
assert_eq!(selection.rendered(), "memory_store = \"/srv/memory/kmp\"");
assert_eq!(
SelectedMemory::parse(" ").expect_err("empty"),
SelectionRefusal::Empty
);
assert_eq!(
SelectedMemory::parse("memory/kmp").expect_err("relative"),
SelectionRefusal::Relative("memory/kmp".to_string())
);
assert!(matches!(
SelectedMemory::parse("~/memory").expect_err("unexpanded"),
SelectionRefusal::Unexpanded { .. }
));
assert!(matches!(
SelectedMemory::parse("/srv/mem\"ory").expect_err("unquotable"),
SelectionRefusal::NotQuotable(_)
));
}
#[test]
fn a_directory_that_does_not_exist_yet_is_a_valid_choice() {
let temp = tempfile::tempdir().expect("tempdir");
let fresh = temp.path().join("not-created-yet");
let selection = SelectedMemory::parse(fresh.to_str().expect("path")).expect("absolute");
ensure_openable(&selection).expect("a fresh directory is selectable");
assert!(
!fresh.exists(),
"checking a selection must not bring the store into being"
);
}
#[test]
fn an_incompatible_store_is_refused_and_preserved_byte_for_byte() {
let temp = tempfile::tempdir().expect("tempdir");
let old = temp.path().join("old-store");
std::fs::create_dir_all(old.join("store")).expect("store dir");
std::fs::write(kmp_adapter_embedded::format_version_path(&old), "2\n").expect("stamp");
let artifact = old.join("store").join("retired-layout.bin");
std::fs::write(&artifact, b"irreplaceable memory").expect("legacy store");
let refusal =
select_memory(old.to_str().expect("path")).expect_err("an unopenable store is refused");
assert!(matches!(refusal, SelectionRefusal::Incompatible { .. }));
let message = refusal.to_string();
assert!(message.contains("cannot open"), "{message}");
assert!(message.contains("nothing was migrated"), "{message}");
assert_eq!(
std::fs::read(&artifact).expect("the old store is still there"),
b"irreplaceable memory"
);
assert_eq!(
std::fs::read_to_string(kmp_adapter_embedded::format_version_path(&old))
.expect("stamp"),
"2\n",
"the refusal must not restamp a store it would not open"
);
}
#[test]
fn a_file_where_a_directory_was_named_is_refused_without_touching_it() {
let temp = tempfile::tempdir().expect("tempdir");
let file = temp.path().join("not-a-directory");
std::fs::write(&file, b"unrelated").expect("write");
let refusal = select_memory(file.to_str().expect("path")).expect_err("not a directory");
assert!(matches!(refusal, SelectionRefusal::NotADirectory(_)));
assert_eq!(std::fs::read(&file).expect("still there"), b"unrelated");
}
#[test]
fn a_supported_store_is_selectable() {
let temp = tempfile::tempdir().expect("tempdir");
let supported = temp.path().join("supported");
std::fs::create_dir_all(&supported).expect("dir");
std::fs::write(
kmp_adapter_embedded::format_version_path(&supported),
format!("{}\n", StorageEngine::Sqlite.format_version()),
)
.expect("stamp");
select_memory(supported.to_str().expect("path")).expect("a stamped store is selectable");
}
#[test]
fn a_saved_selection_is_read_back_and_a_broken_one_is_reported() {
assert_eq!(parse_saved_selection("").expect("no selection"), None);
assert_eq!(
parse_saved_selection("memory_store = \"/srv/memory\"\n").expect("selection"),
Some(SelectedMemory(PathBuf::from("/srv/memory")))
);
assert_eq!(
parse_saved_selection("[future]\nmemory_store = \"/srv/memory\"\n")
.expect("a key inside a table is not this setting"),
None
);
let relative = parse_saved_selection("memory_store = \"memory\"\n")
.expect_err("a relative selection is visible, not ignored");
assert!(
relative.contains("line 1 has invalid memory_store"),
"{relative}"
);
assert!(relative.contains("absolute path"), "{relative}");
let unquoted = parse_saved_selection("memory_store = /srv/memory\n")
.expect_err("an unquoted value is not TOML we accept");
assert!(unquoted.contains("quoted value"), "{unquoted}");
}
}