1use serde::{Deserialize, Serialize};
4
5const 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 Pr {
22 number: u64,
23 },
24 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 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 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
96fn 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
105fn 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}