differential_engine/plan/identity.rs
1//! Review identity and state locations (ADR 0013, `spec/persistence.md`).
2//!
3//! All pure: what a review is *called* and *where its state lives* are domain
4//! decisions. Only actually reading and writing there is the adapter's.
5
6use std::path::{Path, PathBuf};
7
8use sha1::{Digest, Sha1};
9
10/// Truncated sha1, hex. Sixteen characters is plenty to key a per-repo
11/// directory and short enough to read in a path.
12fn short_hash(h: Sha1) -> String {
13 hex::encode(h.finalize())[..16].to_string()
14}
15
16/// A review's identity: the resolved base sha plus the head spec AS TYPED.
17///
18/// The spec, not the resolved head: a branch name keeps one review alive as
19/// its tip moves, where a sha would file every commit as a new review and
20/// strand the reviewer's progress behind it.
21///
22/// The spelling is therefore part of the name, which is why one range typed
23/// two ways used to be two reviews. `review_identity::resolve` closes that:
24/// it adopts an existing review on the same line of history and records the
25/// join, so this stays a pure function of the two strings.
26pub fn review_id(base_sha: &str, head_spec: &str) -> String {
27 let mut h = Sha1::new();
28 h.update(base_sha.as_bytes());
29 h.update([0]);
30 h.update(head_spec.as_bytes());
31 short_hash(h)
32}
33
34/// A review the reader named. The name IS the identity, so neither endpoint
35/// is in the key: rebase the base or the head and the session survives, the
36/// way a pull request survives a force-push because it is an object rather
37/// than a range.
38///
39/// The leading tag byte keeps a name out of `review_id`'s space. That hash
40/// starts with a resolved sha — hex ASCII — so it can never begin with `0x01`.
41pub fn review_id_named(name: &str) -> String {
42 let mut h = Sha1::new();
43 h.update([1]);
44 h.update(name.as_bytes());
45 short_hash(h)
46}
47
48/// A review of a pull request or merge request (ADR 0029). The request is
49/// the identity, the way a name is: no endpoint is in the key, so a
50/// force-push reopens the same review. Its own tag byte keeps it out of both
51/// other spaces.
52pub fn review_id_remote(remote: &crate::schema::Remote) -> String {
53 let mut h = Sha1::new();
54 h.update([2]);
55 h.update(remote.forge.as_bytes());
56 h.update([0]);
57 h.update(remote.project.as_bytes());
58 h.update([0]);
59 h.update(remote.id.as_bytes());
60 short_hash(h)
61}
62
63/// A plan document's content hash — its immutable identity, and what findings
64/// record so re-anchoring knows which document they were written against.
65pub fn plan_hash(json: &str) -> String {
66 let mut h = Sha1::new();
67 h.update(json.as_bytes());
68 short_hash(h)
69}
70
71/// The grouping cache directory for a repo, given its git common dir.
72///
73/// Under the common dir, not the worktree's `.git`, so worktrees of one repo
74/// share a cache — and outside the tracked tree, since a grouping is a local
75/// artefact and not something to commit (ADR 0009).
76pub fn grouping_cache_dir(common_dir: &Path) -> PathBuf {
77 cache_dir(common_dir).join("grouping")
78}
79
80/// Everything regenerable, in ONE subtree.
81///
82/// `reviews/` is deliberately a sibling rather than a child. A review's
83/// findings are the reader's own work and cannot be recomputed, so nothing that
84/// clears the cache may be able to reach them — and with the two rooted apart,
85/// that is a property of the layout rather than of a command remembering to be
86/// careful (ADR 0009, ADR 0013).
87pub fn cache_dir(common_dir: &Path) -> PathBuf {
88 common_dir.join("differential").join("cache")
89}
90
91/// Where the pre-group document is left for the model to read, given the git
92/// common dir.
93///
94/// A sibling of the grouping cache and for the same reasons: shared across
95/// worktrees, and outside the tracked tree because it describes one local run
96/// (ADR 0009, ADR 0022).
97pub fn artefact_dir(common_dir: &Path) -> PathBuf {
98 cache_dir(common_dir).join("document")
99}
100
101/// Where every review is filed. The directory is the catalogue: there is no
102/// list file beside it that could disagree with what is on disk.
103pub fn reviews_dir(common_dir: &Path) -> PathBuf {
104 common_dir.join("differential").join("reviews")
105}
106
107/// One review's sidecar directory, given the git common dir.
108pub fn review_dir(common_dir: &Path, review_id: &str) -> PathBuf {
109 reviews_dir(common_dir).join(review_id)
110}
111
112/// What a review was opened as: the base sha and the head spec as typed.
113///
114/// Adoption needs the spelling back to ask git what it means now, and the id
115/// is a hash, so the id cannot answer that. Written on open.
116pub fn identity_path(common_dir: &Path, review_id: &str) -> PathBuf {
117 review_dir(common_dir, review_id).join("identity.json")
118}
119
120/// A redirect: this id's progress lives under the id named inside.
121///
122/// Present only in a directory that adopted another review, and read before
123/// anything else, so the join costs one file read and no git call.
124pub fn alias_path(common_dir: &Path, review_id: &str) -> PathBuf {
125 review_dir(common_dir, review_id).join("alias")
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn state_lives_under_the_common_dir_not_the_tracked_tree() {
134 let common = Path::new("/repo/.git");
135 assert!(
136 grouping_cache_dir(common).ends_with("differential/cache/grouping"),
137 "{:?}",
138 grouping_cache_dir(common)
139 );
140 assert!(
141 review_dir(common, "abc123").ends_with("differential/reviews/abc123"),
142 "{:?}",
143 review_dir(common, "abc123")
144 );
145 }
146}