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
286pub type ProposalId = String;
301
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304pub struct AcceptanceProposal {
305 pub proposal_id: ProposalId,
306 pub issue_id: IssueId,
307 pub acceptance: Acceptance,
308 #[serde(default, skip_serializing_if = "String::is_empty")]
311 pub rationale: String,
312 #[serde(default, skip_serializing_if = "String::is_empty")]
315 pub proposed_by: String,
316 pub created_at: u64,
317}
318
319impl AcceptanceProposal {
320 pub fn new(
321 issue_id: impl Into<IssueId>,
322 acceptance: Acceptance,
323 rationale: impl Into<String>,
324 proposed_by: impl Into<String>,
325 ) -> Self {
326 let now = SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .map(|d| d.as_secs())
329 .unwrap_or(0);
330 Self::with_timestamp(issue_id, acceptance, rationale, proposed_by, now)
331 }
332
333 pub fn with_timestamp(
334 issue_id: impl Into<IssueId>,
335 acceptance: Acceptance,
336 rationale: impl Into<String>,
337 proposed_by: impl Into<String>,
338 created_at: u64,
339 ) -> Self {
340 let issue_id = issue_id.into();
341 let proposal_id = compute_proposal_id(&issue_id, &acceptance);
342 Self {
343 proposal_id,
344 issue_id,
345 acceptance,
346 rationale: rationale.into(),
347 proposed_by: proposed_by.into(),
348 created_at,
349 }
350 }
351
352 pub fn id_is_consistent(&self) -> bool {
353 self.proposal_id == compute_proposal_id(&self.issue_id, &self.acceptance)
354 }
355}
356
357fn compute_proposal_id(issue_id: &str, acceptance: &Acceptance) -> ProposalId {
360 #[derive(Serialize)]
361 struct View<'a> {
362 proposal_for: &'a str,
363 acceptance: &'a Acceptance,
364 }
365 canonical::hash(&View { proposal_for: issue_id, acceptance })
366}
367
368impl IssueLog {
369 fn proposals_dir(&self) -> io::Result<PathBuf> {
370 let dir = self.dir.join("proposals");
371 fs::create_dir_all(&dir)?;
372 Ok(dir)
373 }
374
375 pub fn put_proposal(&self, p: &AcceptanceProposal) -> io::Result<()> {
377 let path = self.proposals_dir()?.join(format!("{}.json", p.proposal_id));
378 if path.exists() {
379 return Ok(());
380 }
381 let bytes = serde_json::to_vec(p)
382 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
383 let tmp = path.with_extension("json.tmp");
384 let mut f = fs::File::create(&tmp)?;
385 f.write_all(&bytes)?;
386 f.sync_all()?;
387 fs::rename(&tmp, &path)?;
388 Ok(())
389 }
390
391 pub fn get_proposal(&self, id: &ProposalId) -> io::Result<Option<AcceptanceProposal>> {
392 let path = self.proposals_dir()?.join(format!("{id}.json"));
393 if !path.exists() {
394 return Ok(None);
395 }
396 let bytes = fs::read(&path)?;
397 serde_json::from_slice(&bytes)
398 .map(Some)
399 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
400 }
401
402 pub fn proposals_for(&self, issue_id: &IssueId) -> io::Result<Vec<AcceptanceProposal>> {
404 let mut out = Vec::new();
405 for entry in fs::read_dir(self.proposals_dir()?)? {
406 let path = entry?.path();
407 if path.extension().and_then(|e| e.to_str()) != Some("json") {
408 continue;
409 }
410 let bytes = fs::read(&path)?;
411 let p: AcceptanceProposal = serde_json::from_slice(&bytes)
412 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
413 if &p.issue_id == issue_id {
414 out.push(p);
415 }
416 }
417 out.sort_by(|a, b| (a.created_at, &a.proposal_id).cmp(&(b.created_at, &b.proposal_id)));
418 Ok(out)
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn gcd_delta() -> Acceptance {
427 Acceptance::TypedDelta {
428 api: vec![ApiEntry {
429 name: "gcd".into(),
430 signature: "(Int, Int) -> Int".into(),
431 kind: ApiChangeKind::Added,
432 }],
433 examples: vec!["gcd(12, 8) == 4".into()],
434 }
435 }
436
437 #[test]
438 fn same_content_hashes_equal_regardless_of_timestamp() {
439 let a = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 1);
440 let b = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 999);
441 assert_eq!(a.issue_id, b.issue_id, "created_at must not affect identity");
442 }
443
444 #[test]
445 fn different_acceptance_hashes_differ() {
446 let a = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
447 let b = Issue::with_timestamp(
448 "x", "", Acceptance::FailingExample { example: "gcd(12, 8) == 4".into() },
449 None, BTreeSet::new(), None, 1,
450 );
451 assert_ne!(a.issue_id, b.issue_id);
452 }
453
454 #[test]
455 fn shape_tag_round_trips_through_json() {
456 let i = Issue::with_timestamp("x", "b", gcd_delta(), Some("op_1".into()), BTreeSet::new(), None, 1);
457 let json = serde_json::to_string(&i).unwrap();
458 assert!(json.contains("\"shape\":\"typed_delta\""), "{json}");
459 let back: Issue = serde_json::from_str(&json).unwrap();
460 assert_eq!(back, i);
461 let ff = Issue::with_timestamp("y", "", Acceptance::FreeForm {}, None, BTreeSet::new(), None, 1);
462 let json = serde_json::to_string(&ff).unwrap();
463 assert!(json.contains("\"shape\":\"free_form\""), "{json}");
464 assert!(!ff.acceptance.is_machine_evaluable());
465 assert!(i.acceptance.is_machine_evaluable());
466 }
467
468 #[test]
469 fn log_put_get_list_and_idempotent_put() {
470 let tmp = tempfile::tempdir().unwrap();
471 let log = IssueLog::open(tmp.path()).unwrap();
472 let i = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
473 log.put(&i).unwrap();
474 log.put(&i).unwrap(); assert_eq!(log.get(&i.issue_id).unwrap(), Some(i.clone()));
476 assert_eq!(log.list_ids().unwrap(), vec![i.issue_id.clone()]);
477 assert_eq!(log.get(&"missing".to_string()).unwrap(), None);
478 }
479
480 #[test]
481 fn proposal_identity_is_issue_plus_acceptance() {
482 let a = AcceptanceProposal::with_timestamp("iss", gcd_delta(), "why", "qwen", 1);
483 let b = AcceptanceProposal::with_timestamp("iss", gcd_delta(), "other reason", "human", 9);
484 assert_eq!(a.proposal_id, b.proposal_id, "rationale/proposer/time are not identity");
485 let c = AcceptanceProposal::with_timestamp("other", gcd_delta(), "why", "qwen", 1);
486 assert_ne!(a.proposal_id, c.proposal_id, "a proposal is for one issue");
487 assert!(a.id_is_consistent());
488 }
489
490 #[test]
491 fn proposals_round_trip_and_do_not_leak_into_issue_ids() {
492 let dir = tempfile::tempdir().unwrap();
493 let log = IssueLog::open(dir.path()).unwrap();
494 let issue = Issue::with_timestamp("vague", "", Acceptance::FreeForm {}, None, BTreeSet::new(), None, 1);
495 log.put(&issue).unwrap();
496 let p = AcceptanceProposal::with_timestamp(issue.issue_id.clone(), gcd_delta(), "", "", 2);
497 log.put_proposal(&p).unwrap();
498 log.put_proposal(&p).unwrap();
499 assert_eq!(log.get_proposal(&p.proposal_id).unwrap(), Some(p.clone()));
500 assert_eq!(log.proposals_for(&issue.issue_id).unwrap(), vec![p]);
501 assert!(log.proposals_for(&"nope".to_string()).unwrap().is_empty());
502 assert_eq!(log.list_ids().unwrap(), vec![issue.issue_id]);
504 }
505}