Skip to main content

git_internal/internal/object/
patchset.rs

1//! AI PatchSet snapshot.
2//!
3//! `PatchSet` stores one immutable candidate diff produced during a
4//! `Run`.
5//!
6//! # How to use this object
7//!
8//! - Create one `PatchSet` per candidate diff worth retaining.
9//! - Use `sequence` to preserve ordering between multiple candidates in
10//!   the same run.
11//! - Attach diff artifacts, touched files, and rationale before
12//!   persistence.
13//!
14//! # How it works with other objects
15//!
16//! - `Run` is the canonical owner through `PatchSet.run`.
17//! - `Evidence` may validate a specific patchset via `patchset_id`.
18//! - `Decision` selects the chosen patchset, if any.
19//!
20//! # How Libra should call it
21//!
22//! Libra should use `PatchSet` as immutable staging history. Acceptance,
23//! rejection, or promotion to repository commit should be represented by
24//! `Decision` and Libra projections rather than by mutating the
25//! `PatchSet`.
26
27use std::fmt;
28
29use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32use crate::{
33    errors::GitError,
34    hash::ObjectHash,
35    internal::object::{
36        ObjectTrait,
37        integrity::IntegrityHash,
38        types::{ActorRef, ArtifactRef, Header, ObjectType},
39    },
40};
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "snake_case")]
44pub enum DiffFormat {
45    UnifiedDiff,
46    GitDiff,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51pub enum ChangeType {
52    Add,
53    Modify,
54    Delete,
55    Rename,
56    Copy,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct TouchedFile {
62    /// Repository-relative path affected by the candidate diff.
63    pub path: String,
64    /// Coarse change category for the touched file.
65    pub change_type: ChangeType,
66    /// Number of added lines attributed to this file in the patch.
67    pub lines_added: u32,
68    /// Number of deleted lines attributed to this file in the patch.
69    pub lines_deleted: u32,
70}
71
72impl TouchedFile {
73    /// Create one touched-file summary entry for a patchset.
74    pub fn new(
75        path: impl Into<String>,
76        change_type: ChangeType,
77        lines_added: u32,
78        lines_deleted: u32,
79    ) -> Result<Self, String> {
80        let path = path.into();
81        if path.trim().is_empty() {
82            return Err("path cannot be empty".to_string());
83        }
84        Ok(Self {
85            path,
86            change_type,
87            lines_added,
88            lines_deleted,
89        })
90    }
91}
92
93/// Immutable candidate diff snapshot for one `Run`.
94///
95/// A `PatchSet` stores the proposed change and its metadata, while the
96/// higher-level verdict about whether that change is accepted lives
97/// elsewhere.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(deny_unknown_fields)]
100pub struct PatchSet {
101    /// Common object header carrying the immutable object id, type,
102    /// creator, and timestamps.
103    #[serde(flatten)]
104    header: Header,
105    /// Canonical owning run for this candidate diff.
106    run: Uuid,
107    /// Ordering of this candidate among patchsets produced by the same
108    /// run.
109    sequence: u32,
110    /// Repository integrity hash representing the diff baseline or
111    /// associated commit context.
112    commit: IntegrityHash,
113    /// Diff serialization format used for the stored patch candidate.
114    format: DiffFormat,
115    /// Optional artifact pointer to the full diff payload.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    artifact: Option<ArtifactRef>,
118    /// File-level summary of paths touched by the candidate diff.
119    #[serde(default)]
120    touched: Vec<TouchedFile>,
121    /// Optional human-readable rationale for why this candidate was
122    /// generated.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    rationale: Option<String>,
125}
126
127impl PatchSet {
128    /// Create a new patchset candidate for the given run.
129    pub fn new(created_by: ActorRef, run: Uuid, commit: impl AsRef<str>) -> Result<Self, String> {
130        let commit = commit.as_ref().parse()?;
131        Ok(Self {
132            header: Header::new(ObjectType::PatchSet, created_by)?,
133            run,
134            sequence: 0,
135            commit,
136            format: DiffFormat::UnifiedDiff,
137            artifact: None,
138            touched: Vec::new(),
139            rationale: None,
140        })
141    }
142
143    /// Return the immutable header for this patchset.
144    pub fn header(&self) -> &Header {
145        &self.header
146    }
147
148    /// Return the canonical owning run id.
149    pub fn run(&self) -> Uuid {
150        self.run
151    }
152
153    /// Return the patchset ordering number within the run.
154    pub fn sequence(&self) -> u32 {
155        self.sequence
156    }
157
158    /// Set the patchset ordering number before persistence.
159    pub fn set_sequence(&mut self, sequence: u32) {
160        self.sequence = sequence;
161    }
162
163    /// Return the associated integrity hash.
164    pub fn commit(&self) -> &IntegrityHash {
165        &self.commit
166    }
167
168    /// Return the diff serialization format.
169    pub fn format(&self) -> &DiffFormat {
170        &self.format
171    }
172
173    /// Return the diff artifact pointer, if present.
174    pub fn artifact(&self) -> Option<&ArtifactRef> {
175        self.artifact.as_ref()
176    }
177
178    /// Return the touched-file summary entries.
179    pub fn touched(&self) -> &[TouchedFile] {
180        &self.touched
181    }
182
183    /// Return the human-readable patch rationale, if present.
184    pub fn rationale(&self) -> Option<&str> {
185        self.rationale.as_deref()
186    }
187
188    /// Set or clear the diff artifact pointer.
189    pub fn set_artifact(&mut self, artifact: Option<ArtifactRef>) {
190        self.artifact = artifact;
191    }
192
193    /// Append one touched-file summary entry.
194    pub fn add_touched(&mut self, file: TouchedFile) {
195        self.touched.push(file);
196    }
197
198    /// Set or clear the human-readable rationale.
199    pub fn set_rationale(&mut self, rationale: Option<String>) {
200        self.rationale = rationale;
201    }
202}
203
204impl fmt::Display for PatchSet {
205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206        write!(f, "PatchSet: {}", self.header.object_id())
207    }
208}
209
210impl ObjectTrait for PatchSet {
211    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
212    where
213        Self: Sized,
214    {
215        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
216    }
217
218    fn get_type(&self) -> ObjectType {
219        ObjectType::PatchSet
220    }
221
222    fn get_size(&self) -> usize {
223        match serde_json::to_vec(self) {
224            Ok(v) => v.len(),
225            Err(e) => {
226                tracing::warn!("failed to compute PatchSet size: {}", e);
227                0
228            }
229        }
230    }
231
232    fn to_data(&self) -> Result<Vec<u8>, GitError> {
233        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    // Coverage:
242    // - patchset creation defaults
243    // - canonical run link, ordering default, and diff-format default
244
245    fn test_hash_hex() -> String {
246        IntegrityHash::compute(b"ai-process-test").to_hex()
247    }
248
249    #[test]
250    fn test_patchset_creation() {
251        let actor = ActorRef::agent("test-agent").expect("actor");
252        let run = Uuid::from_u128(0x1);
253        let base_hash = test_hash_hex();
254
255        let patchset = PatchSet::new(actor, run, &base_hash).expect("patchset");
256
257        assert_eq!(patchset.header().object_type(), &ObjectType::PatchSet);
258        assert_eq!(patchset.run(), run);
259        assert_eq!(patchset.sequence(), 0);
260        assert_eq!(patchset.format(), &DiffFormat::UnifiedDiff);
261        assert!(patchset.touched().is_empty());
262    }
263}