1#![deny(clippy::cast_possible_truncation)]
3
4use std::{
16 collections::HashMap,
17 time::{SystemTime, UNIX_EPOCH},
18};
19
20use objects::object::{State, StateId, Status};
21use serde::{Deserialize, Serialize};
22use sley::{ObjectId, Repository};
23
24use super::git_core::{GitProjectionError, GitProjectionResult, git_err};
25
26pub const NOTES_REF: &str = "refs/notes/heddle";
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct HeddleNote {
33 pub state_id: String,
34 pub change_id: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub source_state: Option<State>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub agent: Option<NoteAgent>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub confidence: Option<f32>,
41 pub status: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub omitted_annotations_breakdown: Option<OmittedBreakdown>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub signal_counts: Option<SignalCounts>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub attribution: Option<NoteAttribution>,
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct NoteAgent {
61 pub provider: String,
62 pub model: String,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
67pub struct OmittedBreakdown {
68 #[serde(default)]
69 pub internal: u32,
70 #[serde(default)]
71 pub team: u32,
72 #[serde(default)]
73 pub restricted: u32,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
78pub struct SignalCounts {
79 #[serde(default)]
80 pub novelty: u32,
81 #[serde(default)]
82 pub test_reachability: u32,
83 #[serde(default)]
84 pub pattern_deviation: u32,
85 #[serde(default)]
86 pub invariant_adjacency: u32,
87 #[serde(default)]
88 pub self_flagged_uncertainty: u32,
89}
90
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct NoteAttribution {
93 pub principal_name: String,
94 pub principal_email: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub agent: Option<NoteAgent>,
97}
98
99impl HeddleNote {
100 pub fn from_state(state: &State) -> Self {
102 let status = match state.status {
103 Status::Draft => "draft".to_string(),
104 Status::Published => "published".to_string(),
105 };
106 let agent = state.attribution.agent.as_ref().map(|a| NoteAgent {
107 provider: a.provider.clone(),
108 model: a.model.clone(),
109 });
110 Self {
111 state_id: state.id().to_string_full(),
112 change_id: state.change_id.to_string_full(),
113 source_state: Some(state.clone()),
114 agent,
115 confidence: state.confidence,
116 status,
117 omitted_annotations_breakdown: None,
118 signal_counts: None,
119 attribution: None,
120 }
121 }
122
123 pub fn with_omitted_breakdown(mut self, breakdown: OmittedBreakdown) -> Self {
125 self.omitted_annotations_breakdown = Some(breakdown);
126 self
127 }
128
129 pub fn with_signal_counts(mut self, counts: SignalCounts) -> Self {
131 self.signal_counts = Some(counts);
132 self
133 }
134
135 pub fn with_attribution(mut self, attribution: NoteAttribution) -> Self {
137 self.attribution = Some(attribution);
138 self
139 }
140
141 pub fn to_json_bytes(&self) -> GitProjectionResult<Vec<u8>> {
142 serde_json::to_vec_pretty(self)
143 .map_err(|e| GitProjectionError::Git(format!("note serialize: {e}")))
144 }
145
146 pub fn from_json_bytes(bytes: &[u8]) -> GitProjectionResult<Self> {
147 serde_json::from_slice(bytes)
148 .map_err(|e| GitProjectionError::Git(format!("note parse: {e}")))
149 }
150}
151
152fn notes_ref() -> sley::notes::NotesRef {
153 sley::notes::NotesRef::expand(NOTES_REF)
154}
155
156pub fn write_note(
161 repo: &Repository,
162 commit_oid: ObjectId,
163 note: &HeddleNote,
164) -> GitProjectionResult<()> {
165 let json = note.to_json_bytes()?;
166 let notes_ref = notes_ref();
167 let refs = repo.references();
168 sley::notes::upsert_note_bytes_for(
169 repo.git_dir(),
170 repo.object_format(),
171 &refs,
172 ¬es_ref,
173 &commit_oid,
174 &json,
175 "heddle: state metadata",
176 &git_projection_notes_identity(),
177 sley::notes::notes_ref_expected(&refs, ¬es_ref).map_err(git_err)?,
178 )
179 .map_err(git_err)?;
180 Ok(())
181}
182
183pub fn remove_notes(
198 repo: &Repository,
199 commit_oids: &std::collections::HashSet<ObjectId>,
200) -> GitProjectionResult<()> {
201 if commit_oids.is_empty() {
202 return Ok(());
203 }
204 let notes_ref = notes_ref();
205 let refs = repo.references();
206 let annotated: Vec<ObjectId> = commit_oids.iter().copied().collect();
207 sley::notes::remove_notes_for(
208 repo.git_dir(),
209 repo.object_format(),
210 &refs,
211 ¬es_ref,
212 &annotated,
213 "heddle: retract state metadata",
214 &git_projection_notes_identity(),
215 sley::notes::notes_ref_expected(&refs, ¬es_ref).map_err(git_err)?,
216 )
217 .map_err(git_err)?;
218 Ok(())
219}
220
221pub fn read_note(
223 repo: &Repository,
224 commit_oid: ObjectId,
225) -> GitProjectionResult<Option<HeddleNote>> {
226 let Some(bytes) = repo
227 .read_note_bytes(¬es_ref(), &commit_oid)
228 .map_err(git_err)?
229 else {
230 return Ok(None);
231 };
232 HeddleNote::from_json_bytes(&bytes).map(Some)
233}
234
235pub fn read_identity_mappings(repo: &Repository) -> GitProjectionResult<Vec<(StateId, ObjectId)>> {
237 read_all_notes(repo)?
238 .into_iter()
239 .map(|(oid, note)| {
240 Ok((
241 StateId::parse(¬e.state_id)
242 .map_err(|error| GitProjectionError::InvalidMapping(error.to_string()))?,
243 oid,
244 ))
245 })
246 .collect()
247}
248
249pub fn read_all_notes(repo: &Repository) -> GitProjectionResult<HashMap<ObjectId, HeddleNote>> {
251 let mut out = HashMap::new();
252 for note_entry in repo.list_notes(¬es_ref()).map_err(git_err)? {
253 let object = repo.read_object(¬e_entry.blob).map_err(git_err)?;
254 if object.object_type != sley::GitObjectType::Blob {
257 continue;
258 }
259 if let Ok(note) = HeddleNote::from_json_bytes(&object.body) {
260 out.insert(note_entry.annotated, note);
261 }
262 }
263 Ok(out)
264}
265
266fn git_projection_notes_identity() -> sley::notes::NotesCommitIdentity {
267 let seconds = SystemTime::now()
268 .duration_since(UNIX_EPOCH)
269 .map(|d| d.as_secs() as i64)
270 .unwrap_or(0);
271 let ident = format!("Heddle <heddle@local> {seconds} +0000").into_bytes();
272 sley::notes::NotesCommitIdentity {
273 author: ident.clone(),
274 committer: ident,
275 }
276}