Skip to main content

diffler_core/
source.rs

1//! What a review is of, with a filesystem-safe persistence key and a label.
2
3use serde::{Deserialize, Serialize};
4
5/// Characters of an oid shown in a label; full oids stay in the key.
6const SHORT_OID: usize = 7;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
9#[serde(tag = "kind", rename_all = "snake_case")]
10pub enum ReviewSource {
11    #[default]
12    WorkingTree,
13    Commit {
14        oid: String,
15    },
16    Range {
17        oldest: String,
18        newest: String,
19    },
20    /// Keyed on the number so review state survives pushes.
21    Pr {
22        number: u64,
23    },
24    /// The working tree three-dot against `rev`. We store `rev` as named and
25    /// resolve it at diff time, so the review follows the ref.
26    Against {
27        rev: String,
28    },
29    Walkthrough {
30        id: String,
31    },
32}
33
34impl ReviewSource {
35    pub fn commit(oid: impl Into<String>) -> Self {
36        Self::Commit { oid: oid.into() }
37    }
38
39    pub fn range(oldest: impl Into<String>, newest: impl Into<String>) -> Self {
40        Self::Range {
41            oldest: oldest.into(),
42            newest: newest.into(),
43        }
44    }
45
46    pub fn pr(number: u64) -> Self {
47        Self::Pr { number }
48    }
49
50    pub fn against(rev: impl Into<String>) -> Self {
51        Self::Against { rev: rev.into() }
52    }
53
54    pub fn walkthrough(id: impl Into<String>) -> Self {
55        Self::Walkthrough { id: id.into() }
56    }
57
58    /// Persistence key and filename stem. Oids are dash-free hex, so `-` is an
59    /// unambiguous separator; refs and walkthrough ids are sanitised.
60    pub fn key(&self) -> String {
61        match self {
62            Self::WorkingTree => "working".to_owned(),
63            Self::Commit { oid } => format!("commit-{oid}"),
64            Self::Range { oldest, newest } => format!("range-{oldest}-{newest}"),
65            Self::Pr { number } => format!("pr-{number}"),
66            Self::Against { rev } => format!("against-{}", filename_safe(rev)),
67            Self::Walkthrough { id } => format!("walkthrough-{}", filename_safe(id)),
68        }
69    }
70
71    /// A walkthrough's title lives in its session, so it labels by id here.
72    pub fn label(&self) -> String {
73        match self {
74            Self::WorkingTree => "working tree".to_owned(),
75            Self::Commit { oid } => format!("commit {}", short(oid)),
76            Self::Range { oldest, newest } => {
77                format!("range {}..{}", short(oldest), short(newest))
78            }
79            Self::Pr { number } => format!("PR #{number}"),
80            Self::Against { rev } => format!("vs {}", short_rev(rev)),
81            Self::Walkthrough { id } => format!("walkthrough {}", short_id(id)),
82        }
83    }
84}
85
86fn short(oid: &str) -> &str {
87    oid.get(..SHORT_OID).unwrap_or(oid)
88}
89
90const SHORT_ID: usize = 8;
91
92fn short_id(id: &str) -> &str {
93    id.get(..SHORT_ID).unwrap_or(id)
94}
95
96/// A raw oid shortens like the other arms; a ref name stays whole.
97fn short_rev(rev: &str) -> &str {
98    if rev.len() >= SHORT_OID && rev.chars().all(|c| c.is_ascii_hexdigit()) {
99        short(rev)
100    } else {
101        rev
102    }
103}
104
105/// Collapses unsafe characters to `-`, so `feat/x` and `feat-x` share a file.
106fn filename_safe(rev: &str) -> String {
107    rev.chars()
108        .map(|c| {
109            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
110                c
111            } else {
112                '-'
113            }
114        })
115        .collect()
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn keys_are_deterministic_and_distinct_per_source() {
124        assert_eq!(ReviewSource::WorkingTree.key(), "working");
125        assert_eq!(ReviewSource::commit("abc123").key(), "commit-abc123");
126        assert_eq!(ReviewSource::range("aaa", "bbb").key(), "range-aaa-bbb");
127        assert_eq!(ReviewSource::pr(42).key(), "pr-42");
128        assert_eq!(ReviewSource::against("main").key(), "against-main");
129        assert_eq!(ReviewSource::walkthrough("w1").key(), "walkthrough-w1");
130    }
131
132    #[test]
133    fn against_keys_are_filename_safe() {
134        assert_eq!(
135            ReviewSource::against("origin/main").key(),
136            "against-origin-main"
137        );
138        assert_eq!(ReviewSource::against("HEAD~1").key(), "against-HEAD-1");
139        assert_eq!(
140            ReviewSource::against("feat/x").key(),
141            ReviewSource::against("feat-x").key()
142        );
143    }
144
145    #[test]
146    fn walkthrough_keys_are_filename_safe() {
147        assert_eq!(
148            ReviewSource::walkthrough("feature/login").key(),
149            "walkthrough-feature-login"
150        );
151        assert_eq!(
152            ReviewSource::walkthrough("../../etc/passwd").key(),
153            "walkthrough-..-..-etc-passwd"
154        );
155    }
156
157    #[test]
158    fn labels_shorten_oids() {
159        assert_eq!(ReviewSource::WorkingTree.label(), "working tree");
160        assert_eq!(
161            ReviewSource::commit("0123456789abcdef").label(),
162            "commit 0123456"
163        );
164        assert_eq!(
165            ReviewSource::range("0123456789", "fedcba9876").label(),
166            "range 0123456..fedcba9"
167        );
168        assert_eq!(ReviewSource::against("main").label(), "vs main");
169        assert_eq!(
170            ReviewSource::against("origin/main").label(),
171            "vs origin/main"
172        );
173        assert_eq!(ReviewSource::against("HEAD~1").label(), "vs HEAD~1");
174        assert_eq!(
175            ReviewSource::against("0123456789abcdef").label(),
176            "vs 0123456"
177        );
178        assert_eq!(
179            ReviewSource::walkthrough("0123456789abcdef").label(),
180            "walkthrough 01234567"
181        );
182    }
183
184    #[test]
185    fn short_oid_tolerates_a_short_string() {
186        assert_eq!(ReviewSource::commit("ab").label(), "commit ab");
187        assert_eq!(ReviewSource::walkthrough("ab").label(), "walkthrough ab");
188    }
189
190    #[test]
191    fn round_trips_through_json_as_a_tagged_descriptor() {
192        for source in [
193            ReviewSource::WorkingTree,
194            ReviewSource::commit("abc"),
195            ReviewSource::range("aaa", "bbb"),
196            ReviewSource::pr(3),
197            ReviewSource::against("origin/main"),
198            ReviewSource::walkthrough("w1"),
199        ] {
200            let json = serde_json::to_string(&source).expect("serialize");
201            let back: ReviewSource = serde_json::from_str(&json).expect("deserialize");
202            assert_eq!(source, back);
203        }
204    }
205}