use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::canonical;
pub type IssueId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ApiChangeKind {
#[default]
Added,
Changed,
Removed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiEntry {
pub name: String,
pub signature: String,
#[serde(default)]
pub kind: ApiChangeKind,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "shape", rename_all = "snake_case")]
pub enum Acceptance {
TypedDelta {
api: Vec<ApiEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
examples: Vec<String>,
},
FailingExample { example: String },
MetricInvariant { predicate: String, window: String },
Evidence {
subject: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
invariants: Vec<String>,
},
FreeForm {},
}
impl Acceptance {
pub fn shape(&self) -> &'static str {
match self {
Acceptance::TypedDelta { .. } => "typed_delta",
Acceptance::FailingExample { .. } => "failing_example",
Acceptance::MetricInvariant { .. } => "metric_invariant",
Acceptance::Evidence { .. } => "evidence",
Acceptance::FreeForm {} => "free_form",
}
}
pub fn is_machine_evaluable(&self) -> bool {
!matches!(self, Acceptance::FreeForm {})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Issue {
pub issue_id: IssueId,
pub title: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub body: String,
pub acceptance: Acceptance,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub deps: BTreeSet<IssueId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
pub created_at: u64,
}
impl Issue {
pub fn new(
title: impl Into<String>,
body: impl Into<String>,
acceptance: Acceptance,
base: Option<String>,
deps: BTreeSet<IssueId>,
project: Option<String>,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self::with_timestamp(title, body, acceptance, base, deps, project, now)
}
#[allow(clippy::too_many_arguments)]
pub fn with_timestamp(
title: impl Into<String>,
body: impl Into<String>,
acceptance: Acceptance,
base: Option<String>,
deps: BTreeSet<IssueId>,
project: Option<String>,
created_at: u64,
) -> Self {
let title = title.into();
let body = body.into();
let issue_id = compute_issue_id(
&title,
&body,
&acceptance,
base.as_deref(),
&deps,
project.as_deref(),
);
Self { issue_id, title, body, acceptance, base, deps, project, created_at }
}
}
fn compute_issue_id(
title: &str,
body: &str,
acceptance: &Acceptance,
base: Option<&str>,
deps: &BTreeSet<IssueId>,
project: Option<&str>,
) -> IssueId {
let view = CanonicalIssueView { title, body, acceptance, base, deps, project };
canonical::hash(&view)
}
#[derive(Serialize)]
struct CanonicalIssueView<'a> {
title: &'a str,
body: &'a str,
acceptance: &'a Acceptance,
#[serde(skip_serializing_if = "Option::is_none")]
base: Option<&'a str>,
#[serde(skip_serializing_if = "BTreeSet::is_empty")]
deps: &'a BTreeSet<IssueId>,
#[serde(skip_serializing_if = "Option::is_none")]
project: Option<&'a str>,
}
pub struct IssueLog {
dir: PathBuf,
}
impl IssueLog {
pub fn open(root: &Path) -> io::Result<Self> {
let dir = root.join("issues");
fs::create_dir_all(&dir)?;
Ok(Self { dir })
}
fn path(&self, id: &IssueId) -> PathBuf {
self.dir.join(format!("{id}.json"))
}
pub fn put(&self, issue: &Issue) -> io::Result<()> {
let path = self.path(&issue.issue_id);
if path.exists() {
return Ok(());
}
let bytes = serde_json::to_vec(issue)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("json.tmp");
let mut f = fs::File::create(&tmp)?;
f.write_all(&bytes)?;
f.sync_all()?;
fs::rename(&tmp, &path)?;
Ok(())
}
pub fn get(&self, id: &IssueId) -> io::Result<Option<Issue>> {
let path = self.path(id);
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path)?;
let issue: Issue = serde_json::from_slice(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(Some(issue))
}
pub fn list_ids(&self) -> io::Result<Vec<IssueId>> {
let mut ids = Vec::new();
for entry in fs::read_dir(&self.dir)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
ids.push(stem.to_string());
}
}
ids.sort();
Ok(ids)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gcd_delta() -> Acceptance {
Acceptance::TypedDelta {
api: vec![ApiEntry {
name: "gcd".into(),
signature: "(Int, Int) -> Int".into(),
kind: ApiChangeKind::Added,
}],
examples: vec!["gcd(12, 8) == 4".into()],
}
}
#[test]
fn same_content_hashes_equal_regardless_of_timestamp() {
let a = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 1);
let b = Issue::with_timestamp("add gcd", "", gcd_delta(), None, BTreeSet::new(), None, 999);
assert_eq!(a.issue_id, b.issue_id, "created_at must not affect identity");
}
#[test]
fn different_acceptance_hashes_differ() {
let a = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
let b = Issue::with_timestamp(
"x", "", Acceptance::FailingExample { example: "gcd(12, 8) == 4".into() },
None, BTreeSet::new(), None, 1,
);
assert_ne!(a.issue_id, b.issue_id);
}
#[test]
fn shape_tag_round_trips_through_json() {
let i = Issue::with_timestamp("x", "b", gcd_delta(), Some("op_1".into()), BTreeSet::new(), None, 1);
let json = serde_json::to_string(&i).unwrap();
assert!(json.contains("\"shape\":\"typed_delta\""), "{json}");
let back: Issue = serde_json::from_str(&json).unwrap();
assert_eq!(back, i);
let ff = Issue::with_timestamp("y", "", Acceptance::FreeForm {}, None, BTreeSet::new(), None, 1);
let json = serde_json::to_string(&ff).unwrap();
assert!(json.contains("\"shape\":\"free_form\""), "{json}");
assert!(!ff.acceptance.is_machine_evaluable());
assert!(i.acceptance.is_machine_evaluable());
}
#[test]
fn log_put_get_list_and_idempotent_put() {
let tmp = tempfile::tempdir().unwrap();
let log = IssueLog::open(tmp.path()).unwrap();
let i = Issue::with_timestamp("x", "", gcd_delta(), None, BTreeSet::new(), None, 1);
log.put(&i).unwrap();
log.put(&i).unwrap(); assert_eq!(log.get(&i.issue_id).unwrap(), Some(i.clone()));
assert_eq!(log.list_ids().unwrap(), vec![i.issue_id.clone()]);
assert_eq!(log.get(&"missing".to_string()).unwrap(), None);
}
}