Skip to main content

speccoder_runtime/
code.rs

1//! A feature's code as a person reads it: what its branch changed
2//! since it forked — by commit, by file — and its files. The revision is
3//! never the caller's: it is the feature's branch, or `HEAD` before
4//! speccoder worked on it; a file is read only when the tree lists it.
5
6use std::path::Path;
7
8use serde::Serialize;
9
10use crate::git::Git;
11
12/// One commit of the feature's branch.
13#[derive(Debug, Clone, Serialize)]
14pub struct Commit {
15    /// Full id.
16    pub id: String,
17    /// Subject line.
18    pub subject: String,
19    /// `Spec-Task` trailer (`feature/T005`), when a tick.
20    pub task: Option<String>,
21    /// `Verdict` trailer, when a tick.
22    pub verdict: Option<String>,
23    /// Author date, ISO 8601.
24    pub date: String,
25}
26
27/// One changed file.
28#[derive(Debug, Clone, Serialize)]
29pub struct File {
30    /// Path in the repository.
31    pub path: String,
32    /// Lines added (none for a binary file).
33    pub add: Option<u64>,
34    /// Lines removed.
35    pub del: Option<u64>,
36}
37
38/// What the feature's branch changed since it forked.
39#[derive(Debug, Clone, Serialize)]
40pub struct Changes {
41    /// The branch, when speccoder has worked on the feature.
42    pub branch: Option<String>,
43    /// Where it forked from the person's `HEAD`.
44    pub base: Option<String>,
45    /// Its commits, newest first.
46    pub commits: Vec<Commit>,
47    /// The files it changed.
48    pub files: Vec<File>,
49    /// The whole change as a patch (cut at `PATCH_LIMIT`).
50    pub patch: String,
51    /// Whether the patch was cut.
52    pub cut: bool,
53}
54
55/// The largest patch sent whole.
56pub const PATCH_LIMIT: usize = 2 * 1024 * 1024;
57/// The largest file shown.
58pub const FILE_LIMIT: usize = 512 * 1024;
59
60/// The feature's branch, when it exists — by the branch convention, after a
61/// snapshot (not the whole report: this runs on every file opened).
62fn branch_of(root: &Path, feature: Option<&str>) -> Option<String> {
63    let inspected = crate::inspect(root, feature);
64    let Some(speccoder_core::CommandData::Inspect(snapshot)) = inspected.data() else {
65        return None;
66    };
67    let branch = format!("speccoder/{}", snapshot.feature.as_ref()?.feature.name);
68    Git::at(root)
69        .succeeds(&[
70            "rev-parse",
71            "--verify",
72            "-q",
73            &format!("refs/heads/{branch}"),
74        ])
75        .unwrap_or(false)
76        .then_some(branch)
77}
78
79/// What the feature's branch changed since it forked from `HEAD`.
80#[must_use]
81pub fn changes(root: &Path, feature: Option<&str>) -> Changes {
82    let empty = |branch| Changes {
83        branch,
84        base: None,
85        commits: Vec::new(),
86        files: Vec::new(),
87        patch: String::new(),
88        cut: false,
89    };
90    let Some(branch) = branch_of(root, feature) else {
91        return empty(None);
92    };
93    let g = Git::at(root);
94    let Ok(head) = g.line(&["rev-parse", "HEAD"]) else {
95        return empty(Some(branch));
96    };
97    let Ok(base) = g.line(&["merge-base", &branch, &head]) else {
98        return empty(Some(branch));
99    };
100    let range = format!("{base}..{branch}");
101    let commits = g
102        .run(&[
103            "log",
104            "--no-color",
105            "--format=%H%x1f%s%x1f%(trailers:key=Spec-Task,valueonly,separator=%x2c)%x1f%(trailers:key=Verdict,valueonly,separator=%x2c)%x1f%aI%x1e",
106            &range,
107        ])
108        .map(|b| {
109            String::from_utf8_lossy(&b)
110                .split('\x1e')
111                .filter_map(|rec| {
112                    let f: Vec<&str> = rec.trim().split('\x1f').collect();
113                    let [id, subject, task, verdict, date] = f.as_slice() else { return None };
114                    let some = |s: &str| (!s.trim().is_empty()).then(|| s.trim().to_owned());
115                    Some(Commit {
116                        id: (*id).to_owned(),
117                        subject: (*subject).to_owned(),
118                        task: some(task),
119                        verdict: some(verdict),
120                        date: (*date).to_owned(),
121                    })
122                })
123                .collect()
124        })
125        .unwrap_or_default();
126    // `-z`: paths as they are (no quoting), and a rename as its new path —
127    // without it a rename reads `src/{a.rs => b.rs}`.
128    let files = g
129        .run(&["diff", "--numstat", "-z", "-M", &base, &branch])
130        .map(|b| numstat(&b))
131        .unwrap_or_default();
132    let mut patch = g
133        .run(&["diff", "--no-color", "-M", &base, &branch])
134        .map(|b| String::from_utf8_lossy(&b).into_owned())
135        .unwrap_or_default();
136    let cut = patch.len() > PATCH_LIMIT;
137    if cut {
138        let mut at = PATCH_LIMIT;
139        while !patch.is_char_boundary(at) {
140            at -= 1;
141        }
142        patch.truncate(at);
143    }
144    Changes {
145        branch: Some(branch),
146        base: Some(base),
147        commits,
148        files,
149        patch,
150        cut,
151    }
152}
153
154/// `git diff --numstat -z`: `add\tdel\tpath\0`, or for a rename
155/// `add\tdel\t\0old\0new\0` (the new path is the file's).
156fn numstat(out: &[u8]) -> Vec<File> {
157    let text = String::from_utf8_lossy(out);
158    let mut fields = text.split('\0');
159    let mut files = Vec::new();
160    while let Some(head) = fields.next() {
161        let mut it = head.splitn(3, '\t');
162        let (Some(a), Some(d), Some(p)) = (it.next(), it.next(), it.next()) else {
163            continue;
164        };
165        let path = if p.is_empty() {
166            // A rename: the old path, then the new one.
167            let _old = fields.next();
168            fields.next().unwrap_or_default().to_owned()
169        } else {
170            p.to_owned()
171        };
172        files.push(File {
173            path,
174            add: a.parse().ok(),
175            del: d.parse().ok(),
176        });
177    }
178    files
179}
180
181/// The revision whose files are read: the feature's branch, else `HEAD`.
182fn revision(root: &Path, feature: Option<&str>) -> String {
183    branch_of(root, feature).unwrap_or_else(|| "HEAD".to_owned())
184}
185
186/// The files at the feature's revision (at most 20 000).
187#[must_use]
188pub fn tree(root: &Path, feature: Option<&str>) -> (String, Vec<String>) {
189    let rev = revision(root, feature);
190    let files = Git::at(root)
191        .run(&["ls-tree", "-r", "--name-only", "-z", &rev])
192        .map(|b| {
193            crate::git::nul_fields(&b)
194                .into_iter()
195                .take(20_000)
196                .collect()
197        })
198        .unwrap_or_default();
199    (rev, files)
200}
201
202/// One file at the feature's revision: its text, or why not (not in the
203/// tree, binary, too large).
204///
205/// # Errors
206///
207/// Why the file is not shown.
208pub fn file(root: &Path, feature: Option<&str>, path: &str) -> Result<String, String> {
209    let (rev, files) = tree(root, feature);
210    if !files.iter().any(|f| f == path) {
211        return Err(format!("{path} is not in {rev}"));
212    }
213    let bytes = Git::at(root)
214        // `./`: relative to this directory — a project may sit below the
215        // repository's top level, and the tree's paths are relative to it.
216        .run(&["show", &format!("{rev}:./{path}")])
217        .map_err(|e| e.0)?;
218    if bytes.len() > FILE_LIMIT {
219        return Err(format!("{path} is larger than {} KB", FILE_LIMIT / 1024));
220    }
221    if bytes.contains(&0) {
222        return Err(format!("{path} is binary"));
223    }
224    Ok(String::from_utf8_lossy(&bytes).into_owned())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn git(dir: &Path, args: &[&str]) {
232        let ok = std::process::Command::new("git")
233            .args([
234                "-c",
235                "user.name=t",
236                "-c",
237                "user.email=t@e",
238                "-c",
239                "commit.gpgsign=false",
240            ])
241            .args(args)
242            .current_dir(dir)
243            .status()
244            .is_ok_and(|s| s.success());
245        assert!(ok, "git {args:?}");
246    }
247
248    #[test]
249    fn numstat_reads_renames_and_unusual_paths() {
250        let out = b"3\t1\tsrc/lib.rs\x002\t0\t\x00src/a.rs\x00src/b.rs\x00-\t-\tlogo.png\x001\t0\tdocs/\xc3\xbcber.md\x00";
251        let files = numstat(out);
252        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
253        assert_eq!(
254            paths,
255            ["src/lib.rs", "src/b.rs", "logo.png", "docs/über.md"]
256        );
257        assert_eq!((files[2].add, files[2].del), (None, None));
258        assert_eq!(files[1].add, Some(2));
259    }
260
261    /// A project below the repository's top level reads its own files, by
262    /// its own paths; anything its tree does not list is refused.
263    #[test]
264    fn a_nested_project_reads_its_files_and_nothing_else() {
265        let repo = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
266        let project = repo.path().join("apps/web");
267        std::fs::create_dir_all(project.join("src")).unwrap_or_else(|e| panic!("{e}"));
268        std::fs::write(project.join("src/lib.rs"), "pub fn ok() {}\n")
269            .unwrap_or_else(|e| panic!("{e}"));
270        std::fs::write(repo.path().join("secret.txt"), "outside\n")
271            .unwrap_or_else(|e| panic!("{e}"));
272        git(repo.path(), &["init", "-q"]);
273        git(repo.path(), &["add", "-A"]);
274        git(repo.path(), &["commit", "-qm", "init"]);
275        let (rev, files) = tree(&project, None);
276        assert_eq!(rev, "HEAD");
277        assert_eq!(files, ["src/lib.rs"]);
278        assert_eq!(
279            file(&project, None, "src/lib.rs").as_deref(),
280            Ok("pub fn ok() {}\n")
281        );
282        for bad in [
283            "../../secret.txt",
284            "secret.txt",
285            "src/../../../secret.txt",
286            "src",
287        ] {
288            assert!(file(&project, None, bad).is_err(), "{bad}");
289        }
290    }
291}