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