1use std::collections::BTreeMap;
13
14use camino::Utf8Path;
15use serde::{Deserialize, Serialize};
16
17use crate::atomic;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::Kind;
22pub use crate::profile::{
23 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
24};
25
26pub const MANIFEST_PATH: &str = ".release-kit/manifest.json";
28
29pub const SCHEMA_VERSION: u64 = 11;
56
57const OLDEST_READABLE_SCHEMA: u64 = 1;
59
60const PLACEMENT_SCHEMA: u64 = 7;
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
73#[serde(rename_all = "kebab-case")]
74pub enum CheckoutMode {
75 LinkedWorktree,
78 MainWorktree,
82}
83
84impl CheckoutMode {
85 #[must_use]
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Self::LinkedWorktree => "linked-worktree",
90 Self::MainWorktree => "main-worktree",
91 }
92 }
93
94 #[must_use]
96 pub const fn runbook_label(self) -> &'static str {
97 match self {
98 Self::LinkedWorktree => "worktree",
99 Self::MainWorktree => "branches",
100 }
101 }
102
103 pub fn parse(raw: &str) -> Result<Self, RkError> {
112 match raw {
113 "linked-worktree" | "worktree" => Ok(Self::LinkedWorktree),
114 "main-worktree" | "branches" => Ok(Self::MainWorktree),
115 other => Err(RkError::Usage(format!(
116 "unknown checkout mode '{other}'; the modes are: linked-worktree, main-worktree"
117 ))),
118 }
119 }
120}
121
122impl<'de> serde::Deserialize<'de> for CheckoutMode {
123 fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
124 let raw = String::deserialize(reader)?;
125 Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum Integration {
140 Local,
143 Forge,
146}
147
148impl Integration {
149 #[must_use]
151 pub const fn as_str(self) -> &'static str {
152 match self {
153 Self::Local => "local",
154 Self::Forge => "forge",
155 }
156 }
157
158 pub fn parse(raw: &str) -> Result<Self, RkError> {
164 match raw {
165 "local" => Ok(Self::Local),
166 "forge" => Ok(Self::Forge),
167 other => Err(RkError::Usage(format!(
168 "unknown integration mode '{other}'; the modes are: local, forge"
169 ))),
170 }
171 }
172}
173
174impl<'de> serde::Deserialize<'de> for Integration {
175 fn deserialize<D: serde::Deserializer<'de>>(reader: D) -> Result<Self, D::Error> {
176 let raw = String::deserialize(reader)?;
177 Self::parse(&raw).map_err(|error| serde::de::Error::custom(error.to_string()))
178 }
179}
180
181pub(crate) const fn integration_forge() -> Integration {
186 Integration::Forge
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum Style {
197 Trunk,
200 Lines,
203}
204
205impl Style {
206 #[must_use]
208 pub const fn as_str(self) -> &'static str {
209 match self {
210 Self::Trunk => "trunk",
211 Self::Lines => "lines",
212 }
213 }
214
215 pub fn parse(raw: &str) -> Result<Self, RkError> {
221 match raw {
222 "trunk" => Ok(Self::Trunk),
223 "lines" => Ok(Self::Lines),
224 other => Err(RkError::Usage(format!(
225 "unknown style '{other}'; the styles are: trunk, lines"
226 ))),
227 }
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "lowercase")]
237pub enum Provider {
238 CodeQl,
242 Semgrep,
245}
246
247impl Provider {
248 #[must_use]
250 pub const fn as_str(self) -> &'static str {
251 match self {
252 Self::CodeQl => "codeql",
253 Self::Semgrep => "semgrep",
254 }
255 }
256
257 pub fn parse(raw: &str) -> Result<Option<Self>, RkError> {
264 match raw {
265 "codeql" => Ok(Some(Self::CodeQl)),
266 "semgrep" => Ok(Some(Self::Semgrep)),
267 "off" => Ok(None),
268 other => Err(RkError::Usage(format!(
269 "unknown code scanning provider '{other}'; the providers are: codeql, semgrep, and off turns the capability off"
270 ))),
271 }
272 }
273}
274
275#[derive(Debug, Serialize, Deserialize)]
281pub struct Manifest {
282 pub schema_version: u64,
284 pub rk_version: String,
286 pub origin: String,
288 pub landed_at: String,
290 pub profile: ProfileSnapshot,
293 pub git: GitWorkflow,
296 pub capabilities: CapabilityRequests,
298 pub parameters: Parameters,
301 pub files: Vec<FileRecord>,
303 pub pins: BTreeMap<String, String>,
306}
307
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310pub struct Parameters {
311 #[serde(default)]
315 pub repo: String,
316 #[serde(default, deserialize_with = "read_contact")]
321 pub security_contact: String,
322 #[serde(default = "response_best_effort", deserialize_with = "read_response")]
326 pub security_response: String,
327 #[serde(default)]
331 pub required_check: String,
332 #[serde(default)]
335 pub required_workflow: String,
336}
337
338fn response_best_effort() -> String {
340 crate::config::RESPONSE_DEFAULT.to_owned()
341}
342
343fn read_contact<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
349 canonical(reader, "security_contact", crate::config::canonical_contact)
350}
351
352fn read_response<'de, D: serde::Deserializer<'de>>(reader: D) -> Result<String, D::Error> {
354 canonical(
355 reader,
356 "security_response",
357 crate::config::canonical_response,
358 )
359}
360
361fn canonical<'de, D: serde::Deserializer<'de>>(
363 reader: D,
364 field: &str,
365 judge: impl Fn(&str) -> Result<String, String>,
366) -> Result<String, D::Error> {
367 let raw = String::deserialize(reader)?;
368 let canonical = judge(&raw)
369 .map_err(|reason| serde::de::Error::custom(format!("parameters.{field}: {reason}")))?;
370 if canonical == raw {
371 Ok(canonical)
372 } else {
373 Err(serde::de::Error::custom(format!(
374 "parameters.{field} is not canonical: the record carries {raw:?} where a landing writes {canonical:?}"
375 )))
376 }
377}
378
379#[derive(Debug, Serialize, Deserialize)]
381pub struct FileRecord {
382 pub destination: String,
384 pub kind: Kind,
386 pub sha256: Digest,
389 #[serde(default, skip_serializing_if = "Placement::is_whole")]
393 pub placement: Placement,
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
398#[serde(rename_all = "lowercase")]
399pub enum Placement {
400 #[default]
402 Whole,
403 Region,
406}
407
408impl Placement {
409 #[must_use]
411 pub const fn is_whole(&self) -> bool {
412 matches!(self, Self::Whole)
413 }
414
415 #[must_use]
417 pub const fn as_str(self) -> &'static str {
418 match self {
419 Self::Whole => "whole",
420 Self::Region => "region",
421 }
422 }
423}
424
425pub mod legacy {
435 use serde_json::{Map, Value, json};
436
437 fn take(object: &mut Map<String, Value>, key: &str) -> Option<Value> {
439 object.remove(key)
440 }
441
442 #[must_use]
453 pub fn convert(mut value: Value) -> Value {
454 let Some(record) = value.as_object_mut() else {
455 return value;
456 };
457 record.remove("payload_sha256");
458 if let Some(files) = record.get_mut("files").and_then(Value::as_array_mut) {
459 for file in files.iter_mut().filter_map(Value::as_object_mut) {
460 file.remove("baseline_sha256");
461 }
462 }
463 if record.contains_key("profile") {
464 return value;
465 }
466 let tech = take(record, "tech").and_then(|v| v.as_str().map(str::to_owned));
467 let forge = take(record, "forge").and_then(|v| v.as_str().map(str::to_owned));
468 let mut parameters = take(record, "parameters")
469 .and_then(|v| v.as_object().cloned())
470 .unwrap_or_default();
471 parameters.remove("scopes");
472 let checkout_mode = match parameters
473 .remove("workflow")
474 .and_then(|v| v.as_str().map(str::to_owned))
475 .as_deref()
476 {
477 Some("worktree") => "linked-worktree",
478 _ => "main-worktree",
481 };
482 let style = parameters.remove("style").unwrap_or(Value::Null);
483 let trunk = parameters
484 .remove("trunk")
485 .unwrap_or_else(|| json!(crate::config::TRUNK_DEFAULT));
486 let line_prefix = parameters
487 .remove("line_prefix")
488 .unwrap_or_else(|| json!(crate::config::LINE_PREFIX_DEFAULT));
489 let nix = parameters.remove("nix").unwrap_or(json!(false));
490 let scorecard = parameters.remove("scorecard").unwrap_or(json!(false));
491 let code_scanning = parameters.remove("code_scanning").unwrap_or(Value::Null);
492 let mut release = Map::new();
493 release.insert("mode".into(), json!("automatic"));
494 if let Some(tech) = &tech {
495 release.insert("driver".into(), json!(tech));
496 }
497 if !style.is_null() {
498 release.insert("style".into(), style);
499 }
500 release.insert("line_prefix".into(), line_prefix);
501 let technologies: Vec<Value> = tech.iter().map(|t| json!(t)).collect();
502 record.insert(
503 "profile".into(),
504 json!({
505 "technologies": technologies,
506 "forge": forge,
507 "release": Value::Object(release),
508 }),
509 );
510 record.insert(
511 "git".into(),
512 json!({ "trunk": trunk, "checkout_mode": checkout_mode }),
513 );
514 record.insert(
515 "capabilities".into(),
516 json!({
517 "nix_packaging": nix,
518 "reporting_policy": true,
519 "scorecard": scorecard,
520 "code_scanning": code_scanning,
521 }),
522 );
523 record.insert("parameters".into(), Value::Object(parameters));
524 value
525 }
526}
527
528impl Manifest {
529 #[must_use]
531 pub fn file(&self, destination: &str) -> Option<&FileRecord> {
532 self.files
533 .iter()
534 .find(|file| file.destination == destination)
535 }
536}
537
538pub fn load(target: &Utf8Path) -> Result<Option<Manifest>, RkError> {
547 let path = target.join(MANIFEST_PATH);
548 let bytes = match std::fs::read(&path) {
549 Ok(bytes) => bytes,
550 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
551 Err(e) => {
552 return Err(RkError::refusal(
553 Diagnostic::new(Reason::Io, format!("cannot read {path}: {e}"))
554 .expected("a readable landing record")
555 .target_state("unchanged"),
556 ));
557 }
558 };
559 let value: serde_json::Value = serde_json::from_slice(&bytes)
560 .map_err(|e| record_invalid(&path, &format!("{path} is not a landing record: {e}")))?;
561 let schema = value
566 .get("schema_version")
567 .and_then(serde_json::Value::as_u64);
568 if !schema.is_some_and(|version| (OLDEST_READABLE_SCHEMA..=SCHEMA_VERSION).contains(&version)) {
569 let found = schema.map_or_else(|| "none".to_owned(), |version| version.to_string());
570 return Err(RkError::refusal(
571 Diagnostic::new(
572 Reason::UnsupportedSchema,
573 format!(
574 "{path} declares schema_version {found}, and this binary knows only {OLDEST_READABLE_SCHEMA} through {SCHEMA_VERSION}"
575 ),
576 )
577 .expected("a landing record at a schema this binary knows")
578 .action("install the rk release that wrote this record, or a newer one")
579 .target_state("unchanged"),
580 ));
581 }
582 let declared = schema.unwrap_or(SCHEMA_VERSION);
583 let value = if declared < SCHEMA_VERSION {
584 legacy::convert(value)
585 } else {
586 value
587 };
588 let mut manifest: Manifest = serde_json::from_value(value).map_err(|e| {
589 record_invalid(
590 &path,
591 &format!("{path} does not parse at schema_version {declared}: {e}"),
592 )
593 })?;
594 for file in &mut manifest.files {
598 if declared < PLACEMENT_SCHEMA && crate::landing::block_markers(&file.destination).is_some()
599 {
600 file.placement = Placement::Region;
601 }
602 }
603 Ok(Some(manifest))
604}
605
606fn record_invalid(path: &Utf8Path, message: &str) -> RkError {
613 RkError::refusal(
614 Diagnostic::new(Reason::RecordInvalid, message)
615 .expected("a landing record this binary wrote, or one it can convert")
616 .action(format!(
617 "restore {path} from Git, or repair the file it names, and retry"
618 ))
619 .target_state("nothing was written"),
620 )
621}
622
623pub fn write(target: &Utf8Path, manifest: &Manifest) -> Result<(), RkError> {
629 let path = target.join(MANIFEST_PATH);
630 atomic::write(path.as_std_path(), &render(manifest)?)?;
631 Ok(())
632}
633
634pub fn render(manifest: &Manifest) -> Result<Vec<u8>, RkError> {
640 let text = serde_json::to_string_pretty(manifest).map_err(anyhow::Error::from)?;
641 Ok(format!("{text}\n").into_bytes())
642}
643
644#[must_use]
646pub fn now() -> String {
647 humantime::format_rfc3339_seconds(std::time::SystemTime::now()).to_string()
648}
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
652#[serde(rename_all = "kebab-case")]
653pub enum Alignment {
654 Aligned,
656 BinaryNewer,
658 TargetNewer,
661}
662
663impl Alignment {
664 #[must_use]
666 pub const fn as_str(self) -> &'static str {
667 match self {
668 Self::Aligned => "aligned",
669 Self::BinaryNewer => "binary-newer",
670 Self::TargetNewer => "target-newer",
671 }
672 }
673}
674
675#[must_use]
677pub fn alignment(recorded: &str, binary: &str) -> Alignment {
678 let recorded = recorded
680 .split_once('+')
681 .map_or(recorded, |(version, _)| version);
682 let binary = binary
683 .split_once('+')
684 .map_or(binary, |(version, _)| version);
685 let recorded_core = numeric_core(recorded);
686 let binary_core = numeric_core(binary);
687 match binary_core.cmp(&recorded_core) {
688 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
689 std::cmp::Ordering::Less => Alignment::TargetNewer,
690 std::cmp::Ordering::Equal => {
691 let recorded_pre = recorded.split_once('-').map(|(_, pre)| pre);
696 let binary_pre = binary.split_once('-').map(|(_, pre)| pre);
697 match (recorded_pre, binary_pre) {
698 (Some(_), None) => Alignment::BinaryNewer,
699 (None, Some(_)) => Alignment::TargetNewer,
700 (None, None) => Alignment::Aligned,
701 (Some(r), Some(b)) => match prerelease_cmp(b, r) {
702 std::cmp::Ordering::Greater => Alignment::BinaryNewer,
703 std::cmp::Ordering::Less => Alignment::TargetNewer,
704 std::cmp::Ordering::Equal => Alignment::Aligned,
705 },
706 }
707 }
708 }
709}
710
711#[must_use]
714pub fn version_is_newer(candidate: &str, pinned: &str) -> bool {
715 alignment(pinned, candidate) == Alignment::BinaryNewer
716}
717
718fn prerelease_cmp(a: &str, b: &str) -> std::cmp::Ordering {
725 let numeric = |identifier: &str| identifier.bytes().all(|byte| byte.is_ascii_digit());
726 let mut left = a.split('.');
727 let mut right = b.split('.');
728 loop {
729 match (left.next(), right.next()) {
730 (None, None) => return std::cmp::Ordering::Equal,
731 (None, Some(_)) => return std::cmp::Ordering::Less,
732 (Some(_), None) => return std::cmp::Ordering::Greater,
733 (Some(x), Some(y)) => {
734 let ordering = match (numeric(x), numeric(y)) {
735 (true, true) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
736 (true, false) => std::cmp::Ordering::Less,
737 (false, true) => std::cmp::Ordering::Greater,
738 (false, false) => x.cmp(y),
739 };
740 if ordering != std::cmp::Ordering::Equal {
741 return ordering;
742 }
743 }
744 }
745 }
746}
747
748fn numeric_core(version: &str) -> Vec<u64> {
750 let core = version.split_once('-').map_or(version, |(core, _)| core);
751 core.split('.')
752 .map(|part| part.parse::<u64>().unwrap_or(0))
753 .collect()
754}
755
756#[cfg(test)]
757mod tests {
758 use super::{
759 Alignment, CapabilityRequests, CheckoutMode, FileRecord, GitWorkflow, Manifest, Parameters,
760 Placement, ProfileSnapshot, Provider, ReleaseIntent, ReleaseMode, Style, alignment,
761 };
762 use crate::digest::Digest;
763 use crate::landing::Integration;
764 use crate::landing::Kind;
765
766 #[test]
770 fn the_manifest_schema_snapshot_holds() {
771 let manifest = Manifest {
772 schema_version: super::SCHEMA_VERSION,
773 rk_version: "0.1.0".into(),
774 origin: "init".into(),
775 landed_at: "2026-08-29T00:00:00Z".into(),
776 profile: ProfileSnapshot {
777 technologies: vec!["rust".into()],
778 forge: Some("github".into()),
779 release: ReleaseIntent {
780 mode: ReleaseMode::Automatic,
781 driver: Some("rust".into()),
782 style: Some(Style::Trunk),
783 line_prefix: Some(crate::config::LINE_PREFIX_DEFAULT.to_owned()),
784 },
785 },
786 git: GitWorkflow {
787 trunk: crate::config::TRUNK_DEFAULT.to_owned(),
788 checkout_mode: CheckoutMode::LinkedWorktree,
789 integration: Integration::Local,
790 },
791 capabilities: CapabilityRequests {
792 nix_packaging: true,
793 reporting_policy: true,
794 scorecard: true,
795 code_scanning: Some(Provider::Semgrep),
796 workflow_guards: Some(false),
797 },
798 parameters: Parameters {
799 repo: "acme/widget".into(),
800 security_contact: String::new(),
801 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
802 required_check: String::new(),
803 required_workflow: String::new(),
804 },
805 files: vec![
806 FileRecord {
807 destination: "release-plz.toml".into(),
808 kind: Kind::Seeded,
809 sha256: Digest::of(b""),
810 placement: Placement::Whole,
811 },
812 FileRecord {
813 destination: "AGENTS.md".into(),
814 kind: Kind::Rendered,
815 sha256: Digest::of(b""),
816 placement: Placement::Region,
817 },
818 ],
819 pins: std::iter::once(("release-plz".to_owned(), "0.3.160".to_owned())).collect(),
820 };
821 let empty = Digest::of(b"").to_string();
822 let text = serde_json::to_string(&manifest).expect("a manifest serializes");
823 assert_eq!(
824 text,
825 format!(
826 r#"{{"schema_version":11,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"}},"capabilities":{{"nix_packaging":true,"reporting_policy":true,"scorecard":true,"code_scanning":"semgrep","workflow_guards":false}},"parameters":{{"repo":"acme/widget","security_contact":"","security_response":"best-effort","required_check":"","required_workflow":""}},"files":[{{"destination":"release-plz.toml","kind":"seeded","sha256":"{empty}"}},{{"destination":"AGENTS.md","kind":"rendered","sha256":"{empty}","placement":"region"}}],"pins":{{"release-plz":"0.3.160"}}}}"#
827 ),
828 "a whole file omits its placement, and no retired digest field survives"
829 );
830 assert!(!text.contains("payload_sha256") && !text.contains("baseline_sha256"));
831 let release_less = Manifest {
833 profile: ProfileSnapshot {
834 technologies: vec![],
835 forge: None,
836 release: ReleaseIntent {
837 mode: ReleaseMode::None,
838 driver: None,
839 style: None,
840 line_prefix: None,
841 },
842 },
843 capabilities: CapabilityRequests {
844 nix_packaging: false,
845 reporting_policy: false,
846 scorecard: false,
847 code_scanning: None,
848 workflow_guards: Some(false),
849 },
850 parameters: Parameters {
851 repo: String::new(),
852 security_contact: String::new(),
853 security_response: crate::config::RESPONSE_DEFAULT.to_owned(),
854 required_check: String::new(),
855 required_workflow: String::new(),
856 },
857 files: vec![],
858 pins: std::collections::BTreeMap::new(),
859 ..manifest
860 };
861 assert_eq!(
862 serde_json::to_string(&release_less).expect("serializes"),
863 r#"{"schema_version":11,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{"technologies":[],"release":{"mode":"none"}},"git":{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"},"capabilities":{"nix_packaging":false,"reporting_policy":false,"scorecard":false,"workflow_guards":false},"parameters":{"repo":"","security_contact":"","security_response":"best-effort","required_check":"","required_workflow":""},"files":[],"pins":{}}"#
864 );
865 }
866
867 #[test]
875 fn a_schema_1_record_reads_as_branches_and_a_newer_schema_refuses() {
876 let dir = tempfile::tempdir().expect("a scratch target exists");
877 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
878 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
879 let record = |schema: u64| {
880 format!(
881 r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","scopes":["api"]}},"files":[],"pins":{{}}}}"#
882 )
883 };
884 std::fs::write(target.join(super::MANIFEST_PATH), record(1)).expect("the record writes");
885 let manifest = super::load(target)
886 .expect("a schema-1 record loads")
887 .expect("the record exists");
888 assert_eq!(manifest.git.checkout_mode, CheckoutMode::MainWorktree);
889 assert_eq!(manifest.profile.technologies, vec!["rust".to_owned()]);
890 assert_eq!(manifest.profile.forge.as_deref(), Some("github"));
891 assert_eq!(manifest.profile.release.mode, ReleaseMode::Automatic);
892 assert_eq!(manifest.profile.release.driver.as_deref(), Some("rust"));
893 assert_eq!(
894 manifest.profile.release.style, None,
895 "a pre-style record carries no style; the upgrade demands one"
896 );
897 assert_eq!(
898 manifest.profile.release.line_prefix.as_deref(),
899 Some(crate::config::LINE_PREFIX_DEFAULT)
900 );
901 assert_eq!(manifest.git.trunk, crate::config::TRUNK_DEFAULT);
902 assert!(
903 !manifest.capabilities.nix_packaging,
904 "a pre-nix record reads as opt-out, so an upgrade adds nothing unrequested"
905 );
906 assert!(
907 manifest.capabilities.reporting_policy,
908 "an older landing carried the policy, so the record says so"
909 );
910 assert_eq!(
911 manifest.parameters.security_contact, "",
912 "a pre-policy record names no contact, which is what its policy landed"
913 );
914 assert_eq!(
915 manifest.parameters.security_response,
916 crate::config::RESPONSE_DEFAULT,
917 "a pre-policy record promises no window, which is what its policy landed"
918 );
919
920 for schema in 2..=8 {
921 std::fs::write(
922 target.join(super::MANIFEST_PATH),
923 format!(
924 r#"{{"schema_version":{schema},"rk_version":"0.1.0","payload_sha256":"0000000000000000000000000000000000000000000000000000000000000000","origin":"init","tech":"rust","forge":"github","landed_at":"2026-08-29T00:00:00Z","parameters":{{"repo":"acme/widget","workflow":"worktree","style":"lines","nix":true,"scorecard":true,"code_scanning":"semgrep","trunk":"main","line_prefix":"stable/"}},"files":[{{"destination":"AGENTS.md","kind":"rendered","sha256":"0000000000000000000000000000000000000000000000000000000000000000","baseline_sha256":"0000000000000000000000000000000000000000000000000000000000000000"}}],"pins":{{}}}}"#
925 ),
926 )
927 .expect("the record writes");
928 let manifest = super::load(target)
929 .expect("an earlier record loads")
930 .expect("the record exists");
931 assert_eq!(manifest.schema_version, schema);
932 assert_eq!(manifest.git.checkout_mode, CheckoutMode::LinkedWorktree);
933 assert_eq!(manifest.git.trunk, "main");
937 assert_eq!(manifest.profile.release.style, Some(Style::Lines));
938 assert_eq!(
939 manifest.profile.release.line_prefix.as_deref(),
940 Some("stable/")
941 );
942 assert!(manifest.capabilities.nix_packaging && manifest.capabilities.scorecard);
943 assert_eq!(manifest.capabilities.code_scanning, Some(Provider::Semgrep));
944 assert_eq!(
945 manifest.files[0].placement,
946 if schema < super::PLACEMENT_SCHEMA {
947 Placement::Region
948 } else {
949 Placement::Whole
950 },
951 "a block destination below the placement schema reads as a region"
952 );
953 let rewritten = super::render(&manifest).expect("renders");
954 let text = String::from_utf8(rewritten).expect("text");
955 assert!(!text.contains("baseline_sha256"), "{text}");
956 assert!(
957 !text.contains("\"tech\""),
958 "the flat identity moved: {text}"
959 );
960 }
961
962 std::fs::write(target.join(super::MANIFEST_PATH), record(999)).expect("the record writes");
963 let refused = super::load(target).expect_err("a schema-999 record refuses");
964 assert_eq!(
965 refused.reason(),
966 crate::diagnostic::Reason::UnsupportedSchema
967 );
968 let message = refused.to_string();
969 assert!(message.contains("999"), "{message}");
970 assert!(message.contains(super::MANIFEST_PATH), "{message}");
971 assert!(
972 !message.to_lowercase().contains("bundle"),
973 "the record schema stands alone: {message}"
974 );
975 }
976
977 #[test]
982 fn a_record_carrying_an_uncanonical_security_parameter_refuses() {
983 let dir = tempfile::tempdir().expect("a scratch target exists");
984 let target = camino::Utf8Path::from_path(dir.path()).expect("utf-8 path");
985 std::fs::create_dir_all(target.join(".release-kit")).expect("the record dir writes");
986 for (field, value) in [
987 ("security_contact", "team@acme.example\\nsecond line"),
990 ("security_contact", " team@acme.example "),
991 ("security_response", "90d"),
992 ("security_response", "0 days"),
993 ("security_response", "07 days"),
994 ("security_response", "1 days"),
995 ("security_response", ""),
996 ] {
997 let record = format!(
998 r#"{{"schema_version":10,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["rust"],"forge":"github","release":{{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":false}},"parameters":{{"repo":"acme/widget","{field}":"{value}"}},"files":[],"pins":{{}}}}"#
999 );
1000 std::fs::write(target.join(super::MANIFEST_PATH), record).expect("the record writes");
1001 let refused = super::load(target).expect_err("an uncanonical record refuses");
1002 assert!(refused.to_string().contains(field), "{field}: {refused}");
1003 }
1004 }
1005
1006 #[test]
1007 fn alignment_orders_versions_numerically() {
1008 assert_eq!(alignment("0.1.0", "0.1.0"), Alignment::Aligned);
1009 assert_eq!(alignment("0.1.0", "0.2.0"), Alignment::BinaryNewer);
1010 assert_eq!(alignment("0.10.0", "0.9.9"), Alignment::TargetNewer);
1011 assert_eq!(alignment("0.1.0-rc.1", "0.1.0"), Alignment::BinaryNewer);
1012 assert_eq!(alignment("0.1.0", "0.1.0-rc.1"), Alignment::TargetNewer);
1013 }
1014
1015 #[test]
1020 fn alignment_orders_numeric_prerelease_identifiers_numerically() {
1021 assert_eq!(
1022 alignment("0.1.0-rc.10", "0.1.0-rc.2"),
1023 Alignment::TargetNewer
1024 );
1025 assert_eq!(
1026 alignment("0.1.0-rc.2", "0.1.0-rc.10"),
1027 Alignment::BinaryNewer
1028 );
1029 assert_eq!(alignment("0.1.0-rc.1", "0.1.0-rc.1"), Alignment::Aligned);
1030 assert_eq!(
1031 alignment("0.1.0-alpha", "0.1.0-alpha.1"),
1032 Alignment::BinaryNewer
1033 );
1034 assert_eq!(alignment("0.1.0-1", "0.1.0-alpha"), Alignment::BinaryNewer);
1035 assert_eq!(
1036 alignment("1.0.0-100000000000000000000", "1.0.0-99999999999999999999"),
1037 Alignment::TargetNewer,
1038 "identifiers past the u64 range still compare numerically"
1039 );
1040 assert_eq!(
1041 alignment("1.0.0-99999999999999999999", "1.0.0-100000000000000000000"),
1042 Alignment::BinaryNewer
1043 );
1044 }
1045
1046 #[test]
1049 fn alignment_ignores_build_metadata() {
1050 assert_eq!(alignment("1.2.10+build", "1.2.9"), Alignment::TargetNewer);
1051 assert_eq!(alignment("1.2.9", "1.2.10+build"), Alignment::BinaryNewer);
1052 assert_eq!(alignment("1.0.0+alpha", "1.0.0+beta"), Alignment::Aligned);
1053 assert_eq!(
1054 alignment("1.2.10-rc.1+build", "1.2.10-rc.1"),
1055 Alignment::Aligned
1056 );
1057 assert_eq!(
1058 alignment("1.2.10-rc.1+build", "1.2.10"),
1059 Alignment::BinaryNewer
1060 );
1061 }
1062}