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, 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    /// A forge pull request. The diff renders as a range resolved at open
25    /// time, but review state keys on the PR number so it survives pushes.
26    Pr {
27        number: u64,
28    },
29    /// Everything the working tree carries over `rev`, three-dot. `rev` is
30    /// stored as the human named it and resolved at diff time, so the review
31    /// follows the ref as it moves.
32    Against {
33        rev: String,
34    },
35    /// An agent-published walkthrough: its own comments, its own viewed and
36    /// seen marks, nothing shared with the working tree, a PR, a commit or a
37    /// range review.
38    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    /// Stable persistence key, also the on-disk filename stem. The `-`
68    /// separator is unambiguous because git/jj oids are dash-free hex; every
69    /// character is filesystem-safe. A walkthrough id is agent-supplied text,
70    /// not an oid, so it goes through the same sanitising a ref name does.
71    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    /// Human-facing description of what is being reviewed. A walkthrough's
83    /// title lives in its own session, out of reach here, so this names it
84    /// generically; a caller holding that session shows the title instead.
85    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
103/// Characters of a walkthrough id shown in its fallback label.
104const SHORT_ID: usize = 8;
105
106fn short_id(id: &str) -> &str {
107    id.get(..SHORT_ID).unwrap_or(id)
108}
109
110/// A raw oid shortens like the other arms; a ref name stays whole.
111fn 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
119/// Ref names carry `/` and other characters a filename cannot, so they collapse
120/// to `-`. `feat/x` and `feat-x` therefore share one review file, the accepted
121/// cost of a flat key.
122fn 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        // the documented collision: a flat key cannot tell these apart
156        assert_eq!(
157            ReviewSource::against("feat/x").key(),
158            ReviewSource::against("feat-x").key()
159        );
160    }
161
162    /// An agent-supplied walkthrough id is arbitrary text, not an oid: a `/`
163    /// in it must not turn the key into a path with a missing directory.
164    #[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}