Some checks failed
PR Checks / rust-gates (pull_request) Failing after 46s
Two features need edits to stock ServUO sources, because the events they depend on do not exist. This adds the rung ladder of PLAN.md §2.2.1, the unsupported-version path of §2.2.2, and the record and cache Phase 4 will read. Three decisions were not settled by the plan: * The engine is fully native, with no `git`. §2.2.1 wrote rung 1 as "apply verbatim with git apply", but §1 chose the release tarball specifically so there would be no git on the shard host, and rung 2 needs a native applier regardless. Rung 1 keeps its distinct, stronger verdict — the whole file reproduced the diff's `index` pre-image, computed as a git blob SHA1 in process — while the write goes through the same code path as rung 2. On the real trees here that is not academic: the shipped .patch files are CRLF in a Windows checkout and two of their three targets are LF, so `git apply` refuses patches this applies correctly. * Per-patch metadata is declared by the release, with a built-in fallback. Which patches form one all-or-nothing unit, which companion .cs follows which, whether a CORE rebuild is needed and what declining costs are not derivable from a diff. servuo-plugins now declares them; overlay v0.1.1 is in the current bundle and declares nothing, so a built-in copy stands in for it. A checked-in fixture of the release workflow's own jq output asserts the two descriptions are identical, so the repos cannot drift quietly. * Pre-images are cached in the state directory. The tier edits files the operator owns, and `/etc/runicgateway/patches/originals/` is what turns "here are the hunks we added" into a revert anyone can verify — kept out of the ServUO tree, which uninstall has promised never to clean up. Everything else follows §2.2.1: exact matching with only line-ending and trailing-whitespace normalization, exactly one occurrence or it fails, all-or-nothing per patch file and again per feature, and a byte-preserving splice so nothing outside a hunk can be reformatted. Verified against the ServUO 57.4 tree on this machine across four scratch roots: a hand-patched tree (rung 0), a reverse-applied stock one (rung 1 on the real EventSink.cs, its blob matching the patch's declared pre-image), a mixed-rung feature, a tree with edits inside two patched regions (rung 3 — nothing written, nothing held back applied, no companions copied), and a non-57.4 tree both with and without the extra consent flag. Three consecutive runs left install.json byte-identical and the cached pre-image still pre-patch. Three reporting defects the live runs caught are fixed with tests: a dry run and a held-back patch both claimed to be "applied", the core-rebuild warning fired when nothing had been written and named a Scripts file as core, and a declined tier announced the loss of features install.json showed as applied. Refused patches are now cached too, since the refusal message names that path. Refs: docs/installer/PLAN.md §2.2, §5 Phase 3 Co-Authored-By: Claude <noreply@anthropic.com>
592 lines
22 KiB
Rust
592 lines
22 KiB
Rust
//! Unified-diff parsing.
|
|
//!
|
|
//! The patch tier resolves a file through the rung ladder of `docs/installer/PLAN.md` §2.2.1, and
|
|
//! every rung is answered from the diff itself: a unified diff already carries the stock text of
|
|
//! each region it edits — the context lines plus the `-` lines **are** the pre-image, and the
|
|
//! context lines plus the `+` lines are the post-image. Nothing else has to be shipped alongside
|
|
//! the patch for the installer to know what it is looking for.
|
|
//!
|
|
//! Three properties of this parser are load-bearing rather than tidiness:
|
|
//!
|
|
//! - **Everything is bytes, never `String`.** The three ServUO files the tier edits are CRLF and
|
|
//! are not guaranteed to be UTF-8; a lossy decode would corrupt bytes on write-back, and a strict
|
|
//! one would refuse to patch a shard over a stray `0x92` in a comment. Line content is compared
|
|
//! after normalization but written back verbatim.
|
|
//! - **The `index` line is optional and its absence is not a defect.** `commandlogging-event.patch`
|
|
//! is a plain `---`/`+++` diff with no `diff --git` header at all, so rung 1 (whole-file
|
|
//! pre-image hash) is structurally unavailable for it. That is fine: rung 2 matches on content
|
|
//! and is the stronger guarantee anyway. A parser that required the header would have rejected a
|
|
//! patch we ship.
|
|
//! - **Hunk line counts are checked against the lines actually present.** A truncated or
|
|
//! hand-edited diff whose `@@` header promises more lines than it carries would otherwise
|
|
//! reconstruct a short pre-image, which is exactly the sort of thing that then matches somewhere
|
|
//! it should not.
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
|
|
/// One line inside a hunk body.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum HunkLine {
|
|
/// ` ` — present on both sides.
|
|
Context(Vec<u8>),
|
|
/// `-` — present in the stock file only.
|
|
Removed(Vec<u8>),
|
|
/// `+` — present in the patched file only.
|
|
Added(Vec<u8>),
|
|
}
|
|
|
|
impl HunkLine {
|
|
fn content(&self) -> &[u8] {
|
|
match self {
|
|
Self::Context(b) | Self::Removed(b) | Self::Added(b) => b,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Hunk {
|
|
/// 1-based line number in the stock file, from the `@@` header. **Advisory only** — the match
|
|
/// is made by content, since an insertion anywhere above shifts every number below it. It is
|
|
/// used to prefer the nearest candidate when reporting, and nowhere else.
|
|
pub old_start: usize,
|
|
pub old_count: usize,
|
|
pub new_start: usize,
|
|
pub new_count: usize,
|
|
pub lines: Vec<HunkLine>,
|
|
/// The stock file's last line has no trailing newline (`\ No newline at end of file` after a
|
|
/// `-` or context line).
|
|
pub old_no_newline: bool,
|
|
/// Likewise for the patched file.
|
|
pub new_no_newline: bool,
|
|
}
|
|
|
|
impl Hunk {
|
|
/// The stock text this hunk expects to find: context + removed, in order.
|
|
pub fn pre_image(&self) -> Vec<&[u8]> {
|
|
self.lines
|
|
.iter()
|
|
.filter(|l| !matches!(l, HunkLine::Added(_)))
|
|
.map(HunkLine::content)
|
|
.collect()
|
|
}
|
|
|
|
/// The text this hunk leaves behind: context + added, in order.
|
|
pub fn post_image(&self) -> Vec<&[u8]> {
|
|
self.lines
|
|
.iter()
|
|
.filter(|l| !matches!(l, HunkLine::Removed(_)))
|
|
.map(HunkLine::content)
|
|
.collect()
|
|
}
|
|
|
|
/// Whether this hunk changes anything. A hunk of pure context is a no-op and must not be
|
|
/// counted as applied work — nor searched for, since its pre- and post-images are identical
|
|
/// and rung 0 could never be distinguished from rung 2.
|
|
pub fn is_noop(&self) -> bool {
|
|
!self
|
|
.lines
|
|
.iter()
|
|
.any(|l| matches!(l, HunkLine::Added(_) | HunkLine::Removed(_)))
|
|
}
|
|
}
|
|
|
|
/// One file section of a patch. Our patches carry one each, but a diff may hold several and
|
|
/// silently applying the first would be a quiet way to half-patch a tree.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct FilePatch {
|
|
/// From `+++ b/<path>`, with the `b/` prefix stripped and separators left as `/`.
|
|
pub path: String,
|
|
/// The abbreviated blob hash of the stock file, from `index <old>..<new>`. `None` when the
|
|
/// diff has no `index` line, which makes rung 1 unavailable for this file — see the module
|
|
/// docs.
|
|
pub pre_blob: Option<String>,
|
|
pub post_blob: Option<String>,
|
|
pub hunks: Vec<Hunk>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Patch {
|
|
pub files: Vec<FilePatch>,
|
|
}
|
|
|
|
impl Patch {
|
|
/// The single file this patch edits.
|
|
///
|
|
/// The tier declares one target per patch (`tier.json`), so a diff that turned out to edit two
|
|
/// files would mean the declaration and the artifact disagree — and the installer would have
|
|
/// checked only one of them against the rung ladder before writing both.
|
|
pub fn single_file(&self) -> Result<&FilePatch> {
|
|
match self.files.as_slice() {
|
|
[only] => Ok(only),
|
|
[] => bail!("this patch contains no file sections"),
|
|
many => bail!(
|
|
"this patch edits {} files ({}); the tier declares one target per patch",
|
|
many.len(),
|
|
many.iter()
|
|
.map(|f| f.path.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Splits a buffer into lines **without** their terminators, tolerating CRLF, LF and a final line
|
|
/// with no terminator at all.
|
|
///
|
|
/// A trailing newline does not produce a final empty line: `b"a\n"` is one line, matching how every
|
|
/// diff tool counts them.
|
|
pub fn split_lines(data: &[u8]) -> Vec<&[u8]> {
|
|
let mut out = Vec::new();
|
|
let mut start = 0usize;
|
|
for (i, b) in data.iter().enumerate() {
|
|
if *b == b'\n' {
|
|
let mut end = i;
|
|
if end > start && data[end - 1] == b'\r' {
|
|
end -= 1;
|
|
}
|
|
out.push(&data[start..end]);
|
|
start = i + 1;
|
|
}
|
|
}
|
|
if start < data.len() {
|
|
let mut end = data.len();
|
|
if end > start && data[end - 1] == b'\r' {
|
|
end -= 1;
|
|
}
|
|
out.push(&data[start..end]);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The comparison form of a line: trailing whitespace removed.
|
|
///
|
|
/// This is the *whole* of the licence PLAN.md §2.2.1 grants — line-ending and trailing-whitespace
|
|
/// normalization, nothing else. There is no fuzz and no context reduction: dropping context to
|
|
/// force a match is precisely how a hunk lands in the wrong method. Line endings are already gone
|
|
/// by the time this runs, since [`split_lines`] strips them.
|
|
pub fn normalize(line: &[u8]) -> &[u8] {
|
|
let mut end = line.len();
|
|
while end > 0 && (line[end - 1] == b' ' || line[end - 1] == b'\t' || line[end - 1] == b'\r') {
|
|
end -= 1;
|
|
}
|
|
&line[..end]
|
|
}
|
|
|
|
/// Parses a `.patch` file.
|
|
pub fn parse(data: &[u8]) -> Result<Patch> {
|
|
let lines = split_lines(data);
|
|
let mut files: Vec<FilePatch> = Vec::new();
|
|
let mut pending_blobs: Option<(String, String)> = None;
|
|
let mut i = 0usize;
|
|
|
|
while i < lines.len() {
|
|
let line = lines[i];
|
|
|
|
if line.starts_with(b"index ") {
|
|
// `index <old>..<new>[ <mode>]`. Abbreviated to 7+ hex characters by git, so rung 1
|
|
// compares by prefix rather than for equality.
|
|
pending_blobs = parse_index(line);
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
if line.starts_with(b"--- ") && i + 1 < lines.len() && lines[i + 1].starts_with(b"+++ ") {
|
|
let path = header_path(lines[i + 1], b"+++ ")
|
|
.or_else(|| header_path(lines[i], b"--- "))
|
|
.with_context(|| {
|
|
format!(
|
|
"cannot read the target path from the diff header at line {}",
|
|
i + 2
|
|
)
|
|
})?;
|
|
let (pre_blob, post_blob) = match pending_blobs.take() {
|
|
Some((a, b)) => (Some(a), Some(b)),
|
|
None => (None, None),
|
|
};
|
|
i += 2;
|
|
|
|
let mut hunks = Vec::new();
|
|
while i < lines.len() && lines[i].starts_with(b"@@") {
|
|
let (hunk, next) = parse_hunk(&lines, i)?;
|
|
hunks.push(hunk);
|
|
i = next;
|
|
}
|
|
if hunks.is_empty() {
|
|
bail!("the diff section for {path} contains no hunks");
|
|
}
|
|
files.push(FilePatch {
|
|
path,
|
|
pre_blob,
|
|
post_blob,
|
|
hunks,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// `diff --git`, `new file mode`, `similarity index`, a covering-letter preamble — anything
|
|
// outside a hunk body is skipped. Only `index` is worth keeping.
|
|
i += 1;
|
|
}
|
|
|
|
if files.is_empty() {
|
|
bail!("no unified-diff sections found — this file is not a patch");
|
|
}
|
|
Ok(Patch { files })
|
|
}
|
|
|
|
/// `--- a/Scripts/Commands/Logging.cs` → `Scripts/Commands/Logging.cs`.
|
|
///
|
|
/// The trailing tab-separated timestamp some tools append is dropped, and so is the one-letter
|
|
/// prefix directory git uses. `/dev/null` yields `None`, which makes a pure-creation diff fall back
|
|
/// to the other header rather than producing a file called `dev/null`.
|
|
fn header_path(line: &[u8], marker: &[u8]) -> Option<String> {
|
|
let rest = line.strip_prefix(marker)?;
|
|
let rest = match rest.iter().position(|b| *b == b'\t') {
|
|
Some(tab) => &rest[..tab],
|
|
None => rest,
|
|
};
|
|
let text = String::from_utf8_lossy(rest).trim().replace('\\', "/");
|
|
if text.is_empty() || text == "/dev/null" {
|
|
return None;
|
|
}
|
|
// git writes `a/` and `b/`; `-p1` semantics. A path with no prefix (`patch -p0` style) is left
|
|
// alone rather than having its first directory eaten.
|
|
for prefix in ["a/", "b/", "i/", "w/", "c/", "o/"] {
|
|
if let Some(stripped) = text.strip_prefix(prefix) {
|
|
return Some(stripped.to_string());
|
|
}
|
|
}
|
|
Some(text)
|
|
}
|
|
|
|
fn parse_index(line: &[u8]) -> Option<(String, String)> {
|
|
let rest = String::from_utf8_lossy(line.strip_prefix(b"index ")?).to_string();
|
|
let head = rest.split_whitespace().next()?;
|
|
let (old, new) = head.split_once("..")?;
|
|
let hex = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit());
|
|
if !hex(old) || !hex(new) {
|
|
return None;
|
|
}
|
|
Some((old.to_ascii_lowercase(), new.to_ascii_lowercase()))
|
|
}
|
|
|
|
/// Parses one hunk, starting at the `@@` header. Returns the hunk and the index of the line after
|
|
/// it.
|
|
fn parse_hunk(lines: &[&[u8]], start: usize) -> Result<(Hunk, usize)> {
|
|
let header = String::from_utf8_lossy(lines[start]).to_string();
|
|
let (old_start, old_count, new_start, new_count) = parse_hunk_header(&header)
|
|
.with_context(|| format!("cannot parse hunk header at line {}: {header}", start + 1))?;
|
|
|
|
let mut body = Vec::new();
|
|
let mut old_no_newline = false;
|
|
let mut new_no_newline = false;
|
|
let mut seen_old = 0usize;
|
|
let mut seen_new = 0usize;
|
|
let mut i = start + 1;
|
|
|
|
while i < lines.len() {
|
|
let line = lines[i];
|
|
// The marker describes the line *above* it, and which side it applies to depends on that
|
|
// line's kind: a `-` line means the stock file ended there, a `+` line the patched one, and
|
|
// a context line both.
|
|
if line.starts_with(b"\\ ") {
|
|
match body.last() {
|
|
Some(HunkLine::Removed(_)) => old_no_newline = true,
|
|
Some(HunkLine::Added(_)) => new_no_newline = true,
|
|
Some(HunkLine::Context(_)) => {
|
|
old_no_newline = true;
|
|
new_no_newline = true;
|
|
}
|
|
None => {}
|
|
}
|
|
i += 1;
|
|
continue;
|
|
}
|
|
if seen_old >= old_count && seen_new >= new_count {
|
|
break;
|
|
}
|
|
|
|
let (kind, rest) = match line.first() {
|
|
Some(b' ') => (0u8, &line[1..]),
|
|
Some(b'-') => (1, &line[1..]),
|
|
Some(b'+') => (2, &line[1..]),
|
|
// git emits a genuinely empty line for an empty context line rather than a lone space,
|
|
// and trailing whitespace is routinely stripped in transit. Treating it as context is
|
|
// what every patch tool does.
|
|
None => (0, line),
|
|
// Anything else ends the hunk — the next `@@`, `diff --git`, or trailing prose.
|
|
Some(_) => break,
|
|
};
|
|
|
|
match kind {
|
|
0 => {
|
|
seen_old += 1;
|
|
seen_new += 1;
|
|
body.push(HunkLine::Context(rest.to_vec()));
|
|
}
|
|
1 => {
|
|
seen_old += 1;
|
|
body.push(HunkLine::Removed(rest.to_vec()));
|
|
}
|
|
_ => {
|
|
seen_new += 1;
|
|
body.push(HunkLine::Added(rest.to_vec()));
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
// A header that promises more than the body delivers reconstructs a short pre-image, which is
|
|
// then liable to match a place the author never meant. Refusing is the only safe reading.
|
|
if seen_old != old_count || seen_new != new_count {
|
|
bail!(
|
|
"hunk at line {} declares -{old_start},{old_count} +{new_start},{new_count} but \
|
|
carries {seen_old} old and {seen_new} new lines — the patch is truncated or malformed",
|
|
start + 1
|
|
);
|
|
}
|
|
|
|
Ok((
|
|
Hunk {
|
|
old_start,
|
|
old_count,
|
|
new_start,
|
|
new_count,
|
|
lines: body,
|
|
old_no_newline,
|
|
new_no_newline,
|
|
},
|
|
i,
|
|
))
|
|
}
|
|
|
|
/// `@@ -75,16 +75,27 @@ optional section heading` → `(75, 16, 75, 27)`.
|
|
///
|
|
/// A count may be omitted, which means 1 (`@@ -75 +75,2 @@`), and a count of 0 is legal for a pure
|
|
/// insertion or deletion.
|
|
fn parse_hunk_header(header: &str) -> Option<(usize, usize, usize, usize)> {
|
|
let inner = header.strip_prefix("@@")?;
|
|
let end = inner.find("@@")?;
|
|
let mut parts = inner[..end].split_whitespace();
|
|
let old = parts.next()?.strip_prefix('-')?;
|
|
let new = parts.next()?.strip_prefix('+')?;
|
|
|
|
let range = |s: &str| -> Option<(usize, usize)> {
|
|
match s.split_once(',') {
|
|
Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
|
|
None => Some((s.parse().ok()?, 1)),
|
|
}
|
|
};
|
|
let (old_start, old_count) = range(old)?;
|
|
let (new_start, new_count) = range(new)?;
|
|
Some((old_start, old_count, new_start, new_count))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn parse_str(text: &str) -> Result<Patch> {
|
|
parse(text.as_bytes())
|
|
}
|
|
|
|
const SIMPLE: &str = "\
|
|
diff --git a/Scripts/Commands/Logging.cs b/Scripts/Commands/Logging.cs
|
|
index 5dd3f54..9ab1c22 100644
|
|
--- a/Scripts/Commands/Logging.cs
|
|
+++ b/Scripts/Commands/Logging.cs
|
|
@@ -75,4 +75,6 @@ namespace Server.Commands
|
|
return o;
|
|
}
|
|
|
|
+ public static event Action<Mobile, string> OnWrite;
|
|
+
|
|
public static void WriteLine(Mobile from, string text)
|
|
";
|
|
|
|
#[test]
|
|
fn a_git_format_patch_parses_whole() {
|
|
let patch = parse_str(SIMPLE).unwrap();
|
|
let file = patch.single_file().unwrap();
|
|
assert_eq!(file.path, "Scripts/Commands/Logging.cs");
|
|
assert_eq!(file.pre_blob.as_deref(), Some("5dd3f54"));
|
|
assert_eq!(file.post_blob.as_deref(), Some("9ab1c22"));
|
|
assert_eq!(file.hunks.len(), 1);
|
|
|
|
let hunk = &file.hunks[0];
|
|
assert_eq!((hunk.old_start, hunk.old_count), (75, 4));
|
|
assert_eq!((hunk.new_start, hunk.new_count), (75, 6));
|
|
assert_eq!(hunk.pre_image().len(), 4);
|
|
assert_eq!(hunk.post_image().len(), 6);
|
|
assert!(!hunk.is_noop());
|
|
}
|
|
|
|
#[test]
|
|
fn a_plain_diff_without_a_git_header_parses_and_offers_no_blob() {
|
|
// commandlogging-event.patch is exactly this shape. Rung 1 is unavailable for it, which is
|
|
// a fact to report — not a parse error, and certainly not a reason to skip the patch.
|
|
let text = SIMPLE
|
|
.lines()
|
|
.filter(|l| !l.starts_with("diff --git") && !l.starts_with("index "))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let patch = parse_str(&text).unwrap();
|
|
let file = patch.single_file().unwrap();
|
|
assert_eq!(file.path, "Scripts/Commands/Logging.cs");
|
|
assert_eq!(file.pre_blob, None);
|
|
assert_eq!(file.hunks.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_crlf_patch_yields_the_same_lines_as_an_lf_one() {
|
|
// Every .patch this tier ships is CRLF in a Windows checkout and LF in the tarball CI
|
|
// builds. The two must parse identically or a patch would apply on one platform and not
|
|
// the other.
|
|
let crlf = SIMPLE.replace('\n', "\r\n");
|
|
assert_eq!(parse_str(&crlf).unwrap(), parse_str(SIMPLE).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn pre_and_post_images_are_the_two_sides_of_the_hunk() {
|
|
let patch = parse_str(
|
|
"\
|
|
--- a/x
|
|
+++ b/x
|
|
@@ -1,3 +1,3 @@
|
|
keep
|
|
-old
|
|
+new
|
|
tail
|
|
",
|
|
)
|
|
.unwrap();
|
|
let hunk = &patch.single_file().unwrap().hunks[0];
|
|
assert_eq!(hunk.pre_image(), vec![&b"keep"[..], b"old", b"tail"]);
|
|
assert_eq!(hunk.post_image(), vec![&b"keep"[..], b"new", b"tail"]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_no_newline_marker_attaches_to_the_side_it_describes() {
|
|
// Three cases, because the marker follows the line it describes and the side depends on
|
|
// that line's kind. Getting this wrong writes a spurious trailing newline into a file that
|
|
// never had one — a one-byte change that shows up in every future hash comparison.
|
|
let old_only = parse_str(
|
|
"--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n",
|
|
)
|
|
.unwrap();
|
|
let hunk = &old_only.single_file().unwrap().hunks[0];
|
|
assert!(hunk.old_no_newline && !hunk.new_no_newline);
|
|
|
|
let new_only = parse_str(
|
|
"--- a/x\n+++ b/x\n@@ -1,1 +1,1 @@\n-old\n+new\n\\ No newline at end of file\n",
|
|
)
|
|
.unwrap();
|
|
let hunk = &new_only.single_file().unwrap().hunks[0];
|
|
assert!(!hunk.old_no_newline && hunk.new_no_newline);
|
|
|
|
let both = parse_str(
|
|
"--- a/x\n+++ b/x\n@@ -1,1 +1,2 @@\n+added\n same\n\\ No newline at end of file\n",
|
|
)
|
|
.unwrap();
|
|
let hunk = &both.single_file().unwrap().hunks[0];
|
|
assert!(hunk.old_no_newline && hunk.new_no_newline);
|
|
}
|
|
|
|
#[test]
|
|
fn several_hunks_and_several_files_are_all_kept() {
|
|
let patch = parse_str(
|
|
"\
|
|
diff --git a/one b/one
|
|
index aaaaaaa..bbbbbbb 100644
|
|
--- a/one
|
|
+++ b/one
|
|
@@ -1,1 +1,2 @@
|
|
a
|
|
+b
|
|
@@ -10,1 +11,2 @@
|
|
c
|
|
+d
|
|
diff --git a/two b/two
|
|
--- a/two
|
|
+++ b/two
|
|
@@ -5,1 +5,2 @@
|
|
e
|
|
+f
|
|
",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(patch.files.len(), 2);
|
|
assert_eq!(patch.files[0].hunks.len(), 2);
|
|
assert_eq!(patch.files[1].hunks.len(), 1);
|
|
// The second file has no index line of its own and must not inherit the first's.
|
|
assert_eq!(patch.files[1].pre_blob, None);
|
|
|
|
// And a multi-file patch is refused where the tier expects one target, rather than being
|
|
// silently half-applied.
|
|
let err = patch.single_file().unwrap_err().to_string();
|
|
assert!(err.contains("edits 2 files"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_omitted_count_means_one() {
|
|
let patch = parse_str("--- a/x\n+++ b/x\n@@ -7 +7,2 @@\n a\n+b\n").unwrap();
|
|
let hunk = &patch.single_file().unwrap().hunks[0];
|
|
assert_eq!((hunk.old_start, hunk.old_count), (7, 1));
|
|
assert_eq!((hunk.new_start, hunk.new_count), (7, 2));
|
|
}
|
|
|
|
#[test]
|
|
fn a_truncated_hunk_is_rejected() {
|
|
// The header promises four old lines; two are present. Reconstructing the short pre-image
|
|
// and hunting for it is how a patch lands somewhere nobody intended.
|
|
let err = parse_str("--- a/x\n+++ b/x\n@@ -1,4 +1,4 @@\n a\n b\n")
|
|
.unwrap_err()
|
|
.to_string();
|
|
assert!(err.contains("truncated or malformed"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_file_that_is_not_a_patch_is_rejected() {
|
|
assert!(parse_str("# patches\n\nUnified diffs against stock ServUO.\n").is_err());
|
|
assert!(parse_str("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn line_splitting_agrees_with_how_diffs_count_lines() {
|
|
assert_eq!(split_lines(b"a\nb\n"), vec![&b"a"[..], b"b"]);
|
|
assert_eq!(split_lines(b"a\r\nb"), vec![&b"a"[..], b"b"]);
|
|
assert_eq!(split_lines(b""), Vec::<&[u8]>::new());
|
|
assert_eq!(split_lines(b"\n"), vec![&b""[..]]);
|
|
assert_eq!(split_lines(b"a"), vec![&b"a"[..]]);
|
|
}
|
|
|
|
#[test]
|
|
fn normalization_covers_line_endings_and_trailing_space_and_nothing_else() {
|
|
assert_eq!(normalize(b"code \t"), b"code");
|
|
assert_eq!(normalize(b"code\r"), b"code");
|
|
// Leading indentation is content: two methods differing only in nesting are different
|
|
// places, and collapsing them is how an anchor becomes ambiguous.
|
|
assert_ne!(normalize(b" code"), normalize(b"code"));
|
|
assert_eq!(normalize(b" "), b"");
|
|
}
|
|
|
|
#[test]
|
|
fn a_pure_context_hunk_is_a_noop() {
|
|
let patch = parse_str("--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n a\n b\n").unwrap();
|
|
assert!(patch.single_file().unwrap().hunks[0].is_noop());
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_context_line_written_without_its_space_is_still_context() {
|
|
// Mailers and editors strip the trailing space off a blank context line routinely, and
|
|
// every patch tool tolerates it. Both spellings must produce the same pre-image.
|
|
let padded = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n \n b\n+c\n").unwrap();
|
|
let bare = parse_str("--- a/x\n+++ b/x\n@@ -1,3 +1,4 @@\n a\n\n b\n+c\n").unwrap();
|
|
assert_eq!(
|
|
padded.single_file().unwrap().hunks[0].pre_image(),
|
|
bare.single_file().unwrap().hunks[0].pre_image()
|
|
);
|
|
}
|
|
}
|