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 }
}
pub fn computed_id(&self) -> IssueId {
compute_issue_id(
&self.title,
&self.body,
&self.acceptance,
self.base.as_deref(),
&self.deps,
self.project.as_deref(),
)
}
pub fn id_is_consistent(&self) -> bool {
self.issue_id == self.computed_id()
}
}
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)
}
}
pub type ProposalId = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcceptanceProposal {
pub proposal_id: ProposalId,
pub issue_id: IssueId,
pub acceptance: Acceptance,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub rationale: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub proposed_by: String,
pub created_at: u64,
}
impl AcceptanceProposal {
pub fn new(
issue_id: impl Into<IssueId>,
acceptance: Acceptance,
rationale: impl Into<String>,
proposed_by: impl Into<String>,
) -> Self {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Self::with_timestamp(issue_id, acceptance, rationale, proposed_by, now)
}
pub fn with_timestamp(
issue_id: impl Into<IssueId>,
acceptance: Acceptance,
rationale: impl Into<String>,
proposed_by: impl Into<String>,
created_at: u64,
) -> Self {
let issue_id = issue_id.into();
let proposal_id = compute_proposal_id(&issue_id, &acceptance);
Self {
proposal_id,
issue_id,
acceptance,
rationale: rationale.into(),
proposed_by: proposed_by.into(),
created_at,
}
}
pub fn id_is_consistent(&self) -> bool {
self.proposal_id == compute_proposal_id(&self.issue_id, &self.acceptance)
}
}
fn compute_proposal_id(issue_id: &str, acceptance: &Acceptance) -> ProposalId {
#[derive(Serialize)]
struct View<'a> {
proposal_for: &'a str,
acceptance: &'a Acceptance,
}
canonical::hash(&View { proposal_for: issue_id, acceptance })
}
impl IssueLog {
fn proposals_dir(&self) -> io::Result<PathBuf> {
let dir = self.dir.join("proposals");
fs::create_dir_all(&dir)?;
Ok(dir)
}
pub fn put_proposal(&self, p: &AcceptanceProposal) -> io::Result<()> {
let path = self.proposals_dir()?.join(format!("{}.json", p.proposal_id));
if path.exists() {
return Ok(());
}
let bytes = serde_json::to_vec(p)
.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_proposal(&self, id: &ProposalId) -> io::Result<Option<AcceptanceProposal>> {
let path = self.proposals_dir()?.join(format!("{id}.json"));
if !path.exists() {
return Ok(None);
}
let bytes = fs::read(&path)?;
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
pub fn proposals_for(&self, issue_id: &IssueId) -> io::Result<Vec<AcceptanceProposal>> {
let mut out = Vec::new();
for entry in fs::read_dir(self.proposals_dir()?)? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let bytes = fs::read(&path)?;
let p: AcceptanceProposal = serde_json::from_slice(&bytes)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
if &p.issue_id == issue_id {
out.push(p);
}
}
out.sort_by(|a, b| (a.created_at, &a.proposal_id).cmp(&(b.created_at, &b.proposal_id)));
Ok(out)
}
}
#[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);
}
#[test]
fn proposal_identity_is_issue_plus_acceptance() {
let a = AcceptanceProposal::with_timestamp("iss", gcd_delta(), "why", "qwen", 1);
let b = AcceptanceProposal::with_timestamp("iss", gcd_delta(), "other reason", "human", 9);
assert_eq!(a.proposal_id, b.proposal_id, "rationale/proposer/time are not identity");
let c = AcceptanceProposal::with_timestamp("other", gcd_delta(), "why", "qwen", 1);
assert_ne!(a.proposal_id, c.proposal_id, "a proposal is for one issue");
assert!(a.id_is_consistent());
}
#[test]
fn proposals_round_trip_and_do_not_leak_into_issue_ids() {
let dir = tempfile::tempdir().unwrap();
let log = IssueLog::open(dir.path()).unwrap();
let issue = Issue::with_timestamp("vague", "", Acceptance::FreeForm {}, None, BTreeSet::new(), None, 1);
log.put(&issue).unwrap();
let p = AcceptanceProposal::with_timestamp(issue.issue_id.clone(), gcd_delta(), "", "", 2);
log.put_proposal(&p).unwrap();
log.put_proposal(&p).unwrap();
assert_eq!(log.get_proposal(&p.proposal_id).unwrap(), Some(p.clone()));
assert_eq!(log.proposals_for(&issue.issue_id).unwrap(), vec![p]);
assert!(log.proposals_for(&"nope".to_string()).unwrap().is_empty());
assert_eq!(log.list_ids().unwrap(), vec![issue.issue_id]);
}
}