use std::fmt::{self, Write as _};
use serde::{Deserialize, Serialize};
use super::ContextValue;
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ContentHash(pub String);
pub fn content_hash(bytes: &[u8]) -> ContentHash {
use sha2::Digest as _;
let digest = sha2::Sha256::digest(bytes);
ContentHash(digest.iter().map(|byte| format!("{byte:02x}")).collect())
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FilesRead(pub Vec<FileRead>);
impl ContextValue for FilesRead {
const KEY: &'static str = "files_read";
}
impl FilesRead {
pub fn paths(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(|file| file.path.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRead {
pub path: String,
pub hash: ContentHash,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<LinesShown>,
}
impl FileRead {
pub fn new(path: impl Into<String>, hash: ContentHash) -> Self {
Self {
path: path.into(),
hash,
lines: None,
}
}
#[must_use]
pub fn with_lines(mut self, lines: LinesShown) -> Self {
self.lines = Some(lines);
self
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct LinesShown {
pub first: usize,
pub count: usize,
pub total: usize,
}
impl LinesShown {
pub fn whole(&self) -> bool {
self.first <= 1 && self.count >= self.total
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct FilesChanged(pub Vec<FileChange>);
impl ContextValue for FilesChanged {
const KEY: &'static str = "files_changed";
}
impl FilesChanged {
pub fn paths(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(|change| change.path.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileChange {
pub path: String,
pub kind: ChangeKind,
pub added: usize,
pub removed: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hunks: Vec<Hunk>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<FileState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<FileState>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FileState {
pub bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lines: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hash: Option<ContentHash>,
}
impl FileState {
pub fn of(bytes: &[u8]) -> Self {
let breaks = bytes.iter().filter(|byte| **byte == b'\n').count();
let unended = !bytes.is_empty() && !bytes.ends_with(b"\n");
Self {
bytes: bytes.len() as u64,
lines: Some(breaks + usize::from(unended)),
hash: Some(content_hash(bytes)),
}
}
pub fn unread(bytes: u64) -> Self {
Self {
bytes,
lines: None,
hash: None,
}
}
}
impl FileChange {
pub fn created(path: impl Into<String>, lines: usize) -> Self {
Self {
path: path.into(),
kind: ChangeKind::Created,
added: lines,
removed: 0,
hunks: Vec::new(),
before_text: None,
before: None,
after: None,
}
}
pub fn modified(path: impl Into<String>, added: usize, removed: usize) -> Self {
Self {
path: path.into(),
kind: ChangeKind::Modified,
added,
removed,
hunks: Vec::new(),
before_text: None,
before: None,
after: None,
}
}
pub fn deleted(path: impl Into<String>, lines: usize) -> Self {
Self {
path: path.into(),
kind: ChangeKind::Deleted,
added: 0,
removed: lines,
hunks: Vec::new(),
before_text: None,
before: None,
after: None,
}
}
#[must_use]
pub fn with_hunks(mut self, hunks: Vec<Hunk>) -> Self {
self.hunks = hunks;
self
}
#[must_use]
pub fn with_before_text(mut self, text: impl Into<String>) -> Self {
self.before_text = Some(text.into());
self
}
pub fn unified(&self) -> String {
if self.hunks.is_empty() {
return String::new();
}
let side = |gone| {
if self.kind == gone {
"/dev/null"
} else {
self.path.as_str()
}
};
let mut out = format!(
"--- {}\n+++ {}\n",
side(ChangeKind::Created),
side(ChangeKind::Deleted)
);
for hunk in &self.hunks {
let _ = writeln!(out, "@@ -{} +{} @@", hunk.old, hunk.new);
for line in &hunk.lines {
out.push(line.sign());
out.push_str(line.text());
if !line.text().ends_with(['\r', '\n']) {
out.push_str("\n\\ No newline at end of file\n");
}
}
}
out
}
pub fn after_text(&self) -> Option<String> {
let before = match self.kind {
ChangeKind::Deleted => return None,
ChangeKind::Created => "",
ChangeKind::Modified => self.before_text.as_deref()?,
};
let lines = self.hunks.iter().flat_map(|hunk| &hunk.lines);
let counted = |sign| lines.clone().filter(|line| line.sign() == sign).count();
if (counted('+'), counted('-')) != (self.added, self.removed) {
return None;
}
let mut old = text_lines(before);
let mut next = 1;
let mut out = String::with_capacity(before.len());
for hunk in &self.hunks {
while next < hunk.old.start {
out.push_str(old.next()?);
next += 1;
}
for line in &hunk.lines {
if let HunkLine::Added(text) = line {
out.push_str(text);
continue;
}
if old.next()? != line.text() {
return None;
}
next += 1;
if let HunkLine::Same(text) = line {
out.push_str(text);
}
}
}
out.extend(old);
Some(out)
}
pub fn changed_lines(&self) -> impl Iterator<Item = usize> + '_ {
self.hunks.iter().flat_map(|hunk| {
let new = hunk.lines.iter().filter(|line| line.sign() != '-');
new.zip(hunk.new.start..)
.filter_map(|(line, at)| matches!(line, HunkLine::Added(_)).then_some(at))
})
}
#[must_use]
pub fn with_before(mut self, before: FileState) -> Self {
self.before = Some(before);
self
}
#[must_use]
pub fn with_after(mut self, after: FileState) -> Self {
self.after = Some(after);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Hunk {
pub old: Lines,
pub new: Lines,
pub lines: Vec<HunkLine>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Lines {
pub start: usize,
pub count: usize,
}
impl fmt::Display for Lines {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.count {
1 => write!(f, "{}", self.start),
0 => write!(f, "{},0", self.start.saturating_sub(1)),
count => write!(f, "{},{count}", self.start),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub enum HunkLine {
Same(String),
Removed(String),
Added(String),
}
impl HunkLine {
pub fn text(&self) -> &str {
match self {
Self::Same(text) | Self::Removed(text) | Self::Added(text) => text,
}
}
pub fn sign(&self) -> char {
match self {
Self::Same(_) => ' ',
Self::Removed(_) => '-',
Self::Added(_) => '+',
}
}
}
impl From<HunkLine> for String {
fn from(line: HunkLine) -> Self {
let sign = line.sign();
let (HunkLine::Same(mut text) | HunkLine::Removed(mut text) | HunkLine::Added(mut text)) =
line;
text.insert(0, sign);
text
}
}
impl TryFrom<String> for HunkLine {
type Error = String;
fn try_from(mut line: String) -> Result<Self, Self::Error> {
let make = match line.chars().next() {
Some(' ') => Self::Same,
Some('-') => Self::Removed,
Some('+') => Self::Added,
_ => return Err(format!("a hunk line starts with ' ', '-' or '+': {line:?}")),
};
line.remove(0);
Ok(make(line))
}
}
fn text_lines(mut text: &str) -> impl Iterator<Item = &str> {
std::iter::from_fn(move || {
let end = match text.find(['\r', '\n']) {
Some(at) if text.get(at..).is_some_and(|rest| rest.starts_with("\r\n")) => at + 2,
Some(at) => at + 1,
None => text.len(),
};
let (line, rest) = text.split_at_checked(end)?;
text = rest;
(!line.is_empty()).then_some(line)
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeKind {
Created,
Modified,
Deleted,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutputCut {
pub kept: KeptEnd,
pub kept_lines: usize,
pub total_lines: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spill: Option<String>,
}
impl ContextValue for OutputCut {
const KEY: &'static str = "output_cut";
}
impl OutputCut {
pub fn cut_lines(&self) -> usize {
self.total_lines.saturating_sub(self.kept_lines)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KeptEnd {
Start,
Ends,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitStatus {
Exited(i32),
Signaled,
TimedOut,
Killed,
}
impl ContextValue for ExitStatus {
const KEY: &'static str = "exit_status";
}
impl ExitStatus {
pub fn success(&self) -> bool {
matches!(self, Self::Exited(0))
}
pub fn code(&self) -> Option<i32> {
match self {
Self::Exited(code) => Some(*code),
Self::Signaled | Self::TimedOut | Self::Killed => None,
}
}
}
#[cfg(test)]
mod tests;