Skip to main content

git_internal/internal/object/
patchset.rs

1//! AI PatchSet Definition
2//!
3//! A `PatchSet` represents a proposed set of code changes (diffs) generated by an agent.
4//!
5//! # Generations
6//!
7//! PatchSets are often created in generations (iterations).
8//! If a PatchSet fails validation or code review, the agent may generate a new PatchSet (generation N+1)
9//! for the same `Run`.
10//!
11//! # Content
12//!
13//! The actual diff content is stored as an `ArtifactRef` (e.g., pointing to a file in object storage),
14//! while `TouchedFile` provides a lightweight summary for UI/indexing.
15
16use std::fmt;
17
18use serde::{Deserialize, Serialize};
19use uuid::Uuid;
20
21use crate::{
22    errors::GitError,
23    hash::ObjectHash,
24    internal::object::{
25        ObjectTrait,
26        integrity::IntegrityHash,
27        types::{ActorRef, ArtifactRef, Header, ObjectType},
28    },
29};
30
31/// Patch application status.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "snake_case")]
34pub enum ApplyStatus {
35    /// Patch is generated but not yet applied to the repo.
36    Proposed,
37    /// Patch has been applied (committed) to the repo.
38    Applied,
39    /// Patch was rejected by validation or user.
40    Rejected,
41    /// A newer generation has replaced this patch.
42    Superseded,
43}
44
45impl ApplyStatus {
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            ApplyStatus::Proposed => "proposed",
49            ApplyStatus::Applied => "applied",
50            ApplyStatus::Rejected => "rejected",
51            ApplyStatus::Superseded => "superseded",
52        }
53    }
54}
55
56impl fmt::Display for ApplyStatus {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "{}", self.as_str())
59    }
60}
61
62/// Diff format for patch content.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "snake_case")]
65pub enum DiffFormat {
66    /// Standard unified diff format.
67    UnifiedDiff,
68    /// Git-specific diff format (with binary support etc).
69    GitDiff,
70}
71
72/// Type of change for a file.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum ChangeType {
76    Add,
77    Modify,
78    Delete,
79    Rename,
80    Copy,
81}
82
83/// Touched file summary in a patchset.
84///
85/// Provides a quick overview of what files are modified without parsing the full diff.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct TouchedFile {
88    pub path: String,
89    pub change_type: ChangeType,
90    pub lines_added: u32,
91    pub lines_deleted: u32,
92}
93
94impl TouchedFile {
95    pub fn new(
96        path: impl Into<String>,
97        change_type: ChangeType,
98        lines_added: u32,
99        lines_deleted: u32,
100    ) -> Result<Self, String> {
101        let path = path.into();
102        if path.trim().is_empty() {
103            return Err("path cannot be empty".to_string());
104        }
105        Ok(Self {
106            path,
107            change_type,
108            lines_added,
109            lines_deleted,
110        })
111    }
112}
113
114/// PatchSet object containing a candidate diff.
115/// Each generation represents a new candidate diff for the same run.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct PatchSet {
118    #[serde(flatten)]
119    header: Header,
120    run_id: Uuid,
121    generation: u32,
122    base_commit_sha: IntegrityHash,
123    diff_format: DiffFormat,
124    diff_artifact: Option<ArtifactRef>,
125    #[serde(default)]
126    touched_files: Vec<TouchedFile>,
127    rationale: Option<String>,
128    apply_status: ApplyStatus,
129}
130
131impl PatchSet {
132    /// Create a new patchset object
133    pub fn new(
134        repo_id: Uuid,
135        created_by: ActorRef,
136        run_id: Uuid,
137        base_commit_sha: impl AsRef<str>,
138        generation: u32,
139    ) -> Result<Self, String> {
140        let base_commit_sha = base_commit_sha.as_ref().parse()?;
141        Ok(Self {
142            header: Header::new(ObjectType::PatchSet, repo_id, created_by)?,
143            run_id,
144            generation,
145            base_commit_sha,
146            diff_format: DiffFormat::UnifiedDiff,
147            diff_artifact: None,
148            touched_files: Vec::new(),
149            rationale: None,
150            apply_status: ApplyStatus::Proposed,
151        })
152    }
153
154    pub fn header(&self) -> &Header {
155        &self.header
156    }
157
158    pub fn run_id(&self) -> Uuid {
159        self.run_id
160    }
161
162    pub fn generation(&self) -> u32 {
163        self.generation
164    }
165
166    pub fn base_commit_sha(&self) -> &IntegrityHash {
167        &self.base_commit_sha
168    }
169
170    pub fn diff_format(&self) -> &DiffFormat {
171        &self.diff_format
172    }
173
174    pub fn diff_artifact(&self) -> Option<&ArtifactRef> {
175        self.diff_artifact.as_ref()
176    }
177
178    pub fn touched_files(&self) -> &[TouchedFile] {
179        &self.touched_files
180    }
181
182    pub fn rationale(&self) -> Option<&str> {
183        self.rationale.as_deref()
184    }
185
186    pub fn apply_status(&self) -> &ApplyStatus {
187        &self.apply_status
188    }
189
190    pub fn set_diff_artifact(&mut self, diff_artifact: Option<ArtifactRef>) {
191        self.diff_artifact = diff_artifact;
192    }
193
194    pub fn add_touched_file(&mut self, file: TouchedFile) {
195        self.touched_files.push(file);
196    }
197
198    pub fn set_rationale(&mut self, rationale: Option<String>) {
199        self.rationale = rationale;
200    }
201
202    pub fn set_apply_status(&mut self, apply_status: ApplyStatus) {
203        self.apply_status = apply_status;
204    }
205}
206
207impl fmt::Display for PatchSet {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        write!(f, "PatchSet: {}", self.header.object_id())
210    }
211}
212
213impl ObjectTrait for PatchSet {
214    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
215    where
216        Self: Sized,
217    {
218        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
219    }
220
221    fn get_type(&self) -> ObjectType {
222        ObjectType::PatchSet
223    }
224
225    fn get_size(&self) -> usize {
226        serde_json::to_vec(self).map(|v| v.len()).unwrap_or(0)
227    }
228
229    fn to_data(&self) -> Result<Vec<u8>, GitError> {
230        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn test_hash_hex() -> String {
239        IntegrityHash::compute(b"ai-process-test").to_hex()
240    }
241
242    #[test]
243    fn test_patchset_creation() {
244        let repo_id = Uuid::from_u128(0x0123456789abcdef0123456789abcdef);
245        let actor = ActorRef::agent("test-agent").expect("actor");
246        let run_id = Uuid::from_u128(0x1);
247        let base_hash = test_hash_hex();
248
249        let patchset = PatchSet::new(repo_id, actor, run_id, &base_hash, 1).expect("patchset");
250
251        assert_eq!(patchset.header().object_type(), &ObjectType::PatchSet);
252        assert_eq!(patchset.generation(), 1);
253        assert_eq!(patchset.diff_format(), &DiffFormat::UnifiedDiff);
254        assert_eq!(patchset.apply_status(), &ApplyStatus::Proposed);
255        assert!(patchset.touched_files().is_empty());
256    }
257}