use std::fmt;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::{
errors::GitError,
hash::ObjectHash,
internal::object::{
ObjectTrait,
integrity::IntegrityHash,
types::{ActorRef, ArtifactRef, Header, ObjectType},
},
};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DiffFormat {
UnifiedDiff,
GitDiff,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChangeType {
Add,
Modify,
Delete,
Rename,
Copy,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TouchedFile {
pub path: String,
pub change_type: ChangeType,
pub lines_added: u32,
pub lines_deleted: u32,
}
impl TouchedFile {
pub fn new(
path: impl Into<String>,
change_type: ChangeType,
lines_added: u32,
lines_deleted: u32,
) -> Result<Self, String> {
let path = path.into();
if path.trim().is_empty() {
return Err("path cannot be empty".to_string());
}
Ok(Self {
path,
change_type,
lines_added,
lines_deleted,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PatchSet {
#[serde(flatten)]
header: Header,
run: Uuid,
sequence: u32,
commit: IntegrityHash,
format: DiffFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
artifact: Option<ArtifactRef>,
#[serde(default)]
touched: Vec<TouchedFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
rationale: Option<String>,
}
impl PatchSet {
pub fn new(created_by: ActorRef, run: Uuid, commit: impl AsRef<str>) -> Result<Self, String> {
let commit = commit.as_ref().parse()?;
Ok(Self {
header: Header::new(ObjectType::PatchSet, created_by)?,
run,
sequence: 0,
commit,
format: DiffFormat::UnifiedDiff,
artifact: None,
touched: Vec::new(),
rationale: None,
})
}
pub fn header(&self) -> &Header {
&self.header
}
pub fn run(&self) -> Uuid {
self.run
}
pub fn sequence(&self) -> u32 {
self.sequence
}
pub fn set_sequence(&mut self, sequence: u32) {
self.sequence = sequence;
}
pub fn commit(&self) -> &IntegrityHash {
&self.commit
}
pub fn format(&self) -> &DiffFormat {
&self.format
}
pub fn artifact(&self) -> Option<&ArtifactRef> {
self.artifact.as_ref()
}
pub fn touched(&self) -> &[TouchedFile] {
&self.touched
}
pub fn rationale(&self) -> Option<&str> {
self.rationale.as_deref()
}
pub fn set_artifact(&mut self, artifact: Option<ArtifactRef>) {
self.artifact = artifact;
}
pub fn add_touched(&mut self, file: TouchedFile) {
self.touched.push(file);
}
pub fn set_rationale(&mut self, rationale: Option<String>) {
self.rationale = rationale;
}
}
impl fmt::Display for PatchSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PatchSet: {}", self.header.object_id())
}
}
impl ObjectTrait for PatchSet {
fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
where
Self: Sized,
{
serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
}
fn get_type(&self) -> ObjectType {
ObjectType::PatchSet
}
fn get_size(&self) -> usize {
match serde_json::to_vec(self) {
Ok(v) => v.len(),
Err(e) => {
tracing::warn!("failed to compute PatchSet size: {}", e);
0
}
}
}
fn to_data(&self) -> Result<Vec<u8>, GitError> {
serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_hash_hex() -> String {
IntegrityHash::compute(b"ai-process-test").to_hex()
}
#[test]
fn test_patchset_creation() {
let actor = ActorRef::agent("test-agent").expect("actor");
let run = Uuid::from_u128(0x1);
let base_hash = test_hash_hex();
let patchset = PatchSet::new(actor, run, &base_hash).expect("patchset");
assert_eq!(patchset.header().object_type(), &ObjectType::PatchSet);
assert_eq!(patchset.run(), run);
assert_eq!(patchset.sequence(), 0);
assert_eq!(patchset.format(), &DiffFormat::UnifiedDiff);
assert!(patchset.touched().is_empty());
}
}