1use std::collections::{BTreeMap, HashMap};
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19pub const MEM_META_DIR: &str = ".memstead";
29
30pub const ARCHIVE_META_DIR: &str = ".memstead";
44pub const ARCHIVE_CONFIG_PATH: &str = ".memstead/config.json";
46pub const ARCHIVE_SCHEMA_PREFIX: &str = ".memstead/schema/";
49pub const ARCHIVE_PROVENANCE_PATH: &str = ".memstead/provenance.json";
54pub const ARCHIVE_ANCHORS_PATH: &str = ".memstead/anchors.json";
62pub const ARCHIVE_EXTENSION: &str = "mem";
66
67#[derive(Debug, thiserror::Error)]
72pub enum ConfigError {
73 #[error("config file not found: {0}")]
74 NotFound(String),
75 #[error("invalid JSON in config file: {0}")]
76 InvalidJson(String),
77 #[error("config validation failed:\n{}", .0.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n"))]
78 ValidationFailed(Vec<String>),
79 #[error("{0}")]
80 Other(String),
81 #[error("io error: {0}")]
82 Io(#[from] std::io::Error),
83 #[error("json error: {0}")]
84 Json(#[from] serde_json::Error),
85}
86
87#[derive(Debug, Clone)]
93pub struct ConfigCheckResult {
94 pub valid: bool,
95 pub errors: Vec<String>,
96 pub warnings: Vec<String>,
97 pub error_code: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RoleConfig {
111 pub include: Vec<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub exclude: Option<Vec<String>>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct PublishConfig {
119 pub roles: HashMap<String, RoleConfig>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct CommunityOverride {
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub resolution: Option<f64>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub seed: Option<u32>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ReadMemSpec {
143 pub source: ReadMemSource,
144 #[serde(default, skip_serializing_if = "Option::is_none", rename = "cacheKey")]
152 pub cache_key: Option<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
161#[serde(tag = "type", rename_all = "camelCase")]
162pub enum ReadMemSource {
163 Local,
166 Url { url: String },
169 }
175
176#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct SchemaRef {
183 pub name: String,
184 pub version: semver::Version,
185}
186
187impl SchemaRef {
188 pub fn new(name: impl Into<String>, version: semver::Version) -> Self {
189 Self {
190 name: name.into(),
191 version,
192 }
193 }
194
195 pub fn as_display(&self) -> String {
196 format!("{}@{}", self.name, self.version)
197 }
198}
199
200impl std::fmt::Display for SchemaRef {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 write!(f, "{}@{}", self.name, self.version)
203 }
204}
205
206impl std::str::FromStr for SchemaRef {
207 type Err = String;
208
209 fn from_str(s: &str) -> Result<Self, Self::Err> {
210 let trimmed = s.trim();
211 if trimmed.is_empty() {
212 return Err("schema reference must not be empty (expected \"name@x.y.z\")".into());
213 }
214 let (name, version_str) = trimmed.split_once('@').ok_or_else(|| {
215 format!(
216 "schema reference '{trimmed}' must include an exact version — expected \"name@x.y.z\""
217 )
218 })?;
219 if name.is_empty() {
220 return Err("schema reference name must not be empty".into());
221 }
222 if version_str == "latest" {
223 return Err(format!(
224 "schema reference '{trimmed}' uses 'latest' — exact semver versions only"
225 ));
226 }
227 if version_str.starts_with(['^', '~', '>', '<', '=', '*']) {
228 return Err(format!(
229 "schema reference '{trimmed}' uses range syntax — exact semver only (e.g. 'default@1.0.0')"
230 ));
231 }
232 let version = semver::Version::parse(version_str).map_err(|e| {
233 format!("schema reference '{trimmed}' has invalid semver version '{version_str}': {e}")
234 })?;
235 Ok(Self {
236 name: name.to_string(),
237 version,
238 })
239 }
240}
241
242impl Serialize for SchemaRef {
243 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
244 serializer.serialize_str(&self.as_display())
245 }
246}
247
248impl<'de> Deserialize<'de> for SchemaRef {
249 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
250 let s = String::deserialize(deserializer)?;
251 s.parse::<SchemaRef>().map_err(serde::de::Error::custom)
252 }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
270#[serde(rename_all = "camelCase")]
271pub struct VcsConfig {
272 pub gitdir: String,
275 #[serde(default = "vcs_worktree_default")]
278 pub worktree: String,
279}
280
281fn vcs_worktree_default() -> String {
282 ".".to_string()
283}
284
285fn deserialize_vcs_tolerant<'de, D>(deserializer: D) -> Result<Option<VcsConfig>, D::Error>
295where
296 D: serde::Deserializer<'de>,
297{
298 let value = Option::<Value>::deserialize(deserializer)?;
299 match value {
300 None | Some(Value::Null) => Ok(None),
301 Some(Value::Object(_)) => {
302 let v = value.unwrap();
303 Ok(Some(
304 serde_json::from_value(v).map_err(serde::de::Error::custom)?,
305 ))
306 }
307 Some(_) => Ok(None),
308 }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct MemConfig {
315 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub name: Option<String>,
324
325 #[serde(skip_serializing_if = "Option::is_none")]
332 pub version: Option<semver::Version>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
337 pub description: Option<String>,
338
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub authors: Option<Vec<String>>,
342
343 #[serde(skip_serializing_if = "Option::is_none")]
349 pub schema: Option<SchemaRef>,
350
351 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
362 pub write_guidance: HashMap<String, Value>,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 pub rules: Option<Value>,
365
366 #[serde(skip_serializing_if = "Option::is_none")]
367 pub publish: Option<PublishConfig>,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub language: Option<String>,
370 #[serde(
382 rename = "readMems",
383 default,
384 skip_serializing_if = "BTreeMap::is_empty"
385 )]
386 pub read_mems: BTreeMap<String, ReadMemSpec>,
387 #[serde(skip_serializing_if = "Option::is_none")]
388 pub community: Option<CommunityOverride>,
389
390 #[serde(
403 default,
404 deserialize_with = "deserialize_vcs_tolerant",
405 skip_serializing_if = "Option::is_none"
406 )]
407 pub vcs: Option<VcsConfig>,
408
409 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub unregistered_at: Option<String>,
423
424 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
444 pub sync_state: BTreeMap<String, String>,
445
446 #[serde(flatten)]
455 pub extra: HashMap<String, Value>,
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
475#[serde(deny_unknown_fields, rename_all = "camelCase")]
476pub struct PublishedMemConfig {
477 pub format: u32,
478 pub name: String,
479 pub version: semver::Version,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 pub description: Option<String>,
482 #[serde(skip_serializing_if = "Option::is_none")]
483 pub authors: Option<Vec<String>>,
484 pub schema: SchemaRef,
485}
486
487pub const PUBLISHED_MEM_FORMAT: u32 = 3;
493
494#[derive(Debug, thiserror::Error)]
498pub enum PublishConversionError {
499 #[error("config.version is required for mem publish — set it in .memstead/config.json")]
500 MissingVersion,
501 #[error(
502 "config must declare `schema` (e.g. \"default@1.0.0\") — set it in .memstead/config.json"
503 )]
504 MissingSchema,
505 #[error(
506 "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)"
507 )]
508 MissingName,
509}
510
511pub fn published_config_from(
525 config: &MemConfig,
526 name: &str,
527) -> Result<PublishedMemConfig, PublishConversionError> {
528 let version = config
529 .version
530 .clone()
531 .ok_or(PublishConversionError::MissingVersion)?;
532 let schema = config
533 .schema
534 .clone()
535 .ok_or(PublishConversionError::MissingSchema)?;
536 let resolved_name = if name.is_empty() {
537 config
538 .name
539 .clone()
540 .ok_or(PublishConversionError::MissingName)?
541 } else {
542 name.to_string()
543 };
544 Ok(PublishedMemConfig {
545 format: PUBLISHED_MEM_FORMAT,
546 name: resolved_name,
547 version,
548 description: config.description.clone(),
549 authors: config.authors.clone(),
550 schema,
551 })
552}
553
554const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
559 "version",
560 "description",
561 "authors",
562 "schema",
563 "writeGuidance",
564 "rules",
565 "publish",
566 "language",
567 "readMems",
568 "community",
569 "vcs",
570 "syncState",
571];
572
573const LEGACY_TOMBSTONE_KEYS: &[(&str, &str)] = &[
585 (
586 "types",
587 "Legacy `types: [...]` field detected — replace with `schema: \"<name>@<version>\"` \
588 (e.g. `\"schema\": \"default@1.0.0\"`).",
589 ),
590 (
591 "name",
592 "Legacy `name` field detected — the mem leaf folder under `__MEMSTEAD:mems/` (or the \
593 disk basename on the legacy disk path) is path-derived under the unified layout; \
594 remove the field from `.memstead/config.json`.",
595 ),
596 (
597 "belongsTo",
598 "Legacy `belongsTo` field detected — cross-mem authorization moved to the \
599 workspace-level `[cross_mem_links]` section in `.memstead/workspace.toml`. Remove the \
600 field from `.memstead/config.json` and add an entry under `[cross_mem_links]` \
601 instead.",
602 ),
603];
604
605pub fn check_config(config: &Value) -> ConfigCheckResult {
611 let mut errors = Vec::new();
612 let mut warnings = Vec::new();
613
614 let obj = match config.as_object() {
615 Some(o) => o,
616 None => {
617 errors.push("(root): config must be an object".to_string());
618 return ConfigCheckResult {
619 valid: false,
620 errors,
621 warnings,
622 error_code: None,
623 };
624 }
625 };
626
627 let mut legacy_field_hit = false;
633 for (key, message) in LEGACY_TOMBSTONE_KEYS {
634 if obj.contains_key(*key) {
635 errors.push((*message).to_string());
636 legacy_field_hit = true;
637 }
638 }
639
640 match obj.get("schema") {
643 Some(Value::String(s)) => {
644 if let Err(e) = s.parse::<SchemaRef>() {
645 errors.push(format!("schema: {e}"));
646 }
647 }
648 Some(_) => errors.push(
649 "schema: must be a string of the form \"<name>@<x.y.z>\" \
650 (exact version pin, e.g. \"default@1.0.0\")"
651 .to_string(),
652 ),
653 None => errors.push(
654 "Config must declare `schema` — exact pin of the form \
655 \"<name>@<x.y.z>\" (e.g. \"default@1.0.0\")"
656 .to_string(),
657 ),
658 }
659
660 if let Some(Value::Object(mems)) = obj.get("readMems") {
667 for (name, spec) in mems {
668 let entry_path = format!("readMems.{name}");
669
670 let spec_obj = match spec.as_object() {
671 Some(o) => o,
672 None => {
673 errors.push(format!("{entry_path}: read-mem entry must be an object"));
674 continue;
675 }
676 };
677
678 let source = match spec_obj.get("source").and_then(|v| v.as_object()) {
679 Some(s) => s,
680 None => {
681 errors.push(format!(
682 "{entry_path}.source: read-mem entry must declare a source \
683 (e.g. {{\"type\": \"local\"}} or {{\"type\": \"url\", \"url\": \"…\"}})"
684 ));
685 continue;
686 }
687 };
688
689 match source.get("type").and_then(|v| v.as_str()) {
690 Some("local") => {}
691 Some("url") => match source.get("url").and_then(|v| v.as_str()) {
692 Some(u) if !u.is_empty() => {}
693 _ => errors.push(format!(
694 "{entry_path}.source.url: url source must declare a non-empty 'url' string"
695 )),
696 },
697 Some(other) => errors.push(format!(
700 "{entry_path}.source.type: unknown source type '{other}' \
701 (expected 'local' or 'url')"
702 )),
703 None => errors.push(format!(
704 "{entry_path}.source.type: source must declare a 'type' \
705 ('local' or 'url')"
706 )),
707 }
708 }
709 }
710
711 for key in obj.keys() {
721 if KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str())
722 || LEGACY_TOMBSTONE_KEYS
723 .iter()
724 .any(|(k, _)| *k == key.as_str())
725 {
726 continue;
727 }
728 warnings.push(format!(
729 "Unknown config key '{key}' \u{2014} will be ignored"
730 ));
731 }
732
733 let error_code = if legacy_field_hit {
734 Some("LEGACY_FIELD_PRESENT".to_string())
735 } else {
736 None
737 };
738
739 ConfigCheckResult {
740 valid: errors.is_empty(),
741 errors,
742 warnings,
743 error_code,
744 }
745}
746
747pub fn load_config(mem_dir: &Path) -> Result<(Value, PathBuf), ConfigError> {
754 let config_path = mem_dir.join(MEM_META_DIR).join("config.json");
755 let raw = std::fs::read_to_string(&config_path).map_err(|e| {
756 if e.kind() == std::io::ErrorKind::NotFound {
757 ConfigError::NotFound(config_path.display().to_string())
758 } else {
759 ConfigError::Io(e)
760 }
761 })?;
762 let parsed: Value = serde_json::from_str(&raw)
763 .map_err(|_| ConfigError::InvalidJson(config_path.display().to_string()))?;
764 Ok((parsed, config_path))
765}
766
767pub fn parse_mem_config(value: &Value) -> Result<MemConfig, ConfigError> {
769 serde_json::from_value(value.clone()).map_err(|e| ConfigError::Other(e.to_string()))
770}
771
772pub fn load_and_validate(mem_dir: &Path) -> Result<MemConfig, ConfigError> {
774 let (raw, _path) = load_config(mem_dir)?;
775
776 let result = check_config(&raw);
777 if !result.valid {
778 return Err(ConfigError::ValidationFailed(result.errors));
779 }
780
781 parse_mem_config(&raw)
782}
783
784fn write_config(config_path: &Path, config: &Value) -> Result<(), ConfigError> {
790 let json = serde_json::to_string_pretty(config)? + "\n";
791 std::fs::write(config_path, json)?;
792 Ok(())
793}
794
795fn commit_config(
797 config_path: &Path,
798 config: &Value,
799 dry_run: bool,
800) -> Result<ConfigCheckResult, ConfigError> {
801 let check = check_config(config);
802 if !check.valid {
803 return Ok(check);
804 }
805 if !dry_run {
806 write_config(config_path, config)?;
807 }
808 Ok(check)
809}
810
811const ALLOWED_UPDATE_FIELDS: &[&str] = &[
821 "version",
822 "description",
823 "authors",
824 "writeGuidance",
825 "rules",
826 "readMems",
827 "schema",
828 "language",
829 "publish",
830];
831
832const PROTECTED_FIELDS: &[&str] = &["name"];
833
834pub fn update_config_field(
836 config_path: &Path,
837 config: &mut Value,
838 field: &str,
839 value: Value,
840 dry_run: bool,
841) -> Result<ConfigCheckResult, ConfigError> {
842 if PROTECTED_FIELDS.contains(&field) {
843 return Err(ConfigError::Other(format!("Field '{field}' is protected")));
844 }
845
846 let obj = config
847 .as_object_mut()
848 .ok_or_else(|| ConfigError::Other("config must be an object".into()))?;
849
850 if !ALLOWED_UPDATE_FIELDS.contains(&field) {
851 return Err(ConfigError::Other(format!(
852 "Field '{field}' is not a recognized config field. Allowed: {}",
853 ALLOWED_UPDATE_FIELDS.join(", ")
854 )));
855 }
856
857 obj.insert(field.to_string(), value);
858 commit_config(config_path, config, dry_run)
859}
860
861#[cfg(test)]
866mod tests {
867 use super::*;
868 use serde_json::json;
869
870 fn minimal_valid_config() -> Value {
873 json!({
874 "schema": "default@1.0.0"
875 })
876 }
877
878 #[test]
879 fn check_valid_minimal_config() {
880 let result = check_config(&minimal_valid_config());
881 assert!(result.valid, "errors: {:?}", result.errors);
882 }
883
884 #[test]
889 fn check_missing_name_now_valid() {
890 let config = json!({"schema": "default@1.0.0"});
891 let result = check_config(&config);
892 assert!(result.valid, "errors: {:?}", result.errors);
893 }
894
895 #[test]
899 fn parse_mem_config_name_none_when_field_absent() {
900 let config = json!({"schema": "default@1.0.0"});
901 let parsed = parse_mem_config(&config).expect("name-less config parses");
902 assert!(parsed.name.is_none());
903 }
904
905 #[test]
909 fn mem_config_omits_name_when_none_on_serialize() {
910 let cfg = MemConfig {
911 name: None,
912 version: None,
913 description: None,
914 authors: None,
915 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
916 write_guidance: Default::default(),
917 rules: None,
918 publish: None,
919 language: None,
920 read_mems: Default::default(),
921 community: None,
922 vcs: None,
923 unregistered_at: None,
924 sync_state: Default::default(),
925 extra: Default::default(),
926 };
927 let json = serde_json::to_string(&cfg).unwrap();
928 assert!(
929 !json.contains("\"name\""),
930 "serialized config must omit `name` when None, got: {json}"
931 );
932 }
933
934 #[test]
938 fn check_legacy_name_field_rejected() {
939 for value in [json!(""), json!("@test/mem")] {
940 let config = json!({"name": value, "schema": "default@1.0.0"});
941 let result = check_config(&config);
942 assert!(!result.valid, "name={value}: expected reject");
943 assert_eq!(
944 result.error_code.as_deref(),
945 Some("LEGACY_FIELD_PRESENT"),
946 "name={value}: expected LEGACY_FIELD_PRESENT envelope"
947 );
948 assert!(
949 result.errors.iter().any(|e| e.contains("Legacy `name`")),
950 "name={value}: errors {:?}",
951 result.errors
952 );
953 }
954 }
955
956 #[test]
957 fn check_missing_schema() {
958 let config = json!({});
959 let result = check_config(&config);
960 assert!(!result.valid);
961 assert!(result.errors.iter().any(|e| e.contains("`schema`")));
962 }
963
964 #[test]
965 fn check_legacy_types_array_rejected() {
966 let config = json!({"types": ["spec"], "schema": "default@1.0.0"});
967 let result = check_config(&config);
968 assert!(!result.valid);
969 assert!(result.errors.iter().any(|e| e.contains("Legacy `types:")));
970 assert_eq!(
971 result.error_code.as_deref(),
972 Some("LEGACY_FIELD_PRESENT"),
973 "expected LEGACY_FIELD_PRESENT envelope for legacy `types`"
974 );
975 }
976
977 #[test]
978 fn check_schema_wrong_shape() {
979 let config = json!({"schema": ["default@1.0.0"]});
980 let result = check_config(&config);
981 assert!(!result.valid);
982 }
983
984 #[test]
985 fn check_schema_bare_name_rejected() {
986 let config = json!({"schema": "default"});
990 let result = check_config(&config);
991 assert!(!result.valid, "expected bare-name pin to be rejected");
992 assert!(
993 result.errors.iter().any(|e| e.contains("schema")),
994 "errors: {:?}",
995 result.errors
996 );
997 }
998
999 #[test]
1000 fn check_schema_range_syntax_rejected() {
1001 for s in [
1002 "default@^1.0.0",
1003 "default@~1.0.0",
1004 "default@latest",
1005 "default@>=1.0.0",
1006 ] {
1007 let config = json!({"schema": s});
1008 let result = check_config(&config);
1009 assert!(!result.valid, "expected '{s}' to be rejected");
1010 }
1011 }
1012
1013 #[test]
1014 fn check_schema_valid_exact_pin() {
1015 let config = json!({"schema": "default@1.0.0"});
1016 let result = check_config(&config);
1017 assert!(result.valid, "errors: {:?}", result.errors);
1018 }
1019
1020 #[test]
1021 fn schema_pin_versioned_parses() {
1022 let pin: SchemaRef = "software@1.2.3".parse().unwrap();
1023 assert_eq!(pin.name, "software");
1024 assert_eq!(pin.version, semver::Version::new(1, 2, 3));
1025 assert_eq!(pin.as_display(), "software@1.2.3");
1026 }
1027
1028 #[test]
1029 fn schema_pin_bare_name_rejected() {
1030 for bad in ["software", "Default", "foo/bar", "", " "] {
1034 assert!(
1035 bad.parse::<SchemaRef>().is_err(),
1036 "expected '{bad}' to be rejected"
1037 );
1038 }
1039 }
1040
1041 #[test]
1042 fn schema_pin_serde_round_trip() {
1043 let versioned: SchemaRef = serde_json::from_str(r#""software@1.0.0""#).unwrap();
1044 assert_eq!(versioned.as_display(), "software@1.0.0");
1045 let as_json = serde_json::to_string(&versioned).unwrap();
1046 assert_eq!(as_json, r#""software@1.0.0""#);
1047 }
1048
1049 #[test]
1050 fn publish_rejects_missing_schema() {
1051 let json = json!({ "version": "1.0.0" });
1054 let config = parse_mem_config(&json).unwrap();
1055 let err = published_config_from(&config, "demo").unwrap_err();
1056 assert!(matches!(err, PublishConversionError::MissingSchema));
1057 }
1058
1059 #[test]
1060 fn publish_accepts_versioned_pin() {
1061 let json = json!({
1062 "version": "1.0.0",
1063 "schema": "software@2.3.4"
1064 });
1065 let config = parse_mem_config(&json).unwrap();
1066 let published = published_config_from(&config, "demo").expect("versioned pin publishes");
1067 assert_eq!(published.name, "demo");
1068 assert_eq!(published.schema.name, "software");
1069 assert_eq!(published.schema.version, semver::Version::new(2, 3, 4));
1070 }
1071
1072 #[test]
1073 fn check_legacy_default_schema_field_is_ignored() {
1074 let config = json!({
1078 "schema": "default@1.0.0",
1079 "defaultSchema": "spec"
1080 });
1081 let result = check_config(&config);
1082 assert!(result.valid, "errors: {:?}", result.errors);
1083 }
1084
1085 #[test]
1086 fn config_preserves_unknown_fields_on_roundtrip() {
1087 let raw = json!({
1091 "schema": "default@1.0.0",
1092 "defaultSchema": "concept"
1093 });
1094 let cfg: MemConfig = serde_json::from_value(raw).expect("config deserialized");
1095 assert!(
1096 cfg.extra.contains_key("defaultSchema"),
1097 "legacy field should be preserved in extra: {:?}",
1098 cfg.extra
1099 );
1100
1101 let reserialized = serde_json::to_value(&cfg).expect("config reserialized");
1102 assert_eq!(
1103 reserialized.get("defaultSchema").and_then(|v| v.as_str()),
1104 Some("concept"),
1105 "round-trip should preserve the legacy field"
1106 );
1107 }
1108
1109 #[test]
1110 fn check_unknown_keys_warned() {
1111 let config = json!({
1112 "schema": "default@1.0.0",
1113 "unknownKey": "value"
1114 });
1115 let result = check_config(&config);
1116 assert!(result.valid);
1117 assert!(result.warnings.iter().any(|w| w.contains("unknownKey")));
1118 }
1119
1120 #[test]
1124 fn legacy_tombstone_does_not_double_warn() {
1125 let config = json!({ "name": "x", "schema": "default@1.0.0" });
1126 let result = check_config(&config);
1127 assert!(!result.valid);
1128 let unknown_warning = result
1129 .warnings
1130 .iter()
1131 .any(|w| w.contains("Unknown config key 'name'"));
1132 assert!(
1133 !unknown_warning,
1134 "legacy tombstone must not also surface as unknown-key warning: {:?}",
1135 result.warnings
1136 );
1137 }
1138
1139 #[test]
1142 fn slim_config_with_only_retained_core_fields_loads() {
1143 let raw = json!({
1151 "schema": "default@1.0.0",
1152 "writeGuidance": {
1153 "style": "structured",
1154 "audience": "agent"
1155 },
1156 "vcs": { "gitdir": ".git", "worktree": "." }
1157 });
1158 let check = check_config(&raw);
1159 assert!(check.valid, "errors: {:?}", check.errors);
1160 let parsed = parse_mem_config(&raw).expect("slim config parses");
1161 assert!(parsed.name.is_none());
1162 assert_eq!(parsed.write_guidance.len(), 2);
1163 assert_eq!(
1164 parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1165 Some("structured")
1166 );
1167 assert!(parsed.vcs.is_some());
1168 assert!(parsed.extra.is_empty());
1169 }
1170
1171 #[test]
1172 fn legacy_projections_block_lands_in_extra_without_error() {
1173 let raw = json!({
1179 "schema": "default@1.0.0",
1180 "mediums": {
1181 "codebase": {
1182 "type": "codebase",
1183 "scope": { "tree": [{ "path": "src/", "mode": "allow" }] }
1184 }
1185 },
1186 "projections": {
1187 "p1": {
1188 "intent": "test",
1189 "sources": [{ "medium_ref": "codebase" }],
1190 "destination": { "medium_ref": "graph" }
1191 }
1192 }
1193 });
1194 let check = check_config(&raw);
1195 assert!(
1196 check.valid,
1197 "legacy projections/mediums must load without errors: {:?}",
1198 check.errors
1199 );
1200 let projection_warned = check.warnings.iter().any(|w| w.contains("projections"));
1201 let mediums_warned = check.warnings.iter().any(|w| w.contains("mediums"));
1202 assert!(
1203 projection_warned && mediums_warned,
1204 "unknown-key warnings expected for projections and mediums: {:?}",
1205 check.warnings
1206 );
1207
1208 let parsed = parse_mem_config(&raw).expect("legacy config parses");
1209 assert!(
1210 parsed.extra.contains_key("projections"),
1211 "legacy `projections` must land in extra: {:?}",
1212 parsed.extra.keys().collect::<Vec<_>>()
1213 );
1214 assert!(
1215 parsed.extra.contains_key("mediums"),
1216 "legacy `mediums` must land in extra: {:?}",
1217 parsed.extra.keys().collect::<Vec<_>>()
1218 );
1219 }
1220
1221 #[test]
1222 fn write_guidance_round_trips_as_string_map() {
1223 let raw = json!({
1228 "schema": "default@1.0.0",
1229 "writeGuidance": {
1230 "style": "structured",
1231 "patterns": ["extract", "summarise"],
1232 "nested": { "depth": 2, "flag": true },
1233 "count": 42
1234 }
1235 });
1236 let parsed = parse_mem_config(&raw).expect("config parses");
1237 assert_eq!(parsed.write_guidance.len(), 4);
1238 assert_eq!(
1239 parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1240 Some("structured")
1241 );
1242 let wire = serde_json::to_value(&parsed).expect("reserialize");
1243 let guidance = wire
1244 .get("writeGuidance")
1245 .and_then(|v| v.as_object())
1246 .expect("writeGuidance present in wire form");
1247 assert_eq!(guidance.len(), 4);
1248 assert_eq!(
1249 guidance.get("style").and_then(|v| v.as_str()),
1250 Some("structured")
1251 );
1252 assert_eq!(
1253 guidance
1254 .get("patterns")
1255 .and_then(|v| v.as_array())
1256 .map(|a| a.len()),
1257 Some(2)
1258 );
1259 assert_eq!(
1260 guidance
1261 .get("nested")
1262 .and_then(|v| v.get("depth"))
1263 .and_then(|v| v.as_u64()),
1264 Some(2)
1265 );
1266 }
1267
1268 #[test]
1269 fn write_guidance_empty_map_omits_from_wire() {
1270 let parsed: MemConfig = serde_json::from_value(minimal_valid_config()).unwrap();
1274 assert!(parsed.write_guidance.is_empty());
1275 let wire = serde_json::to_value(&parsed).unwrap();
1276 assert!(
1277 wire.get("writeGuidance").is_none(),
1278 "empty writeGuidance must be omitted from the wire: {wire}"
1279 );
1280 }
1281
1282 #[test]
1283 fn published_config_strips_extra_and_write_guidance() {
1284 let mut extra = HashMap::new();
1290 extra.insert(
1291 "projections".to_string(),
1292 json!({ "p1": { "intent": "x" } }),
1293 );
1294 let mut guidance = HashMap::new();
1295 guidance.insert("style".to_string(), json!("structured"));
1296 let mut sync_state = BTreeMap::new();
1297 sync_state.insert(
1298 "engine-graph/source-files".to_string(),
1299 "deadbeef".to_string(),
1300 );
1301 let cfg = MemConfig {
1302 name: Some("demo".to_string()),
1303 version: Some(semver::Version::new(0, 1, 0)),
1304 description: None,
1305 authors: None,
1306 schema: Some("default@1.0.0".parse().unwrap()),
1307 write_guidance: guidance,
1308 rules: None,
1309 publish: None,
1310 language: None,
1311 read_mems: BTreeMap::new(),
1312 community: None,
1313 vcs: None,
1314 unregistered_at: None,
1315 sync_state,
1316 extra,
1317 };
1318 let published = published_config_from(&cfg, "").expect("publish projection");
1319 let wire = serde_json::to_value(&published).expect("serialize");
1320 assert!(
1321 wire.get("projections").is_none(),
1322 "extra must not leak into published wire: {wire}"
1323 );
1324 assert!(
1325 wire.get("writeGuidance").is_none(),
1326 "writeGuidance must not leak into published wire: {wire}"
1327 );
1328 assert!(
1329 wire.get("syncState").is_none(),
1330 "syncState must not leak into published wire: {wire}"
1331 );
1332 }
1333
1334 #[test]
1345 fn update_config_field_protected() {
1346 let tmp = tempfile::tempdir().unwrap();
1347 let config_path = tmp.path().join("config.json");
1348
1349 let mut config = minimal_valid_config();
1350 let err =
1351 update_config_field(&config_path, &mut config, "name", json!("new"), true).unwrap_err();
1352 assert!(err.to_string().contains("protected"));
1353 }
1354
1355 #[test]
1356 fn update_config_field_unknown() {
1357 let tmp = tempfile::tempdir().unwrap();
1358 let config_path = tmp.path().join("config.json");
1359
1360 let mut config = minimal_valid_config();
1361 let err = update_config_field(&config_path, &mut config, "banana", json!("yellow"), true)
1362 .unwrap_err();
1363 assert!(err.to_string().contains("not a recognized"));
1364 }
1365
1366 #[test]
1367 fn update_config_field_allowed() {
1368 let tmp = tempfile::tempdir().unwrap();
1369 let config_path = tmp.path().join("config.json");
1370
1371 let mut config = minimal_valid_config();
1372 let result =
1373 update_config_field(&config_path, &mut config, "language", json!("en"), true).unwrap();
1374 assert!(result.valid, "errors: {:?}", result.errors);
1375 assert_eq!(config["language"], "en");
1376 }
1377
1378 #[test]
1385 fn parse_accepts_valid_semver_version() {
1386 let cfg = json!({
1387 "schema": "default@1.0.0",
1388 "version": "1.2.3-beta.4"
1389 });
1390 let parsed = parse_mem_config(&cfg).expect("valid semver should parse");
1391 let v = parsed.version.expect("version present");
1392 assert_eq!(v.major, 1);
1393 assert_eq!(v.minor, 2);
1394 assert_eq!(v.patch, 3);
1395 assert!(!v.pre.is_empty());
1396 }
1397
1398 #[test]
1399 fn parse_rejects_invalid_semver_version() {
1400 let cfg = json!({
1402 "schema": "default@1.0.0",
1403 "version": "1.2"
1404 });
1405 let err = parse_mem_config(&cfg).expect_err("invalid semver must fail at parse");
1406 let msg = format!("{err}");
1407 assert!(
1408 msg.contains("version"),
1409 "error should mention version: {msg}"
1410 );
1411 }
1412
1413 #[test]
1414 fn parse_rejects_non_semver_garbage_version() {
1415 let cfg = json!({
1416 "schema": "default@1.0.0",
1417 "version": "potato"
1418 });
1419 let err = parse_mem_config(&cfg).expect_err("garbage must fail at parse");
1420 let msg = format!("{err}");
1421 assert!(
1422 msg.contains("version"),
1423 "error should mention version: {msg}"
1424 );
1425 }
1426
1427 #[test]
1431 fn parse_accepts_read_mems_with_local_source() {
1432 let cfg = json!({
1433 "schema": "default@1.0.0",
1434 "readMems": {
1435 "internal-notes": { "source": { "type": "local" } }
1436 }
1437 });
1438 let check = check_config(&cfg);
1439 assert!(check.valid, "errors: {:?}", check.errors);
1440 let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1441 let spec = parsed
1442 .read_mems
1443 .get("internal-notes")
1444 .expect("entry present");
1445 assert!(matches!(spec.source, ReadMemSource::Local));
1446 }
1447
1448 #[test]
1449 fn parse_accepts_read_mems_with_url_source() {
1450 let cfg = json!({
1451 "schema": "default@1.0.0",
1452 "readMems": {
1453 "aws-patterns": {
1454 "source": {
1455 "type": "url",
1456 "url": "https://example.com/aws-patterns.mem"
1457 }
1458 }
1459 }
1460 });
1461 let check = check_config(&cfg);
1462 assert!(check.valid, "errors: {:?}", check.errors);
1463 let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1464 let spec = parsed.read_mems.get("aws-patterns").expect("entry present");
1465 match &spec.source {
1466 ReadMemSource::Url { url } => {
1467 assert_eq!(url, "https://example.com/aws-patterns.mem")
1468 }
1469 _ => panic!("expected Url source, got {:?}", spec.source),
1470 }
1471 }
1472
1473 #[test]
1474 fn parse_accepts_empty_read_mems_map() {
1475 let cfg = json!({
1476 "schema": "default@1.0.0",
1477 "readMems": {}
1478 });
1479 let parsed = parse_mem_config(&cfg).expect("empty readMems must parse");
1480 assert!(parsed.read_mems.is_empty());
1481 }
1482
1483 #[test]
1484 fn parse_accepts_omitted_read_mems() {
1485 let cfg = json!({
1486 "schema": "default@1.0.0"
1487 });
1488 let parsed = parse_mem_config(&cfg).expect("omitted readMems must parse");
1489 assert!(parsed.read_mems.is_empty());
1490 }
1491
1492 #[test]
1493 fn check_rejects_read_mem_without_source() {
1494 let cfg = json!({
1495 "schema": "default@1.0.0",
1496 "readMems": { "p": {} }
1497 });
1498 let check = check_config(&cfg);
1499 assert!(!check.valid);
1500 assert!(
1501 check.errors.iter().any(|e| e.contains("source")),
1502 "errors: {:?}",
1503 check.errors
1504 );
1505 }
1506
1507 #[test]
1508 fn check_rejects_read_mem_with_unknown_source_type() {
1509 let cfg = json!({
1510 "schema": "default@1.0.0",
1511 "readMems": {
1512 "p": { "source": { "type": "ftp", "url": "ftp://..." } }
1513 }
1514 });
1515 let check = check_config(&cfg);
1516 assert!(!check.valid);
1517 assert!(
1518 check
1519 .errors
1520 .iter()
1521 .any(|e| e.contains("unknown source type")),
1522 "errors: {:?}",
1523 check.errors
1524 );
1525 }
1526
1527 #[test]
1528 fn check_rejects_url_source_with_empty_url() {
1529 let cfg = json!({
1530 "schema": "default@1.0.0",
1531 "readMems": {
1532 "p": { "source": { "type": "url", "url": "" } }
1533 }
1534 });
1535 let check = check_config(&cfg);
1536 assert!(!check.valid);
1537 assert!(
1538 check.errors.iter().any(|e| e.contains("url source")),
1539 "errors: {:?}",
1540 check.errors
1541 );
1542 }
1543
1544 #[test]
1545 fn check_rejects_registry_source_type_reserved_for_phase_d() {
1546 let cfg = json!({
1547 "schema": "default@1.0.0",
1548 "readMems": {
1549 "p": { "source": { "type": "registry" } }
1550 }
1551 });
1552 let check = check_config(&cfg);
1553 assert!(!check.valid);
1557 assert!(
1558 check
1559 .errors
1560 .iter()
1561 .any(|e| e.contains("unknown source type")),
1562 "errors: {:?}",
1563 check.errors
1564 );
1565 }
1566
1567 #[test]
1572 fn read_mems_serialization_order_is_key_sorted() {
1573 let cfg = json!({
1574 "schema": "default@1.0.0",
1575 "readMems": {
1576 "zebra": { "source": { "type": "local" } },
1577 "alpha": { "source": { "type": "local" } },
1578 "mango": { "source": { "type": "local" } }
1579 }
1580 });
1581 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1582 let reserialized = serde_json::to_string(&parsed).expect("serialization must succeed");
1583 let alpha = reserialized.find("alpha").expect("alpha present");
1584 let mango = reserialized.find("mango").expect("mango present");
1585 let zebra = reserialized.find("zebra").expect("zebra present");
1586 assert!(
1587 alpha < mango && mango < zebra,
1588 "expected alpha < mango < zebra, got: {reserialized}"
1589 );
1590 }
1591
1592 #[test]
1595 fn vcs_config_round_trips_through_serde_with_both_fields() {
1596 let cfg = json!({
1597 "schema": "default@1.0.0",
1598 "vcs": { "gitdir": "../.git", "worktree": ".." }
1599 });
1600 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1601 let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1602 assert_eq!(vcs.gitdir, "../.git");
1603 assert_eq!(vcs.worktree, "..");
1604
1605 let reserialized = serde_json::to_value(&parsed).unwrap();
1607 let round = parse_mem_config(&reserialized).expect("round-trip parse");
1608 assert_eq!(round.vcs.as_ref().unwrap().gitdir, "../.git");
1609 assert_eq!(round.vcs.as_ref().unwrap().worktree, "..");
1610 }
1611
1612 #[test]
1613 fn vcs_config_worktree_defaults_to_dot_when_omitted() {
1614 let cfg = json!({
1615 "schema": "default@1.0.0",
1616 "vcs": { "gitdir": ".git" }
1617 });
1618 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1619 let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1620 assert_eq!(vcs.gitdir, ".git");
1621 assert_eq!(vcs.worktree, ".", "worktree must default to \".\"");
1622 }
1623
1624 #[test]
1625 fn vcs_config_absent_is_none() {
1626 let cfg = json!({ "schema": "default@1.0.0" });
1627 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1628 assert!(parsed.vcs.is_none(), "missing vcs must deserialize to None");
1629 }
1630
1631 #[test]
1632 fn vcs_field_tolerates_legacy_string_value() {
1633 let cfg = json!({
1637 "schema": "default@1.0.0",
1638 "vcs": "system"
1639 });
1640 let parsed = parse_mem_config(&cfg).expect("legacy vcs string must parse");
1641 assert!(
1642 parsed.vcs.is_none(),
1643 "legacy string must deserialize to None"
1644 );
1645 }
1646
1647 #[test]
1648 fn published_config_strips_vcs() {
1649 let mut cfg = MemConfig {
1656 name: Some("demo".to_string()),
1657 version: Some(semver::Version::new(0, 1, 0)),
1658 description: None,
1659 authors: None,
1660 schema: Some("default@1.0.0".parse().unwrap()),
1661 write_guidance: HashMap::new(),
1662 rules: None,
1663 publish: None,
1664 language: None,
1665 read_mems: BTreeMap::new(),
1666 community: None,
1667 vcs: None,
1668 unregistered_at: None,
1669 sync_state: BTreeMap::new(),
1670 extra: HashMap::new(),
1671 };
1672 cfg.vcs = Some(VcsConfig {
1673 gitdir: ".git".to_string(),
1674 worktree: ".".to_string(),
1675 });
1676 let published = published_config_from(&cfg, "").expect("valid projection");
1677 let wire = serde_json::to_value(&published).expect("serialize");
1678 assert!(
1679 wire.get("vcs").is_none(),
1680 "published wire form must not carry vcs: got {wire}"
1681 );
1682 }
1683
1684 #[test]
1691 fn belongs_to_field_is_legacy_tombstone() {
1692 let cfg = json!({
1693 "schema": "default@1.0.0",
1694 "belongsTo": ["main"]
1695 });
1696 let result = check_config(&cfg);
1697 assert!(!result.valid, "belongsTo presence must fail validation");
1698 assert_eq!(result.error_code.as_deref(), Some("LEGACY_FIELD_PRESENT"));
1699 assert!(
1700 result
1701 .errors
1702 .iter()
1703 .any(|e| e.contains("belongsTo") && e.contains("cross_mem_links")),
1704 "tombstone error must name the field and the replacement section: {:?}",
1705 result.errors
1706 );
1707 }
1708}