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}
35
36impl ReviewSource {
37 pub fn commit(oid: impl Into<String>) -> Self {
38 Self::Commit { oid: oid.into() }
39 }
40
41 pub fn range(oldest: impl Into<String>, newest: impl Into<String>) -> Self {
42 Self::Range {
43 oldest: oldest.into(),
44 newest: newest.into(),
45 }
46 }
47
48 pub fn pr(number: u64) -> Self {
49 Self::Pr { number }
50 }
51
52 pub fn against(rev: impl Into<String>) -> Self {
53 Self::Against { rev: rev.into() }
54 }
55
56 pub fn key(&self) -> String {
60 match self {
61 Self::WorkingTree => "working".to_owned(),
62 Self::Commit { oid } => format!("commit-{oid}"),
63 Self::Range { oldest, newest } => format!("range-{oldest}-{newest}"),
64 Self::Pr { number } => format!("pr-{number}"),
65 Self::Against { rev } => format!("against-{}", filename_safe(rev)),
66 }
67 }
68
69 pub fn label(&self) -> String {
71 match self {
72 Self::WorkingTree => "working tree".to_owned(),
73 Self::Commit { oid } => format!("commit {}", short(oid)),
74 Self::Range { oldest, newest } => {
75 format!("range {}..{}", short(oldest), short(newest))
76 }
77 Self::Pr { number } => format!("PR #{number}"),
78 Self::Against { rev } => format!("vs {}", short_rev(rev)),
79 }
80 }
81}
82
83fn short(oid: &str) -> &str {
84 oid.get(..SHORT_OID).unwrap_or(oid)
85}
86
87fn short_rev(rev: &str) -> &str {
89 if rev.len() >= SHORT_OID && rev.chars().all(|c| c.is_ascii_hexdigit()) {
90 short(rev)
91 } else {
92 rev
93 }
94}
95
96fn filename_safe(rev: &str) -> String {
100 rev.chars()
101 .map(|c| {
102 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
103 c
104 } else {
105 '-'
106 }
107 })
108 .collect()
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn keys_are_deterministic_and_distinct_per_source() {
117 assert_eq!(ReviewSource::WorkingTree.key(), "working");
118 assert_eq!(ReviewSource::commit("abc123").key(), "commit-abc123");
119 assert_eq!(ReviewSource::range("aaa", "bbb").key(), "range-aaa-bbb");
120 assert_eq!(ReviewSource::pr(42).key(), "pr-42");
121 assert_eq!(ReviewSource::against("main").key(), "against-main");
122 }
123
124 #[test]
125 fn against_keys_are_filename_safe() {
126 assert_eq!(
127 ReviewSource::against("origin/main").key(),
128 "against-origin-main"
129 );
130 assert_eq!(ReviewSource::against("HEAD~1").key(), "against-HEAD-1");
131 assert_eq!(
133 ReviewSource::against("feat/x").key(),
134 ReviewSource::against("feat-x").key()
135 );
136 }
137
138 #[test]
139 fn labels_shorten_oids() {
140 assert_eq!(ReviewSource::WorkingTree.label(), "working tree");
141 assert_eq!(
142 ReviewSource::commit("0123456789abcdef").label(),
143 "commit 0123456"
144 );
145 assert_eq!(
146 ReviewSource::range("0123456789", "fedcba9876").label(),
147 "range 0123456..fedcba9"
148 );
149 assert_eq!(ReviewSource::against("main").label(), "vs main");
150 assert_eq!(
151 ReviewSource::against("origin/main").label(),
152 "vs origin/main"
153 );
154 assert_eq!(ReviewSource::against("HEAD~1").label(), "vs HEAD~1");
155 assert_eq!(
156 ReviewSource::against("0123456789abcdef").label(),
157 "vs 0123456"
158 );
159 }
160
161 #[test]
162 fn short_oid_tolerates_a_short_string() {
163 assert_eq!(ReviewSource::commit("ab").label(), "commit ab");
164 }
165
166 #[test]
167 fn round_trips_through_json_as_a_tagged_descriptor() {
168 for source in [
169 ReviewSource::WorkingTree,
170 ReviewSource::commit("abc"),
171 ReviewSource::range("aaa", "bbb"),
172 ReviewSource::pr(3),
173 ReviewSource::against("origin/main"),
174 ] {
175 let json = serde_json::to_string(&source).expect("serialize");
176 let back: ReviewSource = serde_json::from_str(&json).expect("deserialize");
177 assert_eq!(source, back);
178 }
179 }
180}