Skip to main content

speccoder_runtime/bug/
candidate.rs

1//! `--candidate PATCH|pr:N`: someone else's fix, judged as the attempt —
2//! no implementer, no retries. A diff that touches a frozen reproduction, a
3//! held-out path or a judge input is refused before it is applied; one that
4//! does not apply is refused with the hunks git rejected.
5
6use std::path::Path;
7
8use speccoder_core::bug::{Candidate, CandidateOrigin, CandidateRefusal};
9
10use crate::integrity::sha256_hex;
11
12/// What `--candidate` names.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum Spec {
15    /// A unified diff file.
16    Patch(String),
17    /// A GitHub pull request.
18    Pr(u64),
19}
20
21/// `pr:N` or a path.
22#[must_use]
23pub fn parse(spec: &str) -> Option<Spec> {
24    let spec = spec.trim();
25    if let Some(n) = spec.strip_prefix("pr:") {
26        return n.trim().trim_start_matches('#').parse().ok().map(Spec::Pr);
27    }
28    (!spec.is_empty()).then(|| Spec::Patch(spec.to_owned()))
29}
30
31/// The candidate's record for `diff`.
32#[must_use]
33pub fn record(spec: &Spec, diff: &str, head: Option<String>) -> Candidate {
34    Candidate {
35        origin: match spec {
36            Spec::Patch(_) => CandidateOrigin::Patch,
37            Spec::Pr(_) => CandidateOrigin::Pr,
38        },
39        path: match spec {
40            Spec::Patch(p) => Some(p.clone()),
41            Spec::Pr(_) => None,
42        },
43        number: match spec {
44            Spec::Pr(n) => Some(*n),
45            Spec::Patch(_) => None,
46        },
47        digest: format!("sha256:{}", sha256_hex(diff.as_bytes())),
48        head_sha: head,
49        applied: false,
50        refused: None,
51    }
52}
53
54/// The patch file `spec` names, read. From the terminal a path is the
55/// person's to give; through any other interface (web, ACP, MCP) it must be
56/// a file inside the project at `root`.
57///
58/// # Errors
59///
60/// It cannot be read, or it lies outside the project.
61pub fn read_patch(
62    root: &Path,
63    cwd: &Path,
64    spec: &str,
65    via: speccoder_core::actions::Via,
66) -> Result<String, String> {
67    if via == speccoder_core::actions::Via::Terminal {
68        return std::fs::read_to_string(cwd.join(spec))
69            .or_else(|_| std::fs::read_to_string(spec))
70            .map_err(|e| format!("{spec}: {e}"));
71    }
72    let outside = || {
73        format!(
74            "{spec}: a candidate patch given through {} must be a file in the project",
75            via.as_str()
76        )
77    };
78    let base = std::fs::canonicalize(root).map_err(|e| format!("{}: {e}", root.display()))?;
79    let file = std::fs::canonicalize(root.join(spec)).map_err(|_| outside())?;
80    if !file.starts_with(&base) || !file.is_file() {
81        return Err(outside());
82    }
83    std::fs::read_to_string(&file).map_err(|e| format!("{spec}: {e}"))
84}
85
86/// The paths a diff touches, as git itself reads it (both sides of a
87/// rename, quoted paths, mode-only changes), from `git apply --numstat` run
88/// forward and in reverse at the repository `repo`. `Err`: git cannot read
89/// it as a patch, or it carries a binary hunk or a symbolic link — neither
90/// is applied as a candidate (`BUG-CANDIDATE-APPLY`).
91pub fn touched(repo: &Path, diff: &str) -> Result<Vec<String>, CandidateRefusal> {
92    let refuse = |paths: Vec<String>| CandidateRefusal {
93        code: "BUG-CANDIDATE-APPLY".to_owned(),
94        paths,
95    };
96    let git = crate::git::Git::at(repo);
97    let mut out: Vec<String> = Vec::new();
98    let mut binary: Vec<String> = Vec::new();
99    for reverse in [false, true] {
100        let mut args = vec!["apply", "--numstat", "-z"];
101        if reverse {
102            args.push("-R");
103        }
104        args.push("-");
105        let o = git
106            .run_with_stdin(&args, diff.as_bytes())
107            .map_err(|e| refuse(vec![e.0]))?;
108        let fields = crate::git::nul_fields(&o);
109        let mut i = 0;
110        while i < fields.len() {
111            let mut parts = fields[i].splitn(3, '\t');
112            let (added, deleted, path) = (
113                parts.next().unwrap_or_default(),
114                parts.next().unwrap_or_default(),
115                parts.next().unwrap_or_default(),
116            );
117            i += 1;
118            // A rename may be written `n\tm\t\0old\0new\0`.
119            let paths: Vec<String> = if path.is_empty() {
120                let p = fields[i..].iter().take(2).cloned().collect();
121                i += 2;
122                p
123            } else {
124                vec![path.to_owned()]
125            };
126            for p in paths.into_iter().filter(|p| !p.is_empty()) {
127                if added == "-" && deleted == "-" && !binary.contains(&p) {
128                    binary.push(p.clone());
129                }
130                if !out.contains(&p) {
131                    out.push(p);
132                }
133            }
134        }
135    }
136    if out.is_empty() {
137        return Err(refuse(vec!["no file in the patch".to_owned()]));
138    }
139    if !binary.is_empty() {
140        return Err(refuse(
141            binary
142                .into_iter()
143                .map(|p| format!("{p}: a binary hunk"))
144                .collect(),
145        ));
146    }
147    let summary = git
148        .run_with_stdin(&["apply", "--summary", "-"], diff.as_bytes())
149        .map_err(|e| refuse(vec![e.0]))?;
150    let links: Vec<String> = String::from_utf8_lossy(&summary)
151        .lines()
152        .filter(|l| l.contains("120000"))
153        .map(|l| format!("{}: a symbolic link", l.trim()))
154        .collect();
155    if !links.is_empty() {
156        return Err(refuse(links));
157    }
158    Ok(out)
159}
160
161/// The touched paths that are judge inputs: protected paths, frozen tests,
162/// held-out paths.
163#[must_use]
164pub fn judge_paths(
165    touched: &[String],
166    policy: &crate::tools::policy::Policy,
167    frozen: &[String],
168    held_out: &[String],
169) -> Vec<String> {
170    touched
171        .iter()
172        .filter(|p| {
173            policy.protected().write_class(p).is_some()
174                || frozen.contains(p)
175                || held_out.contains(p)
176        })
177        .cloned()
178        .collect()
179}
180
181/// Applies `diff` in `wt_root` after `git apply --check`. `Err`: the
182/// refusal with git's rejected hunks.
183pub fn apply(wt_root: &Path, tmp: &Path, diff: &str) -> Result<(), CandidateRefusal> {
184    let refuse = |paths: Vec<String>| CandidateRefusal {
185        code: "BUG-CANDIDATE-APPLY".to_owned(),
186        paths,
187    };
188    let _ = std::fs::create_dir_all(tmp);
189    let file = tmp.join(format!("candidate-{}.patch", std::process::id()));
190    std::fs::write(&file, diff).map_err(|e| refuse(vec![e.to_string()]))?;
191    let path = file.display().to_string();
192    let git = crate::git::Git::at(wt_root);
193    let result = git
194        .run(&["apply", "--check", &path])
195        .and_then(|_| git.run(&["apply", &path]));
196    let _ = std::fs::remove_file(&file);
197    result.map(|_| ()).map_err(|e| {
198        refuse(
199            e.0.lines()
200                .filter(|l| l.contains("error") || l.contains("patch failed"))
201                .map(str::to_owned)
202                .collect(),
203        )
204    })
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn a_candidate_names_its_paths() {
213        assert_eq!(parse("pr:12"), Some(Spec::Pr(12)));
214        assert_eq!(
215            parse("fix.patch"),
216            Some(Spec::Patch("fix.patch".to_owned()))
217        );
218        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
219        let diff = "diff --git a/src/a.rs b/src/a.rs\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n-x\n+y\ndiff --git a/tests/new.rs b/tests/new.rs\nnew file mode 100644\n--- /dev/null\n+++ b/tests/new.rs\n@@ -0,0 +1 @@\n+t\n";
220        assert_eq!(
221            touched(dir.path(), diff).unwrap_or_else(|e| panic!("{e:?}")),
222            ["src/a.rs", "tests/new.rs"]
223        );
224        let r = record(&Spec::Pr(3), diff, Some("abc".to_owned()));
225        assert_eq!(r.number, Some(3));
226        assert!(r.digest.starts_with("sha256:"));
227    }
228
229    #[test]
230    fn hunks_without_plus_minus_lines_still_name_their_paths() {
231        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
232        let d = dir.path();
233        let paths = |diff: &str| touched(d, diff);
234        // A rename at 100% similarity: both sides, no `---`/`+++` lines.
235        let rename = "diff --git a/tests/repro.rs b/src/gone.rs\nsimilarity index 100%\nrename from tests/repro.rs\nrename to src/gone.rs\n";
236        assert_eq!(
237            paths(rename).unwrap_or_else(|e| panic!("{e:?}")),
238            ["src/gone.rs", "tests/repro.rs"]
239        );
240        // A mode-only change.
241        let mode =
242            "diff --git a/speccoder.toml b/speccoder.toml\nold mode 100644\nnew mode 100755\n";
243        assert_eq!(
244            paths(mode).unwrap_or_else(|e| panic!("{e:?}")),
245            ["speccoder.toml"]
246        );
247        // A quoted path.
248        let quoted = "diff --git \"a/tests/sp\\303\\244ce.rs\" \"b/tests/sp\\303\\244ce.rs\"\n--- \"a/tests/sp\\303\\244ce.rs\"\n+++ \"b/tests/sp\\303\\244ce.rs\"\n@@ -1 +1 @@\n-x\n+y\n";
249        assert_eq!(
250            paths(quoted).unwrap_or_else(|e| panic!("{e:?}")),
251            ["tests/sp\u{e4}ce.rs"]
252        );
253        // A binary hunk and a symbolic link are refused outright.
254        let binary = "diff --git a/b.bin b/b.bin\nindex 88768ef..3e3315e 100644\nGIT binary patch\nliteral 5\nMcmZQzO3KUw00MIXJOBUy\n\nliteral 5\nMcmZQzOv=my00M6TI{*Lx\n\n";
255        assert!(paths(binary).is_err_and(|r| r.code == "BUG-CANDIDATE-APPLY"));
256        let link = "diff --git a/link b/link\nnew file mode 120000\nindex 0000000..8d14cbf\n--- /dev/null\n+++ b/link\n@@ -0,0 +1 @@\n+speccoder.toml\n\\ No newline at end of file\n";
257        assert!(paths(link).is_err_and(|r| r.code == "BUG-CANDIDATE-APPLY"));
258        assert!(paths("not a patch\n").is_err());
259    }
260}