1use std::fmt;
40use std::path::{Path, PathBuf};
41
42use crate::loader::{
43 MetadataPolarityFormat, SCHEMA_FORMAT_MARKER_FILE, SchemaLoadError,
44 load_authoring_package_from_memory, load_schema_from_memory_with_format,
45};
46use crate::schema::Schema;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LegacyScope {
51 TypeTop,
53 MetadataField,
55 ExemplarRelation,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum LegacyRewrite {
63 Rename { to: &'static str },
65 Drop { pointer: &'static str },
69 InvertBool { to: &'static str },
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct LegacyKey {
79 pub retired: &'static str,
80 pub scope: LegacyScope,
81 pub rewrite: LegacyRewrite,
82}
83
84pub const LEGACY_KEYS: &[LegacyKey] = &[
90 LegacyKey {
91 retired: "propagating_relationships",
92 scope: LegacyScope::TypeTop,
93 rewrite: LegacyRewrite::Rename {
94 to: "no_self_loop_relationships",
95 },
96 },
97 LegacyKey {
98 retired: "examples",
99 scope: LegacyScope::TypeTop,
100 rewrite: LegacyRewrite::Drop {
101 pointer: "exemplar",
102 },
103 },
104 LegacyKey {
105 retired: "to",
106 scope: LegacyScope::ExemplarRelation,
107 rewrite: LegacyRewrite::Rename { to: "target" },
108 },
109 LegacyKey {
110 retired: "type",
111 scope: LegacyScope::ExemplarRelation,
112 rewrite: LegacyRewrite::Rename { to: "rel_type" },
113 },
114 LegacyKey {
115 retired: "optional",
116 scope: LegacyScope::MetadataField,
117 rewrite: LegacyRewrite::InvertBool { to: "required" },
118 },
119];
120
121#[derive(Debug, thiserror::Error)]
123pub enum MigrateError {
124 #[error("{path} is not a schema package directory (no schema.yaml)")]
125 NotAPackage { path: PathBuf },
126
127 #[error(
128 "{path} is a sealed schema package (it carries `{marker}`, the seal marker), not \
129 authoring input — `schema migrate` rewrites the directories you author, never a \
130 sealed copy. Migrate the package's source directory instead.",
131 marker = SCHEMA_FORMAT_MARKER_FILE
132 )]
133 SealedPackage { path: PathBuf },
134
135 #[error("reading {path}: {source}")]
136 Io {
137 path: PathBuf,
138 #[source]
139 source: std::io::Error,
140 },
141
142 #[error("{file} is not valid UTF-8; the rewriter edits text only")]
143 NotUtf8 { file: String },
144
145 #[error(
146 "{file}:{line}: `{key}: {value}` cannot be rewritten mechanically — the retired \
147 `{key}` key takes a YAML boolean here. Fix the value by hand, then retry."
148 )]
149 UnmigratableValue {
150 file: String,
151 line: usize,
152 key: &'static str,
153 value: String,
154 },
155
156 #[error(
160 "the package does not load even with every retired key translated, so its problem is \
161 not a retired key — run `memstead schema validate` and fix that first: {source}"
162 )]
163 PackageDoesNotLoad {
164 #[source]
165 source: SchemaLoadError,
166 },
167
168 #[error(
172 "the rewrite still refuses under the authoring read, so a retired key sits where the \
173 rewriter does not edit (a quoted key, a flow-style mapping) — nothing was written; \
174 fix that occurrence by hand and retry: {source}"
175 )]
176 RewriteLeavesViolations {
177 #[source]
178 source: SchemaLoadError,
179 },
180
181 #[error(
185 "the rewrite does not reproduce the loader's translation of the original ({detail}); \
186 nothing was written — this is an engine defect, please report it"
187 )]
188 Unfaithful { detail: String },
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum RewriteAction {
194 Renamed { to: &'static str },
196 Removed { reason: String },
198 Replaced { with: String },
200 Inserted { line: String, reason: String },
202}
203
204impl fmt::Display for RewriteAction {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 match self {
207 RewriteAction::Renamed { to } => write!(f, "renamed to `{to}`"),
208 RewriteAction::Removed { reason } => write!(f, "removed ({reason})"),
209 RewriteAction::Replaced { with } => write!(f, "replaced by `{with}`"),
210 RewriteAction::Inserted { line, reason } => {
211 write!(f, "gets `{line}` inserted ({reason})")
212 }
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct Rewrite {
220 pub line: usize,
222 pub key: &'static str,
225 pub path: String,
228 pub action: RewriteAction,
229}
230
231impl fmt::Display for Rewrite {
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 write!(f, "line {}: `{}` {}", self.line, self.path, self.action)
234 }
235}
236
237#[derive(Debug, Clone)]
239pub struct FileMigration {
240 pub rel_path: String,
242 pub rewrites: Vec<Rewrite>,
243 pub new_text: String,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct BareField {
254 pub type_name: String,
255 pub field: String,
256}
257
258#[derive(Debug, Clone)]
261pub struct MigrateReport {
262 pub package: PathBuf,
263 pub schema: String,
265 pub files: Vec<FileMigration>,
268 pub legacy_polarity: bool,
272 pub required_added: Vec<BareField>,
274}
275
276impl MigrateReport {
277 pub fn rewrite_count(&self) -> usize {
278 self.files.iter().map(|f| f.rewrites.len()).sum()
279 }
280
281 pub fn is_noop(&self) -> bool {
282 self.rewrite_count() == 0
283 }
284
285 pub fn changed_files(&self) -> impl Iterator<Item = &FileMigration> {
287 self.files.iter().filter(|f| !f.rewrites.is_empty())
288 }
289}
290
291pub fn migrate_package(dir: &Path) -> Result<MigrateReport, MigrateError> {
294 let manifest_path = dir.join("schema.yaml");
295 if !manifest_path.is_file() {
296 return Err(MigrateError::NotAPackage {
297 path: dir.to_path_buf(),
298 });
299 }
300 if dir.join(SCHEMA_FORMAT_MARKER_FILE).is_file() {
301 return Err(MigrateError::SealedPackage {
302 path: dir.to_path_buf(),
303 });
304 }
305 let manifest = read_text(&manifest_path, "schema.yaml")?;
306 let schema_id = manifest_id(&manifest);
307
308 let types_dir = dir.join("types");
309 let mut stems: Vec<String> = Vec::new();
310 if types_dir.is_dir() {
311 let entries = std::fs::read_dir(&types_dir).map_err(|source| MigrateError::Io {
312 path: types_dir.clone(),
313 source,
314 })?;
315 for entry in entries {
316 let entry = entry.map_err(|source| MigrateError::Io {
317 path: types_dir.clone(),
318 source,
319 })?;
320 let name = entry.file_name().to_string_lossy().into_owned();
321 if let Some(stem) = name.strip_suffix(".yaml") {
322 stems.push(stem.to_string());
323 }
324 }
325 }
326 stems.sort();
327
328 let mut before: Vec<(String, String)> = Vec::with_capacity(stems.len());
329 for stem in &stems {
330 let rel = format!("types/{stem}.yaml");
331 before.push((
332 stem.clone(),
333 read_text(&types_dir.join(format!("{stem}.yaml")), &rel)?,
334 ));
335 }
336 let legacy_polarity = before.iter().any(|(_, text)| carries_legacy_optional(text));
339
340 let mut files = Vec::with_capacity(stems.len());
341 let mut required_added = Vec::new();
342 for (stem, text) in &before {
343 let rel = format!("types/{stem}.yaml");
344 let outcome = rewrite_type_file(&rel, text, legacy_polarity)?;
345 required_added.extend(outcome.required_added.into_iter().map(|field| BareField {
346 type_name: stem.clone(),
347 field,
348 }));
349 files.push(FileMigration {
350 rel_path: rel,
351 rewrites: outcome.rewrites,
352 new_text: outcome.new_text,
353 });
354 }
355
356 let report = MigrateReport {
357 package: dir.to_path_buf(),
358 schema: schema_id,
359 files,
360 legacy_polarity,
361 required_added,
362 };
363 if report.is_noop() {
364 return Ok(report);
365 }
366
367 let after: Vec<(String, String)> = report
372 .files
373 .iter()
374 .map(|f| {
375 let stem = f
376 .rel_path
377 .trim_start_matches("types/")
378 .trim_end_matches(".yaml");
379 (stem.to_string(), f.new_text.clone())
380 })
381 .collect();
382 let reference_format = if legacy_polarity {
383 MetadataPolarityFormat::Legacy
384 } else {
385 MetadataPolarityFormat::RequiredOptIn
386 };
387 let original = load_schema_from_memory_with_format(&manifest, &before, reference_format)
388 .map_err(|source| MigrateError::PackageDoesNotLoad { source })?;
389 let migrated = load_authoring_package_from_memory(&manifest, &after)
390 .map_err(|source| MigrateError::RewriteLeavesViolations { source })?;
391 if let Some(detail) = first_difference(&original, &migrated) {
392 return Err(MigrateError::Unfaithful { detail });
393 }
394 Ok(report)
395}
396
397pub fn write_migration(report: &MigrateReport) -> Result<(), MigrateError> {
400 for file in report.changed_files() {
401 let path = report.package.join(&file.rel_path);
402 std::fs::write(&path, &file.new_text).map_err(|source| MigrateError::Io {
403 path: path.clone(),
404 source,
405 })?;
406 }
407 Ok(())
408}
409
410pub fn next_steps(package: &Path, schema: &str) -> Vec<String> {
412 let dir = package.display();
413 vec![
414 format!("memstead schema validate {dir}"),
415 format!("memstead schema install {dir}"),
416 format!("memstead mem set-schema <mem> {schema}"),
417 ]
418}
419
420fn read_text(path: &Path, rel: &str) -> Result<String, MigrateError> {
421 let bytes = std::fs::read(path).map_err(|source| MigrateError::Io {
422 path: path.to_path_buf(),
423 source,
424 })?;
425 String::from_utf8(bytes).map_err(|_| MigrateError::NotUtf8 {
426 file: rel.to_string(),
427 })
428}
429
430fn manifest_id(manifest: &str) -> String {
431 #[derive(serde::Deserialize)]
432 struct Head {
433 #[serde(default)]
434 name: String,
435 #[serde(default)]
436 version: String,
437 }
438 match serde_yaml_ng::from_str::<Head>(manifest) {
439 Ok(h) if !h.name.is_empty() => format!("{}@{}", h.name, h.version),
440 _ => "<unparsed manifest>".to_string(),
441 }
442}
443
444fn first_difference(a: &Schema, b: &Schema) -> Option<String> {
447 let mut names: Vec<&String> = a.types.keys().collect();
448 names.sort();
449 let mut other: Vec<&String> = b.types.keys().collect();
450 other.sort();
451 if names != other {
452 return Some(format!("type set {names:?} vs {other:?}"));
453 }
454 for name in names {
455 let ta = &a.types[name];
456 let tb = &b.types[name];
457 if ta.no_self_loop_relationships != tb.no_self_loop_relationships {
458 return Some(format!(
459 "type '{name}': no_self_loop_relationships {:?} vs {:?}",
460 ta.no_self_loop_relationships, tb.no_self_loop_relationships
461 ));
462 }
463 let fa: Vec<(&str, bool)> = ta
464 .metadata_fields
465 .iter()
466 .map(|f| (f.key.as_str(), f.is_required()))
467 .collect();
468 let fb: Vec<(&str, bool)> = tb
469 .metadata_fields
470 .iter()
471 .map(|f| (f.key.as_str(), f.is_required()))
472 .collect();
473 if fa != fb {
474 return Some(format!(
475 "type '{name}': metadata field requiredness {fa:?} vs {fb:?}"
476 ));
477 }
478 let ra: Option<Vec<(&str, &str, Option<&str>)>> = ta.exemplar.as_ref().map(|e| {
479 e.relations
480 .iter()
481 .map(|r| (r.target_slug(), r.rel_type_name(), r.description.as_deref()))
482 .collect()
483 });
484 let rb: Option<Vec<(&str, &str, Option<&str>)>> = tb.exemplar.as_ref().map(|e| {
485 e.relations
486 .iter()
487 .map(|r| (r.target_slug(), r.rel_type_name(), r.description.as_deref()))
488 .collect()
489 });
490 if ra != rb {
491 return Some(format!(
492 "type '{name}': exemplar relations {ra:?} vs {rb:?}"
493 ));
494 }
495 }
496 None
497}
498
499#[derive(Debug)]
505struct KeyLine<'a> {
506 idx: usize,
508 col: usize,
510 key: &'a str,
511 value: &'a str,
514 path: Vec<&'a str>,
517 parent: usize,
520}
521
522struct Frame<'a> {
523 indent: usize,
524 name: &'a str,
525 line: usize,
526}
527
528const ITEM: &str = "[]";
529
530fn scan_key_lines(text: &str) -> Vec<KeyLine<'_>> {
535 let mut frames: Vec<Frame<'_>> = Vec::new();
536 let mut out = Vec::new();
537 let mut opaque_deeper_than: Option<usize> = None;
540
541 for (idx, raw) in text.lines().enumerate() {
542 let trimmed = raw.trim_start_matches(' ');
543 if trimmed.trim().is_empty() {
544 continue;
545 }
546 let indent = raw.len() - trimmed.len();
547 if let Some(limit) = opaque_deeper_than {
548 if indent > limit {
549 continue;
550 }
551 opaque_deeper_than = None;
552 }
553 if trimmed.starts_with('#') {
554 continue;
555 }
556 let starts_item = trimmed == "-" || trimmed.starts_with("- ");
557 while let Some(top) = frames.last() {
561 let close =
562 top.indent > indent || (top.indent == indent && !(starts_item && top.name != ITEM));
563 if close {
564 frames.pop();
565 } else {
566 break;
567 }
568 }
569
570 let mut content = trimmed;
571 let mut col = indent;
572 while content == "-" || content.starts_with("- ") {
573 frames.push(Frame {
574 indent: col,
575 name: ITEM,
576 line: idx,
577 });
578 let rest = &content[1..];
579 let rest_trimmed = rest.trim_start_matches(' ');
580 col += content.len() - rest_trimmed.len();
581 content = rest_trimmed;
582 }
583 if content.is_empty() {
584 continue;
585 }
586 let Some((key, value)) = split_key(content) else {
587 opaque_deeper_than = Some(col);
589 continue;
590 };
591 let value = strip_comment(value).trim();
592 let parent = frames.last().map(|f| f.line).unwrap_or(usize::MAX);
593 frames.push(Frame {
594 indent: col,
595 name: key,
596 line: idx,
597 });
598 out.push(KeyLine {
599 idx,
600 col,
601 key,
602 value,
603 path: frames.iter().map(|f| f.name).collect(),
604 parent,
605 });
606 if !value.is_empty() {
607 opaque_deeper_than = Some(col);
610 }
611 }
612 out
613}
614
615fn split_key(content: &str) -> Option<(&str, &str)> {
617 let first = content.chars().next()?;
618 if matches!(
619 first,
620 '"' | '\'' | '[' | '{' | '&' | '*' | '!' | '|' | '>' | '%' | '@' | '`' | '?'
621 ) {
622 return None;
623 }
624 let bytes = content.as_bytes();
625 for (i, b) in bytes.iter().enumerate() {
626 if *b == b'#' && i > 0 && bytes[i - 1] == b' ' {
627 return None;
628 }
629 if *b == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' ') {
630 let key = content[..i].trim_end();
631 if key.is_empty() {
632 return None;
633 }
634 return Some((key, &content[i + 1..]));
635 }
636 }
637 None
638}
639
640fn strip_comment(value: &str) -> &str {
642 let mut quote: Option<char> = None;
643 let mut prev = ' ';
644 for (i, c) in value.char_indices() {
645 match quote {
646 Some(q) if c == q => quote = None,
647 Some(_) => {}
648 None if c == '"' || c == '\'' => quote = Some(c),
649 None if c == '#' && (prev == ' ' || i == 0) => return &value[..i],
650 None => {}
651 }
652 prev = c;
653 }
654 value
655}
656
657fn matches_scope(path: &[&str], scope: LegacyScope) -> bool {
658 match scope {
659 LegacyScope::TypeTop => path.len() == 1,
660 LegacyScope::MetadataField => {
661 path.len() == 3 && path[0] == "metadata_fields" && path[1] == ITEM
662 }
663 LegacyScope::ExemplarRelation => {
664 path.len() == 4 && path[0] == "exemplar" && path[1] == "relations" && path[2] == ITEM
665 }
666 }
667}
668
669enum Edit {
670 Replace(usize, String),
671 Delete(usize, usize),
673 InsertAfter(usize, String),
675}
676
677struct FileOutcome {
678 rewrites: Vec<Rewrite>,
679 new_text: String,
680 required_added: Vec<String>,
681}
682
683fn carries_legacy_optional(text: &str) -> bool {
686 scan_key_lines(text)
687 .iter()
688 .any(|k| k.key == "optional" && matches_scope(&k.path, LegacyScope::MetadataField))
689}
690
691fn rewrite_type_file(
692 rel: &str,
693 text: &str,
694 legacy_polarity: bool,
695) -> Result<FileOutcome, MigrateError> {
696 let lines: Vec<&str> = text.lines().collect();
697 let keys = scan_key_lines(text);
698
699 let field_name = |parent: usize| -> Option<&str> {
702 keys.iter()
703 .find(|k| k.parent == parent && k.key == "key" && k.path.len() == 3)
704 .map(|k| k.value.trim_matches(|c| c == '"' || c == '\''))
705 };
706 let has_sibling = |parent: usize, key: &str| -> bool {
707 keys.iter().any(|k| k.parent == parent && k.key == key)
708 };
709
710 let mut edits: Vec<Edit> = Vec::new();
711 let mut rewrites: Vec<Rewrite> = Vec::new();
712
713 for k in &keys {
714 let Some(row) = LEGACY_KEYS
715 .iter()
716 .find(|row| row.retired == k.key && matches_scope(&k.path, row.scope))
717 else {
718 continue;
719 };
720 let raw = lines[k.idx];
721 let human_path = match row.scope {
722 LegacyScope::TypeTop => k.key.to_string(),
723 LegacyScope::MetadataField => format!(
724 "metadata_fields[{}].{}",
725 field_name(k.parent).unwrap_or("?"),
726 k.key
727 ),
728 LegacyScope::ExemplarRelation => format!("exemplar.relations[].{}", k.key),
729 };
730 let action = match row.rewrite {
731 LegacyRewrite::Rename { to } => {
732 let mut new_line = String::with_capacity(raw.len() + to.len());
733 new_line.push_str(&raw[..k.col]);
734 new_line.push_str(to);
735 new_line.push_str(&raw[k.col + k.key.len()..]);
736 edits.push(Edit::Replace(k.idx, new_line));
737 RewriteAction::Renamed { to }
738 }
739 LegacyRewrite::Drop { pointer } => {
740 edits.push(Edit::Delete(k.idx, block_end(&lines, k.idx, k.col)));
741 RewriteAction::Removed {
742 reason: format!(
743 "retired and never consumed; author `{pointer}:` instead if you want it"
744 ),
745 }
746 }
747 LegacyRewrite::InvertBool { to } => {
748 let Ok(flag) = serde_yaml_ng::from_str::<bool>(k.value) else {
749 return Err(MigrateError::UnmigratableValue {
750 file: rel.to_string(),
751 line: k.idx + 1,
752 key: row.retired,
753 value: k.value.to_string(),
754 });
755 };
756 if has_sibling(k.parent, to) {
757 edits.push(Edit::Delete(k.idx, k.idx));
758 RewriteAction::Removed {
759 reason: format!("the entry's own `{to}:` wins"),
760 }
761 } else if flag {
762 edits.push(Edit::Delete(k.idx, k.idx));
763 RewriteAction::Removed {
764 reason: "absence means optional".to_string(),
765 }
766 } else {
767 let value_start = k.col + k.key.len();
768 let after_key = &raw[value_start..];
769 let colon = after_key.find(':').unwrap_or(0);
770 let tail = &after_key[colon + 1..];
771 let comment_at = tail.find(" #").map(|i| &tail[i..]).unwrap_or("");
772 let with = format!("{to}: true");
773 let new_line = format!("{}{with}{comment_at}", &raw[..k.col]);
774 edits.push(Edit::Replace(k.idx, new_line));
775 RewriteAction::Replaced { with }
776 }
777 }
778 };
779 rewrites.push(Rewrite {
780 line: k.idx + 1,
781 key: row.retired,
782 path: human_path,
783 action,
784 });
785 }
786
787 let mut required_added = Vec::new();
791 if legacy_polarity {
792 let mut seen = std::collections::BTreeSet::new();
793 for k in &keys {
794 if k.path.len() == 3
795 && k.path[0] == "metadata_fields"
796 && k.path[1] == ITEM
797 && k.key == "key"
798 && seen.insert(k.parent)
799 && !has_sibling(k.parent, "optional")
800 && !has_sibling(k.parent, "required")
801 {
802 let name = k.value.trim_matches(|c| c == '"' || c == '\'');
803 let line = "required: true".to_string();
804 edits.push(Edit::InsertAfter(
805 k.idx,
806 format!("{}{line}", " ".repeat(k.col)),
807 ));
808 rewrites.push(Rewrite {
809 line: k.idx + 1,
810 key: "required",
811 path: format!("metadata_fields[{name}]"),
812 action: RewriteAction::Inserted {
813 line,
814 reason: "written when an absent key meant required; delete the line where you did not mean it".to_string(),
815 },
816 });
817 required_added.push(name.to_string());
818 }
819 }
820 rewrites.sort_by_key(|r| r.line);
821 }
822
823 Ok(FileOutcome {
824 new_text: apply_edits(text, &lines, edits),
825 rewrites,
826 required_added,
827 })
828}
829
830fn block_end(lines: &[&str], start: usize, col: usize) -> usize {
834 let mut end = start;
835 for (j, raw) in lines.iter().enumerate().skip(start + 1) {
836 let trimmed = raw.trim_start_matches(' ');
837 if trimmed.trim().is_empty() {
838 continue;
839 }
840 if raw.len() - trimmed.len() > col {
841 end = j;
842 } else {
843 break;
844 }
845 }
846 end
847}
848
849fn apply_edits(text: &str, lines: &[&str], edits: Vec<Edit>) -> String {
850 let newline = if text.contains("\r\n") { "\r\n" } else { "\n" };
851 let mut out: Vec<(Option<String>, Vec<String>)> = lines
854 .iter()
855 .map(|l| (Some((*l).to_string()), Vec::new()))
856 .collect();
857 for edit in edits {
858 match edit {
859 Edit::Replace(i, s) => out[i].0 = Some(s),
860 Edit::Delete(a, b) => {
861 for slot in out.iter_mut().take(b + 1).skip(a) {
862 slot.0 = None;
863 }
864 }
865 Edit::InsertAfter(i, s) => out[i].1.push(s),
866 }
867 }
868 let mut result = out
869 .into_iter()
870 .flat_map(|(line, inserted)| line.into_iter().chain(inserted))
871 .collect::<Vec<_>>()
872 .join(newline);
873 if text.ends_with('\n') {
874 result.push_str(newline);
875 }
876 result
877}
878
879#[cfg(test)]
880mod tests {
881 use super::*;
882 use crate::loader::load_schema_from_dir;
883
884 fn sentinel_keys_in_source() -> std::collections::BTreeSet<String> {
889 let source = include_str!("types.rs");
890 let re = regex::Regex::new(
891 r#"#\[serde\([^\n]*rename = "([^"]+)"[^\n]*\)\]\s*(?:#\[[^\n]*\]\s*)*pub legacy_\w+"#,
892 )
893 .unwrap();
894 re.captures_iter(source).map(|c| c[1].to_string()).collect()
895 }
896
897 #[test]
898 fn table_and_loader_sentinels_are_the_same_set() {
899 let sentinels = sentinel_keys_in_source();
900 let table: std::collections::BTreeSet<String> =
901 LEGACY_KEYS.iter().map(|k| k.retired.to_string()).collect();
902 assert_eq!(
903 sentinels, table,
904 "LEGACY_KEYS and the legacy_* sentinels in types.rs must name the same retired keys"
905 );
906 assert_eq!(sentinels.len(), 5, "sentinel scan lost a declaration");
907 }
908
909 fn manifest() -> String {
910 r#"name: example
911version: 1.0.0
912description: Example schema for tests
913when_to_use: In migrate tests only
914types:
915 - sample
916relationships:
917 mode: strict
918 definitions:
919 - name: PART_OF
920 description: Hierarchical containment
921 default_weight: 3.0
922 - name: _default
923 description: Fallback weight
924 default_weight: 1.0
925community:
926 resolution: 1.0
927 seed: 42
928"#
929 .to_string()
930 }
931
932 fn type_with(row: &LegacyKey) -> String {
935 let top = match (row.scope, row.retired) {
936 (LegacyScope::TypeTop, "propagating_relationships") => {
937 "propagating_relationships: [PART_OF] # keep me\n".to_string()
938 }
939 (LegacyScope::TypeTop, "examples") => {
940 "examples: # dead list\n - title: One\n body: x\n\n - title: Two\n".to_string()
941 }
942 (LegacyScope::TypeTop, _) => format!("{}: []\n", row.retired),
943 _ => String::new(),
944 };
945 let field_extra = match row.scope {
946 LegacyScope::MetadataField => format!(" {}: true # was optional\n", row.retired),
947 _ => String::new(),
948 };
949 let exemplar = match row.scope {
950 LegacyScope::ExemplarRelation => {
951 let (to_key, type_key) = match row.retired {
952 "to" => ("to", "rel_type"),
953 _ => ("target", "type"),
954 };
955 format!(
956 "exemplar:\n title: Sample one\n metadata:\n status: active\n sections:\n body: Text.\n relations:\n - {to_key}: other-thing\n {type_key}: PART_OF\n"
957 )
958 }
959 _ => String::new(),
960 };
961 format!(
962 r#"name: sample
963description: Sample type for tests
964when_to_use: Whenever a minimal type is needed
965sections:
966 - key: body
967 heading: Body
968 required: true
969 search_weight: 10.0
970 catch_all: true
971 write_rules:
972 - One sentence describing the body.
973metadata_fields:
974 - key: status
975 description: Lifecycle state
976 field_type: string
977 default_value: active
978{field_extra}title_weight: 100.0
979text_fields: [body]
980hierarchy_relationship: PART_OF
981{top}updatable_fields: [title, body, status]
982health_required_fields: [body]
983staleness_threshold_days: 90
984{exemplar}write_rules:
985 - Keep it short.
986"#
987 )
988 }
989
990 fn write_package(dir: &Path, type_text: &str) {
991 std::fs::create_dir_all(dir.join("types")).unwrap();
992 std::fs::write(dir.join("schema.yaml"), manifest()).unwrap();
993 std::fs::write(dir.join("types/sample.yaml"), type_text).unwrap();
994 }
995
996 #[test]
1000 fn every_row_translates_sealed_refuses_authoring_and_rewrites_clean() {
1001 for row in LEGACY_KEYS {
1002 let text = type_with(row);
1003 let types = vec![("sample".to_string(), text.clone())];
1004 let format = if carries_legacy_optional(&text) {
1007 MetadataPolarityFormat::Legacy
1008 } else {
1009 MetadataPolarityFormat::RequiredOptIn
1010 };
1011 load_schema_from_memory_with_format(&manifest(), &types, format)
1012 .unwrap_or_else(|e| panic!("sealed read must translate `{}`: {e}", row.retired));
1013 load_authoring_package_from_memory(&manifest(), &types)
1014 .expect_err(&format!("authoring read must refuse `{}`", row.retired));
1015
1016 let dir = tempfile::tempdir().unwrap();
1017 write_package(dir.path(), &text);
1018 let report = migrate_package(dir.path())
1019 .unwrap_or_else(|e| panic!("migrate must compute for `{}`: {e}", row.retired));
1020 assert_eq!(
1021 report.rewrite_count(),
1022 1,
1023 "exactly one rewrite for `{}`: {:?}",
1024 row.retired,
1025 report.files
1026 );
1027 assert_eq!(
1028 std::fs::read_to_string(dir.path().join("types/sample.yaml")).unwrap(),
1029 text,
1030 "computing never writes"
1031 );
1032 write_migration(&report).unwrap();
1033 load_schema_from_dir(dir.path())
1034 .unwrap_or_else(|e| panic!("migrated `{}` must validate: {e}", row.retired));
1035 }
1036 }
1037
1038 #[test]
1039 fn rename_keeps_value_comment_and_neighbours() {
1040 let row = &LEGACY_KEYS[0];
1041 let dir = tempfile::tempdir().unwrap();
1042 write_package(dir.path(), &type_with(row));
1043 let report = migrate_package(dir.path()).unwrap();
1044 let file = report.changed_files().next().unwrap();
1045 assert!(
1046 file.new_text
1047 .contains("no_self_loop_relationships: [PART_OF] # keep me\n"),
1048 "{}",
1049 file.new_text
1050 );
1051 assert!(!file.new_text.contains("propagating_relationships"));
1052 assert_eq!(file.rewrites[0].path, "propagating_relationships");
1053 assert_eq!(
1054 file.rewrites[0].action,
1055 RewriteAction::Renamed {
1056 to: "no_self_loop_relationships"
1057 }
1058 );
1059 let original = type_with(row);
1061 let before: Vec<&str> = original.lines().collect();
1062 let after: Vec<&str> = file.new_text.lines().collect();
1063 assert_eq!(before.len(), after.len());
1064 for (b, a) in before.iter().zip(&after) {
1065 if !b.starts_with("propagating_relationships") {
1066 assert_eq!(b, a);
1067 }
1068 }
1069 }
1070
1071 #[test]
1072 fn drop_removes_the_whole_block_including_inner_blank_lines() {
1073 let row = LEGACY_KEYS
1074 .iter()
1075 .find(|r| r.retired == "examples")
1076 .unwrap();
1077 let dir = tempfile::tempdir().unwrap();
1078 write_package(dir.path(), &type_with(row));
1079 let report = migrate_package(dir.path()).unwrap();
1080 let file = report.changed_files().next().unwrap();
1081 assert!(!file.new_text.contains("examples"));
1082 assert!(!file.new_text.contains("title: One"));
1083 assert!(!file.new_text.contains("title: Two"));
1084 assert!(
1085 file.new_text
1086 .contains("hierarchy_relationship: PART_OF\nupdatable_fields:"),
1087 "{}",
1088 file.new_text
1089 );
1090 }
1091
1092 #[test]
1093 fn optional_false_becomes_required_true_and_sibling_required_wins() {
1094 let base = type_with(&LEGACY_KEYS[4]);
1095 let text = base.replace(
1096 " optional: true # was optional\n",
1097 " optional: false # must be set\n - key: other\n description: Another\n field_type: string\n required: true\n optional: true\n",
1098 );
1099 let dir = tempfile::tempdir().unwrap();
1100 write_package(dir.path(), &text);
1101 let report = migrate_package(dir.path()).unwrap();
1102 let file = report.changed_files().next().unwrap();
1103 assert!(
1104 file.new_text.contains(" required: true # must be set\n"),
1105 "{}",
1106 file.new_text
1107 );
1108 assert!(!file.new_text.contains("optional"));
1109 assert_eq!(file.rewrites.len(), 2);
1110 assert_eq!(file.rewrites[0].path, "metadata_fields[status].optional");
1111 assert_eq!(
1112 file.rewrites[0].action,
1113 RewriteAction::Replaced {
1114 with: "required: true".to_string()
1115 }
1116 );
1117 assert_eq!(file.rewrites[1].path, "metadata_fields[other].optional");
1118 assert!(matches!(
1119 &file.rewrites[1].action,
1120 RewriteAction::Removed { reason } if reason.contains("`required:` wins")
1121 ));
1122 assert!(report.required_added.is_empty());
1123 }
1124
1125 #[test]
1129 fn bare_fields_get_required_true_only_in_a_pre_flip_package() {
1130 let text = type_with(&LEGACY_KEYS[4]).replace(
1131 " optional: true # was optional\n",
1132 " optional: true\n - key: bare_one\n description: No polarity key\n field_type: string\n",
1133 );
1134 let dir = tempfile::tempdir().unwrap();
1135 write_package(dir.path(), &text);
1136 let report = migrate_package(dir.path()).unwrap();
1137 assert!(report.legacy_polarity);
1138 assert_eq!(
1139 report.required_added,
1140 vec![BareField {
1141 type_name: "sample".to_string(),
1142 field: "bare_one".to_string(),
1143 }]
1144 );
1145 let file = report.changed_files().next().unwrap();
1146 assert!(
1147 file.new_text.contains(
1148 " - key: bare_one\n required: true\n description: No polarity key\n"
1149 ),
1150 "{}",
1151 file.new_text
1152 );
1153 assert_eq!(file.rewrites.len(), 2);
1154 assert_eq!(file.rewrites[1].path, "metadata_fields[bare_one]");
1155 write_migration(&report).unwrap();
1156 let migrated = load_schema_from_dir(dir.path()).unwrap();
1157 let fields: Vec<(String, bool)> = migrated.types["sample"]
1158 .metadata_fields
1159 .iter()
1160 .filter(|f| f.key == "status" || f.key == "bare_one")
1161 .map(|f| (f.key.clone(), f.is_required()))
1162 .collect();
1163 assert_eq!(
1164 fields,
1165 vec![
1166 ("status".to_string(), false),
1167 ("bare_one".to_string(), true)
1168 ]
1169 );
1170
1171 let current = type_with(&LEGACY_KEYS[0]).replace(
1174 " default_value: active\n",
1175 " default_value: active\n - key: bare_one\n description: No polarity key\n field_type: string\n",
1176 );
1177 let dir = tempfile::tempdir().unwrap();
1178 write_package(dir.path(), ¤t);
1179 let report = migrate_package(dir.path()).unwrap();
1180 assert!(!report.legacy_polarity);
1181 assert!(report.required_added.is_empty());
1182 assert_eq!(report.rewrite_count(), 1);
1183 }
1184
1185 #[test]
1186 fn compact_sequence_form_is_tracked() {
1187 let text = type_with(&LEGACY_KEYS[4])
1188 .replace("metadata_fields:\n - key: status", "metadata_fields:\n- key: status")
1189 .replace(" description: Lifecycle state", " description: Lifecycle state")
1190 .replace(" field_type: string\n default_value: active\n optional: true # was optional\n", " field_type: string\n default_value: active\n optional: true\n");
1191 assert!(text.contains("\n- key: status\n description"), "{text}");
1192 let dir = tempfile::tempdir().unwrap();
1193 write_package(dir.path(), &text);
1194 let report = migrate_package(dir.path()).unwrap();
1195 assert_eq!(report.rewrite_count(), 1);
1196 assert_eq!(
1197 report.changed_files().next().unwrap().rewrites[0].path,
1198 "metadata_fields[status].optional"
1199 );
1200 }
1201
1202 #[test]
1203 fn nothing_to_migrate_is_a_noop_and_touches_nothing() {
1204 let clean = type_with(&LegacyKey {
1205 retired: "nothing",
1206 scope: LegacyScope::TypeTop,
1207 rewrite: LegacyRewrite::Rename { to: "nothing" },
1208 });
1209 let dir = tempfile::tempdir().unwrap();
1210 write_package(dir.path(), &clean);
1211 let report = migrate_package(dir.path()).unwrap();
1212 assert!(report.is_noop());
1213 assert_eq!(report.schema, "example@1.0.0");
1214 write_migration(&report).unwrap();
1215 assert_eq!(
1216 std::fs::read_to_string(dir.path().join("types/sample.yaml")).unwrap(),
1217 clean
1218 );
1219 }
1220
1221 #[test]
1222 fn non_boolean_optional_refuses_by_name() {
1223 let text =
1224 type_with(&LEGACY_KEYS[4]).replace("optional: true # was optional", "optional: maybe");
1225 let dir = tempfile::tempdir().unwrap();
1226 write_package(dir.path(), &text);
1227 let err = migrate_package(dir.path()).unwrap_err();
1228 assert!(
1229 matches!(
1230 &err,
1231 MigrateError::UnmigratableValue { file, key, value, .. }
1232 if file == "types/sample.yaml" && *key == "optional" && value == "maybe"
1233 ),
1234 "{err}"
1235 );
1236 }
1237
1238 #[test]
1239 fn sealed_package_and_non_package_refuse() {
1240 let dir = tempfile::tempdir().unwrap();
1241 assert!(matches!(
1242 migrate_package(dir.path()).unwrap_err(),
1243 MigrateError::NotAPackage { .. }
1244 ));
1245 write_package(dir.path(), &type_with(&LEGACY_KEYS[0]));
1246 std::fs::write(dir.path().join(SCHEMA_FORMAT_MARKER_FILE), "{}\n").unwrap();
1247 assert!(matches!(
1248 migrate_package(dir.path()).unwrap_err(),
1249 MigrateError::SealedPackage { .. }
1250 ));
1251 }
1252
1253 #[test]
1256 fn unreachable_spelling_refuses_instead_of_writing() {
1257 let text = type_with(&LEGACY_KEYS[0]).replace(
1258 "propagating_relationships: [PART_OF] # keep me",
1259 "\"propagating_relationships\": [PART_OF]",
1260 ) + "examples: []\n";
1261 let dir = tempfile::tempdir().unwrap();
1262 write_package(dir.path(), &text);
1263 let err = migrate_package(dir.path()).unwrap_err();
1264 assert!(
1265 matches!(err, MigrateError::RewriteLeavesViolations { .. }),
1266 "{err}"
1267 );
1268 }
1269
1270 #[test]
1271 fn scanner_paths() {
1272 let text = "a:\n b: 1 # c\n list:\n - k: v\n opt: true\n - k: w\n text: |\n key: not a key\n - nope: x\n after: 2\n";
1273 let keys = scan_key_lines(text);
1274 let paths: Vec<String> = keys.iter().map(|k| k.path.join("/")).collect();
1275 assert_eq!(
1276 paths,
1277 vec![
1278 "a",
1279 "a/b",
1280 "a/list",
1281 "a/list/[]/k",
1282 "a/list/[]/opt",
1283 "a/list/[]/k",
1284 "a/text",
1285 "a/after"
1286 ]
1287 );
1288 assert_eq!(keys[1].value, "1");
1289 let items: Vec<usize> = keys
1290 .iter()
1291 .filter(|k| k.key == "k")
1292 .map(|k| k.parent)
1293 .collect();
1294 assert_ne!(items[0], items[1], "each sequence item groups its own keys");
1295 }
1296}