Skip to main content

release_kit/config/
migrate.rs

1//! The one bounded migration of a schema 1 configuration into the schema
2//! 2 domains, over the authored text, so every comment survives.
3//!
4//! Schema 1 kept the technology, the forge, and the trunk under
5//! `[project]`, the checkout mode, the style, and every opt-in under
6//! `[landing]`, and the line prefix under `[setup]`. Schema 2 states each
7//! under the domain that owns it. The migration moves each value with the
8//! decor it carried, so a comment the operator wrote travels with its
9//! line, renames the two checkout-mode values, and states the reporting
10//! policy the older landing carried implicitly.
11//!
12//! A comment the template itself wrote is refreshed rather than carried:
13//! a key whose vocabulary moved would otherwise stand beside a sentence
14//! describing the key it replaced. The template's own comments are the
15//! ones marked `# P:`, `# N:`, and `# F:`, and only those are replaced.
16//!
17//! SATISFIES project-profile:a-schema-one-configuration-migrates-in-place
18
19use std::fmt::Write as _;
20use toml_edit::{DocumentMut, Item, Table, Value};
21
22use crate::error::RkError;
23
24/// The three class markers the authored template opens its comments with.
25/// A trailing comment beginning with one of these is the template's own,
26/// so a migration refreshes it; anything else is the operator's and
27/// travels with its value.
28const CLASS_MARKERS: [&str; 3] = ["# P:", "# N:", "# F:"];
29
30/// Whether a trailing comment is the template's own.
31fn templated(decor: Option<&str>) -> bool {
32    decor.is_some_and(|text| {
33        let trimmed = text.trim_start();
34        CLASS_MARKERS
35            .iter()
36            .any(|marker| trimmed.starts_with(marker))
37    })
38}
39
40/// The authored template's trailing comment for one schema 2 key path,
41/// where the template carries one.
42///
43/// The template is a TOML skeleton whose values are substitution tokens,
44/// so it is read line by line rather than parsed: a `[table]` header
45/// names the path above each `key = TOKEN # comment` line below it.
46pub(crate) fn template_comment(path: &[&str]) -> Option<String> {
47    let text = crate::embedded::BLOCKS
48        .get_file("target-config.toml.in")
49        .and_then(include_dir::File::contents_utf8)?;
50    let (key, table) = path.split_last()?;
51    let wanted = table.join(".");
52    let mut current = String::new();
53    for line in text.lines() {
54        let trimmed = line.trim();
55        if let Some(name) = trimmed
56            .strip_prefix('[')
57            .and_then(|rest| rest.strip_suffix(']'))
58        {
59            name.clone_into(&mut current);
60            continue;
61        }
62        let Some((name, rest)) = trimmed.split_once(" = ") else {
63            continue;
64        };
65        if name != *key || current != wanted {
66            continue;
67        }
68        let comment = rest.find(" # ").map(|at| rest[at + 1..].to_owned());
69        return comment.map(|comment| format!(" {comment}"));
70    }
71    None
72}
73
74/// Give the value at `path` the template's own comment, where the comment
75/// it carries is the template's rather than the operator's, answering
76/// whether the text changed.
77///
78/// `add_missing` decides what a value carrying no comment at all takes. A
79/// migration gives it the template's, because the key it describes is new
80/// to that value. An ordinary rewrite does not, because a comment the
81/// operator deleted is a comment the operator deleted.
82fn refresh_comment(document: &mut DocumentMut, path: &[&str], add_missing: bool) -> bool {
83    let Some(comment) = template_comment(path) else {
84        return false;
85    };
86    let mut item = document.as_item_mut();
87    for segment in path {
88        if item.get(segment).is_none() {
89            return false;
90        }
91        item = &mut item[segment];
92    }
93    let Some(value) = item.as_value_mut() else {
94        return false;
95    };
96    let current = value.decor().suffix().and_then(|raw| raw.as_str());
97    // The whitespace in front of a comment is the file's form, which a
98    // project's formatter may have set: alignment is not a stale comment.
99    if current.map(str::trim_start) == Some(comment.trim_start()) {
100        return false;
101    }
102    if current.is_none() && add_missing {
103        value.decor_mut().set_suffix(comment);
104        return true;
105    }
106    if let Some(current) = current.filter(|current| templated(Some(current))) {
107        let padding = &current[..current.len() - current.trim_start().len()];
108        let padding = if padding.is_empty() { " " } else { padding };
109        let refreshed = format!("{padding}{}", comment.trim_start());
110        value.decor_mut().set_suffix(refreshed);
111        return true;
112    }
113    false
114}
115
116/// Give every value in `document` the template's own comment, answering
117/// whether any changed.
118///
119/// A target keeps its configuration across upgrades while this binary
120/// keeps rewriting the values in it, so a comment left alone outlives the
121/// text it explains. A target that moves to local integration reads
122/// `# F: invariant, contains all four rules` beside two of them, which is
123/// the binary stating something false about its own file.
124///
125/// Only the template's own comments, marked by [`CLASS_MARKERS`]. The
126/// operator's carry no marker and are never touched.
127pub(crate) fn refresh_comments(document: &mut DocumentMut) -> bool {
128    let mut changed = false;
129    for path in value_paths(document.as_table(), &[]) {
130        let borrowed: Vec<&str> = path.iter().map(String::as_str).collect();
131        changed |= refresh_comment(document, &borrowed, false);
132    }
133    changed
134}
135
136/// Every path to a value under `table`, each carrying `prefix` ahead of it.
137fn value_paths(table: &Table, prefix: &[String]) -> Vec<Vec<String>> {
138    let mut paths = Vec::new();
139    for (key, item) in table {
140        let mut path = prefix.to_vec();
141        path.push(key.to_owned());
142        match item {
143            Item::Table(inner) => paths.extend(value_paths(inner, &path)),
144            Item::Value(_) => paths.push(path),
145            _ => {}
146        }
147    }
148    paths
149}
150
151/// Move `key` out of `from` into `to` under `name`, decor and all, where
152/// `from` carries it.
153fn move_key(from: &mut Table, key: &str, to: &mut Table, name: &str) -> Option<Value> {
154    // The comment standing above the key is read before the move: the
155    // parser holds it on the source key, which the move discards, and
156    // `project-profile:a-schema-one-configuration-migrates-in-place` asks
157    // for every free-standing comment to survive.
158    let carried = crate::config::key_comments(from, key);
159    let item = from.remove(key)?;
160    let value = item.into_value().ok()?;
161    to.insert(name, Item::Value(value.clone()));
162    if let Some(carried) = carried {
163        crate::config::set_key_comments(to, name, &carried);
164    }
165    Some(value)
166}
167
168/// The key's value where schema 1 stated one, `None` where it stated the
169/// empty "detect" answer.
170///
171/// A dropped detect answer may still carry a comment the operator wrote,
172/// and `project-profile:a-schema-one-configuration-migrates-in-place` asks
173/// for every free-standing comment to survive the migration. The comment
174/// moves onto the table's header, which carries it further if the table
175/// itself empties.
176fn dropped_or_kept(table: &mut Table, key: &str) -> (Option<Value>, Option<String>) {
177    let carried = crate::config::key_comments(table, key);
178    let empty_answer = table.get(key).and_then(Item::as_value).is_some_and(empty);
179    if empty_answer {
180        crate::config::take_comments(table, key);
181        return (None, None);
182    }
183    let value = table.remove(key).and_then(|item| item.into_value().ok());
184    (value, carried)
185}
186
187/// Every key schema 1's `[landing]` table could carry.
188const LANDING_KEYS: [&str; 5] = ["workflow", "style", "nix", "scorecard", "code_scanning"];
189
190/// Whether a value is the empty string, which schema 1 used for "detect".
191fn empty(value: &Value) -> bool {
192    value.as_str().is_some_and(str::is_empty)
193}
194
195/// A schema 1 configuration's text as schema 2, or the text unchanged
196/// where it already states schema 2.
197///
198/// # Errors
199///
200/// Returns the reader's refusal for text that does not parse as TOML.
201#[allow(
202    clippy::too_many_lines,
203    reason = "one pass moves every schema 1 key into the domain that owns it, and splitting it would separate a move from the decor it carries"
204)]
205pub fn to_schema_2(text: &str) -> Result<String, RkError> {
206    let mut document = text
207        .parse::<DocumentMut>()
208        .map_err(|error| super::invalid(error.to_string()))?;
209    if document.get("schema_version").and_then(Item::as_integer) != Some(1) {
210        return Ok(text.to_owned());
211    }
212    // A file stating schema 1 must have a schema 1 shape. The strict
213    // reader never sees this text, so a table schema 1 did not have, or a
214    // legacy container that is not a table, has to refuse here: writing
215    // the generated table over it would replace the operator's content
216    // with a valid file and report success.
217    for name in ["profile", "git", "capabilities"] {
218        if document.get(name).is_some() {
219            return Err(super::invalid(format!(
220                "[{name}] is a schema 2 table and this file states schema_version = 1; set schema_version = 2, or remove the table"
221            )));
222        }
223    }
224    for name in ["project", "landing", "setup"] {
225        if document
226            .get(name)
227            .is_some_and(|item| item.as_table().is_none())
228        {
229            return Err(super::invalid(format!(
230                "{name} must be a table in a schema 1 file"
231            )));
232        }
233    }
234    document["schema_version"] = toml_edit::value(2);
235    if let Some(item) = document.get_mut("schema_version")
236        && let Some(value) = item.as_value_mut()
237    {
238        // The schema line keeps whatever decor the authored one carried.
239        let old = text
240            .parse::<DocumentMut>()
241            .ok()
242            .and_then(|old| old.get("schema_version").and_then(Item::as_value).cloned());
243        if let Some(old) = old {
244            *value.decor_mut() = old.decor().clone();
245        }
246    }
247
248    let mut project = document
249        .remove("project")
250        .and_then(|item| item.into_table().ok())
251        .unwrap_or_default();
252    let mut landing = document
253        .remove("landing")
254        .and_then(|item| item.into_table().ok())
255        .unwrap_or_default();
256
257    // `[profile]`: the one technology becomes the sole entry of the list,
258    // and the forge moves as it is. An empty value meant "detect" and
259    // becomes an absent key.
260    let mut profile = Table::new();
261    let (tech, tech_comments) = dropped_or_kept(&mut project, "tech");
262    if let Some(tech) = tech {
263        let mut list = toml_edit::Array::new();
264        list.push(tech.as_str().unwrap_or_default());
265        let mut value = Value::Array(list);
266        *value.decor_mut() = tech.decor().clone();
267        profile.insert("technologies", Item::Value(value));
268        if let Some(carried) = &tech_comments {
269            crate::config::set_key_comments(&mut profile, "technologies", carried);
270        }
271        let mut release = Table::new();
272        release.insert("mode", toml_edit::value("automatic"));
273        release.insert(
274            "driver",
275            toml_edit::value(tech.as_str().unwrap_or_default()),
276        );
277        move_key(&mut landing, "style", &mut release, "style");
278        let mut setup_table = document
279            .remove("setup")
280            .and_then(|item| item.into_table().ok())
281            .unwrap_or_default();
282        move_key(&mut setup_table, "line_prefix", &mut release, "line_prefix");
283        document.insert("setup", Item::Table(setup_table));
284        profile.insert("release", Item::Table(release));
285    } else {
286        // No technology, whether the key is absent or the schema 1 detect
287        // value: the setup table still loses its prefix key and the style
288        // key goes, because neither belongs to a profile with no automatic
289        // release.
290        if let Some(setup) = document.get_mut("setup").and_then(Item::as_table_mut) {
291            crate::config::take_comments(setup, "line_prefix");
292        }
293        crate::config::take_comments(&mut landing, "style");
294    }
295    let (forge, forge_comments) = dropped_or_kept(&mut project, "forge");
296    if let Some(forge) = forge {
297        profile.insert("forge", Item::Value(forge));
298        if let Some(carried) = &forge_comments {
299            crate::config::set_key_comments(&mut profile, "forge", carried);
300        }
301    }
302
303    // `[git]`: the trunk, and the checkout mode with its values renamed.
304    let mut git = Table::new();
305    move_key(&mut project, "trunk", &mut git, "trunk");
306    // The checkout mode renames its key and its vocabulary at once, so it
307    // cannot go through `move_key`; it reads the source key's comments the
308    // same way, because a rename is still a move to the operator.
309    let mode_comments = crate::config::key_comments(&landing, "workflow");
310    if let Some(mode) = landing
311        .remove("workflow")
312        .and_then(|item| item.into_value().ok())
313    {
314        let renamed = match mode.as_str() {
315            Some("worktree") => Some("linked-worktree"),
316            Some("branches") => Some("main-worktree"),
317            _ => None,
318        };
319        let mut value = renamed.map_or_else(|| mode.clone(), Value::from);
320        *value.decor_mut() = mode.decor().clone();
321        git.insert("checkout_mode", Item::Value(value));
322        if let Some(carried) = &mode_comments {
323            crate::config::set_key_comments(&mut git, "checkout_mode", carried);
324        }
325    }
326
327    // `[capabilities]`: the opt-ins, and the reporting policy the older
328    // landing carried without a key.
329    let mut capabilities = Table::new();
330    move_key(&mut landing, "nix", &mut capabilities, "nix_packaging");
331    capabilities.insert("reporting_policy", toml_edit::value(true));
332    move_key(&mut landing, "scorecard", &mut capabilities, "scorecard");
333    move_key(
334        &mut landing,
335        "code_scanning",
336        &mut capabilities,
337        "code_scanning",
338    );
339
340    // The `[landing]` table has no schema 2 successor, so it is discarded
341    // with whatever is left in it. A key still standing in it is one this
342    // migration does not know, and silently dropping it would take the
343    // unknown-key policy off the schema 1 path: the strict reader never
344    // sees the original text, so this is where `target-config:an-unknown-key-refuses`
345    // has to hold.
346    if let Some(unknown) = landing.iter().map(|(key, _)| key).next() {
347        let mut message = format!("landing.{unknown} is not a key this migration knows");
348        if let Some(nearest) = crate::config::nearest_known(unknown, &LANDING_KEYS) {
349            let _ = write!(message, "; nearest known key: landing.{nearest}");
350        }
351        return Err(super::invalid(message));
352    }
353    // A comment still standing on its header, including one carried off a
354    // key this migration dropped, outlives it.
355    let orphaned = crate::config::take_header_comments(&mut landing);
356
357    // Reassemble in the schema 2 order: the project table keeps its
358    // remaining keys and its decor, the new tables follow it, and the
359    // tables schema 2 keeps stand after them in their authored order.
360    let mut rest: Vec<(String, Item)> = Vec::new();
361    for name in ["security", "setup", "protection"] {
362        if let Some(item) = document.remove(name) {
363            rest.push((name.to_owned(), item));
364        }
365    }
366    document.insert("project", Item::Table(project));
367    for (name, table) in [
368        ("profile", profile),
369        ("git", git),
370        ("capabilities", capabilities),
371    ] {
372        let mut table = table;
373        table.set_implicit(table.is_empty());
374        if name == "profile"
375            && let Some(release) = table.get_mut("release").and_then(Item::as_table_mut)
376        {
377            release.set_implicit(release.is_empty());
378        }
379        document.insert(name, Item::Table(table));
380    }
381    for (name, item) in rest {
382        document.insert(&name, item);
383    }
384    // Every key the migration writes states what it means now: a comment
385    // the operator wrote stays, and the template's own is refreshed. Every
386    // value the document carries rather than a named list, because a list
387    // is one more place a key added later must be remembered.
388    for path in value_paths(document.as_table(), &[]) {
389        let borrowed: Vec<&str> = path.iter().map(String::as_str).collect();
390        refresh_comment(&mut document, &borrowed, true);
391    }
392    // A schema 1 file whose project keys all moved or dropped leaves an
393    // empty table, which
394    // `target-config:an-unanswered-key-is-absent-and-not-empty` asks the
395    // writer to leave out. Pruning it rather than hiding it keeps every
396    // comment the header carried, which hiding would suppress with it.
397    crate::config::prune_empty_tables(&mut document, &["project"]);
398    if let Some(orphaned) = orphaned {
399        crate::config::place_carried(&mut document, &orphaned);
400    }
401    Ok(document.to_string())
402}
403
404#[cfg(test)]
405mod tests {
406    use super::{refresh_comments, to_schema_2};
407
408    /// A template comment a formatter padded is current, and a reworded
409    /// one refreshes after the padding the file carries, while the
410    /// operator's own comment is never touched.
411    #[test]
412    fn a_refresh_keeps_the_padding_in_front_of_a_comment() {
413        let aligned =
414            "[profile.release]\nmode = \"automatic\"       # P: automatic, external, or none\n";
415        let mut document = aligned.parse::<toml_edit::DocumentMut>().expect("parses");
416        assert!(!refresh_comments(&mut document));
417        assert_eq!(document.to_string(), aligned);
418
419        let stale = "[profile.release]\nmode = \"automatic\"       # P: an older sentence\nstyle = \"trunk\" # the operator's note\n";
420        let mut document = stale.parse::<toml_edit::DocumentMut>().expect("parses");
421        assert!(refresh_comments(&mut document));
422        assert_eq!(
423            document.to_string(),
424            "[profile.release]\nmode = \"automatic\"       # P: automatic, external, or none\nstyle = \"trunk\" # the operator's note\n"
425        );
426    }
427
428    /// Every moved key keeps its trailing comment, the mode values rename,
429    /// the reporting policy appears, and the unmoved tables survive.
430    #[test]
431    fn a_schema_1_text_migrates_with_its_comments() {
432        let old = "# heading\nschema_version = 1 # the schema\n\n[project]\nrepo = \"acme/widget\" # operator note\nforge = \"github\" # P: forge\ntech = \"rust\" # P: binding\ntrunk = \"main\" # N: trunk\n\n[landing]\nworkflow = \"worktree\" # P: mode\nstyle = \"lines\" # P: style\nnix = true # P: nix\nscorecard = false\ncode_scanning = \"semgrep\"\n\n[security]\ncontact = \"team\" # keep\n\n[setup]\nrequired_check = \"gate\"\nline_prefix = \"stable/\" # P: prefix\n";
433        let migrated = to_schema_2(old).expect("migrates");
434        let doc: toml::Table = migrated.parse().expect("the result parses");
435        assert_eq!(doc["schema_version"].as_integer(), Some(2));
436        assert!(migrated.contains("schema_version = 2 # the schema"));
437        assert_eq!(doc["project"]["repo"].as_str(), Some("acme/widget"));
438        assert!(doc["project"].get("tech").is_none());
439        assert_eq!(
440            doc["profile"]["technologies"].as_array().map(Vec::len),
441            Some(1)
442        );
443        assert_eq!(doc["profile"]["forge"].as_str(), Some("github"));
444        assert_eq!(
445            doc["profile"]["release"]["mode"].as_str(),
446            Some("automatic")
447        );
448        assert_eq!(doc["profile"]["release"]["driver"].as_str(), Some("rust"));
449        assert_eq!(doc["profile"]["release"]["style"].as_str(), Some("lines"));
450        assert!(
451            migrated.contains("style = \"lines\" # P: trunk or lines; automatic alone"),
452            "the template's own comment states what the key means now: {migrated}"
453        );
454        assert_eq!(
455            doc["profile"]["release"]["line_prefix"].as_str(),
456            Some("stable/")
457        );
458        assert!(migrated.contains("line_prefix = \"stable/\" # P: release-line branch prefix"));
459        assert_eq!(doc["git"]["trunk"].as_str(), Some("main"));
460        assert_eq!(
461            doc["git"]["checkout_mode"].as_str(),
462            Some("linked-worktree")
463        );
464        assert!(
465            migrated.contains(
466                "checkout_mode = \"linked-worktree\" # P: linked-worktree or main-worktree"
467            ),
468            "a renamed vocabulary takes the template's own sentence: {migrated}"
469        );
470        assert!(
471            migrated.contains("repo = \"acme/widget\" # operator note"),
472            "a comment the operator wrote travels with its value: {migrated}"
473        );
474        assert_eq!(doc["capabilities"]["nix_packaging"].as_bool(), Some(true));
475        assert_eq!(
476            doc["capabilities"]["reporting_policy"].as_bool(),
477            Some(true)
478        );
479        assert_eq!(doc["capabilities"]["scorecard"].as_bool(), Some(false));
480        assert_eq!(
481            doc["capabilities"]["code_scanning"].as_str(),
482            Some("semgrep")
483        );
484        assert!(doc.get("landing").is_none());
485        assert!(doc["setup"].get("line_prefix").is_none());
486        assert_eq!(doc["setup"]["required_check"].as_str(), Some("gate"));
487        assert!(migrated.contains("contact = \"team\" # keep"));
488        assert!(migrated.starts_with("# heading\n"));
489        // Idempotent: a schema 2 text passes through unchanged.
490        assert_eq!(to_schema_2(&migrated).expect("passes"), migrated);
491    }
492
493    /// A schema 1 file with an empty technology and forge, the "detect"
494    /// answers, migrates to absent keys.
495    #[test]
496    fn an_empty_detect_answer_becomes_an_absent_key() {
497        let migrated = to_schema_2(
498            "schema_version = 1\n[project]\nforge = \"\"\ntech = \"\"\n[landing]\nworkflow = \"branches\"\n",
499        )
500        .expect("migrates");
501        let doc: toml::Table = migrated.parse().expect("parses");
502        assert!(doc.get("profile").is_none_or(|p| p.get("forge").is_none()));
503        assert!(
504            doc.get("profile")
505                .is_none_or(|p| p.get("technologies").is_none())
506        );
507        assert_eq!(doc["git"]["checkout_mode"].as_str(), Some("main-worktree"));
508        assert!(
509            !migrated.contains("[project]"),
510            "a table every one of whose keys dropped goes with them: {migrated}"
511        );
512    }
513
514    /// SATISFIES project-profile:a-schema-one-configuration-migrates-in-place
515    /// The migrated file loses the header whose every key moved or
516    /// dropped, and keeps the comment that header carried.
517    #[test]
518    fn an_emptied_project_header_goes_and_its_comment_stays() {
519        let migrated = to_schema_2(
520            "schema_version = 1\n\n# the operator's note\n[project]\nforge = \"\"\ntech = \"\"\n\n[security]\ncontact = \"team\"\n",
521        )
522        .expect("migrates");
523        assert!(
524            !migrated.contains("[project]"),
525            "a table every one of whose keys dropped goes with them: {migrated}"
526        );
527        assert!(
528            migrated.contains("# the operator's note"),
529            "the comment the header carried survives: {migrated}"
530        );
531        crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
532    }
533
534    /// SATISFIES project-profile:a-schema-one-configuration-migrates-in-place
535    /// The detect answer and the absent key take the same cleanup path. A
536    /// schema 1 file could state `tech = ""` beside a release-line prefix
537    /// and a style, and both keys belong to an automatic release the
538    /// migrated profile does not have. Leaving either behind writes a
539    /// schema 2 file the strict reader then rejects.
540    #[test]
541    fn an_empty_technology_still_clears_the_automatic_release_keys() {
542        let migrated = to_schema_2(
543            "schema_version = 1\n[project]\ntech = \"\"\nforge = \"github\"\nrepo = \"acme/widget\"\n[landing]\nstyle = \"lines\"\n[setup]\nline_prefix = \"stable/\"\nrequired_check = \"gate\"\n",
544        )
545        .expect("migrates");
546        let doc: toml::Table = migrated.parse().expect("parses");
547        assert!(
548            doc["setup"].get("line_prefix").is_none(),
549            "the prefix belongs to an automatic release: {migrated}"
550        );
551        assert!(
552            doc.get("profile")
553                .is_none_or(|profile| profile.get("release").is_none()),
554            "no technology means no release intent: {migrated}"
555        );
556        assert!(doc.get("landing").is_none(), "{migrated}");
557        assert_eq!(doc["setup"]["required_check"].as_str(), Some("gate"));
558        crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
559    }
560
561    /// SATISFIES project-profile:a-schema-one-configuration-migrates-in-place
562    /// A schema 1 key the migration drops takes the template's own comment
563    /// with it and leaves the operator's behind. Every free-standing
564    /// comment survives, which is what the rule asks for.
565    #[test]
566    fn a_dropped_schema_1_key_keeps_the_operators_comment() {
567        let migrated = to_schema_2(concat!(
568            "schema_version = 1\n\n[project]\nrepo = \"acme/widget\"\n",
569            "# this project has no forge yet\n",
570            "forge = \"\" # P: forge\n",
571            "# and no binding release-kit knows\n",
572            "tech = \"\" # P: binding\n\n",
573            "[landing]\n# the style we used to ask for\nstyle = \"lines\"\n\n",
574            "[setup]\nrequired_check = \"gate\"\n",
575            "# the prefix we used to ask for\nline_prefix = \"stable/\"\n"
576        ))
577        .expect("migrates");
578        for note in [
579            "# this project has no forge yet",
580            "# and no binding release-kit knows",
581            "# the style we used to ask for",
582            "# the prefix we used to ask for",
583        ] {
584            assert!(
585                migrated.contains(note),
586                "authored text survives the migration: {note}: {migrated}"
587            );
588        }
589        assert!(!migrated.contains("forge ="), "{migrated}");
590        assert!(!migrated.contains("tech ="), "{migrated}");
591        assert!(!migrated.contains("style ="), "{migrated}");
592        assert!(!migrated.contains("line_prefix ="), "{migrated}");
593        crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
594    }
595
596    /// SATISFIES project-profile:a-schema-one-configuration-migrates-in-place
597    /// A comment standing above a key the migration moves travels with the
598    /// value it describes, rather than staying beside a key that is gone.
599    #[test]
600    fn a_moved_key_takes_the_comment_above_it() {
601        let migrated = to_schema_2(concat!(
602            "schema_version = 1\n\n[project]\nrepo = \"acme/widget\"\n",
603            "# the one binding this project releases from\ntech = \"rust\"\n",
604            "forge = \"github\"\n",
605            "# why this branch is fixed\ntrunk = \"main\"\n\n",
606            "[landing]\n# why this project takes lines\nstyle = \"lines\"\n",
607            "# why topics use the main checkout\nworkflow = \"branches\"\n"
608        ))
609        .expect("migrates");
610        for note in [
611            "# the one binding this project releases from",
612            "# why this branch is fixed",
613            "# why this project takes lines",
614            "# why topics use the main checkout",
615        ] {
616            assert!(migrated.contains(note), "{note}: {migrated}");
617        }
618        let doc: toml::Table = migrated.parse().expect("parses");
619        assert_eq!(doc["git"]["trunk"].as_str(), Some("main"));
620        assert_eq!(doc["profile"]["release"]["style"].as_str(), Some("lines"));
621        assert_eq!(doc["git"]["checkout_mode"].as_str(), Some("main-worktree"));
622        crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
623    }
624
625    /// SATISFIES target-config:an-unknown-key-refuses
626    /// The strict reader never sees the schema 1 text, so a key the
627    /// migration does not know refuses here. Dropping it silently would
628    /// accept a typo and land the default behaviour under it.
629    #[test]
630    fn an_unknown_schema_1_landing_key_refuses_by_name() {
631        let refusal = to_schema_2("schema_version = 1\n[landing]\nworkflo = \"branches\"\n")
632            .expect_err("an unknown key refuses")
633            .to_string();
634        assert!(refusal.contains("workflo"), "{refusal}");
635        assert!(
636            refusal.contains("nearest known key: landing.workflow"),
637            "{refusal}"
638        );
639    }
640
641    /// SATISFIES target-config:an-unknown-key-refuses
642    /// A file that states schema 1 must have a schema 1 shape. The
643    /// generated tables would otherwise be written over whatever stood
644    /// where they go, and the result would read as valid.
645    #[test]
646    fn a_schema_1_file_with_a_schema_2_shape_refuses() {
647        for (text, named) in [
648            (
649                "schema_version = 1\n[profile]\nforg = \"github\"\n",
650                "[profile]",
651            ),
652            ("schema_version = 1\n[git]\ntrunk = \"main\"\n", "[git]"),
653            (
654                "schema_version = 1\n[capabilities]\nscorecard = true\n",
655                "[capabilities]",
656            ),
657            ("schema_version = 1\nproject = \"acme/widget\"\n", "project"),
658            ("schema_version = 1\nlanding = 3\n", "landing"),
659            (
660                "schema_version = 1\nsetup = \"operator value\"\n[project]\ntech = \"rust\"\n",
661                "setup",
662            ),
663        ] {
664            let refusal = to_schema_2(text)
665                .expect_err("a shape schema 1 never had refuses")
666                .to_string();
667            assert!(refusal.contains(named), "{named}: {refusal}");
668        }
669    }
670}