1use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32
33use crate::discovery::glob::Glob;
34use crate::release::compat::{Impact, Mode, Policy as CompatPolicy};
35use crate::release::version::Version;
36
37pub const DECISION_SCHEMA: &str = "majordomus/landing-decision/v1";
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
50#[serde(rename_all = "snake_case")]
51#[schemars(rename = "ReleaseAllocation")]
52pub enum Allocation {
53 #[default]
55 Off,
56 Landing,
58}
59
60impl Allocation {
61 pub fn as_str(self) -> &'static str {
68 match self {
69 Allocation::Off => "off",
70 Allocation::Landing => "landing",
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct ReleaseExemption {
91 pub id: String,
93 pub description: String,
95 pub paths: Vec<String>,
97}
98
99impl ReleaseExemption {
100 pub fn covers(&self, path: &str) -> bool {
108 self.paths.iter().any(|p| Glob::new(p).matches(path))
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
124#[serde(deny_unknown_fields)]
125pub struct ReleasePolicy {
126 #[serde(default)]
128 pub allocation: Allocation,
129 #[serde(default = "no_floor")]
132 pub floor: Impact,
133 #[serde(default)]
135 pub exempt: Vec<ReleaseExemption>,
136}
137
138fn no_floor() -> Impact {
139 Impact::None
140}
141
142impl Default for ReleasePolicy {
143 fn default() -> Self {
145 ReleasePolicy {
146 allocation: Allocation::Off,
147 floor: no_floor(),
148 exempt: Vec::new(),
149 }
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161pub struct AppliedExemption {
162 pub id: String,
164 pub reason: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
179pub struct LandingDecision {
180 pub schema: String,
182 pub trunk_version: String,
184 pub observed: Impact,
186 pub required_by_compatibility: Impact,
188 pub floor: Impact,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub exemption: Option<AppliedExemption>,
193 pub selected: Impact,
195 pub next_version: String,
197 pub allocation: Allocation,
199 pub mode: Mode,
201 pub explanation: Vec<String>,
203}
204
205fn exemption_for<'a>(
209 policy: &'a ReleasePolicy,
210 changed: &[String],
211) -> Option<&'a ReleaseExemption> {
212 if changed.is_empty() {
213 return None;
214 }
215 policy
216 .exempt
217 .iter()
218 .find(|class| changed.iter().all(|path| class.covers(path)))
219}
220
221const RECORDS: &str = ".ai/repo/releases/";
223
224fn records_release_of(trunk: Version, added: &[String]) -> Option<String> {
232 let path = format!("{RECORDS}v{trunk}.yaml");
233 added.contains(&path).then_some(path)
234}
235
236fn floor_in(mode: Mode, floor: Impact) -> Impact {
240 match (mode, floor) {
241 (Mode::Pre1_0Strict, Impact::Major) => Impact::Minor,
242 (_, floor) => floor,
243 }
244}
245
246pub fn decide(
275 trunk: Version,
276 observed: Impact,
277 policy: &ReleasePolicy,
278 changed: &[String],
279) -> LandingDecision {
280 decide_adding(trunk, observed, policy, changed, &[])
281}
282
283pub fn decide_adding(
310 trunk: Version,
311 observed: Impact,
312 policy: &ReleasePolicy,
313 changed: &[String],
314 added: &[String],
315) -> LandingDecision {
316 let compat = CompatPolicy::for_version(trunk);
317 let required = compat.required_of(observed);
318 let exemption = exemption_for(policy, changed);
319 let record = records_release_of(trunk, added);
320 let floor = if exemption.is_some() || changed.is_empty() || record.is_some() {
325 Impact::None
326 } else {
327 floor_in(compat.mode, policy.floor)
328 };
329 let selected = required.max(floor);
330 let next = trunk.raised_to(selected);
331
332 let mut explanation = vec![
333 format!("the trunk declares {trunk}"),
334 format!(
335 "the change moves the public contract by {}: {}",
336 observed.as_str(),
337 compat.statement()
338 ),
339 format!("compatibility therefore requires {}", required.as_str()),
340 ];
341 match exemption {
342 None if record.is_some() => explanation.push(format!(
343 "the change writes {}, the record of the release the trunk declares, which lands at that release's number, so no policy floor applies",
344 record.unwrap_or_default()
345 )),
346 None if changed.is_empty() => explanation.push(
347 "the change touches no path, which is no landing, so no policy floor applies"
348 .to_string(),
349 ),
350 Some(class) => explanation.push(format!(
351 "every changed path is in the exemption class {} ({}), so the policy floor of {} does not apply; the compatibility requirement still does",
352 class.id,
353 class.description,
354 policy.floor.as_str()
355 )),
356 None if floor != policy.floor => explanation.push(format!(
357 "the policy floor is {}, which the version's mode reads as {}",
358 policy.floor.as_str(),
359 floor.as_str()
360 )),
361 None => explanation.push(format!("the policy floor is {}", policy.floor.as_str())),
362 }
363 explanation.push(format!(
364 "the larger of the two is {}, so the next version is {next}",
365 selected.as_str()
366 ));
367 if policy.allocation == Allocation::Off {
368 explanation.push(
369 "the policy does not allocate at landing (release.allocation: off): this is what a landing would raise to, and a pull request still raises its own version"
370 .to_string(),
371 );
372 }
373
374 LandingDecision {
375 schema: DECISION_SCHEMA.to_string(),
376 trunk_version: trunk.to_string(),
377 observed,
378 required_by_compatibility: required,
379 floor,
380 exemption: exemption.map(|class| AppliedExemption {
381 id: class.id.clone(),
382 reason: class.description.clone(),
383 }),
384 selected,
385 next_version: next.to_string(),
386 allocation: policy.allocation,
387 mode: compat.mode,
388 explanation,
389 }
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct Unreadable(pub String);
402
403impl std::fmt::Display for Unreadable {
404 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405 f.write_str(&self.0)
406 }
407}
408
409impl std::error::Error for Unreadable {}
410
411pub fn next_for(
426 root: &std::path::Path,
427 base: &str,
428 head: &str,
429 policy: &ReleasePolicy,
430) -> Result<LandingDecision, Unreadable> {
431 use crate::release::surface::Surface;
432 let git = |args: &[&str]| -> Result<String, Unreadable> {
433 let out = std::process::Command::new("git")
434 .current_dir(root)
435 .args(args)
436 .output()
437 .map_err(|e| Unreadable(format!("git could not run: {e}")))?;
438 if !out.status.success() {
439 return Err(Unreadable(format!(
440 "git {} failed: {}",
441 args.join(" "),
442 String::from_utf8_lossy(&out.stderr).trim()
443 )));
444 }
445 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
446 };
447 let manifest = git(&[
448 "show",
449 &format!("{base}:{}", crate::release::version::MANIFEST),
450 ])?;
451 let trunk = crate::release::version::declared_in(&manifest)
452 .and_then(|v| Version::parse(&v))
453 .ok_or_else(|| {
454 Unreadable(format!(
455 "{base}:{} declares no version of three numbers",
456 crate::release::version::MANIFEST
457 ))
458 })?;
459 let fork = git(&["merge-base", base, head])?.trim().to_string();
460 let before = Surface::read_ref(root, &fork).map_err(|e| Unreadable(e.to_string()))?;
461 let after = Surface::read_ref(root, head).map_err(|e| Unreadable(e.to_string()))?;
462 let observed = crate::release::compat::diff(&before, &after)
463 .iter()
464 .map(|c| c.impact)
465 .max()
466 .unwrap_or(Impact::None);
467 let names = |text: String| -> Vec<String> {
468 text.lines()
469 .filter(|l| !l.is_empty())
470 .map(str::to_string)
471 .collect()
472 };
473 git(&["diff", "--name-only", "--no-renames", &fork, head]).and_then(|changed| {
474 git(&[
475 "diff",
476 "--name-only",
477 "--no-renames",
478 "--diff-filter=A",
479 &fork,
480 head,
481 ])
482 .map(|added| decide_adding(trunk, observed, policy, &names(changed), &names(added)))
483 })
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[test]
495 fn a_head_landed_onto_itself_is_read_from_git_and_moves_nothing() {
496 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
497 let d =
498 next_for(&root, "HEAD", "HEAD", &ReleasePolicy::default()).expect("readable at HEAD");
499 assert_eq!(d.observed, Impact::None);
500 let committed = std::process::Command::new("git")
501 .current_dir(&root)
502 .args([
503 "show",
504 &format!("HEAD:{}", crate::release::version::MANIFEST),
505 ])
506 .output()
507 .expect("git");
508 let committed =
509 crate::release::version::declared_in(&String::from_utf8_lossy(&committed.stdout));
510 assert_eq!(Some(d.trunk_version.clone()), committed);
511 assert!(!d.explanation.is_empty());
512 assert_eq!(
513 (Allocation::Off.as_str(), Allocation::Landing.as_str()),
514 ("off", "landing")
515 );
516 let refused = next_for(
517 &root,
518 "no-such-ref-of-ours",
519 "HEAD",
520 &ReleasePolicy::default(),
521 )
522 .expect_err("an unknown base is refused");
523 assert!(refused.0.contains("no-such-ref-of-ours"), "{}", refused.0);
524 }
525
526 #[test]
530 fn an_unreadable_input_refuses_the_decision_by_name() {
531 let p = ReleasePolicy::default();
532 let refused = next_for(std::path::Path::new("/no\0where"), "HEAD", "HEAD", &p)
533 .expect_err("no git there");
534 assert!(refused.0.contains("git could not run"), "{}", refused.0);
535
536 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
537 let refused = next_for(&root, "HEAD", "no-such-head-of-ours", &p).expect_err("unknown");
538 assert!(refused.0.contains("merge-base"), "{}", refused.0);
539
540 let dir = tempfile::tempdir().unwrap();
541 let git = |args: &[&str]| {
542 let out = std::process::Command::new("git")
543 .arg("-C")
544 .arg(dir.path())
545 .args(args)
546 .env("GIT_AUTHOR_NAME", "t")
547 .env("GIT_AUTHOR_EMAIL", "t@example.com")
548 .env("GIT_COMMITTER_NAME", "t")
549 .env("GIT_COMMITTER_EMAIL", "t@example.com")
550 .output()
551 .expect("git");
552 assert!(out.status.success(), "git {args:?}: {out:?}");
553 };
554 git(&["init", "-q"]);
555 let manifest = dir.path().join(crate::release::version::MANIFEST);
556 std::fs::create_dir_all(manifest.parent().unwrap()).unwrap();
557 std::fs::write(&manifest, "[package]\nname = \"x\"\nversion = \"one\"\n").unwrap();
558 git(&["add", "-A"]);
559 git(&["commit", "-qm", "base"]);
560 let refused = next_for(dir.path(), "HEAD", "HEAD", &p).expect_err("no version");
561 assert!(refused.0.contains("declares no version"), "{}", refused.0);
562 }
563
564 fn v(text: &str) -> Version {
565 Version::parse(text).expect("a version")
566 }
567
568 fn landing(floor: Impact) -> ReleasePolicy {
569 ReleasePolicy {
570 allocation: Allocation::Landing,
571 floor,
572 exempt: vec![
573 ReleaseExemption {
574 id: "plan-records".into(),
575 description: "plan records change no product behaviour".into(),
576 paths: vec![".ai/repo/project/**".into(), ".ai/repo/sessions/**".into()],
577 },
578 ReleaseExemption {
579 id: "release-record".into(),
580 description: "the record a release writes of itself".into(),
581 paths: vec![
582 ".ai/repo/releases/**".into(),
583 "site/static/releases/**".into(),
584 ],
585 },
586 ],
587 }
588 }
589
590 fn src() -> Vec<String> {
591 vec!["apps/majordomus-cli/src/lib.rs".into()]
592 }
593
594 #[test]
595 fn the_selected_increment_is_the_larger_of_compatibility_and_the_floor() {
596 let p = landing(Impact::Minor);
597 for (observed, trunk, next) in [
598 (Impact::None, "0.20.0", "0.21.0"),
599 (Impact::Patch, "0.20.0", "0.21.0"),
600 (Impact::Minor, "0.20.0", "0.21.0"),
601 (Impact::Major, "0.20.0", "0.21.0"),
602 (Impact::Patch, "1.4.2", "1.5.0"),
603 (Impact::Major, "1.4.2", "2.0.0"),
604 ] {
605 let d = decide(v(trunk), observed, &p, &src());
606 assert_eq!(
607 d.next_version, next,
608 "{observed:?} on {trunk}: {:#?}",
609 d.explanation
610 );
611 }
612 }
613
614 #[test]
615 fn below_one_a_breaking_change_costs_a_minor_and_from_one_a_major() {
616 let p = landing(Impact::None);
617 assert_eq!(
618 decide(v("0.9.0"), Impact::Major, &p, &src()).selected,
619 Impact::Minor
620 );
621 assert_eq!(
622 decide(v("0.9.0"), Impact::Major, &p, &src()).mode,
623 Mode::Pre1_0Strict
624 );
625 assert_eq!(
626 decide(v("1.0.0"), Impact::Major, &p, &src()).selected,
627 Impact::Major
628 );
629 assert_eq!(
630 decide(v("1.0.0"), Impact::Major, &p, &src()).mode,
631 Mode::Semver
632 );
633 }
634
635 #[test]
636 fn with_no_floor_a_change_behind_the_boundary_advances_nothing() {
637 let d = decide(v("0.20.0"), Impact::Patch, &landing(Impact::None), &src());
638 assert_eq!(
639 (d.selected, d.next_version.as_str()),
640 (Impact::None, "0.20.0")
641 );
642 }
643
644 #[test]
645 fn an_exemption_lowers_the_floor_and_never_the_compatibility_requirement() {
646 let p = landing(Impact::Minor);
647 let plan = vec![".ai/repo/project/issues/I9.yaml".to_string()];
648 let d = decide(v("1.0.0"), Impact::None, &p, &plan);
649 assert_eq!(
650 d.exemption.as_ref().map(|e| e.id.as_str()),
651 Some("plan-records")
652 );
653 assert_eq!((d.floor, d.next_version.as_str()), (Impact::None, "1.0.0"));
654 let d = decide(v("1.0.0"), Impact::Major, &p, &plan);
656 assert_eq!(
657 (d.selected, d.next_version.as_str()),
658 (Impact::Major, "2.0.0")
659 );
660 }
661
662 #[test]
663 fn a_change_one_class_covers_only_in_part_is_not_exempt() {
664 let p = landing(Impact::Minor);
665 let mixed = vec![
666 ".ai/repo/project/issues/I9.yaml".to_string(),
667 "apps/majordomus-cli/src/lib.rs".to_string(),
668 ];
669 let d = decide(v("0.20.0"), Impact::None, &p, &mixed);
670 assert!(d.exemption.is_none());
671 assert_eq!(d.next_version, "0.21.0");
672 let split = vec![
674 ".ai/repo/project/issues/I9.yaml".to_string(),
675 ".ai/repo/releases/v0.20.0.yaml".to_string(),
676 ];
677 assert!(decide(v("0.20.0"), Impact::None, &p, &split)
678 .exemption
679 .is_none());
680 }
681
682 #[test]
683 fn the_same_inputs_give_the_same_decision_and_explanation() {
684 let p = landing(Impact::Minor);
685 let a = decide(v("0.20.0"), Impact::Minor, &p, &src());
686 let b = decide(v("0.20.0"), Impact::Minor, &p, &src());
687 assert_eq!(a, b);
688 assert!(a.explanation[0].contains("0.20.0"));
689 assert!(a
690 .explanation
691 .iter()
692 .any(|line| line.contains("floor is minor")));
693 }
694
695 #[test]
696 fn a_policy_that_does_not_allocate_says_so_and_still_decides() {
697 let p = ReleasePolicy {
698 allocation: Allocation::Off,
699 ..landing(Impact::Minor)
700 };
701 let d = decide(v("0.20.0"), Impact::Minor, &p, &src());
702 assert_eq!(d.next_version, "0.21.0");
703 assert!(d
704 .explanation
705 .last()
706 .unwrap()
707 .contains("release.allocation: off"));
708 }
709
710 #[test]
711 fn the_default_policy_changes_nothing() {
712 let d = decide(
713 v("0.20.0"),
714 Impact::Patch,
715 &ReleasePolicy::default(),
716 &src(),
717 );
718 assert_eq!(
719 (d.allocation, d.floor, d.next_version.as_str()),
720 (Allocation::Off, Impact::None, "0.20.0")
721 );
722 }
723
724 #[test]
725 fn the_policy_block_reads_from_the_shape_the_policy_file_has() {
726 let p: ReleasePolicy = serde_json::from_value(serde_json::json!({
727 "allocation": "landing",
728 "floor": "minor",
729 "exempt": [{"id": "plan-records", "description": "plans", "paths": [".ai/repo/project/**"]}],
730 }))
731 .expect("the block");
732 assert_eq!(
733 p,
734 ReleasePolicy {
735 exempt: p.exempt.clone(),
736 ..landing(Impact::Minor)
737 }
738 );
739 assert!(
740 serde_json::from_value::<ReleasePolicy>(serde_json::json!({"skip": true})).is_err(),
741 "there is no switch outside the declared keys"
742 );
743 }
744
745 #[test]
746 fn a_change_of_nothing_is_no_exempt_landing_and_owes_no_floor() {
747 let d = decide(v("0.20.0"), Impact::None, &landing(Impact::Minor), &[]);
748 assert!(d.exemption.is_none());
749 assert_eq!((d.floor, d.selected), (Impact::None, Impact::None));
750 assert_eq!(d.next_version, "0.20.0");
751 assert!(
752 d.explanation.iter().any(|l| l.contains("no landing")),
753 "{:?}",
754 d.explanation
755 );
756 }
757
758 #[test]
759 fn a_major_floor_below_one_asks_a_minor_and_from_one_a_major() {
760 let changed = ["apps/majordomus-cli/src/x.rs".to_string()];
761 let below = decide(v("0.20.0"), Impact::None, &landing(Impact::Major), &changed);
762 assert_eq!(
763 (below.floor, below.next_version.as_str()),
764 (Impact::Minor, "0.21.0")
765 );
766 assert!(
767 below
768 .explanation
769 .iter()
770 .any(|l| l.contains("reads as minor")),
771 "{:?}",
772 below.explanation
773 );
774 let above = decide(v("1.4.0"), Impact::None, &landing(Impact::Major), &changed);
775 assert_eq!(
776 (above.floor, above.next_version.as_str()),
777 (Impact::Major, "2.0.0")
778 );
779 }
780
781 #[test]
782 fn the_record_of_the_trunks_release_lands_at_its_number() {
783 let changed = [
784 ".ai/repo/releases/v0.20.0.yaml".to_string(),
785 "docs/INSTALL.md".to_string(),
786 ".gitattributes".to_string(),
787 ];
788 let added = &changed[..1];
789 let d = decide_adding(
790 v("0.20.0"),
791 Impact::None,
792 &landing(Impact::Minor),
793 &changed,
794 added,
795 );
796 assert_eq!((d.floor, d.next_version.as_str()), (Impact::None, "0.20.0"));
797 assert!(
798 d.explanation
799 .iter()
800 .any(|l| l.contains("record of the release")),
801 "{:?}",
802 d.explanation
803 );
804 let d = decide_adding(
806 v("0.20.0"),
807 Impact::None,
808 &landing(Impact::Minor),
809 &changed,
810 &[],
811 );
812 assert_eq!(d.next_version, "0.21.0");
813 let other = [
815 ".ai/repo/releases/v0.19.0.yaml".to_string(),
816 "docs/INSTALL.md".to_string(),
817 ];
818 let d = decide_adding(
819 v("0.20.0"),
820 Impact::None,
821 &landing(Impact::Minor),
822 &other,
823 &other,
824 );
825 assert_eq!(d.next_version, "0.21.0");
826 }
827}