1use std::fmt::Write as _;
20use toml_edit::{DocumentMut, Item, Table, Value};
21
22use crate::error::RkError;
23
24const CLASS_MARKERS: [&str; 3] = ["# P:", "# N:", "# F:"];
29
30fn 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
40pub(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
74fn 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 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 = ¤t[..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
116pub(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
136fn 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
151fn move_key(from: &mut Table, key: &str, to: &mut Table, name: &str) -> Option<Value> {
154 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
168fn 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
187const LANDING_KEYS: [&str; 5] = ["workflow", "style", "nix", "scorecard", "code_scanning"];
189
190fn empty(value: &Value) -> bool {
192 value.as_str().is_some_and(str::is_empty)
193}
194
195#[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 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 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 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 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 let mut git = Table::new();
305 move_key(&mut project, "trunk", &mut git, "trunk");
306 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 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 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 let orphaned = crate::config::take_header_comments(&mut landing);
356
357 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 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 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 #[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 #[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 assert_eq!(to_schema_2(&migrated).expect("passes"), migrated);
491 }
492
493 #[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 #[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 #[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 #[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 #[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 #[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 #[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}