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, PathBuf};
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/// What git does not show at the feature's revision: the worktree's
203/// untracked, ignored and modified paths, and what of it would stop a run.
204#[derive(Debug, Clone, Default, serde::Serialize)]
205pub struct WorktreeView {
206    /// The feature's worktree, when there is one.
207    pub path: Option<String>,
208    /// `{path, state}`: `untracked`, `ignored` (a folder ends in `/`) or
209    /// `modified`, at most 2000.
210    pub entries: Vec<WorktreeEntry>,
211    /// Untracked lockfiles to commit, build output to ignore: a run stops
212    /// on them.
213    pub blocking: Vec<String>,
214    /// The command that fixes them, in the checkout.
215    pub fix: Option<String>,
216}
217
218/// One path of a [`WorktreeView`].
219#[derive(Debug, Clone, serde::Serialize)]
220pub struct WorktreeEntry {
221    /// Worktree-relative.
222    pub path: String,
223    /// `untracked`, `ignored` or `modified`.
224    pub state: &'static str,
225}
226
227/// The feature's worktree as it is on disk, beyond what git committed.
228#[must_use]
229pub fn worktree_view(root: &Path, feature: Option<&str>) -> WorktreeView {
230    let Some(branch) = branch_of(root, feature) else {
231        return WorktreeView::default();
232    };
233    let Ok(Some(wt)) = crate::worktree::worktree_for(&Git::at(root), &branch) else {
234        return WorktreeView::default();
235    };
236    let out = Git::at(&wt)
237        .run(&[
238            "status",
239            "--porcelain=v1",
240            "-z",
241            "--untracked-files=normal",
242            "--ignored=matching",
243        ])
244        .unwrap_or_default();
245    // Held-out files overlaid for a judge run are never listed:
246    // with a marker that cannot be read, nothing untracked is.
247    let overlaid = crate::held_out::overlaid(&wt);
248    let entries = crate::git::nul_fields(&out)
249        .into_iter()
250        .filter_map(|e| {
251            let (code, path) = (e.get(..2)?, e.get(3..)?.to_owned());
252            let state = match code {
253                "??" => "untracked",
254                "!!" => "ignored",
255                _ => "modified",
256            };
257            let hidden = state != "modified"
258                && overlaid.as_ref().is_none_or(|keys| {
259                    keys.contains(&crate::held_out::overlay_key(path.trim_end_matches('/')))
260                });
261            (!hidden).then_some(WorktreeEntry { path, state })
262        })
263        .take(2000)
264        .collect();
265    let s = setup(root, feature);
266    WorktreeView {
267        path: Some(wt.display().to_string()),
268        entries,
269        blocking: s
270            .config
271            .iter()
272            .chain(&s.left.commit)
273            .chain(&s.left.ignore)
274            .cloned()
275            .collect(),
276        fix: (!s.is_empty()).then(|| s.command(root)),
277    }
278}
279
280/// What of the project's setup stops a run: what the feature's worktree is
281/// left with ([`crate::worktree::Leftovers`]), and the configuration a
282/// check reads that the checkout has but never committed — a worktree,
283/// checked out from a commit, lacks it.
284struct Setup {
285    worktree: Option<PathBuf>,
286    left: crate::worktree::Leftovers,
287    config: Vec<String>,
288}
289
290/// The files `init` writes that a run reads from its worktree.
291const CONFIG: [&str; 2] = [crate::verify::config::FILE, ".config/nextest.toml"];
292
293fn setup(root: &Path, feature: Option<&str>) -> Setup {
294    let worktree = branch_of(root, feature).and_then(|b| {
295        crate::worktree::worktree_for(&Git::at(root), &b)
296            .ok()
297            .flatten()
298    });
299    let left = worktree
300        .as_deref()
301        .map(crate::worktree::leftovers)
302        .unwrap_or_default();
303    let g = Git::at(root);
304    let config = CONFIG
305        .iter()
306        .filter(|p| root.join(p).is_file())
307        .filter(|p| {
308            g.run(&["status", "--porcelain", "--", p])
309                .is_ok_and(|o| !o.trim_ascii().is_empty())
310        })
311        .map(|p| (*p).to_owned())
312        .collect();
313    Setup {
314        worktree,
315        left,
316        config,
317    }
318}
319
320impl Setup {
321    fn is_empty(&self) -> bool {
322        self.left.is_empty() && self.config.is_empty()
323    }
324
325    /// Every file the fix commits.
326    fn paths(&self) -> Vec<String> {
327        let mut paths: Vec<String> = self
328            .config
329            .iter()
330            .chain(&self.left.commit)
331            .cloned()
332            .collect();
333        if !self.left.ignore.is_empty() {
334            paths.push(".gitignore".to_owned());
335        }
336        paths
337    }
338
339    fn command(&self, root: &Path) -> String {
340        let wt = self.worktree.as_deref().unwrap_or(root);
341        self.left.fix(root, wt, &self.config)
342    }
343}
344
345/// The fix of what in the setup stops a run, as an interface offers it:
346/// `None` when nothing does.
347#[must_use]
348pub fn setup_remedy(root: &Path, feature: Option<&str>) -> Option<speccoder_core::Remedy> {
349    let s = setup(root, feature);
350    if s.is_empty() {
351        return None;
352    }
353    let paths = s.paths();
354    let ignore = if s.left.ignore.is_empty() {
355        String::new()
356    } else {
357        format!(
358            "{} added to .gitignore, ",
359            speccoder_core::words::listed(&s.left.ignore)
360        )
361    };
362    Some(speccoder_core::Remedy {
363        label: format!("Commit {}", speccoder_core::words::listed(&paths)),
364        effect: format!("{ignore}in your checkout as `speccoder: setup`; the next run picks it up"),
365        command: s.command(root),
366        undo: Some("git reset --soft HEAD~1".to_owned()),
367        history: true,
368        run: speccoder_core::actions::Runnable::FixSetup,
369    })
370}
371
372/// Fixes the setup that stops a run, in the checkout at `root`, on the
373/// person's command: a lockfile only the worktree's checks wrote is copied
374/// over, build output is added to `.gitignore`, and exactly those files and
375/// the uncommitted configuration are committed (`speccoder: setup`).
376#[must_use]
377pub fn fix_setup(root: &Path, feature: Option<&str>) -> speccoder_core::CommandResult {
378    use speccoder_core::{CommandResult, CommandStatus};
379    let refuse = |status, m: String| CommandResult::new("fix", status, &m);
380    let s = setup(root, feature);
381    if s.is_empty() {
382        return CommandResult::new("fix", CommandStatus::Ok, "nothing in the setup stops a run");
383    }
384    if let Some(wt) = &s.worktree {
385        for f in &s.left.commit {
386            let to = root.join(f);
387            if !to.exists()
388                && let Err(e) = std::fs::copy(wt.join(f), &to)
389            {
390                return refuse(CommandStatus::Error, format!("{f}: {e}"));
391            }
392        }
393    }
394    if !s.left.ignore.is_empty() {
395        let ignore = root.join(".gitignore");
396        let existing = std::fs::read_to_string(&ignore).unwrap_or_default();
397        let sep = if existing.is_empty() || existing.ends_with('\n') {
398            ""
399        } else {
400            "\n"
401        };
402        let text = format!("{existing}{sep}{}\n", s.left.ignore.join("\n"));
403        if let Err(e) = std::fs::write(&ignore, text) {
404            return refuse(CommandStatus::Error, format!(".gitignore: {e}"));
405        }
406    }
407    let paths = s.paths();
408    match crate::init::commit_written_as(root, &paths, "speccoder: setup") {
409        Ok(id) => CommandResult::new(
410            "fix",
411            CommandStatus::Ok,
412            &format!(
413                "committed {} as {id}; the next run takes it into the feature's branch (undo: git reset --soft HEAD~1)",
414                speccoder_core::words::listed(&paths)
415            ),
416        ),
417        Err(e) => refuse(CommandStatus::GitPrecondition, e),
418    }
419}
420
421/// An untracked file of the feature's worktree, read from disk: its text,
422/// or why not. Only what [`worktree_view`] lists as untracked.
423///
424/// # Errors
425///
426/// Why the file is not shown.
427pub fn worktree_file(root: &Path, feature: Option<&str>, path: &str) -> Result<String, String> {
428    let view = worktree_view(root, feature);
429    if !view
430        .entries
431        .iter()
432        .any(|e| e.path == path && e.state == "untracked")
433    {
434        return Err(format!("{path} is not an untracked file of the worktree"));
435    }
436    let wt = PathBuf::from(view.path.unwrap_or_default());
437    let full = wt.join(path);
438    // A link is never followed (it could point into the state directory,
439    // a frozen or held-out set): the file itself, inside the worktree.
440    let meta = std::fs::symlink_metadata(&full).map_err(|e| e.to_string())?;
441    if meta.file_type().is_symlink() {
442        return Err(format!("{path} is a symbolic link: not shown"));
443    }
444    let inside = match (std::fs::canonicalize(&wt), std::fs::canonicalize(&full)) {
445        (Ok(w), Ok(f)) => f.starts_with(&w) && f != w,
446        _ => false,
447    };
448    if !inside || !meta.is_file() {
449        return Err(format!("{path} is not a file of the worktree"));
450    }
451    let overlay = || crate::held_out::overlaid(&wt).is_none_or(|k| !k.is_empty());
452    if overlay() {
453        return Err(
454            "held-out tests are being checked in the worktree: look again when the check ends"
455                .to_owned(),
456        );
457    }
458    let bytes = std::fs::read(&full).map_err(|e| e.to_string())?;
459    // An overlay that began while the file was read: nothing is shown.
460    if overlay() {
461        return Err(
462            "held-out tests are being checked in the worktree: look again when the check ends"
463                .to_owned(),
464        );
465    }
466    if bytes.len() > FILE_LIMIT {
467        return Err(format!("{path} is larger than {} KB", FILE_LIMIT / 1024));
468    }
469    String::from_utf8(bytes).map_err(|_| format!("{path} is not text"))
470}
471
472/// One file at the feature's revision: its text, or why not (not in the
473/// tree, binary, too large).
474///
475/// # Errors
476///
477/// Why the file is not shown.
478pub fn file(root: &Path, feature: Option<&str>, path: &str) -> Result<String, String> {
479    let (rev, files) = tree(root, feature);
480    if !files.iter().any(|f| f == path) {
481        return Err(format!("{path} is not in {rev}"));
482    }
483    let bytes = Git::at(root)
484        // `./`: relative to this directory — a project may sit below the
485        // repository's top level, and the tree's paths are relative to it.
486        .run(&["show", &format!("{rev}:./{path}")])
487        .map_err(|e| e.0)?;
488    if bytes.len() > FILE_LIMIT {
489        return Err(format!("{path} is larger than {} KB", FILE_LIMIT / 1024));
490    }
491    if bytes.contains(&0) {
492        return Err(format!("{path} is binary"));
493    }
494    Ok(String::from_utf8_lossy(&bytes).into_owned())
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    fn git(dir: &Path, args: &[&str]) {
502        let ok = std::process::Command::new("git")
503            .args([
504                "-c",
505                "user.name=t",
506                "-c",
507                "user.email=t@e",
508                "-c",
509                "commit.gpgsign=false",
510            ])
511            .args(args)
512            .current_dir(dir)
513            .status()
514            .is_ok_and(|s| s.success());
515        assert!(ok, "git {args:?}");
516    }
517
518    #[test]
519    fn numstat_reads_renames_and_unusual_paths() {
520        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";
521        let files = numstat(out);
522        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
523        assert_eq!(
524            paths,
525            ["src/lib.rs", "src/b.rs", "logo.png", "docs/über.md"]
526        );
527        assert_eq!((files[2].add, files[2].del), (None, None));
528        assert_eq!(files[1].add, Some(2));
529    }
530
531    /// A project below the repository's top level reads its own files, by
532    /// its own paths; anything its tree does not list is refused.
533    #[test]
534    fn a_nested_project_reads_its_files_and_nothing_else() {
535        let repo = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
536        let project = repo.path().join("apps/web");
537        std::fs::create_dir_all(project.join("src")).unwrap_or_else(|e| panic!("{e}"));
538        std::fs::write(project.join("src/lib.rs"), "pub fn ok() {}\n")
539            .unwrap_or_else(|e| panic!("{e}"));
540        std::fs::write(repo.path().join("secret.txt"), "outside\n")
541            .unwrap_or_else(|e| panic!("{e}"));
542        git(repo.path(), &["init", "-q"]);
543        git(repo.path(), &["add", "-A"]);
544        git(repo.path(), &["commit", "-qm", "init"]);
545        let (rev, files) = tree(&project, None);
546        assert_eq!(rev, "HEAD");
547        assert_eq!(files, ["src/lib.rs"]);
548        assert_eq!(
549            file(&project, None, "src/lib.rs").as_deref(),
550            Ok("pub fn ok() {}\n")
551        );
552        for bad in [
553            "../../secret.txt",
554            "secret.txt",
555            "src/../../../secret.txt",
556            "src",
557        ] {
558            assert!(file(&project, None, bad).is_err(), "{bad}");
559        }
560    }
561}