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.
35pub const fn effort_name(effort: schema::Effort) -> &'static str {
36    match effort {
37        schema::Effort::Focus => "focus",
38        schema::Effort::Skim => "skim",
39        schema::Effort::Noise => "noise",
40    }
41}
42
43/// The ordering role's domain name, identical to its wire value.
44pub const fn role_name(role: schema::Role) -> &'static str {
45    match role {
46        schema::Role::Foundation => "foundation",
47        schema::Role::Consumer => "consumer",
48        schema::Role::Mechanical => "mechanical",
49        schema::Role::Noise => "noise",
50    }
51}
52
53/// How many hex characters of an oid to show a human.
54const SHORT_OID: usize = 12;
55
56/// An oid abbreviated for display.
57///
58/// Deliberately not `git rev-parse --short`: no uniqueness check and no repo
59/// access, because every call site wanted a fixed prefix to print, not an
60/// abbreviation a reader could resolve. An oid that must be typed *back* is a
61/// different problem — the stack's ref name uses its own narrower width, which
62/// `spec/stack.md` documents.
63pub fn short_oid(oid: &str) -> &str {
64    &oid[..SHORT_OID.min(oid.len())]
65}
66
67#[cfg(test)]
68pub(crate) mod test_support {
69    //! Hand-built documents for the pure tests: no repo, no pipeline.
70
71    use crate::schema;
72
73    use super::HunkId;
74
75    /// A document with `classes` (id, member ids, exemplar id) and `files`
76    /// (path, member ids). Hunks are synthesized to cover every id mentioned.
77    pub fn doc_with(
78        classes: &[(&str, &[&str], &str)],
79        files: &[(&str, &[&str])],
80    ) -> schema::PlanDocument {
81        let n = classes
82            .iter()
83            .flat_map(|(_, ids, _)| ids.iter())
84            .chain(files.iter().flat_map(|(_, ids)| ids.iter()))
85            .filter_map(|id| id.strip_prefix('h').and_then(|n| n.parse::<usize>().ok()))
86            .map(|i| i + 1)
87            .max()
88            .unwrap_or(0);
89
90        schema::PlanDocument {
91            schema_version: schema::SCHEMA_VERSION,
92            generator: schema::Generator {
93                tool: "test".into(),
94                version: "0".into(),
95                stages: vec![],
96            },
97            source: schema::Source {
98                kind: schema::SourceKind::Range,
99                base: "0".repeat(40),
100                head: "1".repeat(40),
101                remote: None,
102            },
103            stats: schema::Stats {
104                files: files.len() as u32,
105                hunks: n as u32,
106                classes: classes.len() as u32,
107                binary_files: 0,
108                submodules: 0,
109            },
110            files: files
111                .iter()
112                .map(|(path, ids)| file_entry(path, ids))
113                .collect(),
114            hunks: (0..n).map(hunk_entry).collect(),
115            classes: classes
116                .iter()
117                .map(|(id, ids, exemplar)| schema::ClassEntry {
118                    id: (*id).into(),
119                    hunk_ids: ids.iter().map(|s| (*s).into()).collect(),
120                    exemplar: (*exemplar).into(),
121                    pure_substitution: false,
122                    defines: vec![],
123                    depends_on: vec![],
124                })
125                .collect(),
126            groups: None,
127            reading_plan: None,
128            audit: audit(),
129            symbols: None,
130        }
131    }
132
133    pub fn group(id: &str, effort: schema::Effort, class_ids: &[&str]) -> schema::Group {
134        schema::Group {
135            id: id.into(),
136            label: format!("{id} label"),
137            description: "d".into(),
138            reason: "r".into(),
139            effort,
140            role: None,
141            class_ids: class_ids.iter().map(|s| (*s).into()).collect(),
142            depends_on: vec![],
143            rank: 0,
144            pivot: None,
145        }
146    }
147
148    /// Render ids back to the wire form, so a failing assertion reads as the
149    /// ids a document actually contains.
150    pub fn hunk_ids(hunks: &[HunkId]) -> Vec<String> {
151        hunks.iter().map(HunkId::to_string).collect()
152    }
153
154    fn hunk_entry(i: usize) -> schema::HunkEntry {
155        schema::HunkEntry {
156            id: format!("h{i}"),
157            file: format!("src/f{i}.rs"),
158            old_start: i as u32 + 1,
159            old_count: 1,
160            new_start: i as u32 + 1,
161            new_count: 2,
162            class: String::new(),
163            digest: format!("digest{i}"),
164            nonl_old: false,
165            nonl_new: false,
166            forge_position: schema::ForgePosition {
167                new_line: Some(i as u32 + 1),
168                old_line: Some(i as u32 + 1),
169            },
170        }
171    }
172
173    fn file_entry(path: &str, ids: &[&str]) -> schema::FileEntry {
174        schema::FileEntry {
175            path: path.into(),
176            disposition: schema::Disposition::M,
177            mode: Some("100644".into()),
178            old_mode: None,
179            old_path: None,
180            new_path: None,
181            rename_similarity: None,
182            binary: false,
183            submodule: None,
184            generated: false,
185            generated_by: None,
186            hunk_ids: ids.iter().map(|s| (*s).into()).collect(),
187        }
188    }
189
190    fn audit() -> schema::Audit {
191        schema::Audit {
192            applier_exact: "0/0".into(),
193            tree_assertion: "pass".into(),
194            ..schema::Audit::default()
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn tier_and_role_names_are_the_wire_values() {
205        // If these ever diverge from serde, a renderer composing `[focus]`
206        // and a document saying `"skim"` would disagree silently.
207        for effort in [
208            schema::Effort::Focus,
209            schema::Effort::Skim,
210            schema::Effort::Noise,
211        ] {
212            let wire = serde_json::to_string(&effort).unwrap();
213            assert_eq!(wire, format!("\"{}\"", effort_name(effort)));
214        }
215        for role in [
216            schema::Role::Foundation,
217            schema::Role::Consumer,
218            schema::Role::Mechanical,
219            schema::Role::Noise,
220        ] {
221            let wire = serde_json::to_string(&role).unwrap();
222            assert_eq!(wire, format!("\"{}\"", role_name(role)));
223        }
224    }
225
226    #[test]
227    fn short_oid_truncates_and_tolerates_short_input() {
228        assert_eq!(short_oid("0123456789abcdef0123"), "0123456789ab");
229        assert_eq!(short_oid("abc"), "abc");
230        assert_eq!(short_oid(""), "");
231    }
232}