Skip to main content

git_policy_cli/
standards_review_input.rs

1//! What:
2//!  The pure part of the standards-review shadow run: which landed commits get an entry, and the capped model input
3//!  built from the commit's patch and the excerpts of its changed code files.
4//! Why:
5//!  This is the root `mise.toml` task `shadow-review` after its Git reads: the no-entry, no-code-files and review
6//!  cases, the 2000-line patch cap, the eight-file and 4000-line excerpt caps, and the 24000-unit input cap. Taking
7//!  the patch and the file contents as plain values lets the caps be tested without Git, a network or a clock.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const plan = classify(names); const input = buildReviewInput({ commit });
12//! ```
13
14use super::standards_review_patch::{NewSpan, new_spans};
15use super::standards_review_text::{utf16_length, utf16_prefix};
16use std::collections::BTreeMap;
17
18/// The tracked log's repository-relative path: the repository's switch for the review, and the file a commit changes
19/// without getting an entry. It is frozen history that the service never writes.
20pub const TRACKED_LOG_RELATIVE: &str = "doc/audit/standards-review-gate-shadow.md";
21
22/// The live log's path under the common Git directory: untracked, so a landing never makes a checkout show a change.
23pub const SHARED_LOG_RELATIVE: &str = "cli-git/standards-review-shadow.md";
24
25/// What:
26///  The live log of a repository: the one file every worktree's service instance appends its entries to.
27/// Why:
28///   The log sits in the common Git directory, which `git status` never lists and no worktree copy reaches, and one
29///   file serves all worktrees. The service already knows the directory from the work item, so no Git start finds it.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// join(commonDir, 'cli-git/standards-review-shadow.md')
34/// ```
35pub fn shared_log_path(common_dir: &std::path::Path) -> std::path::PathBuf {
36    return common_dir.join(SHARED_LOG_RELATIVE);
37}
38
39/// What:
40///  Whether a worktree has the tracked shadow log: the review exists only where the repository keeps one.
41/// Why:
42///   The tracked file's presence is the repository's own switch; no configuration names a program, the wrapper leaves a
43///   review item only where it is true, and the service never writes that file.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// existsSync(join(repository, trackedLogRelative))
48/// ```
49pub fn has_shadow_log(repository: &std::path::Path) -> bool {
50    return repository.join(TRACKED_LOG_RELATIVE).is_file();
51}
52
53/// Patch lines kept for the model.
54pub const CAP_LINES: usize = 2000;
55
56/// Code files that get an excerpt.
57pub const MAX_EXCERPT_FILES: usize = 8;
58
59/// Lines of context kept around the changed span of an excerpted file.
60pub const EXCERPT_CONTEXT: i64 = 30;
61
62/// Excerpt lines after which no further file gets an excerpt.
63pub const EXCERPT_LINE_CAP: i64 = 4000;
64
65/// Lines of the head of a file that has no changed line to anchor an excerpt on: the context on both sides and one.
66pub const HEAD_WINDOW: i64 = 2 * EXCERPT_CONTEXT + 1;
67
68/// UTF-16 units of model input.
69pub const INPUT_UNIT_CAP: usize = 24000;
70
71/// Name endings that count as code.
72pub const CODE_SUFFIXES: [&str; 6] = [".ts", ".tsx", ".rs", ".js", ".mjs", ".cjs"];
73
74/// What:
75///  What a landed commit's changed names say before anything is read from Git.
76/// Why:
77///   A commit with no names (a root commit, a merge) or only the log gets no entry, and a commit with no code gets a
78///   three-line skip entry; neither needs the patch or any file, so neither costs a Git start.
79///
80/// In TS you'd write (pseudocode):
81/// ```ts
82/// type Plan = { kind: 'silent' } | { kind: 'no-code' } | { kind: 'review'; codeFiles: string[] };
83/// ```
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub enum Plan {
86    /// No entry at all.
87    Silent,
88    /// The skip entry.
89    NoCode,
90    /// A review of these code files, in name order.
91    Review(Vec<String>),
92}
93
94/// What:
95///  The plan for the names `git diff-tree --name-only -r` prints for the landed commit.
96/// Why:
97///   It is the script's first three branches, in order.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// if (names.every((name) => name === logRelative)) return silent; if (codeFiles.length === 0) return noCode;
102/// ```
103pub fn plan_for(names: &[String]) -> Plan {
104    if names.iter().all(|name| return name == TRACKED_LOG_RELATIVE) {
105        return Plan::Silent;
106    }
107    let code_files: Vec<String> = names
108        .iter()
109        .filter(|name| return CODE_SUFFIXES.iter().any(|suffix| return name.ends_with(suffix)))
110        .cloned()
111        .collect();
112    if code_files.is_empty() {
113        return Plan::NoCode;
114    }
115    return Plan::Review(code_files);
116}
117
118/// What:
119///  Everything the review reads about one landed commit: the patch, and the new-side text of each code file that
120///  will be excerpted. A path missing from `contents` has no new-side file (it was deleted).
121/// Why:
122///   One value is all the pure builder needs, whichever way the reads were made.
123///
124/// In TS you'd write (pseudocode):
125/// ```ts
126/// type LandedCommit = { sha: string; subject: string; patch: string; contents: Map<string, string> };
127/// ```
128#[derive(Clone, Debug, Default, Eq, PartialEq)]
129pub struct LandedCommit {
130    /// Full commit name.
131    pub sha: String,
132    /// Subject line, trimmed.
133    pub subject: String,
134    /// `git show --format= --patch` text.
135    pub patch: String,
136    /// New-side text by path.
137    pub contents: BTreeMap<String, String>,
138}
139
140/// What:
141///  The sizes the entry reports and the text the model receives.
142/// Why:
143///   `model_input` is cut at the unit cap, while `model_input_units` is the length the script prints: the cut length.
144///
145/// In TS you'd write (pseudocode):
146/// ```ts
147/// type ReviewInput = { codeFileCount: number; diffLineCount: number; truncated: boolean; modelInput: string; modelInputUnits: number };
148/// ```
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct ReviewInput {
151    /// Code files in the commit.
152    pub code_file_count: usize,
153    /// Lines of the whole patch (a final newline counts as one more, empty, line).
154    pub diff_line_count: usize,
155    /// Whether the patch is over `CAP_LINES`.
156    pub truncated: bool,
157    /// The text sent to the model.
158    pub model_input: String,
159    /// The reported length of `model_input` in UTF-16 units.
160    pub model_input_units: usize,
161}
162
163/// What:
164///  The excerpt of one file: its header and the text of the window around the changed span.
165///  The window runs from `EXCERPT_CONTEXT` lines before the first changed new-side line to `EXCERPT_CONTEXT` lines
166///  after the last, cut from the new-side file. A file with no changed line to anchor on (a binary file, a mode change,
167///  a pure rename) gets the head of the file instead.
168/// Why:
169///   Returns the window's line count too, which the 4000-line cap adds up. The script cut the window from old-side
170///   numbers, which sit in the wrong place after an earlier insertion, and gave a file without hunks a nonsensical
171///   negative window.
172///
173/// In TS you'd write (pseudocode):
174/// ```ts
175/// excerptParts.push(`// candidate excerpt of ${name}, lines ${from} to ${to} of ${fileLines.length}`);
176/// ```
177fn excerpt(name: &str, content: &str, spans: &[NewSpan]) -> (Vec<String>, i64) {
178    let file_lines: Vec<&str> = content.split('\n').collect();
179    let total: i64 = i64::try_from(file_lines.len()).unwrap_or(i64::MAX);
180    let first_changed: Option<i64> = spans.iter().map(|span| return i64::try_from(span.first).unwrap_or(i64::MAX)).min();
181    let last_changed: Option<i64> = spans.iter().map(|span| return i64::try_from(span.last).unwrap_or(i64::MAX)).max();
182    let (from, to) = match (first_changed, last_changed) {
183        (Some(first), Some(last)) => (1.max(first.saturating_sub(EXCERPT_CONTEXT)), total.min(last.saturating_add(EXCERPT_CONTEXT))),
184        _ => (1, total.min(HEAD_WINDOW)),
185    };
186    let header: String = format!("// candidate excerpt of {name}, lines {from} to {to} of {total}");
187    let begin: usize = usize::try_from(from - 1).unwrap_or(0);
188    let end: usize = usize::try_from(to).unwrap_or(0);
189    let body: String = if begin < end { file_lines[begin..end].join("\n") } else { String::new() };
190    return (vec![header, body], to - from + 1);
191}
192
193/// What:
194///  Build the capped model input for a reviewed commit.
195/// Why:
196///   The input is the first `CAP_LINES` patch lines, a blank line, then each excerpt separated by blank lines, cut to
197///   `INPUT_UNIT_CAP` UTF-16 units.
198///
199/// In TS you'd write (pseudocode):
200/// ```ts
201/// const lunaInput = (lines.slice(0, capLines).join('\n') + '\n\n' + excerptParts.join('\n\n')).slice(0, 24000);
202/// ```
203pub fn build_input(commit: &LandedCommit, code_files: &[String]) -> ReviewInput {
204    let lines: Vec<&str> = commit.patch.split('\n').collect();
205    let kept: &[&str] = &lines[..lines.len().min(CAP_LINES)];
206    let mut parts: Vec<String> = Vec::new();
207    let mut excerpt_lines: i64 = 0;
208    for name in code_files.iter().take(MAX_EXCERPT_FILES) {
209        if excerpt_lines >= EXCERPT_LINE_CAP {
210            break;
211        }
212        let Some(content) = commit.contents.get(name) else {
213            continue;
214        };
215        let spans: Vec<NewSpan> = new_spans(lines.as_slice(), name);
216        let (pieces, covered) = excerpt(name, content, spans.as_slice());
217        parts.extend(pieces);
218        excerpt_lines += covered;
219    }
220    let full: String = format!("{}\n\n{}", kept.join("\n"), parts.join("\n\n"));
221    let units: usize = utf16_length(full.as_str()).min(INPUT_UNIT_CAP);
222    return ReviewInput {
223        code_file_count: code_files.len(),
224        diff_line_count: lines.len(),
225        truncated: lines.len() > CAP_LINES,
226        model_input: utf16_prefix(full.as_str(), INPUT_UNIT_CAP),
227        model_input_units: units,
228    };
229}
230
231/// Controls stay out of the release executable.
232#[cfg(test)]
233#[path = "standards_review_input_tests.rs"]
234mod tests;