1use serde::{Deserialize, Serialize};
8
9const SHORT_OID: usize = 7;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(tag = "kind", rename_all = "snake_case")]
14pub enum ReviewSource {
15 WorkingTree,
16 Commit {
17 oid: String,
18 },
19 Range {
20 oldest: String,
21 newest: String,
22 },
23 Pr {
26 number: u64,
27 },
28 Against {
32 rev: String,
33 },
34 Walkthrough {
38 id: String,
39 },
40}
41
42impl ReviewSource {
43 pub fn commit(oid: impl Into<String>) -> Self {
44 Self::Commit { oid: oid.into() }
45 }
46
47 pub fn range(oldest: impl Into<String>, newest: impl Into<String>) -> Self {
48 Self::Range {
49 oldest: oldest.into(),
50 newest: newest.into(),
51 }
52 }
53
54 pub fn pr(number: u64) -> Self {
55 Self::Pr { number }
56 }
57
58 pub fn against(rev: impl Into<String>) -> Self {
59 Self::Against { rev: rev.into() }
60 }
61
62 pub fn walkthrough(id: impl Into<String>) -> Self {
63 Self::Walkthrough { id: id.into() }
64 }
65
66 pub fn key(&self) -> String {
71 match self {
72 Self::WorkingTree => "working".to_owned(),
73 Self::Commit { oid } => format!("commit-{oid}"),
74 Self::Range { oldest, newest } => format!("range-{oldest}-{newest}"),
75 Self::Pr { number } => format!("pr-{number}"),
76 Self::Against { rev } => format!("against-{}", filename_safe(rev)),
77 Self::Walkthrough { id } => format!("walkthrough-{}", filename_safe(id)),
78 }
79 }
80
81 pub fn label(&self) -> String {
85 match self {
86 Self::WorkingTree => "working tree".to_owned(),
87 Self::Commit { oid } => format!("commit {}", short(oid)),
88 Self::Range { oldest, newest } => {
89 format!("range {}..{}", short(oldest), short(newest))
90 }
91 Self::Pr { number } => format!("PR #{number}"),
92 Self::Against { rev } => format!("vs {}", short_rev(rev)),
93 Self::Walkthrough { id } => format!("walkthrough {}", short_id(id)),
94 }
95 }
96}
97
98fn short(oid: &str) -> &str {
99 oid.get(..SHORT_OID).unwrap_or(oid)
100}
101
102const SHORT_ID: usize = 8;
104
105fn short_id(id: &str) -> &str {
106 id.get(..SHORT_ID).unwrap_or(id)
107}
108
109fn short_rev(rev: &str) -> &str {
111 if rev.len() >= SHORT_OID && rev.chars().all(|c| c.is_ascii_hexdigit()) {
112 short(rev)
113 } else {
114 rev
115 }
116}
117
118fn filename_safe(rev: &str) -> String {
122 rev.chars()
123 .map(|c| {
124 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
125 c
126 } else {
127 '-'
128 }
129 })
130 .collect()
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn keys_are_deterministic_and_distinct_per_source() {
139 assert_eq!(ReviewSource::WorkingTree.key(), "working");
140 assert_eq!(ReviewSource::commit("abc123").key(), "commit-abc123");
141 assert_eq!(ReviewSource::range("aaa", "bbb").key(), "range-aaa-bbb");
142 assert_eq!(ReviewSource::pr(42).key(), "pr-42");
143 assert_eq!(ReviewSource::against("main").key(), "against-main");
144 assert_eq!(ReviewSource::walkthrough("w1").key(), "walkthrough-w1");
145 }
146
147 #[test]
148 fn against_keys_are_filename_safe() {
149 assert_eq!(
150 ReviewSource::against("origin/main").key(),
151 "against-origin-main"
152 );
153 assert_eq!(ReviewSource::against("HEAD~1").key(), "against-HEAD-1");
154 assert_eq!(
156 ReviewSource::against("feat/x").key(),
157 ReviewSource::against("feat-x").key()
158 );
159 }
160
161 #[test]
164 fn walkthrough_keys_are_filename_safe() {
165 assert_eq!(
166 ReviewSource::walkthrough("feature/login").key(),
167 "walkthrough-feature-login"
168 );
169 assert_eq!(
170 ReviewSource::walkthrough("../../etc/passwd").key(),
171 "walkthrough-..-..-etc-passwd"
172 );
173 }
174
175 #[test]
176 fn labels_shorten_oids() {
177 assert_eq!(ReviewSource::WorkingTree.label(), "working tree");
178 assert_eq!(
179 ReviewSource::commit("0123456789abcdef").label(),
180 "commit 0123456"
181 );
182 assert_eq!(
183 ReviewSource::range("0123456789", "fedcba9876").label(),
184 "range 0123456..fedcba9"
185 );
186 assert_eq!(ReviewSource::against("main").label(), "vs main");
187 assert_eq!(
188 ReviewSource::against("origin/main").label(),
189 "vs origin/main"
190 );
191 assert_eq!(ReviewSource::against("HEAD~1").label(), "vs HEAD~1");
192 assert_eq!(
193 ReviewSource::against("0123456789abcdef").label(),
194 "vs 0123456"
195 );
196 assert_eq!(
197 ReviewSource::walkthrough("0123456789abcdef").label(),
198 "walkthrough 01234567"
199 );
200 }
201
202 #[test]
203 fn short_oid_tolerates_a_short_string() {
204 assert_eq!(ReviewSource::commit("ab").label(), "commit ab");
205 assert_eq!(ReviewSource::walkthrough("ab").label(), "walkthrough ab");
206 }
207
208 #[test]
209 fn round_trips_through_json_as_a_tagged_descriptor() {
210 for source in [
211 ReviewSource::WorkingTree,
212 ReviewSource::commit("abc"),
213 ReviewSource::range("aaa", "bbb"),
214 ReviewSource::pr(3),
215 ReviewSource::against("origin/main"),
216 ReviewSource::walkthrough("w1"),
217 ] {
218 let json = serde_json::to_string(&source).expect("serialize");
219 let back: ReviewSource = serde_json::from_str(&json).expect("deserialize");
220 assert_eq!(source, back);
221 }
222 }
223}