use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const MEM_META_DIR: &str = ".memstead";
pub const ARCHIVE_META_DIR: &str = ".memstead";
pub const ARCHIVE_CONFIG_PATH: &str = ".memstead/config.json";
pub const ARCHIVE_SCHEMA_PREFIX: &str = ".memstead/schema/";
pub const ARCHIVE_PROVENANCE_PATH: &str = ".memstead/provenance.json";
pub const ARCHIVE_EXTENSION: &str = "mem";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config file not found: {0}")]
NotFound(String),
#[error("invalid JSON in config file: {0}")]
InvalidJson(String),
#[error("config validation failed:\n{}", .0.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n"))]
ValidationFailed(Vec<String>),
#[error("{0}")]
Other(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone)]
pub struct ConfigCheckResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub error_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleConfig {
pub include: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exclude: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishConfig {
pub roles: HashMap<String, RoleConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommunityOverride {
#[serde(skip_serializing_if = "Option::is_none")]
pub resolution: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadMemSpec {
pub source: ReadMemSource,
#[serde(default, skip_serializing_if = "Option::is_none", rename = "cacheKey")]
pub cache_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ReadMemSource {
Local,
Url { url: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SchemaRef {
pub name: String,
pub version: semver::Version,
}
impl SchemaRef {
pub fn new(name: impl Into<String>, version: semver::Version) -> Self {
Self {
name: name.into(),
version,
}
}
pub fn as_display(&self) -> String {
format!("{}@{}", self.name, self.version)
}
}
impl std::fmt::Display for SchemaRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}@{}", self.name, self.version)
}
}
impl std::str::FromStr for SchemaRef {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("schema reference must not be empty (expected \"name@x.y.z\")".into());
}
let (name, version_str) = trimmed.split_once('@').ok_or_else(|| {
format!(
"schema reference '{trimmed}' must include an exact version — expected \"name@x.y.z\""
)
})?;
if name.is_empty() {
return Err("schema reference name must not be empty".into());
}
if version_str == "latest" {
return Err(format!(
"schema reference '{trimmed}' uses 'latest' — exact semver versions only"
));
}
if version_str.starts_with(['^', '~', '>', '<', '=', '*']) {
return Err(format!(
"schema reference '{trimmed}' uses range syntax — exact semver only (e.g. 'default@1.0.0')"
));
}
let version = semver::Version::parse(version_str).map_err(|e| {
format!("schema reference '{trimmed}' has invalid semver version '{version_str}': {e}")
})?;
Ok(Self {
name: name.to_string(),
version,
})
}
}
impl Serialize for SchemaRef {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.as_display())
}
}
impl<'de> Deserialize<'de> for SchemaRef {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
s.parse::<SchemaRef>().map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct VcsConfig {
pub gitdir: String,
#[serde(default = "vcs_worktree_default")]
pub worktree: String,
}
fn vcs_worktree_default() -> String {
".".to_string()
}
fn deserialize_vcs_tolerant<'de, D>(deserializer: D) -> Result<Option<VcsConfig>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<Value>::deserialize(deserializer)?;
match value {
None | Some(Value::Null) => Ok(None),
Some(Value::Object(_)) => {
let v = value.unwrap();
Ok(Some(
serde_json::from_value(v).map_err(serde::de::Error::custom)?,
))
}
Some(_) => Ok(None),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<semver::Version>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authors: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<SchemaRef>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub write_guidance: HashMap<String, Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rules: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub publish: Option<PublishConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(
rename = "readMems",
default,
skip_serializing_if = "BTreeMap::is_empty"
)]
pub read_mems: BTreeMap<String, ReadMemSpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub community: Option<CommunityOverride>,
#[serde(
default,
deserialize_with = "deserialize_vcs_tolerant",
skip_serializing_if = "Option::is_none"
)]
pub vcs: Option<VcsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unregistered_at: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub sync_state: BTreeMap<String, String>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct PublishedMemConfig {
pub format: u32,
pub name: String,
pub version: semver::Version,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub authors: Option<Vec<String>>,
pub schema: SchemaRef,
}
pub const PUBLISHED_MEM_FORMAT: u32 = 3;
#[derive(Debug, thiserror::Error)]
pub enum PublishConversionError {
#[error("config.version is required for mem publish — set it in .memstead/config.json")]
MissingVersion,
#[error(
"config must declare `schema` (e.g. \"default@1.0.0\") — set it in .memstead/config.json"
)]
MissingSchema,
#[error(
"publish requires an explicit mem name — caller must pass the leaf folder name (Goal 3 of mem-repo-restructure dropped the in-config `name` requirement)"
)]
MissingName,
}
pub fn published_config_from(
config: &MemConfig,
name: &str,
) -> Result<PublishedMemConfig, PublishConversionError> {
let version = config
.version
.clone()
.ok_or(PublishConversionError::MissingVersion)?;
let schema = config
.schema
.clone()
.ok_or(PublishConversionError::MissingSchema)?;
let resolved_name = if name.is_empty() {
config
.name
.clone()
.ok_or(PublishConversionError::MissingName)?
} else {
name.to_string()
};
Ok(PublishedMemConfig {
format: PUBLISHED_MEM_FORMAT,
name: resolved_name,
version,
description: config.description.clone(),
authors: config.authors.clone(),
schema,
})
}
const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
"version",
"description",
"authors",
"schema",
"writeGuidance",
"rules",
"publish",
"language",
"readMems",
"community",
"vcs",
"syncState",
];
const LEGACY_TOMBSTONE_KEYS: &[(&str, &str)] = &[
(
"types",
"Legacy `types: [...]` field detected — replace with `schema: \"<name>@<version>\"` \
(e.g. `\"schema\": \"default@1.0.0\"`).",
),
(
"name",
"Legacy `name` field detected — the mem leaf folder under `__MEMSTEAD:mems/` (or the \
disk basename on the legacy disk path) is path-derived under the unified layout; \
remove the field from `.memstead/config.json`.",
),
(
"belongsTo",
"Legacy `belongsTo` field detected — cross-mem authorization moved to the \
workspace-level `[cross_mem_links]` section in `.memstead/workspace.toml`. Remove the \
field from `.memstead/config.json` and add an entry under `[cross_mem_links]` \
instead.",
),
];
pub fn check_config(config: &Value) -> ConfigCheckResult {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let obj = match config.as_object() {
Some(o) => o,
None => {
errors.push("(root): config must be an object".to_string());
return ConfigCheckResult {
valid: false,
errors,
warnings,
error_code: None,
};
}
};
let mut legacy_field_hit = false;
for (key, message) in LEGACY_TOMBSTONE_KEYS {
if obj.contains_key(*key) {
errors.push((*message).to_string());
legacy_field_hit = true;
}
}
match obj.get("schema") {
Some(Value::String(s)) => {
if let Err(e) = s.parse::<SchemaRef>() {
errors.push(format!("schema: {e}"));
}
}
Some(_) => errors.push(
"schema: must be a string of the form \"<name>@<x.y.z>\" \
(exact version pin, e.g. \"default@1.0.0\")"
.to_string(),
),
None => errors.push(
"Config must declare `schema` — exact pin of the form \
\"<name>@<x.y.z>\" (e.g. \"default@1.0.0\")"
.to_string(),
),
}
if let Some(Value::Object(mems)) = obj.get("readMems") {
for (name, spec) in mems {
let entry_path = format!("readMems.{name}");
let spec_obj = match spec.as_object() {
Some(o) => o,
None => {
errors.push(format!("{entry_path}: read-mem entry must be an object"));
continue;
}
};
let source = match spec_obj.get("source").and_then(|v| v.as_object()) {
Some(s) => s,
None => {
errors.push(format!(
"{entry_path}.source: read-mem entry must declare a source \
(e.g. {{\"type\": \"local\"}} or {{\"type\": \"url\", \"url\": \"…\"}})"
));
continue;
}
};
match source.get("type").and_then(|v| v.as_str()) {
Some("local") => {}
Some("url") => match source.get("url").and_then(|v| v.as_str()) {
Some(u) if !u.is_empty() => {}
_ => errors.push(format!(
"{entry_path}.source.url: url source must declare a non-empty 'url' string"
)),
},
Some(other) => errors.push(format!(
"{entry_path}.source.type: unknown source type '{other}' \
(expected 'local' or 'url')"
)),
None => errors.push(format!(
"{entry_path}.source.type: source must declare a 'type' \
('local' or 'url')"
)),
}
}
}
for key in obj.keys() {
if KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str())
|| LEGACY_TOMBSTONE_KEYS
.iter()
.any(|(k, _)| *k == key.as_str())
{
continue;
}
warnings.push(format!(
"Unknown config key '{key}' \u{2014} will be ignored"
));
}
let error_code = if legacy_field_hit {
Some("LEGACY_FIELD_PRESENT".to_string())
} else {
None
};
ConfigCheckResult {
valid: errors.is_empty(),
errors,
warnings,
error_code,
}
}
pub fn load_config(mem_dir: &Path) -> Result<(Value, PathBuf), ConfigError> {
let config_path = mem_dir.join(MEM_META_DIR).join("config.json");
let raw = std::fs::read_to_string(&config_path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ConfigError::NotFound(config_path.display().to_string())
} else {
ConfigError::Io(e)
}
})?;
let parsed: Value = serde_json::from_str(&raw)
.map_err(|_| ConfigError::InvalidJson(config_path.display().to_string()))?;
Ok((parsed, config_path))
}
pub fn parse_mem_config(value: &Value) -> Result<MemConfig, ConfigError> {
serde_json::from_value(value.clone()).map_err(|e| ConfigError::Other(e.to_string()))
}
pub fn load_and_validate(mem_dir: &Path) -> Result<MemConfig, ConfigError> {
let (raw, _path) = load_config(mem_dir)?;
let result = check_config(&raw);
if !result.valid {
return Err(ConfigError::ValidationFailed(result.errors));
}
parse_mem_config(&raw)
}
fn write_config(config_path: &Path, config: &Value) -> Result<(), ConfigError> {
let json = serde_json::to_string_pretty(config)? + "\n";
std::fs::write(config_path, json)?;
Ok(())
}
fn commit_config(
config_path: &Path,
config: &Value,
dry_run: bool,
) -> Result<ConfigCheckResult, ConfigError> {
let check = check_config(config);
if !check.valid {
return Ok(check);
}
if !dry_run {
write_config(config_path, config)?;
}
Ok(check)
}
const ALLOWED_UPDATE_FIELDS: &[&str] = &[
"version",
"description",
"authors",
"writeGuidance",
"rules",
"readMems",
"schema",
"language",
"publish",
];
const PROTECTED_FIELDS: &[&str] = &["name"];
pub fn update_config_field(
config_path: &Path,
config: &mut Value,
field: &str,
value: Value,
dry_run: bool,
) -> Result<ConfigCheckResult, ConfigError> {
if PROTECTED_FIELDS.contains(&field) {
return Err(ConfigError::Other(format!("Field '{field}' is protected")));
}
let obj = config
.as_object_mut()
.ok_or_else(|| ConfigError::Other("config must be an object".into()))?;
if !ALLOWED_UPDATE_FIELDS.contains(&field) {
return Err(ConfigError::Other(format!(
"Field '{field}' is not a recognized config field. Allowed: {}",
ALLOWED_UPDATE_FIELDS.join(", ")
)));
}
obj.insert(field.to_string(), value);
commit_config(config_path, config, dry_run)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn minimal_valid_config() -> Value {
json!({
"schema": "default@1.0.0"
})
}
#[test]
fn check_valid_minimal_config() {
let result = check_config(&minimal_valid_config());
assert!(result.valid, "errors: {:?}", result.errors);
}
#[test]
fn check_missing_name_now_valid() {
let config = json!({"schema": "default@1.0.0"});
let result = check_config(&config);
assert!(result.valid, "errors: {:?}", result.errors);
}
#[test]
fn parse_mem_config_name_none_when_field_absent() {
let config = json!({"schema": "default@1.0.0"});
let parsed = parse_mem_config(&config).expect("name-less config parses");
assert!(parsed.name.is_none());
}
#[test]
fn mem_config_omits_name_when_none_on_serialize() {
let cfg = MemConfig {
name: None,
version: None,
description: None,
authors: None,
schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
write_guidance: Default::default(),
rules: None,
publish: None,
language: None,
read_mems: Default::default(),
community: None,
vcs: None,
unregistered_at: None,
sync_state: Default::default(),
extra: Default::default(),
};
let json = serde_json::to_string(&cfg).unwrap();
assert!(
!json.contains("\"name\""),
"serialized config must omit `name` when None, got: {json}"
);
}
#[test]
fn check_legacy_name_field_rejected() {
for value in [json!(""), json!("@test/mem")] {
let config = json!({"name": value, "schema": "default@1.0.0"});
let result = check_config(&config);
assert!(!result.valid, "name={value}: expected reject");
assert_eq!(
result.error_code.as_deref(),
Some("LEGACY_FIELD_PRESENT"),
"name={value}: expected LEGACY_FIELD_PRESENT envelope"
);
assert!(
result.errors.iter().any(|e| e.contains("Legacy `name`")),
"name={value}: errors {:?}",
result.errors
);
}
}
#[test]
fn check_missing_schema() {
let config = json!({});
let result = check_config(&config);
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("`schema`")));
}
#[test]
fn check_legacy_types_array_rejected() {
let config = json!({"types": ["spec"], "schema": "default@1.0.0"});
let result = check_config(&config);
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("Legacy `types:")));
assert_eq!(
result.error_code.as_deref(),
Some("LEGACY_FIELD_PRESENT"),
"expected LEGACY_FIELD_PRESENT envelope for legacy `types`"
);
}
#[test]
fn check_schema_wrong_shape() {
let config = json!({"schema": ["default@1.0.0"]});
let result = check_config(&config);
assert!(!result.valid);
}
#[test]
fn check_schema_bare_name_rejected() {
let config = json!({"schema": "default"});
let result = check_config(&config);
assert!(!result.valid, "expected bare-name pin to be rejected");
assert!(
result.errors.iter().any(|e| e.contains("schema")),
"errors: {:?}",
result.errors
);
}
#[test]
fn check_schema_range_syntax_rejected() {
for s in [
"default@^1.0.0",
"default@~1.0.0",
"default@latest",
"default@>=1.0.0",
] {
let config = json!({"schema": s});
let result = check_config(&config);
assert!(!result.valid, "expected '{s}' to be rejected");
}
}
#[test]
fn check_schema_valid_exact_pin() {
let config = json!({"schema": "default@1.0.0"});
let result = check_config(&config);
assert!(result.valid, "errors: {:?}", result.errors);
}
#[test]
fn schema_pin_versioned_parses() {
let pin: SchemaRef = "software@1.2.3".parse().unwrap();
assert_eq!(pin.name, "software");
assert_eq!(pin.version, semver::Version::new(1, 2, 3));
assert_eq!(pin.as_display(), "software@1.2.3");
}
#[test]
fn schema_pin_bare_name_rejected() {
for bad in ["software", "Default", "foo/bar", "", " "] {
assert!(
bad.parse::<SchemaRef>().is_err(),
"expected '{bad}' to be rejected"
);
}
}
#[test]
fn schema_pin_serde_round_trip() {
let versioned: SchemaRef = serde_json::from_str(r#""software@1.0.0""#).unwrap();
assert_eq!(versioned.as_display(), "software@1.0.0");
let as_json = serde_json::to_string(&versioned).unwrap();
assert_eq!(as_json, r#""software@1.0.0""#);
}
#[test]
fn publish_rejects_missing_schema() {
let json = json!({ "version": "1.0.0" });
let config = parse_mem_config(&json).unwrap();
let err = published_config_from(&config, "demo").unwrap_err();
assert!(matches!(err, PublishConversionError::MissingSchema));
}
#[test]
fn publish_accepts_versioned_pin() {
let json = json!({
"version": "1.0.0",
"schema": "software@2.3.4"
});
let config = parse_mem_config(&json).unwrap();
let published = published_config_from(&config, "demo").expect("versioned pin publishes");
assert_eq!(published.name, "demo");
assert_eq!(published.schema.name, "software");
assert_eq!(published.schema.version, semver::Version::new(2, 3, 4));
}
#[test]
fn check_legacy_default_schema_field_is_ignored() {
let config = json!({
"schema": "default@1.0.0",
"defaultSchema": "spec"
});
let result = check_config(&config);
assert!(result.valid, "errors: {:?}", result.errors);
}
#[test]
fn config_preserves_unknown_fields_on_roundtrip() {
let raw = json!({
"schema": "default@1.0.0",
"defaultSchema": "concept"
});
let cfg: MemConfig = serde_json::from_value(raw).expect("config deserialized");
assert!(
cfg.extra.contains_key("defaultSchema"),
"legacy field should be preserved in extra: {:?}",
cfg.extra
);
let reserialized = serde_json::to_value(&cfg).expect("config reserialized");
assert_eq!(
reserialized.get("defaultSchema").and_then(|v| v.as_str()),
Some("concept"),
"round-trip should preserve the legacy field"
);
}
#[test]
fn check_unknown_keys_warned() {
let config = json!({
"schema": "default@1.0.0",
"unknownKey": "value"
});
let result = check_config(&config);
assert!(result.valid);
assert!(result.warnings.iter().any(|w| w.contains("unknownKey")));
}
#[test]
fn legacy_tombstone_does_not_double_warn() {
let config = json!({ "name": "x", "schema": "default@1.0.0" });
let result = check_config(&config);
assert!(!result.valid);
let unknown_warning = result
.warnings
.iter()
.any(|w| w.contains("Unknown config key 'name'"));
assert!(
!unknown_warning,
"legacy tombstone must not also surface as unknown-key warning: {:?}",
result.warnings
);
}
#[test]
fn slim_config_with_only_retained_core_fields_loads() {
let raw = json!({
"schema": "default@1.0.0",
"writeGuidance": {
"style": "structured",
"audience": "agent"
},
"vcs": { "gitdir": ".git", "worktree": "." }
});
let check = check_config(&raw);
assert!(check.valid, "errors: {:?}", check.errors);
let parsed = parse_mem_config(&raw).expect("slim config parses");
assert!(parsed.name.is_none());
assert_eq!(parsed.write_guidance.len(), 2);
assert_eq!(
parsed.write_guidance.get("style").and_then(|v| v.as_str()),
Some("structured")
);
assert!(parsed.vcs.is_some());
assert!(parsed.extra.is_empty());
}
#[test]
fn legacy_projections_block_lands_in_extra_without_error() {
let raw = json!({
"schema": "default@1.0.0",
"mediums": {
"codebase": {
"type": "codebase",
"scope": { "tree": [{ "path": "src/", "mode": "allow" }] }
}
},
"projections": {
"p1": {
"intent": "test",
"sources": [{ "medium_ref": "codebase" }],
"destination": { "medium_ref": "graph" }
}
}
});
let check = check_config(&raw);
assert!(
check.valid,
"legacy projections/mediums must load without errors: {:?}",
check.errors
);
let projection_warned = check.warnings.iter().any(|w| w.contains("projections"));
let mediums_warned = check.warnings.iter().any(|w| w.contains("mediums"));
assert!(
projection_warned && mediums_warned,
"unknown-key warnings expected for projections and mediums: {:?}",
check.warnings
);
let parsed = parse_mem_config(&raw).expect("legacy config parses");
assert!(
parsed.extra.contains_key("projections"),
"legacy `projections` must land in extra: {:?}",
parsed.extra.keys().collect::<Vec<_>>()
);
assert!(
parsed.extra.contains_key("mediums"),
"legacy `mediums` must land in extra: {:?}",
parsed.extra.keys().collect::<Vec<_>>()
);
}
#[test]
fn write_guidance_round_trips_as_string_map() {
let raw = json!({
"schema": "default@1.0.0",
"writeGuidance": {
"style": "structured",
"patterns": ["extract", "summarise"],
"nested": { "depth": 2, "flag": true },
"count": 42
}
});
let parsed = parse_mem_config(&raw).expect("config parses");
assert_eq!(parsed.write_guidance.len(), 4);
assert_eq!(
parsed.write_guidance.get("style").and_then(|v| v.as_str()),
Some("structured")
);
let wire = serde_json::to_value(&parsed).expect("reserialize");
let guidance = wire
.get("writeGuidance")
.and_then(|v| v.as_object())
.expect("writeGuidance present in wire form");
assert_eq!(guidance.len(), 4);
assert_eq!(
guidance.get("style").and_then(|v| v.as_str()),
Some("structured")
);
assert_eq!(
guidance
.get("patterns")
.and_then(|v| v.as_array())
.map(|a| a.len()),
Some(2)
);
assert_eq!(
guidance
.get("nested")
.and_then(|v| v.get("depth"))
.and_then(|v| v.as_u64()),
Some(2)
);
}
#[test]
fn write_guidance_empty_map_omits_from_wire() {
let parsed: MemConfig = serde_json::from_value(minimal_valid_config()).unwrap();
assert!(parsed.write_guidance.is_empty());
let wire = serde_json::to_value(&parsed).unwrap();
assert!(
wire.get("writeGuidance").is_none(),
"empty writeGuidance must be omitted from the wire: {wire}"
);
}
#[test]
fn published_config_strips_extra_and_write_guidance() {
let mut extra = HashMap::new();
extra.insert(
"projections".to_string(),
json!({ "p1": { "intent": "x" } }),
);
let mut guidance = HashMap::new();
guidance.insert("style".to_string(), json!("structured"));
let mut sync_state = BTreeMap::new();
sync_state.insert(
"engine-graph/source-files".to_string(),
"deadbeef".to_string(),
);
let cfg = MemConfig {
name: Some("demo".to_string()),
version: Some(semver::Version::new(0, 1, 0)),
description: None,
authors: None,
schema: Some("default@1.0.0".parse().unwrap()),
write_guidance: guidance,
rules: None,
publish: None,
language: None,
read_mems: BTreeMap::new(),
community: None,
vcs: None,
unregistered_at: None,
sync_state,
extra,
};
let published = published_config_from(&cfg, "").expect("publish projection");
let wire = serde_json::to_value(&published).expect("serialize");
assert!(
wire.get("projections").is_none(),
"extra must not leak into published wire: {wire}"
);
assert!(
wire.get("writeGuidance").is_none(),
"writeGuidance must not leak into published wire: {wire}"
);
assert!(
wire.get("syncState").is_none(),
"syncState must not leak into published wire: {wire}"
);
}
#[test]
fn update_config_field_protected() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.json");
let mut config = minimal_valid_config();
let err =
update_config_field(&config_path, &mut config, "name", json!("new"), true).unwrap_err();
assert!(err.to_string().contains("protected"));
}
#[test]
fn update_config_field_unknown() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.json");
let mut config = minimal_valid_config();
let err = update_config_field(&config_path, &mut config, "banana", json!("yellow"), true)
.unwrap_err();
assert!(err.to_string().contains("not a recognized"));
}
#[test]
fn update_config_field_allowed() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("config.json");
let mut config = minimal_valid_config();
let result =
update_config_field(&config_path, &mut config, "language", json!("en"), true).unwrap();
assert!(result.valid, "errors: {:?}", result.errors);
assert_eq!(config["language"], "en");
}
#[test]
fn parse_accepts_valid_semver_version() {
let cfg = json!({
"schema": "default@1.0.0",
"version": "1.2.3-beta.4"
});
let parsed = parse_mem_config(&cfg).expect("valid semver should parse");
let v = parsed.version.expect("version present");
assert_eq!(v.major, 1);
assert_eq!(v.minor, 2);
assert_eq!(v.patch, 3);
assert!(!v.pre.is_empty());
}
#[test]
fn parse_rejects_invalid_semver_version() {
let cfg = json!({
"schema": "default@1.0.0",
"version": "1.2"
});
let err = parse_mem_config(&cfg).expect_err("invalid semver must fail at parse");
let msg = format!("{err}");
assert!(
msg.contains("version"),
"error should mention version: {msg}"
);
}
#[test]
fn parse_rejects_non_semver_garbage_version() {
let cfg = json!({
"schema": "default@1.0.0",
"version": "potato"
});
let err = parse_mem_config(&cfg).expect_err("garbage must fail at parse");
let msg = format!("{err}");
assert!(
msg.contains("version"),
"error should mention version: {msg}"
);
}
#[test]
fn parse_accepts_read_mems_with_local_source() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": {
"internal-notes": { "source": { "type": "local" } }
}
});
let check = check_config(&cfg);
assert!(check.valid, "errors: {:?}", check.errors);
let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
let spec = parsed
.read_mems
.get("internal-notes")
.expect("entry present");
assert!(matches!(spec.source, ReadMemSource::Local));
}
#[test]
fn parse_accepts_read_mems_with_url_source() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": {
"aws-patterns": {
"source": {
"type": "url",
"url": "https://example.com/aws-patterns.mem"
}
}
}
});
let check = check_config(&cfg);
assert!(check.valid, "errors: {:?}", check.errors);
let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
let spec = parsed.read_mems.get("aws-patterns").expect("entry present");
match &spec.source {
ReadMemSource::Url { url } => {
assert_eq!(url, "https://example.com/aws-patterns.mem")
}
_ => panic!("expected Url source, got {:?}", spec.source),
}
}
#[test]
fn parse_accepts_empty_read_mems_map() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": {}
});
let parsed = parse_mem_config(&cfg).expect("empty readMems must parse");
assert!(parsed.read_mems.is_empty());
}
#[test]
fn parse_accepts_omitted_read_mems() {
let cfg = json!({
"schema": "default@1.0.0"
});
let parsed = parse_mem_config(&cfg).expect("omitted readMems must parse");
assert!(parsed.read_mems.is_empty());
}
#[test]
fn check_rejects_read_mem_without_source() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": { "p": {} }
});
let check = check_config(&cfg);
assert!(!check.valid);
assert!(
check.errors.iter().any(|e| e.contains("source")),
"errors: {:?}",
check.errors
);
}
#[test]
fn check_rejects_read_mem_with_unknown_source_type() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": {
"p": { "source": { "type": "ftp", "url": "ftp://..." } }
}
});
let check = check_config(&cfg);
assert!(!check.valid);
assert!(
check
.errors
.iter()
.any(|e| e.contains("unknown source type")),
"errors: {:?}",
check.errors
);
}
#[test]
fn check_rejects_url_source_with_empty_url() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": {
"p": { "source": { "type": "url", "url": "" } }
}
});
let check = check_config(&cfg);
assert!(!check.valid);
assert!(
check.errors.iter().any(|e| e.contains("url source")),
"errors: {:?}",
check.errors
);
}
#[test]
fn check_rejects_registry_source_type_reserved_for_phase_d() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": {
"p": { "source": { "type": "registry" } }
}
});
let check = check_config(&cfg);
assert!(!check.valid);
assert!(
check
.errors
.iter()
.any(|e| e.contains("unknown source type")),
"errors: {:?}",
check.errors
);
}
#[test]
fn read_mems_serialization_order_is_key_sorted() {
let cfg = json!({
"schema": "default@1.0.0",
"readMems": {
"zebra": { "source": { "type": "local" } },
"alpha": { "source": { "type": "local" } },
"mango": { "source": { "type": "local" } }
}
});
let parsed = parse_mem_config(&cfg).expect("valid config must parse");
let reserialized = serde_json::to_string(&parsed).expect("serialization must succeed");
let alpha = reserialized.find("alpha").expect("alpha present");
let mango = reserialized.find("mango").expect("mango present");
let zebra = reserialized.find("zebra").expect("zebra present");
assert!(
alpha < mango && mango < zebra,
"expected alpha < mango < zebra, got: {reserialized}"
);
}
#[test]
fn vcs_config_round_trips_through_serde_with_both_fields() {
let cfg = json!({
"schema": "default@1.0.0",
"vcs": { "gitdir": "../.git", "worktree": ".." }
});
let parsed = parse_mem_config(&cfg).expect("valid config must parse");
let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
assert_eq!(vcs.gitdir, "../.git");
assert_eq!(vcs.worktree, "..");
let reserialized = serde_json::to_value(&parsed).unwrap();
let round = parse_mem_config(&reserialized).expect("round-trip parse");
assert_eq!(round.vcs.as_ref().unwrap().gitdir, "../.git");
assert_eq!(round.vcs.as_ref().unwrap().worktree, "..");
}
#[test]
fn vcs_config_worktree_defaults_to_dot_when_omitted() {
let cfg = json!({
"schema": "default@1.0.0",
"vcs": { "gitdir": ".git" }
});
let parsed = parse_mem_config(&cfg).expect("valid config must parse");
let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
assert_eq!(vcs.gitdir, ".git");
assert_eq!(vcs.worktree, ".", "worktree must default to \".\"");
}
#[test]
fn vcs_config_absent_is_none() {
let cfg = json!({ "schema": "default@1.0.0" });
let parsed = parse_mem_config(&cfg).expect("valid config must parse");
assert!(parsed.vcs.is_none(), "missing vcs must deserialize to None");
}
#[test]
fn vcs_field_tolerates_legacy_string_value() {
let cfg = json!({
"schema": "default@1.0.0",
"vcs": "system"
});
let parsed = parse_mem_config(&cfg).expect("legacy vcs string must parse");
assert!(
parsed.vcs.is_none(),
"legacy string must deserialize to None"
);
}
#[test]
fn published_config_strips_vcs() {
let mut cfg = MemConfig {
name: Some("demo".to_string()),
version: Some(semver::Version::new(0, 1, 0)),
description: None,
authors: None,
schema: Some("default@1.0.0".parse().unwrap()),
write_guidance: HashMap::new(),
rules: None,
publish: None,
language: None,
read_mems: BTreeMap::new(),
community: None,
vcs: None,
unregistered_at: None,
sync_state: BTreeMap::new(),
extra: HashMap::new(),
};
cfg.vcs = Some(VcsConfig {
gitdir: ".git".to_string(),
worktree: ".".to_string(),
});
let published = published_config_from(&cfg, "").expect("valid projection");
let wire = serde_json::to_value(&published).expect("serialize");
assert!(
wire.get("vcs").is_none(),
"published wire form must not carry vcs: got {wire}"
);
}
#[test]
fn belongs_to_field_is_legacy_tombstone() {
let cfg = json!({
"schema": "default@1.0.0",
"belongsTo": ["main"]
});
let result = check_config(&cfg);
assert!(!result.valid, "belongsTo presence must fail validation");
assert_eq!(result.error_code.as_deref(), Some("LEGACY_FIELD_PRESENT"));
assert!(
result
.errors
.iter()
.any(|e| e.contains("belongsTo") && e.contains("cross_mem_links")),
"tombstone error must name the field and the replacement section: {:?}",
result.errors
);
}
}