use std::path::Path;
use speccoder_core::bug::{Candidate, CandidateOrigin, CandidateRefusal};
use crate::integrity::sha256_hex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Spec {
Patch(String),
Pr(u64),
}
#[must_use]
pub fn parse(spec: &str) -> Option<Spec> {
let spec = spec.trim();
if let Some(n) = spec.strip_prefix("pr:") {
return n.trim().trim_start_matches('#').parse().ok().map(Spec::Pr);
}
(!spec.is_empty()).then(|| Spec::Patch(spec.to_owned()))
}
#[must_use]
pub fn record(spec: &Spec, diff: &str, head: Option<String>) -> Candidate {
Candidate {
origin: match spec {
Spec::Patch(_) => CandidateOrigin::Patch,
Spec::Pr(_) => CandidateOrigin::Pr,
},
path: match spec {
Spec::Patch(p) => Some(p.clone()),
Spec::Pr(_) => None,
},
number: match spec {
Spec::Pr(n) => Some(*n),
Spec::Patch(_) => None,
},
digest: format!("sha256:{}", sha256_hex(diff.as_bytes())),
head_sha: head,
applied: false,
refused: None,
}
}
pub fn read_patch(
root: &Path,
cwd: &Path,
spec: &str,
via: speccoder_core::actions::Via,
) -> Result<String, String> {
if via == speccoder_core::actions::Via::Terminal {
return std::fs::read_to_string(cwd.join(spec))
.or_else(|_| std::fs::read_to_string(spec))
.map_err(|e| format!("{spec}: {e}"));
}
let outside = || {
format!(
"{spec}: a candidate patch given through {} must be a file in the project",
via.as_str()
)
};
let base = std::fs::canonicalize(root).map_err(|e| format!("{}: {e}", root.display()))?;
let file = std::fs::canonicalize(root.join(spec)).map_err(|_| outside())?;
if !file.starts_with(&base) || !file.is_file() {
return Err(outside());
}
std::fs::read_to_string(&file).map_err(|e| format!("{spec}: {e}"))
}
pub fn touched(repo: &Path, diff: &str) -> Result<Vec<String>, CandidateRefusal> {
let refuse = |paths: Vec<String>| CandidateRefusal {
code: "BUG-CANDIDATE-APPLY".to_owned(),
paths,
};
let git = crate::git::Git::at(repo);
let mut out: Vec<String> = Vec::new();
let mut binary: Vec<String> = Vec::new();
for reverse in [false, true] {
let mut args = vec!["apply", "--numstat", "-z"];
if reverse {
args.push("-R");
}
args.push("-");
let o = git
.run_with_stdin(&args, diff.as_bytes())
.map_err(|e| refuse(vec![e.0]))?;
let fields = crate::git::nul_fields(&o);
let mut i = 0;
while i < fields.len() {
let mut parts = fields[i].splitn(3, '\t');
let (added, deleted, path) = (
parts.next().unwrap_or_default(),
parts.next().unwrap_or_default(),
parts.next().unwrap_or_default(),
);
i += 1;
let paths: Vec<String> = if path.is_empty() {
let p = fields[i..].iter().take(2).cloned().collect();
i += 2;
p
} else {
vec![path.to_owned()]
};
for p in paths.into_iter().filter(|p| !p.is_empty()) {
if added == "-" && deleted == "-" && !binary.contains(&p) {
binary.push(p.clone());
}
if !out.contains(&p) {
out.push(p);
}
}
}
}
if out.is_empty() {
return Err(refuse(vec!["no file in the patch".to_owned()]));
}
if !binary.is_empty() {
return Err(refuse(
binary
.into_iter()
.map(|p| format!("{p}: a binary hunk"))
.collect(),
));
}
let summary = git
.run_with_stdin(&["apply", "--summary", "-"], diff.as_bytes())
.map_err(|e| refuse(vec![e.0]))?;
let links: Vec<String> = String::from_utf8_lossy(&summary)
.lines()
.filter(|l| l.contains("120000"))
.map(|l| format!("{}: a symbolic link", l.trim()))
.collect();
if !links.is_empty() {
return Err(refuse(links));
}
Ok(out)
}
#[must_use]
pub fn judge_paths(
touched: &[String],
policy: &crate::tools::policy::Policy,
frozen: &[String],
held_out: &[String],
) -> Vec<String> {
touched
.iter()
.filter(|p| {
policy.protected().write_class(p).is_some()
|| frozen.contains(p)
|| held_out.contains(p)
})
.cloned()
.collect()
}
pub fn apply(wt_root: &Path, tmp: &Path, diff: &str) -> Result<(), CandidateRefusal> {
let refuse = |paths: Vec<String>| CandidateRefusal {
code: "BUG-CANDIDATE-APPLY".to_owned(),
paths,
};
let _ = std::fs::create_dir_all(tmp);
let file = tmp.join(format!("candidate-{}.patch", std::process::id()));
std::fs::write(&file, diff).map_err(|e| refuse(vec![e.to_string()]))?;
let path = file.display().to_string();
let git = crate::git::Git::at(wt_root);
let result = git
.run(&["apply", "--check", &path])
.and_then(|_| git.run(&["apply", &path]));
let _ = std::fs::remove_file(&file);
result.map(|_| ()).map_err(|e| {
refuse(
e.0.lines()
.filter(|l| l.contains("error") || l.contains("patch failed"))
.map(str::to_owned)
.collect(),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_candidate_names_its_paths() {
assert_eq!(parse("pr:12"), Some(Spec::Pr(12)));
assert_eq!(
parse("fix.patch"),
Some(Spec::Patch("fix.patch".to_owned()))
);
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
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";
assert_eq!(
touched(dir.path(), diff).unwrap_or_else(|e| panic!("{e:?}")),
["src/a.rs", "tests/new.rs"]
);
let r = record(&Spec::Pr(3), diff, Some("abc".to_owned()));
assert_eq!(r.number, Some(3));
assert!(r.digest.starts_with("sha256:"));
}
#[test]
fn hunks_without_plus_minus_lines_still_name_their_paths() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
let d = dir.path();
let paths = |diff: &str| touched(d, diff);
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";
assert_eq!(
paths(rename).unwrap_or_else(|e| panic!("{e:?}")),
["src/gone.rs", "tests/repro.rs"]
);
let mode =
"diff --git a/speccoder.toml b/speccoder.toml\nold mode 100644\nnew mode 100755\n";
assert_eq!(
paths(mode).unwrap_or_else(|e| panic!("{e:?}")),
["speccoder.toml"]
);
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";
assert_eq!(
paths(quoted).unwrap_or_else(|e| panic!("{e:?}")),
["tests/sp\u{e4}ce.rs"]
);
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";
assert!(paths(binary).is_err_and(|r| r.code == "BUG-CANDIDATE-APPLY"));
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";
assert!(paths(link).is_err_and(|r| r.code == "BUG-CANDIDATE-APPLY"));
assert!(paths("not a patch\n").is_err());
}
}