Skip to main content

differential_engine/plan/
mod.rs

1//! Domain policy over a plan document: the decisions, without the I/O.
2//!
3//! Everything here is pure and consumes only `schema`. It exists because the
4//! same decisions were being made independently in `crates/tui` and
5//! `crates/stack` — and had already drifted (ADR 0020).
6
7mod counts;
8mod enumerate;
9mod identity;
10mod ids;
11mod source;
12mod staging;
13mod tiers;
14mod view;
15
16pub use counts::LineCounts;
17pub use enumerate::{Enumeration, attr_marks_generated, build_view, classify};
18pub use identity::{
19    alias_path, artefact_dir, cache_dir, grouping_cache_dir, identity_path, plan_hash, review_dir,
20    review_id, review_id_named, review_id_remote, reviews_dir,
21};
22pub use ids::{HunkId, PlanIndex};
23pub use source::{RangeSpec, ReviewSource, parse_range};
24pub use staging::{Staged, cumulative_state, final_state, missing_mode, zero_hunk_state};
25pub use tiers::{Deferral, Fold, ReadingSplit, class_is_generated, generated_files, reading_split};
26pub use view::{ClassMembers, Dependency, FileView, GroupView, ReviewView, all_reviewed};
27
28use crate::schema;
29
30/// The tier's domain name, identical to its wire value.
31///
32/// Renderers compose their own vocabulary from this — a commit subject, a
33/// glyph, a colour — but they all start from one token, so none of them can
34/// drift away from the schema on its own.
35///
36/// A `match` here, not strum as the config names use: `Effort` and `Role` are
37/// `engine::schema` types, and a derive for a consumer's convenience does not
38/// go on the schema (ADR 0008, 0018). The test below pins the two to serde.
39pub const fn effort_name(effort: schema::Effort) -> &'static str {
40    match effort {
41        schema::Effort::Focus => "focus",
42        schema::Effort::Skim => "skim",
43        schema::Effort::Noise => "noise",
44    }
45}
46
47/// The ordering role's domain name, identical to its wire value.
48pub const fn role_name(role: schema::Role) -> &'static str {
49    match role {
50        schema::Role::Foundation => "foundation",
51        schema::Role::Consumer => "consumer",
52        schema::Role::Mechanical => "mechanical",
53        schema::Role::Noise => "noise",
54    }
55}
56
57/// How many hex characters of an oid to show a human.
58const SHORT_OID: usize = 12;
59
60/// An oid abbreviated for display.
61///
62/// Deliberately not `git rev-parse --short`: no uniqueness check and no repo
63/// access, because every call site wanted a fixed prefix to print, not an
64/// abbreviation a reader could resolve. An oid that must be typed *back* is a
65/// different problem — the stack's ref name uses its own narrower width, which
66/// `spec/stack.md` documents.
67pub fn short_oid(oid: &str) -> &str {
68    &oid[..SHORT_OID.min(oid.len())]
69}
70
71#[cfg(test)]
72pub(crate) mod test_support {
73    //! Hand-built documents for the pure tests: no repo, no pipeline.
74
75    use crate::schema;
76
77    use super::HunkId;
78
79    /// A document with `classes` (id, member ids, exemplar id) and `files`
80    /// (path, member ids). Hunks are synthesized to cover every id mentioned.
81    pub fn doc_with(
82        classes: &[(&str, &[&str], &str)],
83        files: &[(&str, &[&str])],
84    ) -> schema::PlanDocument {
85        let n = classes
86            .iter()
87            .flat_map(|(_, ids, _)| ids.iter())
88            .chain(files.iter().flat_map(|(_, ids)| ids.iter()))
89            .filter_map(|id| id.strip_prefix('h').and_then(|n| n.parse::<usize>().ok()))
90            .map(|i| i + 1)
91            .max()
92            .unwrap_or(0);
93
94        schema::PlanDocument {
95            schema_version: schema::SCHEMA_VERSION,
96            generator: schema::Generator {
97                tool: "test".into(),
98                version: "0".into(),
99                stages: vec![],
100            },
101            source: schema::Source {
102                kind: schema::SourceKind::Range,
103                base: "0".repeat(40),
104                head: "1".repeat(40),
105                remote: None,
106            },
107            stats: schema::Stats {
108                files: files.len() as u32,
109                hunks: n as u32,
110                classes: classes.len() as u32,
111                binary_files: 0,
112                submodules: 0,
113            },
114            files: files
115                .iter()
116                .map(|(path, ids)| file_entry(path, ids))
117                .collect(),
118            hunks: (0..n).map(hunk_entry).collect(),
119            classes: classes
120                .iter()
121                .map(|(id, ids, exemplar)| schema::ClassEntry {
122                    id: (*id).into(),
123                    hunk_ids: ids.iter().map(|s| (*s).into()).collect(),
124                    exemplar: (*exemplar).into(),
125                    pure_substitution: false,
126                    defines: vec![],
127                    depends_on: vec![],
128                })
129                .collect(),
130            groups: None,
131            reading_plan: None,
132            audit: audit(),
133            symbols: None,
134        }
135    }
136
137    pub fn group(id: &str, effort: schema::Effort, class_ids: &[&str]) -> schema::Group {
138        schema::Group {
139            id: id.into(),
140            label: format!("{id} label"),
141            description: "d".into(),
142            reason: "r".into(),
143            effort,
144            role: None,
145            class_ids: class_ids.iter().map(|s| (*s).into()).collect(),
146            depends_on: vec![],
147            rank: 0,
148            pivot: None,
149        }
150    }
151
152    /// Render ids back to the wire form, so a failing assertion reads as the
153    /// ids a document actually contains.
154    pub fn hunk_ids(hunks: &[HunkId]) -> Vec<String> {
155        hunks.iter().map(HunkId::to_string).collect()
156    }
157
158    fn hunk_entry(i: usize) -> schema::HunkEntry {
159        schema::HunkEntry {
160            id: format!("h{i}"),
161            file: format!("src/f{i}.rs"),
162            old_start: i as u32 + 1,
163            old_count: 1,
164            new_start: i as u32 + 1,
165            new_count: 2,
166            class: String::new(),
167            digest: format!("digest{i}"),
168            nonl_old: false,
169            nonl_new: false,
170            forge_position: schema::ForgePosition {
171                new_line: Some(i as u32 + 1),
172                old_line: Some(i as u32 + 1),
173            },
174        }
175    }
176
177    fn file_entry(path: &str, ids: &[&str]) -> schema::FileEntry {
178        schema::FileEntry {
179            path: path.into(),
180            disposition: schema::Disposition::M,
181            mode: Some("100644".into()),
182            old_mode: None,
183            old_path: None,
184            new_path: None,
185            rename_similarity: None,
186            binary: false,
187            submodule: None,
188            generated: false,
189            generated_by: None,
190            hunk_ids: ids.iter().map(|s| (*s).into()).collect(),
191        }
192    }
193
194    fn audit() -> schema::Audit {
195        schema::Audit {
196            applier_exact: "0/0".into(),
197            tree_assertion: "pass".into(),
198            ..schema::Audit::default()
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn tier_and_role_names_are_the_wire_values() {
209        // If these ever diverge from serde, a renderer composing `[focus]`
210        // and a document saying `"skim"` would disagree silently.
211        for effort in [
212            schema::Effort::Focus,
213            schema::Effort::Skim,
214            schema::Effort::Noise,
215        ] {
216            let wire = serde_json::to_string(&effort).unwrap();
217            assert_eq!(wire, format!("\"{}\"", effort_name(effort)));
218        }
219        for role in [
220            schema::Role::Foundation,
221            schema::Role::Consumer,
222            schema::Role::Mechanical,
223            schema::Role::Noise,
224        ] {
225            let wire = serde_json::to_string(&role).unwrap();
226            assert_eq!(wire, format!("\"{}\"", role_name(role)));
227        }
228    }
229
230    #[test]
231    fn short_oid_truncates_and_tolerates_short_input() {
232        assert_eq!(short_oid("0123456789abcdef0123"), "0123456789ab");
233        assert_eq!(short_oid("abc"), "abc");
234        assert_eq!(short_oid(""), "");
235    }
236}