differential_engine/plan/source.rs
1//! What a review is *of*: endpoints, and the identity it is filed under.
2
3use crate::EngineError;
4use crate::schema;
5
6/// A revision-range spec, parsed. Pure — resolving `MergeBase` needs a
7/// repository, but deciding what the user asked for does not.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum RangeSpec {
10 /// `a..b`, or two separate revs: the endpoints as typed.
11 Direct { base: String, head: String },
12 /// `a...b`: the base is the merge-base, which is what a merge request's
13 /// diff actually shows.
14 MergeBase { a: String, b: String },
15}
16
17impl RangeSpec {
18 /// The head endpoint **as typed**.
19 ///
20 /// This is a review's identity, not an endpoint: a branch name keeps a
21 /// review stable while its tip moves, where the resolved sha would file
22 /// every new commit as a different review.
23 pub fn head_spec(&self) -> &str {
24 match self {
25 RangeSpec::Direct { head, .. } => head,
26 RangeSpec::MergeBase { b, .. } => b,
27 }
28 }
29}
30
31/// Parse `a..b`, `a...b`, or two separate revs.
32///
33/// One parser, so the endpoints and the review's identity can never disagree
34/// about which side is the head. There used to be a second copy of this in the
35/// CLI, whose only protection against divergence was that its extra arms were
36/// unreachable.
37pub fn parse_range(spec: &[&str]) -> Result<RangeSpec, EngineError> {
38 match spec {
39 [one] => {
40 if let Some((a, b)) = one.split_once("...") {
41 Ok(RangeSpec::MergeBase {
42 a: a.to_string(),
43 b: b.to_string(),
44 })
45 } else if let Some((a, b)) = one.split_once("..") {
46 Ok(RangeSpec::Direct {
47 base: a.to_string(),
48 head: b.to_string(),
49 })
50 } else {
51 Err(EngineError::Range(format!(
52 "single argument must be <base>..<head> or <a>...<b>, got {one:?}"
53 )))
54 }
55 }
56 [a, b] => Ok(RangeSpec::Direct {
57 base: (*a).to_string(),
58 head: (*b).to_string(),
59 }),
60 other => Err(EngineError::Range(format!(
61 "expected one range or two revs, got {} arguments",
62 other.len()
63 ))),
64 }
65}
66
67/// A resolved review source: where the diff comes from, and what the review is
68/// filed under.
69///
70/// `base`/`head` are the diff's endpoints. `head_spec` and `identity_base` are
71/// its *identity* — deliberately separate, because reviewing uncommitted work
72/// diffs against synthesized trees that churn on every edit while the review
73/// itself must survive (ADR 0017).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct ReviewSource {
76 pub base: String,
77 pub head: String,
78 pub kind: schema::SourceKind,
79 /// The head endpoint as typed.
80 pub head_spec: String,
81 /// The base a review is filed under when it differs from `base` — set for
82 /// uncommitted sources, whose `base`/`head` may be synthesized tree oids.
83 pub identity_base: Option<String>,
84 /// The request this range came from, when the reader named one
85 /// (ADR 0029). Written to `source.remote`; nothing else reads it.
86 pub remote: Option<schema::Remote>,
87}
88
89impl ReviewSource {
90 /// A plain committed range.
91 pub fn range(base: String, head: String, head_spec: String) -> Self {
92 ReviewSource {
93 base,
94 head,
95 kind: schema::SourceKind::Range,
96 head_spec,
97 identity_base: None,
98 remote: None,
99 }
100 }
101
102 /// A pull request or merge request: the endpoints the forge gave, and the
103 /// request recorded as the document's `source.remote`. The review's
104 /// identity is the request itself, not these endpoints, so `head_spec`
105 /// is the head sha and no `identity_base` is set.
106 pub fn request(
107 base: String,
108 head: String,
109 kind: schema::SourceKind,
110 remote: schema::Remote,
111 ) -> Self {
112 ReviewSource {
113 base,
114 head_spec: head.clone(),
115 head,
116 kind,
117 identity_base: None,
118 remote: Some(remote),
119 }
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn every_spelling_of_a_range_agrees_on_which_side_is_the_head() {
129 // The property that made the CLI's second parser safe only by
130 // accident: whatever the spelling, `head_spec` is the right-hand side.
131 for spec in [
132 vec!["a..b"],
133 vec!["a...b"],
134 vec!["a", "b"],
135 vec!["refs/heads/a..b"],
136 ] {
137 assert_eq!(parse_range(&spec).unwrap().head_spec(), "b", "{spec:?}");
138 }
139 }
140
141 #[test]
142 fn three_dots_means_merge_base() {
143 assert_eq!(
144 parse_range(&["main...feature"]).unwrap(),
145 RangeSpec::MergeBase {
146 a: "main".into(),
147 b: "feature".into()
148 }
149 );
150 assert_eq!(
151 parse_range(&["main..feature"]).unwrap(),
152 RangeSpec::Direct {
153 base: "main".into(),
154 head: "feature".into()
155 }
156 );
157 }
158
159 #[test]
160 fn a_bare_rev_is_not_a_range() {
161 // The CLI's old helper returned the rev itself here, which no caller
162 // ever saw because resolution errored first. One parser, one answer.
163 assert!(matches!(parse_range(&["main"]), Err(EngineError::Range(_))));
164 assert!(matches!(parse_range(&[]), Err(EngineError::Range(_))));
165 assert!(matches!(
166 parse_range(&["a", "b", "c"]),
167 Err(EngineError::Range(_))
168 ));
169 }
170}