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
40fn 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]) {
77 let Some(comment) = template_comment(path) else {
78 return;
79 };
80 let mut item = document.as_item_mut();
81 for segment in path {
82 if item.get(segment).is_none() {
83 return;
84 }
85 item = &mut item[segment];
86 }
87 let Some(value) = item.as_value_mut() else {
88 return;
89 };
90 let current = value.decor().suffix().and_then(|s| s.as_str());
91 if current.is_none() || templated(current) {
92 value.decor_mut().set_suffix(comment);
93 }
94}
95
96fn move_key(from: &mut Table, key: &str, to: &mut Table, name: &str) -> Option<Value> {
99 let carried = crate::config::key_comments(from, key);
104 let item = from.remove(key)?;
105 let value = item.into_value().ok()?;
106 to.insert(name, Item::Value(value.clone()));
107 if let Some(carried) = carried {
108 crate::config::set_key_comments(to, name, &carried);
109 }
110 Some(value)
111}
112
113fn dropped_or_kept(table: &mut Table, key: &str) -> (Option<Value>, Option<String>) {
122 let carried = crate::config::key_comments(table, key);
123 let empty_answer = table.get(key).and_then(Item::as_value).is_some_and(empty);
124 if empty_answer {
125 crate::config::take_comments(table, key);
126 return (None, None);
127 }
128 let value = table.remove(key).and_then(|item| item.into_value().ok());
129 (value, carried)
130}
131
132const LANDING_KEYS: [&str; 5] = ["workflow", "style", "nix", "scorecard", "code_scanning"];
134
135fn empty(value: &Value) -> bool {
137 value.as_str().is_some_and(str::is_empty)
138}
139
140#[allow(
147 clippy::too_many_lines,
148 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"
149)]
150pub fn to_schema_2(text: &str) -> Result<String, RkError> {
151 let mut document = text
152 .parse::<DocumentMut>()
153 .map_err(|error| super::invalid(error.to_string()))?;
154 if document.get("schema_version").and_then(Item::as_integer) != Some(1) {
155 return Ok(text.to_owned());
156 }
157 for name in ["profile", "git", "capabilities"] {
163 if document.get(name).is_some() {
164 return Err(super::invalid(format!(
165 "[{name}] is a schema 2 table and this file states schema_version = 1; set schema_version = 2, or remove the table"
166 )));
167 }
168 }
169 for name in ["project", "landing", "setup"] {
170 if document
171 .get(name)
172 .is_some_and(|item| item.as_table().is_none())
173 {
174 return Err(super::invalid(format!(
175 "{name} must be a table in a schema 1 file"
176 )));
177 }
178 }
179 document["schema_version"] = toml_edit::value(2);
180 if let Some(item) = document.get_mut("schema_version")
181 && let Some(value) = item.as_value_mut()
182 {
183 let old = text
185 .parse::<DocumentMut>()
186 .ok()
187 .and_then(|old| old.get("schema_version").and_then(Item::as_value).cloned());
188 if let Some(old) = old {
189 *value.decor_mut() = old.decor().clone();
190 }
191 }
192
193 let mut project = document
194 .remove("project")
195 .and_then(|item| item.into_table().ok())
196 .unwrap_or_default();
197 let mut landing = document
198 .remove("landing")
199 .and_then(|item| item.into_table().ok())
200 .unwrap_or_default();
201
202 let mut profile = Table::new();
206 let (tech, tech_comments) = dropped_or_kept(&mut project, "tech");
207 if let Some(tech) = tech {
208 let mut list = toml_edit::Array::new();
209 list.push(tech.as_str().unwrap_or_default());
210 let mut value = Value::Array(list);
211 *value.decor_mut() = tech.decor().clone();
212 profile.insert("technologies", Item::Value(value));
213 if let Some(carried) = &tech_comments {
214 crate::config::set_key_comments(&mut profile, "technologies", carried);
215 }
216 let mut release = Table::new();
217 release.insert("mode", toml_edit::value("automatic"));
218 release.insert(
219 "driver",
220 toml_edit::value(tech.as_str().unwrap_or_default()),
221 );
222 move_key(&mut landing, "style", &mut release, "style");
223 let mut setup_table = document
224 .remove("setup")
225 .and_then(|item| item.into_table().ok())
226 .unwrap_or_default();
227 move_key(&mut setup_table, "line_prefix", &mut release, "line_prefix");
228 document.insert("setup", Item::Table(setup_table));
229 profile.insert("release", Item::Table(release));
230 } else {
231 if let Some(setup) = document.get_mut("setup").and_then(Item::as_table_mut) {
236 crate::config::take_comments(setup, "line_prefix");
237 }
238 crate::config::take_comments(&mut landing, "style");
239 }
240 let (forge, forge_comments) = dropped_or_kept(&mut project, "forge");
241 if let Some(forge) = forge {
242 profile.insert("forge", Item::Value(forge));
243 if let Some(carried) = &forge_comments {
244 crate::config::set_key_comments(&mut profile, "forge", carried);
245 }
246 }
247
248 let mut git = Table::new();
250 move_key(&mut project, "trunk", &mut git, "trunk");
251 let mode_comments = crate::config::key_comments(&landing, "workflow");
255 if let Some(mode) = landing
256 .remove("workflow")
257 .and_then(|item| item.into_value().ok())
258 {
259 let renamed = match mode.as_str() {
260 Some("worktree") => Some("linked-worktree"),
261 Some("branches") => Some("main-worktree"),
262 _ => None,
263 };
264 let mut value = renamed.map_or_else(|| mode.clone(), Value::from);
265 *value.decor_mut() = mode.decor().clone();
266 git.insert("checkout_mode", Item::Value(value));
267 if let Some(carried) = &mode_comments {
268 crate::config::set_key_comments(&mut git, "checkout_mode", carried);
269 }
270 }
271
272 let mut capabilities = Table::new();
275 move_key(&mut landing, "nix", &mut capabilities, "nix_packaging");
276 capabilities.insert("reporting_policy", toml_edit::value(true));
277 move_key(&mut landing, "scorecard", &mut capabilities, "scorecard");
278 move_key(
279 &mut landing,
280 "code_scanning",
281 &mut capabilities,
282 "code_scanning",
283 );
284
285 if let Some(unknown) = landing.iter().map(|(key, _)| key).next() {
292 let mut message = format!("landing.{unknown} is not a key this migration knows");
293 if let Some(nearest) = crate::config::nearest_known(unknown, &LANDING_KEYS) {
294 let _ = write!(message, "; nearest known key: landing.{nearest}");
295 }
296 return Err(super::invalid(message));
297 }
298 let orphaned = crate::config::take_header_comments(&mut landing);
301
302 let mut rest: Vec<(String, Item)> = Vec::new();
306 for name in ["security", "setup", "protection"] {
307 if let Some(item) = document.remove(name) {
308 rest.push((name.to_owned(), item));
309 }
310 }
311 document.insert("project", Item::Table(project));
312 for (name, table) in [
313 ("profile", profile),
314 ("git", git),
315 ("capabilities", capabilities),
316 ] {
317 let mut table = table;
318 table.set_implicit(table.is_empty());
319 if name == "profile"
320 && let Some(release) = table.get_mut("release").and_then(Item::as_table_mut)
321 {
322 release.set_implicit(release.is_empty());
323 }
324 document.insert(name, Item::Table(table));
325 }
326 for (name, item) in rest {
327 document.insert(&name, item);
328 }
329 for path in [
332 &["project", "repo"][..],
333 &["profile", "technologies"],
334 &["profile", "forge"],
335 &["profile", "release", "mode"],
336 &["profile", "release", "driver"],
337 &["profile", "release", "style"],
338 &["profile", "release", "line_prefix"],
339 &["git", "trunk"],
340 &["git", "checkout_mode"],
341 &["capabilities", "nix_packaging"],
342 &["capabilities", "reporting_policy"],
343 &["capabilities", "scorecard"],
344 &["capabilities", "code_scanning"],
345 ] {
346 refresh_comment(&mut document, path);
347 }
348 crate::config::prune_empty_tables(&mut document, &["project"]);
354 if let Some(orphaned) = orphaned {
355 crate::config::place_carried(&mut document, &orphaned);
356 }
357 Ok(document.to_string())
358}
359
360#[cfg(test)]
361mod tests {
362 use super::to_schema_2;
363
364 #[test]
367 fn a_schema_1_text_migrates_with_its_comments() {
368 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";
369 let migrated = to_schema_2(old).expect("migrates");
370 let doc: toml::Table = migrated.parse().expect("the result parses");
371 assert_eq!(doc["schema_version"].as_integer(), Some(2));
372 assert!(migrated.contains("schema_version = 2 # the schema"));
373 assert_eq!(doc["project"]["repo"].as_str(), Some("acme/widget"));
374 assert!(doc["project"].get("tech").is_none());
375 assert_eq!(
376 doc["profile"]["technologies"].as_array().map(Vec::len),
377 Some(1)
378 );
379 assert_eq!(doc["profile"]["forge"].as_str(), Some("github"));
380 assert_eq!(
381 doc["profile"]["release"]["mode"].as_str(),
382 Some("automatic")
383 );
384 assert_eq!(doc["profile"]["release"]["driver"].as_str(), Some("rust"));
385 assert_eq!(doc["profile"]["release"]["style"].as_str(), Some("lines"));
386 assert!(
387 migrated.contains("style = \"lines\" # P: trunk or lines; automatic alone"),
388 "the template's own comment states what the key means now: {migrated}"
389 );
390 assert_eq!(
391 doc["profile"]["release"]["line_prefix"].as_str(),
392 Some("stable/")
393 );
394 assert!(migrated.contains("line_prefix = \"stable/\" # P: release-line branch prefix"));
395 assert_eq!(doc["git"]["trunk"].as_str(), Some("main"));
396 assert_eq!(
397 doc["git"]["checkout_mode"].as_str(),
398 Some("linked-worktree")
399 );
400 assert!(
401 migrated.contains(
402 "checkout_mode = \"linked-worktree\" # P: linked-worktree or main-worktree"
403 ),
404 "a renamed vocabulary takes the template's own sentence: {migrated}"
405 );
406 assert!(
407 migrated.contains("repo = \"acme/widget\" # operator note"),
408 "a comment the operator wrote travels with its value: {migrated}"
409 );
410 assert_eq!(doc["capabilities"]["nix_packaging"].as_bool(), Some(true));
411 assert_eq!(
412 doc["capabilities"]["reporting_policy"].as_bool(),
413 Some(true)
414 );
415 assert_eq!(doc["capabilities"]["scorecard"].as_bool(), Some(false));
416 assert_eq!(
417 doc["capabilities"]["code_scanning"].as_str(),
418 Some("semgrep")
419 );
420 assert!(doc.get("landing").is_none());
421 assert!(doc["setup"].get("line_prefix").is_none());
422 assert_eq!(doc["setup"]["required_check"].as_str(), Some("gate"));
423 assert!(migrated.contains("contact = \"team\" # keep"));
424 assert!(migrated.starts_with("# heading\n"));
425 assert_eq!(to_schema_2(&migrated).expect("passes"), migrated);
427 }
428
429 #[test]
432 fn an_empty_detect_answer_becomes_an_absent_key() {
433 let migrated = to_schema_2(
434 "schema_version = 1\n[project]\nforge = \"\"\ntech = \"\"\n[landing]\nworkflow = \"branches\"\n",
435 )
436 .expect("migrates");
437 let doc: toml::Table = migrated.parse().expect("parses");
438 assert!(doc.get("profile").is_none_or(|p| p.get("forge").is_none()));
439 assert!(
440 doc.get("profile")
441 .is_none_or(|p| p.get("technologies").is_none())
442 );
443 assert_eq!(doc["git"]["checkout_mode"].as_str(), Some("main-worktree"));
444 assert!(
445 !migrated.contains("[project]"),
446 "a table every one of whose keys dropped goes with them: {migrated}"
447 );
448 }
449
450 #[test]
454 fn an_emptied_project_header_goes_and_its_comment_stays() {
455 let migrated = to_schema_2(
456 "schema_version = 1\n\n# the operator's note\n[project]\nforge = \"\"\ntech = \"\"\n\n[security]\ncontact = \"team\"\n",
457 )
458 .expect("migrates");
459 assert!(
460 !migrated.contains("[project]"),
461 "a table every one of whose keys dropped goes with them: {migrated}"
462 );
463 assert!(
464 migrated.contains("# the operator's note"),
465 "the comment the header carried survives: {migrated}"
466 );
467 crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
468 }
469
470 #[test]
477 fn an_empty_technology_still_clears_the_automatic_release_keys() {
478 let migrated = to_schema_2(
479 "schema_version = 1\n[project]\ntech = \"\"\nforge = \"github\"\nrepo = \"acme/widget\"\n[landing]\nstyle = \"lines\"\n[setup]\nline_prefix = \"stable/\"\nrequired_check = \"gate\"\n",
480 )
481 .expect("migrates");
482 let doc: toml::Table = migrated.parse().expect("parses");
483 assert!(
484 doc["setup"].get("line_prefix").is_none(),
485 "the prefix belongs to an automatic release: {migrated}"
486 );
487 assert!(
488 doc.get("profile")
489 .is_none_or(|profile| profile.get("release").is_none()),
490 "no technology means no release intent: {migrated}"
491 );
492 assert!(doc.get("landing").is_none(), "{migrated}");
493 assert_eq!(doc["setup"]["required_check"].as_str(), Some("gate"));
494 crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
495 }
496
497 #[test]
502 fn a_dropped_schema_1_key_keeps_the_operators_comment() {
503 let migrated = to_schema_2(concat!(
504 "schema_version = 1\n\n[project]\nrepo = \"acme/widget\"\n",
505 "# this project has no forge yet\n",
506 "forge = \"\" # P: forge\n",
507 "# and no binding release-kit knows\n",
508 "tech = \"\" # P: binding\n\n",
509 "[landing]\n# the style we used to ask for\nstyle = \"lines\"\n\n",
510 "[setup]\nrequired_check = \"gate\"\n",
511 "# the prefix we used to ask for\nline_prefix = \"stable/\"\n"
512 ))
513 .expect("migrates");
514 for note in [
515 "# this project has no forge yet",
516 "# and no binding release-kit knows",
517 "# the style we used to ask for",
518 "# the prefix we used to ask for",
519 ] {
520 assert!(
521 migrated.contains(note),
522 "authored text survives the migration: {note}: {migrated}"
523 );
524 }
525 assert!(!migrated.contains("forge ="), "{migrated}");
526 assert!(!migrated.contains("tech ="), "{migrated}");
527 assert!(!migrated.contains("style ="), "{migrated}");
528 assert!(!migrated.contains("line_prefix ="), "{migrated}");
529 crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
530 }
531
532 #[test]
536 fn a_moved_key_takes_the_comment_above_it() {
537 let migrated = to_schema_2(concat!(
538 "schema_version = 1\n\n[project]\nrepo = \"acme/widget\"\n",
539 "# the one binding this project releases from\ntech = \"rust\"\n",
540 "forge = \"github\"\n",
541 "# why this branch is fixed\ntrunk = \"main\"\n\n",
542 "[landing]\n# why this project takes lines\nstyle = \"lines\"\n",
543 "# why topics use the main checkout\nworkflow = \"branches\"\n"
544 ))
545 .expect("migrates");
546 for note in [
547 "# the one binding this project releases from",
548 "# why this branch is fixed",
549 "# why this project takes lines",
550 "# why topics use the main checkout",
551 ] {
552 assert!(migrated.contains(note), "{note}: {migrated}");
553 }
554 let doc: toml::Table = migrated.parse().expect("parses");
555 assert_eq!(doc["git"]["trunk"].as_str(), Some("main"));
556 assert_eq!(doc["profile"]["release"]["style"].as_str(), Some("lines"));
557 assert_eq!(doc["git"]["checkout_mode"].as_str(), Some("main-worktree"));
558 crate::config::parse(&migrated).expect("the strict schema 2 reader accepts it");
559 }
560
561 #[test]
566 fn an_unknown_schema_1_landing_key_refuses_by_name() {
567 let refusal = to_schema_2("schema_version = 1\n[landing]\nworkflo = \"branches\"\n")
568 .expect_err("an unknown key refuses")
569 .to_string();
570 assert!(refusal.contains("workflo"), "{refusal}");
571 assert!(
572 refusal.contains("nearest known key: landing.workflow"),
573 "{refusal}"
574 );
575 }
576
577 #[test]
582 fn a_schema_1_file_with_a_schema_2_shape_refuses() {
583 for (text, named) in [
584 (
585 "schema_version = 1\n[profile]\nforg = \"github\"\n",
586 "[profile]",
587 ),
588 ("schema_version = 1\n[git]\ntrunk = \"main\"\n", "[git]"),
589 (
590 "schema_version = 1\n[capabilities]\nscorecard = true\n",
591 "[capabilities]",
592 ),
593 ("schema_version = 1\nproject = \"acme/widget\"\n", "project"),
594 ("schema_version = 1\nlanding = 3\n", "landing"),
595 (
596 "schema_version = 1\nsetup = \"operator value\"\n[project]\ntech = \"rust\"\n",
597 "setup",
598 ),
599 ] {
600 let refusal = to_schema_2(text)
601 .expect_err("a shape schema 1 never had refuses")
602 .to_string();
603 assert!(refusal.contains(named), "{named}: {refusal}");
604 }
605 }
606}