Skip to main content

memstead_schema/
migrate.rs

1//! `schema migrate` — rewrite an authoring package's retired keys into
2//! the current schema language, by exactly the translations the loader
3//! applies to sealed packages.
4//!
5//! The loader keeps every retired key as a serde sentinel on the type
6//! structs (the `legacy_*` fields in `types.rs`): sealed content is
7//! translated so shipped packages keep loading, authoring content
8//! refuses with a rename pointer so the author acts. This module is the
9//! act: it gives a directory package the same translation the sealed
10//! path performs, as a reviewable rewrite of the author's own files.
11//!
12//! One table. [`LEGACY_KEYS`] is the only enumeration of retired keys
13//! and their rewrites. The suite pins it against the sentinel
14//! declarations in `types.rs` (every `legacy_*` sentinel names a table
15//! row and vice versa), and the verb proves each rewrite faithful at
16//! run time: the original loads through the tolerant sealed-style read,
17//! the rewrite through the strict authoring read, and what the loader
18//! resolved from each must agree. A rewrite that does not reproduce the
19//! loader's translation refuses instead of writing.
20//!
21//! Text, not a YAML round-trip: `serde_yaml_ng` drops comments, and an
22//! author package is source — comments, key order, and spacing are the
23//! author's. The rewriter tracks the block-mapping path line by line,
24//! edits only the lines the table names, and leaves every other byte
25//! alone. Keys it cannot reach (quoted keys, flow-style mappings) are
26//! caught by the faithfulness check, never silently skipped.
27//!
28//! Polarity. The retired `optional:` key existed only under the pre-flip
29//! language, where an ABSENT key meant required; the sealed read of an
30//! unmarked package still gives it that meaning
31//! ([`MetadataPolarityFormat::Legacy`]). A package that carries
32//! `optional:` anywhere was therefore written under that language, and
33//! the migration conserves what it meant: every metadata field
34//! declaring neither key gets `required: true`, so the package says
35//! under the current language exactly what the sealed read made of it.
36//! The dry run shows each inserted line; deleting one is the author's
37//! call, not the engine's.
38
39use 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/// Where in a type file a retired key sits.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LegacyScope {
51    /// A top-level key of a type file.
52    TypeTop,
53    /// A key on one entry of `metadata_fields:`.
54    MetadataField,
55    /// A key on one entry of `exemplar: relations:`.
56    ExemplarRelation,
57}
58
59/// What the loader's sealed translation does with the key — and
60/// therefore what the rewriter writes.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum LegacyRewrite {
63    /// The key was renamed; its value carries over unchanged.
64    Rename { to: &'static str },
65    /// Dead vocabulary: the loader consumes nothing from it, so the key
66    /// and its value are removed. `pointer` names the current-language
67    /// key an author reaches for instead.
68    Drop { pointer: &'static str },
69    /// A boolean key of inverted polarity: `<retired>: true` becomes
70    /// absence (the current default), `<retired>: false` becomes
71    /// `<to>: true`. When the entry already carries `<to>`, the loader
72    /// lets it win and the retired key is simply removed.
73    InvertBool { to: &'static str },
74}
75
76/// One retired key: its spelling, where it sits, what replaces it.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct LegacyKey {
79    pub retired: &'static str,
80    pub scope: LegacyScope,
81    pub rewrite: LegacyRewrite,
82}
83
84/// The retired keys the loader still reads on sealed content, with the
85/// translation it applies — the single definition the migrate verb
86/// rewrites from. Mirrors the `legacy_*` serde sentinels in `types.rs`
87/// one-to-one; the suite fails when either side gains a key the other
88/// lacks.
89pub 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/// Why a migration could not be computed or written.
122#[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    /// The package fails to load even under the tolerant sealed-style
157    /// read that translates every retired key — so something other than
158    /// a retired key is wrong, and rewriting spellings would not help.
159    #[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    /// The rewrite still refuses under the strict authoring read. The
169    /// rewriter reached every key it can (block-style, unquoted); what
170    /// remains is a spelling it does not edit.
171    #[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    /// The rewrite loads but resolves differently from the loader's
182    /// own translation of the original — an internal defect, never an
183    /// authoring problem. Nothing is written.
184    #[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/// What one rewrite did to one line.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum RewriteAction {
194    /// The key was renamed on its line; the value is untouched.
195    Renamed { to: &'static str },
196    /// The line (and any block value under it) was removed.
197    Removed { reason: String },
198    /// The line was replaced by another key/value pair.
199    Replaced { with: String },
200    /// A line was inserted after this one (polarity conservation).
201    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/// One rewritten occurrence of a retired key.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct Rewrite {
220    /// 1-based line in the original file.
221    pub line: usize,
222    /// The retired key as written — or the key inserted, for
223    /// [`RewriteAction::Inserted`].
224    pub key: &'static str,
225    /// Where the key sat, as a human path
226    /// (`metadata_fields[source_family].optional`).
227    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/// One type file's migration: the rewrites and the resulting text.
238#[derive(Debug, Clone)]
239pub struct FileMigration {
240    /// Package-relative path (`types/anchor.yaml`).
241    pub rel_path: String,
242    pub rewrites: Vec<Rewrite>,
243    /// The file after the rewrites; equal to the original when
244    /// `rewrites` is empty.
245    pub new_text: String,
246}
247
248/// A metadata field that declared neither `optional:` nor `required:`
249/// in a package written under the pre-flip language (it carried the
250/// retired `optional:` key), and therefore received `required: true` —
251/// the meaning the sealed read gives it.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct BareField {
254    pub type_name: String,
255    pub field: String,
256}
257
258/// The computed migration of one package. Computing never writes;
259/// [`write_migration`] applies it.
260#[derive(Debug, Clone)]
261pub struct MigrateReport {
262    pub package: PathBuf,
263    /// `name@version` from the manifest.
264    pub schema: String,
265    /// Every type file, in stem order; files without rewrites carry an
266    /// empty `rewrites` and their original text.
267    pub files: Vec<FileMigration>,
268    /// Whether the package was read as pre-flip content (it carries the
269    /// retired `optional:` key), which is what made `required_added`
270    /// non-empty.
271    pub legacy_polarity: bool,
272    /// See [`BareField`]. Empty unless `legacy_polarity`.
273    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    /// The files that actually change.
286    pub fn changed_files(&self) -> impl Iterator<Item = &FileMigration> {
287        self.files.iter().filter(|f| !f.rewrites.is_empty())
288    }
289}
290
291/// Compute the migration of the authoring package at `dir`. Reads only;
292/// the report carries the rewritten texts for [`write_migration`].
293pub 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    // Generation is a package-wide fact: one retired `optional:`
337    // anywhere means the whole package was written pre-flip.
338    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    // Faithfulness: the loader's own translation of the original — the
368    // sealed-style read under the generation the package was written
369    // in — is the specification; the rewrite must resolve to the same
370    // schema under the strict authoring read.
371    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
397/// Write the report's changed files in place. Files without rewrites
398/// are not touched.
399pub 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
410/// The author's follow-up commands after a `--write`, in order.
411pub 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
444/// The first place two loaded schemas disagree on what the legacy
445/// translations resolve, or `None` when they agree.
446fn 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// ---------------------------------------------------------------------
500// The line-level rewriter
501// ---------------------------------------------------------------------
502
503/// One `key:` line the scanner found, with its block-mapping path.
504#[derive(Debug)]
505struct KeyLine<'a> {
506    /// 0-based line index.
507    idx: usize,
508    /// Column of the key's first character.
509    col: usize,
510    key: &'a str,
511    /// The inline value, comment stripped and trimmed; empty when the
512    /// value is a nested block.
513    value: &'a str,
514    /// Frame names from the root down to and including this key;
515    /// sequence items appear as `[]`.
516    path: Vec<&'a str>,
517    /// Line index of the frame this key is a child of — identifies the
518    /// sequence item (or mapping) that groups sibling keys.
519    parent: usize,
520}
521
522struct Frame<'a> {
523    indent: usize,
524    name: &'a str,
525    line: usize,
526}
527
528const ITEM: &str = "[]";
529
530/// Walk a block-style YAML document and report every `key:` line with
531/// its path. Block scalars, plain multi-line scalars, and flow
532/// collections are skipped as opaque values; comment and blank lines
533/// are ignored.
534fn scan_key_lines(text: &str) -> Vec<KeyLine<'_>> {
535    let mut frames: Vec<Frame<'_>> = Vec::new();
536    let mut out = Vec::new();
537    // While `Some(col)`, lines indented deeper than `col` are the body
538    // of an opaque value (block scalar, continuation, flow collection).
539    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        // Close frames this line is a sibling (or outdent) of. A `- `
558        // at the parent key's own indent is the compact sequence form
559        // and keeps that key frame open.
560        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            // A scalar item or a flow value: opaque, may continue deeper.
588            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            // Scalar (single or multi-line), block scalar, or flow
608            // collection: anything deeper belongs to it.
609            opaque_deeper_than = Some(col);
610        }
611    }
612    out
613}
614
615/// Split `key: value` on the first `:` that ends a plain, unquoted key.
616fn 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
640/// Cut a trailing ` # comment` off a scalar value, outside quotes.
641fn 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    /// Inclusive line range.
672    Delete(usize, usize),
673    /// A new line placed after the given one.
674    InsertAfter(usize, String),
675}
676
677struct FileOutcome {
678    rewrites: Vec<Rewrite>,
679    new_text: String,
680    required_added: Vec<String>,
681}
682
683/// Does this type file carry the retired `optional:` key on a metadata
684/// field? The generation signal for the whole package.
685fn 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    // Sibling knowledge per grouping frame: the `key:` value naming a
700    // metadata field, and whether its entry carries `required:`.
701    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    // Polarity conservation: in a pre-flip package a field declaring
788    // neither key meant required. Insert the current-language spelling
789    // right under the field's `key:` line, at the same indentation.
790    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
830/// Last line (inclusive) of the block value that starts at `start`:
831/// every following line indented deeper than `col`, blank lines
832/// between them included, trailing blanks excluded.
833fn 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    // Per original line: the (possibly replaced or deleted) line, then
852    // anything inserted after it.
853    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    /// Every `legacy_*` serde sentinel in `types.rs`, by its retired
885    /// spelling — read from the source so the table cannot drift from
886    /// the loader: a sentinel added without a table row (or a row
887    /// without a sentinel) fails here.
888    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    /// A type file carrying one occurrence of the given retired key in
933    /// its scope, with a comment beside it.
934    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    /// Per table row: the sealed read translates the key, the authoring
997    /// read refuses it, and the verb's rewrite validates afterwards —
998    /// the behavioural half of the one-table guarantee.
999    #[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            // The sealed read of an unmarked package: Legacy polarity
1005            // when it carries `optional:`, current otherwise.
1006            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        // Every other line survives byte for byte.
1060        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    /// A pre-flip package (it carries `optional:`) meant "required" by
1126    /// absence; the migration writes that meaning down, and the result
1127    /// resolves exactly as the sealed Legacy read of the original.
1128    #[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        // The same bare field in a current-language package (no
1172        // `optional:` anywhere) is left alone: nothing signals pre-flip.
1173        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(), &current);
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    /// A retired key the rewriter does not edit (quoted) is caught by
1254    /// the faithfulness check instead of being written past.
1255    #[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}