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_EXTENSION: &str = "mem";
58
59#[derive(Debug, thiserror::Error)]
64pub enum ConfigError {
65 #[error("config file not found: {0}")]
66 NotFound(String),
67 #[error("invalid JSON in config file: {0}")]
68 InvalidJson(String),
69 #[error("config validation failed:\n{}", .0.iter().map(|e| format!(" - {e}")).collect::<Vec<_>>().join("\n"))]
70 ValidationFailed(Vec<String>),
71 #[error("{0}")]
72 Other(String),
73 #[error("io error: {0}")]
74 Io(#[from] std::io::Error),
75 #[error("json error: {0}")]
76 Json(#[from] serde_json::Error),
77}
78
79#[derive(Debug, Clone)]
85pub struct ConfigCheckResult {
86 pub valid: bool,
87 pub errors: Vec<String>,
88 pub warnings: Vec<String>,
89 pub error_code: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RoleConfig {
103 pub include: Vec<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub exclude: Option<Vec<String>>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct PublishConfig {
111 pub roles: HashMap<String, RoleConfig>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CommunityOverride {
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub resolution: Option<f64>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub seed: Option<u32>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ReadMemSpec {
135 pub source: ReadMemSource,
136 #[serde(default, skip_serializing_if = "Option::is_none", rename = "cacheKey")]
144 pub cache_key: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
153#[serde(tag = "type", rename_all = "camelCase")]
154pub enum ReadMemSource {
155 Local,
158 Url { url: String },
161 }
167
168#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct SchemaRef {
175 pub name: String,
176 pub version: semver::Version,
177}
178
179impl SchemaRef {
180 pub fn new(name: impl Into<String>, version: semver::Version) -> Self {
181 Self {
182 name: name.into(),
183 version,
184 }
185 }
186
187 pub fn as_display(&self) -> String {
188 format!("{}@{}", self.name, self.version)
189 }
190}
191
192impl std::fmt::Display for SchemaRef {
193 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194 write!(f, "{}@{}", self.name, self.version)
195 }
196}
197
198impl std::str::FromStr for SchemaRef {
199 type Err = String;
200
201 fn from_str(s: &str) -> Result<Self, Self::Err> {
202 let trimmed = s.trim();
203 if trimmed.is_empty() {
204 return Err("schema reference must not be empty (expected \"name@x.y.z\")".into());
205 }
206 let (name, version_str) = trimmed.split_once('@').ok_or_else(|| {
207 format!(
208 "schema reference '{trimmed}' must include an exact version — expected \"name@x.y.z\""
209 )
210 })?;
211 if name.is_empty() {
212 return Err("schema reference name must not be empty".into());
213 }
214 if version_str == "latest" {
215 return Err(format!(
216 "schema reference '{trimmed}' uses 'latest' — exact semver versions only"
217 ));
218 }
219 if version_str.starts_with(['^', '~', '>', '<', '=', '*']) {
220 return Err(format!(
221 "schema reference '{trimmed}' uses range syntax — exact semver only (e.g. 'default@1.0.0')"
222 ));
223 }
224 let version = semver::Version::parse(version_str).map_err(|e| {
225 format!("schema reference '{trimmed}' has invalid semver version '{version_str}': {e}")
226 })?;
227 Ok(Self {
228 name: name.to_string(),
229 version,
230 })
231 }
232}
233
234impl Serialize for SchemaRef {
235 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
236 serializer.serialize_str(&self.as_display())
237 }
238}
239
240impl<'de> Deserialize<'de> for SchemaRef {
241 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
242 let s = String::deserialize(deserializer)?;
243 s.parse::<SchemaRef>().map_err(serde::de::Error::custom)
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
262#[serde(rename_all = "camelCase")]
263pub struct VcsConfig {
264 pub gitdir: String,
267 #[serde(default = "vcs_worktree_default")]
270 pub worktree: String,
271}
272
273fn vcs_worktree_default() -> String {
274 ".".to_string()
275}
276
277fn deserialize_vcs_tolerant<'de, D>(deserializer: D) -> Result<Option<VcsConfig>, D::Error>
287where
288 D: serde::Deserializer<'de>,
289{
290 let value = Option::<Value>::deserialize(deserializer)?;
291 match value {
292 None | Some(Value::Null) => Ok(None),
293 Some(Value::Object(_)) => {
294 let v = value.unwrap();
295 Ok(Some(
296 serde_json::from_value(v).map_err(serde::de::Error::custom)?,
297 ))
298 }
299 Some(_) => Ok(None),
300 }
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub struct MemConfig {
307 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub name: Option<String>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
324 pub version: Option<semver::Version>,
325
326 #[serde(skip_serializing_if = "Option::is_none")]
329 pub description: Option<String>,
330
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub authors: Option<Vec<String>>,
334
335 #[serde(skip_serializing_if = "Option::is_none")]
341 pub schema: Option<SchemaRef>,
342
343 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
354 pub write_guidance: HashMap<String, Value>,
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub rules: Option<Value>,
357
358 #[serde(skip_serializing_if = "Option::is_none")]
359 pub publish: Option<PublishConfig>,
360 #[serde(skip_serializing_if = "Option::is_none")]
361 pub language: Option<String>,
362 #[serde(
374 rename = "readMems",
375 default,
376 skip_serializing_if = "BTreeMap::is_empty"
377 )]
378 pub read_mems: BTreeMap<String, ReadMemSpec>,
379 #[serde(skip_serializing_if = "Option::is_none")]
380 pub community: Option<CommunityOverride>,
381
382 #[serde(
395 default,
396 deserialize_with = "deserialize_vcs_tolerant",
397 skip_serializing_if = "Option::is_none"
398 )]
399 pub vcs: Option<VcsConfig>,
400
401 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub unregistered_at: Option<String>,
415
416 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
436 pub sync_state: BTreeMap<String, String>,
437
438 #[serde(flatten)]
447 pub extra: HashMap<String, Value>,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
467#[serde(deny_unknown_fields, rename_all = "camelCase")]
468pub struct PublishedMemConfig {
469 pub format: u32,
470 pub name: String,
471 pub version: semver::Version,
472 #[serde(skip_serializing_if = "Option::is_none")]
473 pub description: Option<String>,
474 #[serde(skip_serializing_if = "Option::is_none")]
475 pub authors: Option<Vec<String>>,
476 pub schema: SchemaRef,
477}
478
479pub const PUBLISHED_MEM_FORMAT: u32 = 3;
485
486#[derive(Debug, thiserror::Error)]
490pub enum PublishConversionError {
491 #[error("config.version is required for mem publish — set it in .memstead/config.json")]
492 MissingVersion,
493 #[error(
494 "config must declare `schema` (e.g. \"default@1.0.0\") — set it in .memstead/config.json"
495 )]
496 MissingSchema,
497 #[error(
498 "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)"
499 )]
500 MissingName,
501}
502
503pub fn published_config_from(
517 config: &MemConfig,
518 name: &str,
519) -> Result<PublishedMemConfig, PublishConversionError> {
520 let version = config
521 .version
522 .clone()
523 .ok_or(PublishConversionError::MissingVersion)?;
524 let schema = config
525 .schema
526 .clone()
527 .ok_or(PublishConversionError::MissingSchema)?;
528 let resolved_name = if name.is_empty() {
529 config
530 .name
531 .clone()
532 .ok_or(PublishConversionError::MissingName)?
533 } else {
534 name.to_string()
535 };
536 Ok(PublishedMemConfig {
537 format: PUBLISHED_MEM_FORMAT,
538 name: resolved_name,
539 version,
540 description: config.description.clone(),
541 authors: config.authors.clone(),
542 schema,
543 })
544}
545
546const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
551 "version",
552 "description",
553 "authors",
554 "schema",
555 "writeGuidance",
556 "rules",
557 "publish",
558 "language",
559 "readMems",
560 "community",
561 "vcs",
562 "syncState",
563];
564
565const LEGACY_TOMBSTONE_KEYS: &[(&str, &str)] = &[
577 (
578 "types",
579 "Legacy `types: [...]` field detected — replace with `schema: \"<name>@<version>\"` \
580 (e.g. `\"schema\": \"default@1.0.0\"`).",
581 ),
582 (
583 "name",
584 "Legacy `name` field detected — the mem leaf folder under `__MEMSTEAD:mems/` (or the \
585 disk basename on the legacy disk path) is path-derived under the unified layout; \
586 remove the field from `.memstead/config.json`.",
587 ),
588 (
589 "belongsTo",
590 "Legacy `belongsTo` field detected — cross-mem authorization moved to the \
591 workspace-level `[cross_mem_links]` section in `.memstead/workspace.toml`. Remove the \
592 field from `.memstead/config.json` and add an entry under `[cross_mem_links]` \
593 instead.",
594 ),
595];
596
597pub fn check_config(config: &Value) -> ConfigCheckResult {
603 let mut errors = Vec::new();
604 let mut warnings = Vec::new();
605
606 let obj = match config.as_object() {
607 Some(o) => o,
608 None => {
609 errors.push("(root): config must be an object".to_string());
610 return ConfigCheckResult {
611 valid: false,
612 errors,
613 warnings,
614 error_code: None,
615 };
616 }
617 };
618
619 let mut legacy_field_hit = false;
625 for (key, message) in LEGACY_TOMBSTONE_KEYS {
626 if obj.contains_key(*key) {
627 errors.push((*message).to_string());
628 legacy_field_hit = true;
629 }
630 }
631
632 match obj.get("schema") {
635 Some(Value::String(s)) => {
636 if let Err(e) = s.parse::<SchemaRef>() {
637 errors.push(format!("schema: {e}"));
638 }
639 }
640 Some(_) => errors.push(
641 "schema: must be a string of the form \"<name>@<x.y.z>\" \
642 (exact version pin, e.g. \"default@1.0.0\")"
643 .to_string(),
644 ),
645 None => errors.push(
646 "Config must declare `schema` — exact pin of the form \
647 \"<name>@<x.y.z>\" (e.g. \"default@1.0.0\")"
648 .to_string(),
649 ),
650 }
651
652 if let Some(Value::Object(mems)) = obj.get("readMems") {
659 for (name, spec) in mems {
660 let entry_path = format!("readMems.{name}");
661
662 let spec_obj = match spec.as_object() {
663 Some(o) => o,
664 None => {
665 errors.push(format!("{entry_path}: read-mem entry must be an object"));
666 continue;
667 }
668 };
669
670 let source = match spec_obj.get("source").and_then(|v| v.as_object()) {
671 Some(s) => s,
672 None => {
673 errors.push(format!(
674 "{entry_path}.source: read-mem entry must declare a source \
675 (e.g. {{\"type\": \"local\"}} or {{\"type\": \"url\", \"url\": \"…\"}})"
676 ));
677 continue;
678 }
679 };
680
681 match source.get("type").and_then(|v| v.as_str()) {
682 Some("local") => {}
683 Some("url") => match source.get("url").and_then(|v| v.as_str()) {
684 Some(u) if !u.is_empty() => {}
685 _ => errors.push(format!(
686 "{entry_path}.source.url: url source must declare a non-empty 'url' string"
687 )),
688 },
689 Some(other) => errors.push(format!(
692 "{entry_path}.source.type: unknown source type '{other}' \
693 (expected 'local' or 'url')"
694 )),
695 None => errors.push(format!(
696 "{entry_path}.source.type: source must declare a 'type' \
697 ('local' or 'url')"
698 )),
699 }
700 }
701 }
702
703 for key in obj.keys() {
713 if KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str())
714 || LEGACY_TOMBSTONE_KEYS
715 .iter()
716 .any(|(k, _)| *k == key.as_str())
717 {
718 continue;
719 }
720 warnings.push(format!(
721 "Unknown config key '{key}' \u{2014} will be ignored"
722 ));
723 }
724
725 let error_code = if legacy_field_hit {
726 Some("LEGACY_FIELD_PRESENT".to_string())
727 } else {
728 None
729 };
730
731 ConfigCheckResult {
732 valid: errors.is_empty(),
733 errors,
734 warnings,
735 error_code,
736 }
737}
738
739pub fn load_config(mem_dir: &Path) -> Result<(Value, PathBuf), ConfigError> {
746 let config_path = mem_dir.join(MEM_META_DIR).join("config.json");
747 let raw = std::fs::read_to_string(&config_path).map_err(|e| {
748 if e.kind() == std::io::ErrorKind::NotFound {
749 ConfigError::NotFound(config_path.display().to_string())
750 } else {
751 ConfigError::Io(e)
752 }
753 })?;
754 let parsed: Value = serde_json::from_str(&raw)
755 .map_err(|_| ConfigError::InvalidJson(config_path.display().to_string()))?;
756 Ok((parsed, config_path))
757}
758
759pub fn parse_mem_config(value: &Value) -> Result<MemConfig, ConfigError> {
761 serde_json::from_value(value.clone()).map_err(|e| ConfigError::Other(e.to_string()))
762}
763
764pub fn load_and_validate(mem_dir: &Path) -> Result<MemConfig, ConfigError> {
766 let (raw, _path) = load_config(mem_dir)?;
767
768 let result = check_config(&raw);
769 if !result.valid {
770 return Err(ConfigError::ValidationFailed(result.errors));
771 }
772
773 parse_mem_config(&raw)
774}
775
776fn write_config(config_path: &Path, config: &Value) -> Result<(), ConfigError> {
782 let json = serde_json::to_string_pretty(config)? + "\n";
783 std::fs::write(config_path, json)?;
784 Ok(())
785}
786
787fn commit_config(
789 config_path: &Path,
790 config: &Value,
791 dry_run: bool,
792) -> Result<ConfigCheckResult, ConfigError> {
793 let check = check_config(config);
794 if !check.valid {
795 return Ok(check);
796 }
797 if !dry_run {
798 write_config(config_path, config)?;
799 }
800 Ok(check)
801}
802
803const ALLOWED_UPDATE_FIELDS: &[&str] = &[
813 "version",
814 "description",
815 "authors",
816 "writeGuidance",
817 "rules",
818 "readMems",
819 "schema",
820 "language",
821 "publish",
822];
823
824const PROTECTED_FIELDS: &[&str] = &["name"];
825
826pub fn update_config_field(
828 config_path: &Path,
829 config: &mut Value,
830 field: &str,
831 value: Value,
832 dry_run: bool,
833) -> Result<ConfigCheckResult, ConfigError> {
834 if PROTECTED_FIELDS.contains(&field) {
835 return Err(ConfigError::Other(format!("Field '{field}' is protected")));
836 }
837
838 let obj = config
839 .as_object_mut()
840 .ok_or_else(|| ConfigError::Other("config must be an object".into()))?;
841
842 if !ALLOWED_UPDATE_FIELDS.contains(&field) {
843 return Err(ConfigError::Other(format!(
844 "Field '{field}' is not a recognized config field. Allowed: {}",
845 ALLOWED_UPDATE_FIELDS.join(", ")
846 )));
847 }
848
849 obj.insert(field.to_string(), value);
850 commit_config(config_path, config, dry_run)
851}
852
853#[cfg(test)]
858mod tests {
859 use super::*;
860 use serde_json::json;
861
862 fn minimal_valid_config() -> Value {
865 json!({
866 "schema": "default@1.0.0"
867 })
868 }
869
870 #[test]
871 fn check_valid_minimal_config() {
872 let result = check_config(&minimal_valid_config());
873 assert!(result.valid, "errors: {:?}", result.errors);
874 }
875
876 #[test]
881 fn check_missing_name_now_valid() {
882 let config = json!({"schema": "default@1.0.0"});
883 let result = check_config(&config);
884 assert!(result.valid, "errors: {:?}", result.errors);
885 }
886
887 #[test]
891 fn parse_mem_config_name_none_when_field_absent() {
892 let config = json!({"schema": "default@1.0.0"});
893 let parsed = parse_mem_config(&config).expect("name-less config parses");
894 assert!(parsed.name.is_none());
895 }
896
897 #[test]
901 fn mem_config_omits_name_when_none_on_serialize() {
902 let cfg = MemConfig {
903 name: None,
904 version: None,
905 description: None,
906 authors: None,
907 schema: Some(SchemaRef::new("default", semver::Version::new(1, 0, 0))),
908 write_guidance: Default::default(),
909 rules: None,
910 publish: None,
911 language: None,
912 read_mems: Default::default(),
913 community: None,
914 vcs: None,
915 unregistered_at: None,
916 sync_state: Default::default(),
917 extra: Default::default(),
918 };
919 let json = serde_json::to_string(&cfg).unwrap();
920 assert!(
921 !json.contains("\"name\""),
922 "serialized config must omit `name` when None, got: {json}"
923 );
924 }
925
926 #[test]
930 fn check_legacy_name_field_rejected() {
931 for value in [json!(""), json!("@test/mem")] {
932 let config = json!({"name": value, "schema": "default@1.0.0"});
933 let result = check_config(&config);
934 assert!(!result.valid, "name={value}: expected reject");
935 assert_eq!(
936 result.error_code.as_deref(),
937 Some("LEGACY_FIELD_PRESENT"),
938 "name={value}: expected LEGACY_FIELD_PRESENT envelope"
939 );
940 assert!(
941 result.errors.iter().any(|e| e.contains("Legacy `name`")),
942 "name={value}: errors {:?}",
943 result.errors
944 );
945 }
946 }
947
948 #[test]
949 fn check_missing_schema() {
950 let config = json!({});
951 let result = check_config(&config);
952 assert!(!result.valid);
953 assert!(result.errors.iter().any(|e| e.contains("`schema`")));
954 }
955
956 #[test]
957 fn check_legacy_types_array_rejected() {
958 let config = json!({"types": ["spec"], "schema": "default@1.0.0"});
959 let result = check_config(&config);
960 assert!(!result.valid);
961 assert!(result.errors.iter().any(|e| e.contains("Legacy `types:")));
962 assert_eq!(
963 result.error_code.as_deref(),
964 Some("LEGACY_FIELD_PRESENT"),
965 "expected LEGACY_FIELD_PRESENT envelope for legacy `types`"
966 );
967 }
968
969 #[test]
970 fn check_schema_wrong_shape() {
971 let config = json!({"schema": ["default@1.0.0"]});
972 let result = check_config(&config);
973 assert!(!result.valid);
974 }
975
976 #[test]
977 fn check_schema_bare_name_rejected() {
978 let config = json!({"schema": "default"});
982 let result = check_config(&config);
983 assert!(!result.valid, "expected bare-name pin to be rejected");
984 assert!(
985 result.errors.iter().any(|e| e.contains("schema")),
986 "errors: {:?}",
987 result.errors
988 );
989 }
990
991 #[test]
992 fn check_schema_range_syntax_rejected() {
993 for s in [
994 "default@^1.0.0",
995 "default@~1.0.0",
996 "default@latest",
997 "default@>=1.0.0",
998 ] {
999 let config = json!({"schema": s});
1000 let result = check_config(&config);
1001 assert!(!result.valid, "expected '{s}' to be rejected");
1002 }
1003 }
1004
1005 #[test]
1006 fn check_schema_valid_exact_pin() {
1007 let config = json!({"schema": "default@1.0.0"});
1008 let result = check_config(&config);
1009 assert!(result.valid, "errors: {:?}", result.errors);
1010 }
1011
1012 #[test]
1013 fn schema_pin_versioned_parses() {
1014 let pin: SchemaRef = "software@1.2.3".parse().unwrap();
1015 assert_eq!(pin.name, "software");
1016 assert_eq!(pin.version, semver::Version::new(1, 2, 3));
1017 assert_eq!(pin.as_display(), "software@1.2.3");
1018 }
1019
1020 #[test]
1021 fn schema_pin_bare_name_rejected() {
1022 for bad in ["software", "Default", "foo/bar", "", " "] {
1026 assert!(
1027 bad.parse::<SchemaRef>().is_err(),
1028 "expected '{bad}' to be rejected"
1029 );
1030 }
1031 }
1032
1033 #[test]
1034 fn schema_pin_serde_round_trip() {
1035 let versioned: SchemaRef = serde_json::from_str(r#""software@1.0.0""#).unwrap();
1036 assert_eq!(versioned.as_display(), "software@1.0.0");
1037 let as_json = serde_json::to_string(&versioned).unwrap();
1038 assert_eq!(as_json, r#""software@1.0.0""#);
1039 }
1040
1041 #[test]
1042 fn publish_rejects_missing_schema() {
1043 let json = json!({ "version": "1.0.0" });
1046 let config = parse_mem_config(&json).unwrap();
1047 let err = published_config_from(&config, "demo").unwrap_err();
1048 assert!(matches!(err, PublishConversionError::MissingSchema));
1049 }
1050
1051 #[test]
1052 fn publish_accepts_versioned_pin() {
1053 let json = json!({
1054 "version": "1.0.0",
1055 "schema": "software@2.3.4"
1056 });
1057 let config = parse_mem_config(&json).unwrap();
1058 let published = published_config_from(&config, "demo").expect("versioned pin publishes");
1059 assert_eq!(published.name, "demo");
1060 assert_eq!(published.schema.name, "software");
1061 assert_eq!(published.schema.version, semver::Version::new(2, 3, 4));
1062 }
1063
1064 #[test]
1065 fn check_legacy_default_schema_field_is_ignored() {
1066 let config = json!({
1070 "schema": "default@1.0.0",
1071 "defaultSchema": "spec"
1072 });
1073 let result = check_config(&config);
1074 assert!(result.valid, "errors: {:?}", result.errors);
1075 }
1076
1077 #[test]
1078 fn config_preserves_unknown_fields_on_roundtrip() {
1079 let raw = json!({
1083 "schema": "default@1.0.0",
1084 "defaultSchema": "concept"
1085 });
1086 let cfg: MemConfig = serde_json::from_value(raw).expect("config deserialized");
1087 assert!(
1088 cfg.extra.contains_key("defaultSchema"),
1089 "legacy field should be preserved in extra: {:?}",
1090 cfg.extra
1091 );
1092
1093 let reserialized = serde_json::to_value(&cfg).expect("config reserialized");
1094 assert_eq!(
1095 reserialized.get("defaultSchema").and_then(|v| v.as_str()),
1096 Some("concept"),
1097 "round-trip should preserve the legacy field"
1098 );
1099 }
1100
1101 #[test]
1102 fn check_unknown_keys_warned() {
1103 let config = json!({
1104 "schema": "default@1.0.0",
1105 "unknownKey": "value"
1106 });
1107 let result = check_config(&config);
1108 assert!(result.valid);
1109 assert!(result.warnings.iter().any(|w| w.contains("unknownKey")));
1110 }
1111
1112 #[test]
1116 fn legacy_tombstone_does_not_double_warn() {
1117 let config = json!({ "name": "x", "schema": "default@1.0.0" });
1118 let result = check_config(&config);
1119 assert!(!result.valid);
1120 let unknown_warning = result
1121 .warnings
1122 .iter()
1123 .any(|w| w.contains("Unknown config key 'name'"));
1124 assert!(
1125 !unknown_warning,
1126 "legacy tombstone must not also surface as unknown-key warning: {:?}",
1127 result.warnings
1128 );
1129 }
1130
1131 #[test]
1134 fn slim_config_with_only_retained_core_fields_loads() {
1135 let raw = json!({
1143 "schema": "default@1.0.0",
1144 "writeGuidance": {
1145 "style": "structured",
1146 "audience": "agent"
1147 },
1148 "vcs": { "gitdir": ".git", "worktree": "." }
1149 });
1150 let check = check_config(&raw);
1151 assert!(check.valid, "errors: {:?}", check.errors);
1152 let parsed = parse_mem_config(&raw).expect("slim config parses");
1153 assert!(parsed.name.is_none());
1154 assert_eq!(parsed.write_guidance.len(), 2);
1155 assert_eq!(
1156 parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1157 Some("structured")
1158 );
1159 assert!(parsed.vcs.is_some());
1160 assert!(parsed.extra.is_empty());
1161 }
1162
1163 #[test]
1164 fn legacy_projections_block_lands_in_extra_without_error() {
1165 let raw = json!({
1171 "schema": "default@1.0.0",
1172 "mediums": {
1173 "codebase": {
1174 "type": "codebase",
1175 "scope": { "tree": [{ "path": "src/", "mode": "allow" }] }
1176 }
1177 },
1178 "projections": {
1179 "p1": {
1180 "intent": "test",
1181 "sources": [{ "medium_ref": "codebase" }],
1182 "destination": { "medium_ref": "graph" }
1183 }
1184 }
1185 });
1186 let check = check_config(&raw);
1187 assert!(
1188 check.valid,
1189 "legacy projections/mediums must load without errors: {:?}",
1190 check.errors
1191 );
1192 let projection_warned = check.warnings.iter().any(|w| w.contains("projections"));
1193 let mediums_warned = check.warnings.iter().any(|w| w.contains("mediums"));
1194 assert!(
1195 projection_warned && mediums_warned,
1196 "unknown-key warnings expected for projections and mediums: {:?}",
1197 check.warnings
1198 );
1199
1200 let parsed = parse_mem_config(&raw).expect("legacy config parses");
1201 assert!(
1202 parsed.extra.contains_key("projections"),
1203 "legacy `projections` must land in extra: {:?}",
1204 parsed.extra.keys().collect::<Vec<_>>()
1205 );
1206 assert!(
1207 parsed.extra.contains_key("mediums"),
1208 "legacy `mediums` must land in extra: {:?}",
1209 parsed.extra.keys().collect::<Vec<_>>()
1210 );
1211 }
1212
1213 #[test]
1214 fn write_guidance_round_trips_as_string_map() {
1215 let raw = json!({
1220 "schema": "default@1.0.0",
1221 "writeGuidance": {
1222 "style": "structured",
1223 "patterns": ["extract", "summarise"],
1224 "nested": { "depth": 2, "flag": true },
1225 "count": 42
1226 }
1227 });
1228 let parsed = parse_mem_config(&raw).expect("config parses");
1229 assert_eq!(parsed.write_guidance.len(), 4);
1230 assert_eq!(
1231 parsed.write_guidance.get("style").and_then(|v| v.as_str()),
1232 Some("structured")
1233 );
1234 let wire = serde_json::to_value(&parsed).expect("reserialize");
1235 let guidance = wire
1236 .get("writeGuidance")
1237 .and_then(|v| v.as_object())
1238 .expect("writeGuidance present in wire form");
1239 assert_eq!(guidance.len(), 4);
1240 assert_eq!(
1241 guidance.get("style").and_then(|v| v.as_str()),
1242 Some("structured")
1243 );
1244 assert_eq!(
1245 guidance
1246 .get("patterns")
1247 .and_then(|v| v.as_array())
1248 .map(|a| a.len()),
1249 Some(2)
1250 );
1251 assert_eq!(
1252 guidance
1253 .get("nested")
1254 .and_then(|v| v.get("depth"))
1255 .and_then(|v| v.as_u64()),
1256 Some(2)
1257 );
1258 }
1259
1260 #[test]
1261 fn write_guidance_empty_map_omits_from_wire() {
1262 let parsed: MemConfig = serde_json::from_value(minimal_valid_config()).unwrap();
1266 assert!(parsed.write_guidance.is_empty());
1267 let wire = serde_json::to_value(&parsed).unwrap();
1268 assert!(
1269 wire.get("writeGuidance").is_none(),
1270 "empty writeGuidance must be omitted from the wire: {wire}"
1271 );
1272 }
1273
1274 #[test]
1275 fn published_config_strips_extra_and_write_guidance() {
1276 let mut extra = HashMap::new();
1282 extra.insert(
1283 "projections".to_string(),
1284 json!({ "p1": { "intent": "x" } }),
1285 );
1286 let mut guidance = HashMap::new();
1287 guidance.insert("style".to_string(), json!("structured"));
1288 let mut sync_state = BTreeMap::new();
1289 sync_state.insert(
1290 "engine-graph/source-files".to_string(),
1291 "deadbeef".to_string(),
1292 );
1293 let cfg = MemConfig {
1294 name: Some("demo".to_string()),
1295 version: Some(semver::Version::new(0, 1, 0)),
1296 description: None,
1297 authors: None,
1298 schema: Some("default@1.0.0".parse().unwrap()),
1299 write_guidance: guidance,
1300 rules: None,
1301 publish: None,
1302 language: None,
1303 read_mems: BTreeMap::new(),
1304 community: None,
1305 vcs: None,
1306 unregistered_at: None,
1307 sync_state,
1308 extra,
1309 };
1310 let published = published_config_from(&cfg, "").expect("publish projection");
1311 let wire = serde_json::to_value(&published).expect("serialize");
1312 assert!(
1313 wire.get("projections").is_none(),
1314 "extra must not leak into published wire: {wire}"
1315 );
1316 assert!(
1317 wire.get("writeGuidance").is_none(),
1318 "writeGuidance must not leak into published wire: {wire}"
1319 );
1320 assert!(
1321 wire.get("syncState").is_none(),
1322 "syncState must not leak into published wire: {wire}"
1323 );
1324 }
1325
1326 #[test]
1337 fn update_config_field_protected() {
1338 let tmp = tempfile::tempdir().unwrap();
1339 let config_path = tmp.path().join("config.json");
1340
1341 let mut config = minimal_valid_config();
1342 let err =
1343 update_config_field(&config_path, &mut config, "name", json!("new"), true).unwrap_err();
1344 assert!(err.to_string().contains("protected"));
1345 }
1346
1347 #[test]
1348 fn update_config_field_unknown() {
1349 let tmp = tempfile::tempdir().unwrap();
1350 let config_path = tmp.path().join("config.json");
1351
1352 let mut config = minimal_valid_config();
1353 let err = update_config_field(&config_path, &mut config, "banana", json!("yellow"), true)
1354 .unwrap_err();
1355 assert!(err.to_string().contains("not a recognized"));
1356 }
1357
1358 #[test]
1359 fn update_config_field_allowed() {
1360 let tmp = tempfile::tempdir().unwrap();
1361 let config_path = tmp.path().join("config.json");
1362
1363 let mut config = minimal_valid_config();
1364 let result =
1365 update_config_field(&config_path, &mut config, "language", json!("en"), true).unwrap();
1366 assert!(result.valid, "errors: {:?}", result.errors);
1367 assert_eq!(config["language"], "en");
1368 }
1369
1370 #[test]
1377 fn parse_accepts_valid_semver_version() {
1378 let cfg = json!({
1379 "schema": "default@1.0.0",
1380 "version": "1.2.3-beta.4"
1381 });
1382 let parsed = parse_mem_config(&cfg).expect("valid semver should parse");
1383 let v = parsed.version.expect("version present");
1384 assert_eq!(v.major, 1);
1385 assert_eq!(v.minor, 2);
1386 assert_eq!(v.patch, 3);
1387 assert!(!v.pre.is_empty());
1388 }
1389
1390 #[test]
1391 fn parse_rejects_invalid_semver_version() {
1392 let cfg = json!({
1394 "schema": "default@1.0.0",
1395 "version": "1.2"
1396 });
1397 let err = parse_mem_config(&cfg).expect_err("invalid semver must fail at parse");
1398 let msg = format!("{err}");
1399 assert!(
1400 msg.contains("version"),
1401 "error should mention version: {msg}"
1402 );
1403 }
1404
1405 #[test]
1406 fn parse_rejects_non_semver_garbage_version() {
1407 let cfg = json!({
1408 "schema": "default@1.0.0",
1409 "version": "potato"
1410 });
1411 let err = parse_mem_config(&cfg).expect_err("garbage must fail at parse");
1412 let msg = format!("{err}");
1413 assert!(
1414 msg.contains("version"),
1415 "error should mention version: {msg}"
1416 );
1417 }
1418
1419 #[test]
1423 fn parse_accepts_read_mems_with_local_source() {
1424 let cfg = json!({
1425 "schema": "default@1.0.0",
1426 "readMems": {
1427 "internal-notes": { "source": { "type": "local" } }
1428 }
1429 });
1430 let check = check_config(&cfg);
1431 assert!(check.valid, "errors: {:?}", check.errors);
1432 let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1433 let spec = parsed
1434 .read_mems
1435 .get("internal-notes")
1436 .expect("entry present");
1437 assert!(matches!(spec.source, ReadMemSource::Local));
1438 }
1439
1440 #[test]
1441 fn parse_accepts_read_mems_with_url_source() {
1442 let cfg = json!({
1443 "schema": "default@1.0.0",
1444 "readMems": {
1445 "aws-patterns": {
1446 "source": {
1447 "type": "url",
1448 "url": "https://example.com/aws-patterns.mem"
1449 }
1450 }
1451 }
1452 });
1453 let check = check_config(&cfg);
1454 assert!(check.valid, "errors: {:?}", check.errors);
1455 let parsed = parse_mem_config(&cfg).expect("valid readMems must parse");
1456 let spec = parsed.read_mems.get("aws-patterns").expect("entry present");
1457 match &spec.source {
1458 ReadMemSource::Url { url } => {
1459 assert_eq!(url, "https://example.com/aws-patterns.mem")
1460 }
1461 _ => panic!("expected Url source, got {:?}", spec.source),
1462 }
1463 }
1464
1465 #[test]
1466 fn parse_accepts_empty_read_mems_map() {
1467 let cfg = json!({
1468 "schema": "default@1.0.0",
1469 "readMems": {}
1470 });
1471 let parsed = parse_mem_config(&cfg).expect("empty readMems must parse");
1472 assert!(parsed.read_mems.is_empty());
1473 }
1474
1475 #[test]
1476 fn parse_accepts_omitted_read_mems() {
1477 let cfg = json!({
1478 "schema": "default@1.0.0"
1479 });
1480 let parsed = parse_mem_config(&cfg).expect("omitted readMems must parse");
1481 assert!(parsed.read_mems.is_empty());
1482 }
1483
1484 #[test]
1485 fn check_rejects_read_mem_without_source() {
1486 let cfg = json!({
1487 "schema": "default@1.0.0",
1488 "readMems": { "p": {} }
1489 });
1490 let check = check_config(&cfg);
1491 assert!(!check.valid);
1492 assert!(
1493 check.errors.iter().any(|e| e.contains("source")),
1494 "errors: {:?}",
1495 check.errors
1496 );
1497 }
1498
1499 #[test]
1500 fn check_rejects_read_mem_with_unknown_source_type() {
1501 let cfg = json!({
1502 "schema": "default@1.0.0",
1503 "readMems": {
1504 "p": { "source": { "type": "ftp", "url": "ftp://..." } }
1505 }
1506 });
1507 let check = check_config(&cfg);
1508 assert!(!check.valid);
1509 assert!(
1510 check
1511 .errors
1512 .iter()
1513 .any(|e| e.contains("unknown source type")),
1514 "errors: {:?}",
1515 check.errors
1516 );
1517 }
1518
1519 #[test]
1520 fn check_rejects_url_source_with_empty_url() {
1521 let cfg = json!({
1522 "schema": "default@1.0.0",
1523 "readMems": {
1524 "p": { "source": { "type": "url", "url": "" } }
1525 }
1526 });
1527 let check = check_config(&cfg);
1528 assert!(!check.valid);
1529 assert!(
1530 check.errors.iter().any(|e| e.contains("url source")),
1531 "errors: {:?}",
1532 check.errors
1533 );
1534 }
1535
1536 #[test]
1537 fn check_rejects_registry_source_type_reserved_for_phase_d() {
1538 let cfg = json!({
1539 "schema": "default@1.0.0",
1540 "readMems": {
1541 "p": { "source": { "type": "registry" } }
1542 }
1543 });
1544 let check = check_config(&cfg);
1545 assert!(!check.valid);
1549 assert!(
1550 check
1551 .errors
1552 .iter()
1553 .any(|e| e.contains("unknown source type")),
1554 "errors: {:?}",
1555 check.errors
1556 );
1557 }
1558
1559 #[test]
1564 fn read_mems_serialization_order_is_key_sorted() {
1565 let cfg = json!({
1566 "schema": "default@1.0.0",
1567 "readMems": {
1568 "zebra": { "source": { "type": "local" } },
1569 "alpha": { "source": { "type": "local" } },
1570 "mango": { "source": { "type": "local" } }
1571 }
1572 });
1573 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1574 let reserialized = serde_json::to_string(&parsed).expect("serialization must succeed");
1575 let alpha = reserialized.find("alpha").expect("alpha present");
1576 let mango = reserialized.find("mango").expect("mango present");
1577 let zebra = reserialized.find("zebra").expect("zebra present");
1578 assert!(
1579 alpha < mango && mango < zebra,
1580 "expected alpha < mango < zebra, got: {reserialized}"
1581 );
1582 }
1583
1584 #[test]
1587 fn vcs_config_round_trips_through_serde_with_both_fields() {
1588 let cfg = json!({
1589 "schema": "default@1.0.0",
1590 "vcs": { "gitdir": "../.git", "worktree": ".." }
1591 });
1592 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1593 let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1594 assert_eq!(vcs.gitdir, "../.git");
1595 assert_eq!(vcs.worktree, "..");
1596
1597 let reserialized = serde_json::to_value(&parsed).unwrap();
1599 let round = parse_mem_config(&reserialized).expect("round-trip parse");
1600 assert_eq!(round.vcs.as_ref().unwrap().gitdir, "../.git");
1601 assert_eq!(round.vcs.as_ref().unwrap().worktree, "..");
1602 }
1603
1604 #[test]
1605 fn vcs_config_worktree_defaults_to_dot_when_omitted() {
1606 let cfg = json!({
1607 "schema": "default@1.0.0",
1608 "vcs": { "gitdir": ".git" }
1609 });
1610 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1611 let vcs = parsed.vcs.as_ref().expect("vcs must be Some");
1612 assert_eq!(vcs.gitdir, ".git");
1613 assert_eq!(vcs.worktree, ".", "worktree must default to \".\"");
1614 }
1615
1616 #[test]
1617 fn vcs_config_absent_is_none() {
1618 let cfg = json!({ "schema": "default@1.0.0" });
1619 let parsed = parse_mem_config(&cfg).expect("valid config must parse");
1620 assert!(parsed.vcs.is_none(), "missing vcs must deserialize to None");
1621 }
1622
1623 #[test]
1624 fn vcs_field_tolerates_legacy_string_value() {
1625 let cfg = json!({
1629 "schema": "default@1.0.0",
1630 "vcs": "system"
1631 });
1632 let parsed = parse_mem_config(&cfg).expect("legacy vcs string must parse");
1633 assert!(
1634 parsed.vcs.is_none(),
1635 "legacy string must deserialize to None"
1636 );
1637 }
1638
1639 #[test]
1640 fn published_config_strips_vcs() {
1641 let mut cfg = MemConfig {
1648 name: Some("demo".to_string()),
1649 version: Some(semver::Version::new(0, 1, 0)),
1650 description: None,
1651 authors: None,
1652 schema: Some("default@1.0.0".parse().unwrap()),
1653 write_guidance: HashMap::new(),
1654 rules: None,
1655 publish: None,
1656 language: None,
1657 read_mems: BTreeMap::new(),
1658 community: None,
1659 vcs: None,
1660 unregistered_at: None,
1661 sync_state: BTreeMap::new(),
1662 extra: HashMap::new(),
1663 };
1664 cfg.vcs = Some(VcsConfig {
1665 gitdir: ".git".to_string(),
1666 worktree: ".".to_string(),
1667 });
1668 let published = published_config_from(&cfg, "").expect("valid projection");
1669 let wire = serde_json::to_value(&published).expect("serialize");
1670 assert!(
1671 wire.get("vcs").is_none(),
1672 "published wire form must not carry vcs: got {wire}"
1673 );
1674 }
1675
1676 #[test]
1683 fn belongs_to_field_is_legacy_tombstone() {
1684 let cfg = json!({
1685 "schema": "default@1.0.0",
1686 "belongsTo": ["main"]
1687 });
1688 let result = check_config(&cfg);
1689 assert!(!result.valid, "belongsTo presence must fail validation");
1690 assert_eq!(result.error_code.as_deref(), Some("LEGACY_FIELD_PRESENT"));
1691 assert!(
1692 result
1693 .errors
1694 .iter()
1695 .any(|e| e.contains("belongsTo") && e.contains("cross_mem_links")),
1696 "tombstone error must name the field and the replacement section: {:?}",
1697 result.errors
1698 );
1699 }
1700}