Skip to main content

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}