Skip to main content

diffler_core/
source.rs

1//! What a review is *of*: the working tree, a single commit, a contiguous
2//! commit range, or everything since a named revision. A source has a
3//! deterministic, filesystem-safe persistence key and a human-facing label, so
4//! review state can be tracked per source and the agent can be told exactly
5//! what the human reviewed.
6
7use serde::{Deserialize, Serialize};
8
9/// Characters of an oid shown in a label; full oids stay in the key.
10const 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    /// A forge pull request. The diff renders as a range resolved at open
24    /// time, but review state keys on the PR number so it survives pushes.
25    Pr {
26        number: u64,
27    },
28    /// Everything the working tree carries over `rev`, three-dot. `rev` is
29    /// stored as the human named it and resolved at diff time, so the review
30    /// follows the ref as it moves.
31    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    /// Stable persistence key, also the on-disk filename stem. The `-`
57    /// separator is unambiguous because git/jj oids are dash-free hex; every
58    /// character is filesystem-safe.
59    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    /// Human-facing description of what is being reviewed.
70    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
87/// A raw oid shortens like the other arms; a ref name stays whole.
88fn 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
96/// Ref names carry `/` and other characters a filename cannot, so they collapse
97/// to `-`. `feat/x` and `feat-x` therefore share one review file, the accepted
98/// cost of a flat key.
99fn 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        // the documented collision: a flat key cannot tell these apart
132        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}