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    /// An agent-published walkthrough: its own comments, its own viewed and
35    /// seen marks, nothing shared with the working tree, a PR, a commit or a
36    /// range review.
37    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    /// Stable persistence key, also the on-disk filename stem. The `-`
67    /// separator is unambiguous because git/jj oids are dash-free hex; every
68    /// character is filesystem-safe. A walkthrough id is agent-supplied text,
69    /// not an oid, so it goes through the same sanitising a ref name does.
70    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    /// Human-facing description of what is being reviewed. A walkthrough's
82    /// title lives in its own session, out of reach here, so this names it
83    /// generically; a caller holding that session shows the title instead.
84    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
102/// Characters of a walkthrough id shown in its fallback label.
103const SHORT_ID: usize = 8;
104
105fn short_id(id: &str) -> &str {
106    id.get(..SHORT_ID).unwrap_or(id)
107}
108
109/// A raw oid shortens like the other arms; a ref name stays whole.
110fn 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
118/// Ref names carry `/` and other characters a filename cannot, so they collapse
119/// to `-`. `feat/x` and `feat-x` therefore share one review file, the accepted
120/// cost of a flat key.
121fn 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        // the documented collision: a flat key cannot tell these apart
155        assert_eq!(
156            ReviewSource::against("feat/x").key(),
157            ReviewSource::against("feat-x").key()
158        );
159    }
160
161    /// An agent-supplied walkthrough id is arbitrary text, not an oid: a `/`
162    /// in it must not turn the key into a path with a missing directory.
163    #[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}