1use serde::{Deserialize, Serialize};
21use std::collections::BTreeSet;
22use std::fs;
23use std::io::{self, Write};
24use std::path::{Path, PathBuf};
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use crate::canonical;
28
29pub type IssueId = String;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
36#[serde(rename_all = "snake_case")]
37pub enum ApiChangeKind {
38 #[default]
39 Added,
40 Changed,
41 Removed,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ApiEntry {
49 pub name: String,
50 pub signature: String,
51 #[serde(default)]
52 pub kind: ApiChangeKind,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "shape", rename_all = "snake_case")]
59pub enum Acceptance {
60 TypedDelta {
65 api: Vec<ApiEntry>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 examples: Vec<String>,
68 },
69 FailingExample { example: String },
72 MetricInvariant { predicate: String, window: String },
76 Evidence {
79 subject: String,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
81 invariants: Vec<String>,
82 },
83 FreeForm {},
85}
86
87impl Acceptance {
88 pub fn shape(&self) -> &'static str {
90 match self {
91 Acceptance::TypedDelta { .. } => "typed_delta",
92 Acceptance::FailingExample { .. } => "failing_example",
93 Acceptance::MetricInvariant { .. } => "metric_invariant",
94 Acceptance::Evidence { .. } => "evidence",
95 Acceptance::FreeForm {} => "free_form",
96 }
97 }
98
99 pub fn is_machine_evaluable(&self) -> bool {
102 !matches!(self, Acceptance::FreeForm {})
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Issue {
109 pub issue_id: IssueId,
110 pub title: String,
111 #[serde(default, skip_serializing_if = "String::is_empty")]
113 pub body: String,
114 pub acceptance: Acceptance,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub base: Option<String>,
119 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
121 pub deps: BTreeSet<IssueId>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub project: Option<String>,
125 pub created_at: u64,
127}
128
129impl Issue {
130 pub fn new(
134 title: impl Into<String>,
135 body: impl Into<String>,
136 acceptance: Acceptance,
137 base: Option<String>,
138 deps: BTreeSet<IssueId>,
139 project: Option<String>,
140 ) -> Self {
141 let now = SystemTime::now()
142 .duration_since(UNIX_EPOCH)
143 .map(|d| d.as_secs())
144 .unwrap_or(0);
145 Self::with_timestamp(title, body, acceptance, base, deps, project, now)
146 }
147
148 #[allow(clippy::too_many_arguments)]
149 pub fn with_timestamp(
150 title: impl Into<String>,
151 body: impl Into<String>,
152 acceptance: Acceptance,
153 base: Option<String>,
154 deps: BTreeSet<IssueId>,
155 project: Option<String>,
156 created_at: u64,
157 ) -> Self {
158 let title = title.into();
159 let body = body.into();
160 let issue_id = compute_issue_id(
161 &title,
162 &body,
163 &acceptance,
164 base.as_deref(),
165 &deps,
166 project.as_deref(),
167 );
168 Self { issue_id, title, body, acceptance, base, deps, project, created_at }
169 }
170
171 pub fn computed_id(&self) -> IssueId {
176 compute_issue_id(
177 &self.title,
178 &self.body,
179 &self.acceptance,
180 self.base.as_deref(),
181 &self.deps,
182 self.project.as_deref(),
183 )
184 }
185
186 pub fn id_is_consistent(&self) -> bool {
188 self.issue_id == self.computed_id()
189 }
190}
191
192fn compute_issue_id(
193 title: &str,
194 body: &str,
195 acceptance: &Acceptance,
196 base: Option<&str>,
197 deps: &BTreeSet<IssueId>,
198 project: Option<&str>,
199) -> IssueId {
200 let view = CanonicalIssueView { title, body, acceptance, base, deps, project };
201 canonical::hash(&view)
202}
203
204#[derive(Serialize)]
207struct CanonicalIssueView<'a> {
208 title: &'a str,
209 body: &'a str,
210 acceptance: &'a Acceptance,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 base: Option<&'a str>,
213 #[serde(skip_serializing_if = "BTreeSet::is_empty")]
214 deps: &'a BTreeSet<IssueId>,
215 #[serde(skip_serializing_if = "Option::is_none")]
216 project: Option<&'a str>,
217}
218
219pub struct IssueLog {
225 dir: PathBuf,
226}
227
228impl IssueLog {
229 pub fn open(root: &Path) -> io::Result<Self> {
230 let dir = root.join("issues");
231 fs::create_dir_all(&dir)?;
232 Ok(Self { dir })
233 }
234
235 fn path(&self, id: &IssueId) -> PathBuf {
236 self.dir.join(format!("{id}.json"))
237 }
238
239 pub fn put(&self, issue: &Issue) -> io::Result<()> {
242 let path = self.path(&issue.issue_id);
243 if path.exists() {
244 return Ok(());
245 }
246 let bytes = serde_json::to_vec(issue)
247 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
248 let tmp = path.with_extension("json.tmp");
249 let mut f = fs::File::create(&tmp)?;
250 f.write_all(&bytes)?;
251 f.sync_all()?;
252 fs::rename(&tmp, &path)?;
253 Ok(())
254 }
255
256 pub fn get(&self, id: &IssueId) -> io::Result<Option<Issue>> {
257 let path = self.path(id);
258 if !path.exists() {
259 return Ok(None);
260 }
261 let bytes = fs::read(&path)?;
262 let issue: Issue = serde_json::from_slice(&bytes)
263 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
264 Ok(Some(issue))
265 }
266
267 pub fn list_ids(&self) -> io::Result<Vec<IssueId>> {
271 let mut ids = Vec::new();
272 for entry in fs::read_dir(&self.dir)? {
273 let path = entry?.path();
274 if path.extension().and_then(|e| e.to_str()) != Some("json") {
275 continue;
276 }
277 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
278 ids.push(stem.to_string());
279 }
280 }
281 ids.sort();
282 Ok(ids)
283 }
284}
285
286#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn gcd_delta() -> Acceptance {
293 Acceptance::TypedDelta {
294 api: vec![ApiEntry {
295 name: "gcd".into(),
296 signature: "(Int, Int) -> Int".into(),
297 kind: ApiChangeKind::Added,
298 }],
299 examples: vec!["gcd(12, 8) == 4".into()],
300 }
301 }
302
303 #[test]
304 fn same_content_hashes_equal_regardless_of_timestamp() {
305 let a = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 1);
306 let b = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 999);
307 assert_eq!(a.issue_id, b.issue_id, "created_at must not affect identity");
308 }
309
310 #[test]
311 fn different_acceptance_hashes_differ() {
312 let a = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
313 let b = Issue::with_timestamp(
314 "x", "", Acceptance::FailingExample { example: "gcd(12, 8) == 4".into() },
315 None, BTreeSet::new(), None, 1,
316 );
317 assert_ne!(a.issue_id, b.issue_id);
318 }
319
320 #[test]
321 fn shape_tag_round_trips_through_json() {
322 let i = Issue::with_timestamp("x", "b", gcd_delta(), Some("op_1".into()), BTreeSet::new(), None, 1);
323 let json = serde_json::to_string(&i).unwrap();
324 assert!(json.contains("\"shape\":\"typed_delta\""), "{json}");
325 let back: Issue = serde_json::from_str(&json).unwrap();
326 assert_eq!(back, i);
327 let ff = Issue::with_timestamp("y", "", Acceptance::FreeForm {}, None, BTreeSet::new(), None, 1);
328 let json = serde_json::to_string(&ff).unwrap();
329 assert!(json.contains("\"shape\":\"free_form\""), "{json}");
330 assert!(!ff.acceptance.is_machine_evaluable());
331 assert!(i.acceptance.is_machine_evaluable());
332 }
333
334 #[test]
335 fn log_put_get_list_and_idempotent_put() {
336 let tmp = tempfile::tempdir().unwrap();
337 let log = IssueLog::open(tmp.path()).unwrap();
338 let i = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
339 log.put(&i).unwrap();
340 log.put(&i).unwrap(); assert_eq!(log.get(&i.issue_id).unwrap(), Some(i.clone()));
342 assert_eq!(log.list_ids().unwrap(), vec![i.issue_id.clone()]);
343 assert_eq!(log.get(&"missing".to_string()).unwrap(), None);
344 }
345}