Skip to main content

kmp_embedded/
memory_selection_refusal.rs

1//! Why a memory selection was refused, in the words an operator can act on.
2//!
3//! Every variant is a refusal, never a correction: KMP does not migrate,
4//! move, convert or overwrite a store it cannot open, and it does not quietly
5//! pick a different one. The store the operator already has keeps every byte
6//! it had, and the selection simply does not happen.
7
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum SelectionRefusal {
13    /// A selection with no path in it.
14    Empty,
15    /// A shell path nothing expands: MCP host configuration is not a shell.
16    /// `subject` names whoever said it, because the same refusal answers the
17    /// explicit environment override and the saved selection alike.
18    Unexpanded {
19        subject: String,
20        value: String,
21        home: Option<PathBuf>,
22    },
23    /// A relative path, which would mean a different store per working
24    /// directory — the opposite of what saving a selection is for.
25    Relative(String),
26    /// A path the settings file cannot carry back unchanged.
27    NotQuotable(String),
28    /// A path that exists and is not a directory.
29    NotADirectory(PathBuf),
30    /// A directory that holds a store this engine will not open.
31    Incompatible { path: PathBuf, reason: String },
32}
33
34impl SelectionRefusal {
35    /// A `~` path, attributed to whoever wrote it.
36    pub fn unexpanded(subject: impl Into<String>, value: impl Into<String>) -> Self {
37        Self::Unexpanded {
38            subject: subject.into(),
39            value: value.into(),
40            home: user_home(),
41        }
42    }
43
44    /// Whether the refusal is about an existing store's bytes, which is the
45    /// case that has to promise it touched nothing.
46    pub fn concerns_an_existing_store(&self) -> bool {
47        matches!(self, Self::Incompatible { .. } | Self::NotADirectory(_))
48    }
49}
50
51/// The operator's home directory, only ever used to suggest the path they
52/// meant when a shell-style one cannot be expanded.
53fn user_home() -> Option<PathBuf> {
54    ["HOME", "USERPROFILE"]
55        .into_iter()
56        .find_map(std::env::var_os)
57        .map(PathBuf::from)
58        .filter(|path| !path.as_os_str().is_empty())
59}
60
61impl fmt::Display for SelectionRefusal {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Empty => write!(
65                formatter,
66                "a user memory selection needs an absolute directory path"
67            ),
68            Self::Unexpanded {
69                subject,
70                value,
71                home,
72            } => {
73                write!(
74                    formatter,
75                    "{subject} `{value}` starts with `~`, but MCP host configuration does not \
76                     expand shell paths"
77                )?;
78                match home
79                    .as_ref()
80                    .and_then(|home| strip_home_prefix(home, value))
81                {
82                    Some(expanded) => write!(formatter, "; use `{}`", expanded.display()),
83                    None => write!(formatter, "; use an absolute path instead"),
84                }
85            }
86            Self::Relative(value) => write!(
87                formatter,
88                "memory selection `{value}` is relative; a saved selection must be an absolute \
89                 path or it would name a different store from every working directory"
90            ),
91            Self::NotQuotable(value) => write!(
92                formatter,
93                "memory selection `{value}` contains a double quote or a line break, which the \
94                 settings file cannot carry back unchanged"
95            ),
96            Self::NotADirectory(path) => write!(
97                formatter,
98                "`{}` exists and is not a directory; the file was left untouched",
99                path.display()
100            ),
101            Self::Incompatible { path, reason } => write!(
102                formatter,
103                "`{}` holds memory this engine cannot open: {reason}; every file in it was left \
104                 exactly as it was, and nothing was migrated, moved or converted",
105                path.display()
106            ),
107        }
108    }
109}
110
111fn strip_home_prefix(home: &Path, value: &str) -> Option<PathBuf> {
112    Path::new(value)
113        .strip_prefix("~")
114        .ok()
115        .map(|suffix| home.join(suffix))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn an_incompatible_store_promises_it_was_left_alone() {
124        let refusal = SelectionRefusal::Incompatible {
125            path: PathBuf::from("/old/store"),
126            reason: "format version 2 is not supported".to_string(),
127        };
128        let message = refusal.to_string();
129        assert!(message.contains("/old/store"), "{message}");
130        assert!(message.contains("format version 2"), "{message}");
131        assert!(message.contains("left exactly as it was"), "{message}");
132        assert!(message.contains("nothing was migrated"), "{message}");
133        assert!(refusal.concerns_an_existing_store());
134    }
135
136    #[test]
137    fn a_relative_or_shell_path_says_why_it_cannot_be_saved() {
138        let relative = SelectionRefusal::Relative("memory".to_string()).to_string();
139        assert!(relative.contains("absolute path"), "{relative}");
140
141        let expandable = SelectionRefusal::Unexpanded {
142            subject: "KMP_MCP_DATA_DIR value".to_string(),
143            value: "~/memory".to_string(),
144            home: Some(PathBuf::from("/home/u")),
145        }
146        .to_string();
147        assert!(
148            expandable.starts_with("KMP_MCP_DATA_DIR value `~/memory` starts with `~`"),
149            "{expandable}"
150        );
151        assert!(
152            expandable.contains("MCP host configuration does not expand shell paths"),
153            "{expandable}"
154        );
155        assert!(expandable.contains("use `/home/u/memory`"), "{expandable}");
156
157        let homeless = SelectionRefusal::Unexpanded {
158            subject: "memory selection".to_string(),
159            value: "~/memory".to_string(),
160            home: None,
161        }
162        .to_string();
163        assert!(homeless.contains("absolute path instead"), "{homeless}");
164        assert!(!SelectionRefusal::Empty.concerns_an_existing_store());
165    }
166}