chore: refactor backend around domain types (#26)
Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
+304
-278
@@ -1,6 +1,5 @@
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use diffy::patch_set::{FileOperation, FilePatch, ParseOptions, PatchSet};
|
||||
@@ -8,315 +7,342 @@ use diffy::{Hunk, Line};
|
||||
|
||||
use crate::diff::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileDiff};
|
||||
use crate::history::FileCommit;
|
||||
use crate::repo::Repo;
|
||||
|
||||
/// Apply a `git format-patch` patch or series with `git am`.
|
||||
///
|
||||
/// Uses the git CLI because it handles the mbox format natively.
|
||||
///
|
||||
/// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("am")
|
||||
.current_dir(repo_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git am`")?;
|
||||
pub struct PatchParser;
|
||||
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.expect("stdin piped")
|
||||
.write_all(patch.as_bytes())?;
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
impl PatchParser {
|
||||
// A malformed input yields one element covering it.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
.map(|message| message.text)
|
||||
.collect()
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `git format-patch` mbox series of `base..tip`, like `git format-patch --stdout`.
|
||||
/// Fails when the range has no commits.
|
||||
///
|
||||
/// The mbox is returned untrimmed. Trailing newlines are part of the format.
|
||||
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.context("failed to spawn `git format-patch`")?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git format-patch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
|
||||
if patch.trim().is_empty() {
|
||||
bail!("no commits between {base} and {tip}");
|
||||
}
|
||||
|
||||
Ok(patch)
|
||||
}
|
||||
|
||||
/// Split a `git format-patch` series into its individual patches, mbox messages.
|
||||
///
|
||||
/// A single patch yields one element.
|
||||
/// A malformed input yields one element covering it.
|
||||
pub fn split_patch_series(patch: &str) -> Vec<&str> {
|
||||
let mut starts = vec![0usize];
|
||||
let mut search_from = 1;
|
||||
|
||||
while let Some(rel) = patch[search_from..].find("\nFrom ") {
|
||||
let ix = search_from + rel + 1;
|
||||
let hex = patch[ix + 5..]
|
||||
.split(|c: char| !c.is_ascii_hexdigit())
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if hex.len() == 40 {
|
||||
starts.push(ix);
|
||||
// Backed by `diffy::patch_set`, which implements git's extended diff format:
|
||||
// rename and copy detection, binary detection, quoted/escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
search_from = ix + 1;
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(Self::file_diff(file?)?);
|
||||
}
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
|
||||
starts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &start)| {
|
||||
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
|
||||
&patch[start..end]
|
||||
// Entries appear in patch order, oldest first as `git format-patch`
|
||||
// produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
Self::envelopes(patch)
|
||||
.into_iter()
|
||||
.filter(|message| !message.id.is_empty())
|
||||
.map(|message| FileCommit {
|
||||
id: message.id.to_string(),
|
||||
summary: message
|
||||
.header("Subject")
|
||||
.map(Self::strip_patch_prefix)
|
||||
.unwrap_or_default(),
|
||||
description: None,
|
||||
author: message
|
||||
.header("From")
|
||||
.map(Self::name_from_address)
|
||||
.unwrap_or_default(),
|
||||
time: message
|
||||
.header("Date")
|
||||
.and_then(|value| gix::date::parse(value.trim(), None).ok())
|
||||
.map(|time| time.seconds)
|
||||
.unwrap_or(0),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// The one parser `split_patch_series` and `patch_commits` share.
|
||||
// A malformed input yields one message covering the whole input.
|
||||
fn envelopes(patch: &str) -> Vec<Envelope<'_>> {
|
||||
let mut messages: Vec<Envelope<'_>> = Vec::new();
|
||||
let mut current: Option<(usize, &str, Vec<&str>)> = None;
|
||||
let mut headers_closed = false;
|
||||
|
||||
let mut offset = 0usize;
|
||||
for line in patch.lines() {
|
||||
let line_start = offset;
|
||||
offset += line.len() + 1;
|
||||
|
||||
let is_envelope = line
|
||||
.strip_prefix("From ")
|
||||
.and_then(|rest| rest.split_whitespace().next())
|
||||
.is_some_and(|id| id.len() == 40);
|
||||
|
||||
if is_envelope {
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope {
|
||||
text: &patch[start..],
|
||||
id,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
let id = line
|
||||
.strip_prefix("From ")
|
||||
.and_then(|rest| rest.split_whitespace().next())
|
||||
.unwrap_or("");
|
||||
|
||||
current = Some((line_start, id, Vec::new()));
|
||||
headers_closed = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((_, _, headers)) = &mut current {
|
||||
if headers_closed {
|
||||
continue;
|
||||
}
|
||||
if line.is_empty() {
|
||||
headers_closed = true;
|
||||
} else {
|
||||
headers.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((start, id, headers)) = current.take() {
|
||||
messages.push(Envelope {
|
||||
text: &patch[start..],
|
||||
id,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
messages.push(Envelope {
|
||||
text: patch,
|
||||
id: "",
|
||||
headers: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
None => from.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// Matches `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]`, etc.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let Some(end) = rest.find(']') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||
rest[end + 1..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, dropped the same way
|
||||
// `git apply -p1` does; rename and copy paths come from their own
|
||||
// headers, unprefixed.
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
operation => {
|
||||
stripped = operation.strip_prefix(1);
|
||||
&stripped
|
||||
}
|
||||
};
|
||||
|
||||
let (path, old_path, status) = match operation {
|
||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
||||
FileOperation::Modify { modified, .. } => {
|
||||
(modified.as_ref(), None, DiffStatus::Modified)
|
||||
}
|
||||
FileOperation::Rename { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
||||
}
|
||||
FileOperation::Copy { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Copied)
|
||||
}
|
||||
};
|
||||
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let mut hunks = Vec::new();
|
||||
|
||||
let patch = file.patch();
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let hunk = Self::hunk_diff(hunk);
|
||||
insertions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Addition)
|
||||
.count();
|
||||
deletions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Deletion)
|
||||
.count();
|
||||
hunks.push(hunk);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FileDiff {
|
||||
path: path.to_owned(),
|
||||
old_path: old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary: patch.is_binary(),
|
||||
hunks,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Backed by [`diffy::patch_set`], which implements git's extended diff format:
|
||||
/// `diff --git` headers, rename and copy detection, binary detection, and
|
||||
/// C-style quoted or octal-escaped paths.
|
||||
pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
|
||||
if !patch.lines().any(|line| line.starts_with("diff --git ")) {
|
||||
return Ok(CommitDiff { files: Vec::new() });
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
// `diffy` reports only the hunk header ranges; the per-line numbers are
|
||||
// counted from them the way the header encodes them.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
for file in PatchSet::parse(patch, ParseOptions::gitdiff()) {
|
||||
files.push(file_diff(file?)?);
|
||||
}
|
||||
let mut old = old_range.start() as u32;
|
||||
let mut new = new_range.start() as u32;
|
||||
let mut lines = Vec::with_capacity(hunk.lines().len());
|
||||
|
||||
Ok(CommitDiff { files })
|
||||
}
|
||||
for line in hunk.lines() {
|
||||
let (kind, text) = match line {
|
||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||
};
|
||||
|
||||
fn file_diff(file: FilePatch<'_, str>) -> Result<FileDiff> {
|
||||
// The `---`/`+++` paths carry the `a/`/`b/` prefix, so the first path
|
||||
// component is dropped, the same way `git apply -p1` does.
|
||||
// Rename and copy paths come from their own headers, unprefixed.
|
||||
let stripped;
|
||||
let operation = match file.operation() {
|
||||
operation @ (FileOperation::Rename { .. } | FileOperation::Copy { .. }) => operation,
|
||||
operation => {
|
||||
stripped = operation.strip_prefix(1);
|
||||
&stripped
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(old), Some(new));
|
||||
old += 1;
|
||||
new += 1;
|
||||
numbers
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
let number = Some(new);
|
||||
new += 1;
|
||||
(None, number)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
let number = Some(old);
|
||||
old += 1;
|
||||
(number, None)
|
||||
}
|
||||
};
|
||||
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old: old_no,
|
||||
new: new_no,
|
||||
text: Self::line_text(text),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let (path, old_path, status) = match operation {
|
||||
FileOperation::Create(path) => (path.as_ref(), None, DiffStatus::Added),
|
||||
FileOperation::Delete(path) => (path.as_ref(), None, DiffStatus::Deleted),
|
||||
FileOperation::Modify { modified, .. } => (modified.as_ref(), None, DiffStatus::Modified),
|
||||
FileOperation::Rename { from, to } => {
|
||||
(to.as_ref(), Some(from.as_ref()), DiffStatus::Renamed)
|
||||
}
|
||||
FileOperation::Copy { from, to } => (to.as_ref(), Some(from.as_ref()), DiffStatus::Copied),
|
||||
};
|
||||
|
||||
let mut insertions = 0usize;
|
||||
let mut deletions = 0usize;
|
||||
let mut hunks = Vec::new();
|
||||
|
||||
let patch = file.patch();
|
||||
|
||||
if let Some(text) = patch.as_text() {
|
||||
for hunk in text.hunks() {
|
||||
let hunk = hunk_diff(hunk);
|
||||
insertions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Addition)
|
||||
.count();
|
||||
deletions += hunk
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|line| line.kind == DiffLineKind::Deletion)
|
||||
.count();
|
||||
hunks.push(hunk);
|
||||
DiffHunk {
|
||||
old_start: old_range.start() as u32,
|
||||
old_lines: old_range.len() as u32,
|
||||
new_start: new_range.start() as u32,
|
||||
new_lines: new_range.len() as u32,
|
||||
lines,
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FileDiff {
|
||||
path: path.to_owned(),
|
||||
old_path: old_path.map(str::to_owned),
|
||||
status,
|
||||
insertions,
|
||||
deletions,
|
||||
binary: patch.is_binary(),
|
||||
hunks,
|
||||
})
|
||||
}
|
||||
|
||||
/// The [`DiffHunk`] of one parsed hunk, including the line number of every line.
|
||||
///
|
||||
/// `diffy` reports only the hunk header ranges. The per-line numbers are
|
||||
/// counted from them the way the header encodes them: context lines advance
|
||||
/// both sides, deletions only the old, insertions only the new.
|
||||
fn hunk_diff(hunk: &Hunk<'_, str>) -> DiffHunk {
|
||||
let old_range = hunk.old_range();
|
||||
let new_range = hunk.new_range();
|
||||
|
||||
let mut old = old_range.start() as u32;
|
||||
let mut new = new_range.start() as u32;
|
||||
let mut lines = Vec::with_capacity(hunk.lines().len());
|
||||
|
||||
for line in hunk.lines() {
|
||||
let (kind, text) = match line {
|
||||
Line::Context(text) => (DiffLineKind::Context, *text),
|
||||
Line::Delete(text) => (DiffLineKind::Deletion, *text),
|
||||
Line::Insert(text) => (DiffLineKind::Addition, *text),
|
||||
};
|
||||
|
||||
let (old_no, new_no) = match kind {
|
||||
DiffLineKind::Context => {
|
||||
let numbers = (Some(old), Some(new));
|
||||
old += 1;
|
||||
new += 1;
|
||||
numbers
|
||||
}
|
||||
DiffLineKind::Addition => {
|
||||
let number = Some(new);
|
||||
new += 1;
|
||||
(None, number)
|
||||
}
|
||||
DiffLineKind::Deletion => {
|
||||
let number = Some(old);
|
||||
old += 1;
|
||||
(number, None)
|
||||
}
|
||||
};
|
||||
|
||||
lines.push(DiffLine {
|
||||
kind,
|
||||
old: old_no,
|
||||
new: new_no,
|
||||
text: line_text(text),
|
||||
});
|
||||
}
|
||||
|
||||
DiffHunk {
|
||||
old_start: old_range.start() as u32,
|
||||
old_lines: old_range.len() as u32,
|
||||
new_start: new_range.start() as u32,
|
||||
new_lines: new_range.len() as u32,
|
||||
lines,
|
||||
fn line_text(text: &str) -> String {
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// The content of a parsed line without its line ending.
|
||||
///
|
||||
/// `diffy` keeps the trailing `\n`, the way `str::lines` splits it off.
|
||||
fn line_text(text: &str) -> String {
|
||||
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||
text.strip_suffix('\r').unwrap_or(text).to_owned()
|
||||
// A `git format-patch` mbox message, split on its `From <40-hex> <date>` envelope.
|
||||
struct Envelope<'a> {
|
||||
text: &'a str,
|
||||
id: &'a str,
|
||||
headers: Vec<&'a str>,
|
||||
}
|
||||
|
||||
/// Commits of a `git format-patch` output, a single patch or a series.
|
||||
///
|
||||
/// Entries appear in patch order, oldest first as `git format-patch` produces them.
|
||||
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
|
||||
let lines: Vec<&str> = patch.lines().collect();
|
||||
impl Envelope<'_> {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
let prefix = format!("{name}: ");
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|line| line.starts_with(&prefix))
|
||||
.map(|line| &line[prefix.len()..])
|
||||
}
|
||||
}
|
||||
|
||||
let mut commits = Vec::new();
|
||||
let mut i = 0;
|
||||
impl Repo {
|
||||
// The git CLI handles the mbox format natively.
|
||||
// TODO: replace with a pure-Rust implementation later without changing callers.
|
||||
pub fn apply_patch(&self, patch: &str) -> Result<()> {
|
||||
let workdir = self
|
||||
.inner
|
||||
.workdir()
|
||||
.context("repository has no worktree")?
|
||||
.to_path_buf();
|
||||
|
||||
while i < lines.len() {
|
||||
// A patch starts with its `From <id> <date>` envelope line.
|
||||
let Some(rest) = lines[i].strip_prefix("From ") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let mut child = Repo::git_command(&workdir)
|
||||
.args(["am"])
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.context("failed to spawn `git am`")?;
|
||||
|
||||
let Some(id) = rest.split_whitespace().next() else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.context("git am has no stdin pipe")?
|
||||
.write_all(patch.as_bytes())?;
|
||||
|
||||
if id.len() != 40 {
|
||||
i += 1;
|
||||
continue;
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
|
||||
let mut author = String::new();
|
||||
let mut summary = String::new();
|
||||
let mut time = 0i64;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Envelope headers run up to the blank line before the commit message.
|
||||
i += 1;
|
||||
while i < lines.len() && !lines[i].is_empty() {
|
||||
let header = lines[i];
|
||||
if let Some(value) = header.strip_prefix("From: ") {
|
||||
author = name_from_address(value);
|
||||
} else if let Some(value) = header.strip_prefix("Subject: ") {
|
||||
summary = strip_patch_prefix(value);
|
||||
} else if let Some(value) = header.strip_prefix("Date: ") {
|
||||
time = gix::date::parse(value.trim(), None)
|
||||
.map(|t| t.seconds)
|
||||
.unwrap_or(0);
|
||||
}
|
||||
i += 1;
|
||||
// Fails when the range has no commits. The mbox is returned untrimmed;
|
||||
// trailing newlines are part of the format.
|
||||
pub fn format_patch_between(&self, base: &str, tip: &str) -> Result<String> {
|
||||
let output = Repo::run_git(
|
||||
self.workdir_or_dot(),
|
||||
&["format-patch", "--stdout", &format!("{base}..{tip}")],
|
||||
"git format-patch",
|
||||
)?;
|
||||
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"git format-patch failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
|
||||
commits.push(FileCommit {
|
||||
id: id.to_string(),
|
||||
summary,
|
||||
description: None,
|
||||
author,
|
||||
time,
|
||||
});
|
||||
}
|
||||
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
|
||||
commits
|
||||
}
|
||||
if patch.trim().is_empty() {
|
||||
bail!("no commits between {base} and {tip}");
|
||||
}
|
||||
|
||||
fn name_from_address(from: &str) -> String {
|
||||
match from.trim().find('<') {
|
||||
Some(ix) => from[..ix].trim().to_string(),
|
||||
None => from.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the patch prefix from a `Subject:` header.
|
||||
///
|
||||
/// Examples are `[PATCH]`, `[PATCH 1/2]` and `[RFC PATCH]`.
|
||||
fn strip_patch_prefix(subject: &str) -> String {
|
||||
let trimmed = subject.trim();
|
||||
let Some(rest) = trimmed.strip_prefix('[') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
let Some(end) = rest.find(']') else {
|
||||
return trimmed.to_string();
|
||||
};
|
||||
if rest[..end].to_ascii_lowercase().contains("patch") {
|
||||
rest[end + 1..].trim().to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
Ok(patch)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user