Skip to main content

memstead_schema/
workspace_config.rs

1//! Cross-mem-link policy value parsing for `.memstead/workspace.toml`.
2//!
3//! [`CrossLinkValue`] is the shared shape behind `[cross_mem_links]`
4//! and `[[mem_management.create]].default_cross_links` — an operator
5//! writes either `"*"` (wildcard) or a list of mem names. Each engine
6//! crate that loads workspace policy (`memstead-base`,
7//! `memstead-engine`, `memstead-mcp`, `memstead-cli`) calls
8//! [`CrossLinkValue::parse_toml`] when lifting those tables; the value
9//! parser lives here so every crate validates the shape identically.
10
11use crate::config::ConfigError;
12
13/// One entry in `[cross_mem_links]` (and the matching shape that
14/// `[[mem_management.create]].default_cross_links` uses). The operator
15/// writes either a list of mem names or the literal string `"*"`; mixed
16/// lists containing `"*"` are rejected at parse with `CONFIG_ERROR`.
17///
18/// Empty lists (`[]`) are valid and behave identically to omission of
19/// the key — kept as an explicit shape so an operator can encode "this
20/// mem is intentionally locked down" without relying on absence.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum CrossLinkValue {
23    /// Wildcard — any current writable target is admitted.
24    Wildcard,
25    /// Explicit allowlist of mem names. May be empty (default-deny
26    /// for that mem).
27    List(Vec<String>),
28}
29
30impl CrossLinkValue {
31    /// Parse a TOML value into a `CrossLinkValue`, rejecting mixed lists
32    /// containing `"*"` and any non-string-list shape. Used by both
33    /// `[cross_mem_links]` and `[[mem_management.create]].default_cross_links`.
34    pub fn parse_toml(location: &str, value: &toml::Value) -> Result<Self, ConfigError> {
35        match value {
36            toml::Value::String(s) if s == "*" => Ok(Self::Wildcard),
37            toml::Value::String(other) => Err(ConfigError::Other(format!(
38                "{location}: expected `\"*\"` or a list of mem names, got string {other:?}"
39            ))),
40            toml::Value::Array(items) => {
41                let mut names: Vec<String> = Vec::with_capacity(items.len());
42                let mut has_wildcard = false;
43                for (idx, item) in items.iter().enumerate() {
44                    match item {
45                        toml::Value::String(s) if s == "*" => {
46                            has_wildcard = true;
47                        }
48                        toml::Value::String(s) if s.is_empty() => {
49                            return Err(ConfigError::Other(format!(
50                                "{location}[{idx}]: mem name must not be empty"
51                            )));
52                        }
53                        toml::Value::String(s) => names.push(s.clone()),
54                        _ => {
55                            return Err(ConfigError::Other(format!(
56                                "{location}[{idx}]: expected string mem name, got {item}"
57                            )));
58                        }
59                    }
60                }
61                if has_wildcard && !names.is_empty() {
62                    return Err(ConfigError::Other(format!(
63                        "{location}: `\"*\"` wildcard must be the sole entry — \
64                         remove the named entries or drop the wildcard"
65                    )));
66                }
67                if has_wildcard {
68                    Ok(Self::Wildcard)
69                } else {
70                    Ok(Self::List(names))
71                }
72            }
73            other => Err(ConfigError::Other(format!(
74                "{location}: expected `\"*\"` or a list of mem names, got {other}"
75            ))),
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn val(s: &str) -> toml::Value {
85        toml::from_str::<toml::Value>(&format!("x = {s}"))
86            .unwrap()
87            .get("x")
88            .unwrap()
89            .clone()
90    }
91
92    #[test]
93    fn wildcard_string_parses() {
94        assert_eq!(
95            CrossLinkValue::parse_toml("[loc]", &val("\"*\"")).unwrap(),
96            CrossLinkValue::Wildcard
97        );
98    }
99
100    #[test]
101    fn name_list_parses() {
102        assert_eq!(
103            CrossLinkValue::parse_toml("[loc]", &val("[\"a\", \"b\"]")).unwrap(),
104            CrossLinkValue::List(vec!["a".to_string(), "b".to_string()])
105        );
106    }
107
108    #[test]
109    fn empty_list_is_default_deny() {
110        assert_eq!(
111            CrossLinkValue::parse_toml("[loc]", &val("[]")).unwrap(),
112            CrossLinkValue::List(vec![])
113        );
114    }
115
116    #[test]
117    fn wildcard_in_a_list_is_a_lone_wildcard() {
118        assert_eq!(
119            CrossLinkValue::parse_toml("[loc]", &val("[\"*\"]")).unwrap(),
120            CrossLinkValue::Wildcard
121        );
122    }
123
124    #[test]
125    fn mixed_wildcard_and_names_is_rejected() {
126        let err = CrossLinkValue::parse_toml("[loc]", &val("[\"*\", \"a\"]")).unwrap_err();
127        assert!(format!("{err}").contains("sole entry"));
128    }
129
130    #[test]
131    fn empty_mem_name_is_rejected() {
132        let err = CrossLinkValue::parse_toml("[loc]", &val("[\"\"]")).unwrap_err();
133        assert!(format!("{err}").contains("must not be empty"));
134    }
135
136    #[test]
137    fn non_string_list_entry_is_rejected() {
138        let err = CrossLinkValue::parse_toml("[loc]", &val("[1]")).unwrap_err();
139        assert!(format!("{err}").contains("expected string mem name"));
140    }
141
142    #[test]
143    fn bare_integer_is_rejected() {
144        let err = CrossLinkValue::parse_toml("[loc]", &val("42")).unwrap_err();
145        assert!(format!("{err}").contains("expected"));
146    }
147}