use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
fn normalized_repro_token(input: &str) -> String {
input.trim().to_ascii_lowercase().replace('-', "_")
}
pub const REPRO_PACK_SCHEMA_V1: &str = "ee.repro_pack.v1";
pub const REPRO_ENV_SCHEMA_V1: &str = "ee.repro_pack.env.v1";
pub const REPRO_MANIFEST_SCHEMA_V1: &str = "ee.repro_pack.manifest.v1";
pub const REPRO_LOCK_SCHEMA_V1: &str = "ee.repro_pack.lock.v1";
pub const REPRO_PROVENANCE_SCHEMA_V1: &str = "ee.repro_pack.provenance.v1";
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReproEnv {
pub schema: &'static str,
pub os: String,
pub arch: String,
pub rust_version: Option<String>,
pub git_commit: Option<String>,
pub git_branch: Option<String>,
pub git_clean: Option<bool>,
pub captured_at: String,
pub env_vars: BTreeMap<String, String>,
pub tool_versions: BTreeMap<String, String>,
}
impl ReproEnv {
#[must_use]
pub fn new(
os: impl Into<String>,
arch: impl Into<String>,
captured_at: impl Into<String>,
) -> Self {
Self {
schema: REPRO_ENV_SCHEMA_V1,
os: os.into(),
arch: arch.into(),
captured_at: captured_at.into(),
..Default::default()
}
}
#[must_use]
pub fn with_rust_version(mut self, version: impl Into<String>) -> Self {
self.rust_version = Some(version.into());
self
}
#[must_use]
pub fn with_git_commit(mut self, commit: impl Into<String>) -> Self {
self.git_commit = Some(commit.into());
self
}
#[must_use]
pub fn with_git_branch(mut self, branch: impl Into<String>) -> Self {
self.git_branch = Some(branch.into());
self
}
#[must_use]
pub fn with_git_clean(mut self, clean: bool) -> Self {
self.git_clean = Some(clean);
self
}
#[must_use]
pub fn with_env_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env_vars.insert(key.into(), value.into());
self
}
#[must_use]
pub fn with_tool_version(
mut self,
tool: impl Into<String>,
version: impl Into<String>,
) -> Self {
self.tool_versions.insert(tool.into(), version.into());
self
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReproManifest {
pub schema: &'static str,
pub name: String,
pub version: String,
pub description: Option<String>,
pub claim_id: Option<String>,
pub demo_id: Option<String>,
pub artifacts: Vec<ReproArtifact>,
pub created_at: String,
pub pack_hash: Option<String>,
}
impl ReproManifest {
#[must_use]
pub fn new(
name: impl Into<String>,
version: impl Into<String>,
created_at: impl Into<String>,
) -> Self {
Self {
schema: REPRO_MANIFEST_SCHEMA_V1,
name: name.into(),
version: version.into(),
created_at: created_at.into(),
..Default::default()
}
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
#[must_use]
pub fn with_claim_id(mut self, id: impl Into<String>) -> Self {
self.claim_id = Some(id.into());
self
}
#[must_use]
pub fn with_demo_id(mut self, id: impl Into<String>) -> Self {
self.demo_id = Some(id.into());
self
}
pub fn add_artifact(&mut self, artifact: ReproArtifact) {
self.artifacts.push(artifact);
}
#[must_use]
pub fn with_pack_hash(mut self, hash: impl Into<String>) -> Self {
self.pack_hash = Some(hash.into());
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReproArtifact {
pub path: String,
pub hash: String,
pub size_bytes: u64,
pub content_type: Option<String>,
pub required: bool,
}
impl ReproArtifact {
#[must_use]
pub fn new(path: impl Into<String>, hash: impl Into<String>, size_bytes: u64) -> Self {
Self {
path: path.into(),
hash: hash.into(),
size_bytes,
content_type: None,
required: true,
}
}
#[must_use]
pub fn with_content_type(mut self, ct: impl Into<String>) -> Self {
self.content_type = Some(ct.into());
self
}
#[must_use]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReproLock {
pub schema: &'static str,
pub lock_version: u32,
pub locked_at: String,
pub dependencies: Vec<ReproDependency>,
pub content_hash: Option<String>,
}
impl ReproLock {
#[must_use]
pub fn new(locked_at: impl Into<String>) -> Self {
Self {
schema: REPRO_LOCK_SCHEMA_V1,
lock_version: 1,
locked_at: locked_at.into(),
..Default::default()
}
}
pub fn add_dependency(&mut self, dep: ReproDependency) {
self.dependencies.push(dep);
}
#[must_use]
pub fn with_content_hash(mut self, hash: impl Into<String>) -> Self {
self.content_hash = Some(hash.into());
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReproDependency {
pub name: String,
pub version: String,
pub source: String,
pub hash: Option<String>,
pub category: DependencyCategory,
}
impl ReproDependency {
#[must_use]
pub fn new(
name: impl Into<String>,
version: impl Into<String>,
source: impl Into<String>,
category: DependencyCategory,
) -> Self {
Self {
name: name.into(),
version: version.into(),
source: source.into(),
hash: None,
category,
}
}
#[must_use]
pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
self.hash = Some(hash.into());
self
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum DependencyCategory {
#[default]
Crate,
System,
Tool,
Data,
Other,
}
impl DependencyCategory {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Crate => "crate",
Self::System => "system",
Self::Tool => "tool",
Self::Data => "data",
Self::Other => "other",
}
}
}
impl fmt::Display for DependencyCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for DependencyCategory {
type Err = ParseDependencyCategoryError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match normalized_repro_token(s).as_str() {
"crate" => Ok(Self::Crate),
"system" => Ok(Self::System),
"tool" => Ok(Self::Tool),
"data" => Ok(Self::Data),
"other" => Ok(Self::Other),
_ => Err(ParseDependencyCategoryError {
input: s.to_owned(),
}),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseDependencyCategoryError {
input: String,
}
impl fmt::Display for ParseDependencyCategoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown dependency category `{}`; expected crate, system, tool, data, or other",
self.input
)
}
}
impl std::error::Error for ParseDependencyCategoryError {}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ReproProvenance {
pub schema: &'static str,
pub trace_id: Option<String>,
pub evidence_id: Option<String>,
pub sources: Vec<ProvenanceSource>,
pub events: Vec<ProvenanceEvent>,
pub verifications: Vec<ProvenanceVerification>,
pub updated_at: String,
}
impl ReproProvenance {
#[must_use]
pub fn new(updated_at: impl Into<String>) -> Self {
Self {
schema: REPRO_PROVENANCE_SCHEMA_V1,
updated_at: updated_at.into(),
..Default::default()
}
}
#[must_use]
pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
self.trace_id = Some(id.into());
self
}
#[must_use]
pub fn with_evidence_id(mut self, id: impl Into<String>) -> Self {
self.evidence_id = Some(id.into());
self
}
pub fn add_source(&mut self, source: ProvenanceSource) {
self.sources.push(source);
}
pub fn add_event(&mut self, event: ProvenanceEvent) {
self.events.push(event);
}
pub fn add_verification(&mut self, verification: ProvenanceVerification) {
self.verifications.push(verification);
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProvenanceSource {
pub uri: String,
pub hash: String,
pub captured_at: String,
pub captured_by: Option<String>,
}
impl ProvenanceSource {
#[must_use]
pub fn new(
uri: impl Into<String>,
hash: impl Into<String>,
captured_at: impl Into<String>,
) -> Self {
Self {
uri: uri.into(),
hash: hash.into(),
captured_at: captured_at.into(),
captured_by: None,
}
}
#[must_use]
pub fn with_captured_by(mut self, by: impl Into<String>) -> Self {
self.captured_by = Some(by.into());
self
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProvenanceEvent {
pub event_type: ProvenanceEventType,
pub timestamp: String,
pub actor: Option<String>,
pub description: Option<String>,
pub inputs: Vec<String>,
pub outputs: Vec<String>,
}
impl ProvenanceEvent {
#[must_use]
pub fn new(event_type: ProvenanceEventType, timestamp: impl Into<String>) -> Self {
Self {
event_type,
timestamp: timestamp.into(),
actor: None,
description: None,
inputs: Vec::new(),
outputs: Vec::new(),
}
}
#[must_use]
pub fn with_actor(mut self, actor: impl Into<String>) -> Self {
self.actor = Some(actor.into());
self
}
#[must_use]
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.description = Some(desc.into());
self
}
pub fn add_input(&mut self, hash: impl Into<String>) {
self.inputs.push(hash.into());
}
pub fn add_output(&mut self, hash: impl Into<String>) {
self.outputs.push(hash.into());
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
pub enum ProvenanceEventType {
Capture,
Build,
Test,
Pack,
Sign,
Publish,
Custom,
}
impl ProvenanceEventType {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Capture => "capture",
Self::Build => "build",
Self::Test => "test",
Self::Pack => "pack",
Self::Sign => "sign",
Self::Publish => "publish",
Self::Custom => "custom",
}
}
}
impl fmt::Display for ProvenanceEventType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for ProvenanceEventType {
type Err = ParseProvenanceEventTypeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match normalized_repro_token(s).as_str() {
"capture" => Ok(Self::Capture),
"build" => Ok(Self::Build),
"test" => Ok(Self::Test),
"pack" => Ok(Self::Pack),
"sign" => Ok(Self::Sign),
"publish" => Ok(Self::Publish),
"custom" => Ok(Self::Custom),
_ => Err(ParseProvenanceEventTypeError {
input: s.to_owned(),
}),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ParseProvenanceEventTypeError {
input: String,
}
impl fmt::Display for ParseProvenanceEventTypeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"unknown provenance event type `{}`; expected capture, build, test, pack, sign, publish, or custom",
self.input
)
}
}
impl std::error::Error for ParseProvenanceEventTypeError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProvenanceVerification {
pub verifier: String,
pub verified_at: String,
pub passed: bool,
pub method: Option<String>,
pub notes: Option<String>,
}
impl ProvenanceVerification {
#[must_use]
pub fn new(verifier: impl Into<String>, verified_at: impl Into<String>, passed: bool) -> Self {
Self {
verifier: verifier.into(),
verified_at: verified_at.into(),
passed,
method: None,
notes: None,
}
}
#[must_use]
pub fn with_method(mut self, method: impl Into<String>) -> Self {
self.method = Some(method.into());
self
}
#[must_use]
pub fn with_notes(mut self, notes: impl Into<String>) -> Self {
self.notes = Some(notes.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), String>;
fn ensure<T: std::fmt::Debug + PartialEq>(actual: T, expected: T, ctx: &str) -> TestResult {
if actual == expected {
Ok(())
} else {
Err(format!("{ctx}: expected {expected:?}, got {actual:?}"))
}
}
#[test]
fn repro_schema_versions_are_stable() -> TestResult {
ensure(REPRO_PACK_SCHEMA_V1, "ee.repro_pack.v1", "pack")?;
ensure(REPRO_ENV_SCHEMA_V1, "ee.repro_pack.env.v1", "env")?;
ensure(
REPRO_MANIFEST_SCHEMA_V1,
"ee.repro_pack.manifest.v1",
"manifest",
)?;
ensure(REPRO_LOCK_SCHEMA_V1, "ee.repro_pack.lock.v1", "lock")?;
ensure(
REPRO_PROVENANCE_SCHEMA_V1,
"ee.repro_pack.provenance.v1",
"provenance",
)
}
#[test]
fn repro_env_builder() -> TestResult {
let env = ReproEnv::new("Linux 6.1", "x86_64", "2026-04-30T12:00:00Z")
.with_rust_version("1.80.0")
.with_git_commit("abc123")
.with_git_branch("main")
.with_git_clean(true)
.with_env_var("CARGO_HOME", "/home/user/.cargo")
.with_tool_version("cargo", "1.80.0");
ensure(env.schema, REPRO_ENV_SCHEMA_V1, "schema")?;
ensure(env.os, "Linux 6.1".to_string(), "os")?;
ensure(env.arch, "x86_64".to_string(), "arch")?;
ensure(env.rust_version, Some("1.80.0".to_string()), "rust")?;
ensure(env.git_commit, Some("abc123".to_string()), "commit")?;
ensure(env.git_branch, Some("main".to_string()), "branch")?;
ensure(env.git_clean, Some(true), "clean")?;
ensure(
env.env_vars.get("CARGO_HOME"),
Some(&"/home/user/.cargo".to_string()),
"env_var",
)?;
ensure(
env.tool_versions.get("cargo"),
Some(&"1.80.0".to_string()),
"tool",
)
}
#[test]
fn repro_manifest_builder() -> TestResult {
let mut manifest = ReproManifest::new("test-pack", "1.0.0", "2026-04-30T12:00:00Z")
.with_description("Test repro pack")
.with_claim_id("claim_00000000000000000000000001")
.with_demo_id("demo_00000000000000000000000001")
.with_pack_hash("blake3:abc123");
manifest.add_artifact(ReproArtifact::new("env.json", "blake3:env123", 256));
ensure(manifest.schema, REPRO_MANIFEST_SCHEMA_V1, "schema")?;
ensure(manifest.name, "test-pack".to_string(), "name")?;
ensure(manifest.version, "1.0.0".to_string(), "version")?;
ensure(manifest.artifacts.len(), 1, "artifact count")
}
#[test]
fn repro_artifact_builder() -> TestResult {
let artifact = ReproArtifact::new("data.json", "sha256:abc", 1024)
.with_content_type("application/json")
.with_required(false);
ensure(artifact.path, "data.json".to_string(), "path")?;
ensure(artifact.hash, "sha256:abc".to_string(), "hash")?;
ensure(artifact.size_bytes, 1024, "size")?;
ensure(
artifact.content_type,
Some("application/json".to_string()),
"content_type",
)?;
ensure(artifact.required, false, "required")
}
#[test]
fn repro_lock_builder() -> TestResult {
let mut lock = ReproLock::new("2026-04-30T12:00:00Z").with_content_hash("blake3:lock123");
lock.add_dependency(ReproDependency::new(
"serde",
"1.0.200",
"crates.io",
DependencyCategory::Crate,
));
ensure(lock.schema, REPRO_LOCK_SCHEMA_V1, "schema")?;
ensure(lock.lock_version, 1, "version")?;
ensure(lock.dependencies.len(), 1, "dep count")
}
#[test]
fn dependency_category_strings_are_stable() -> TestResult {
ensure(DependencyCategory::Crate.as_str(), "crate", "crate")?;
ensure(DependencyCategory::System.as_str(), "system", "system")?;
ensure(DependencyCategory::Tool.as_str(), "tool", "tool")?;
ensure(DependencyCategory::Data.as_str(), "data", "data")?;
ensure(DependencyCategory::Other.as_str(), "other", "other")
}
#[test]
fn dependency_category_round_trip() -> TestResult {
for cat in [
DependencyCategory::Crate,
DependencyCategory::System,
DependencyCategory::Tool,
DependencyCategory::Data,
DependencyCategory::Other,
] {
let parsed = DependencyCategory::from_str(cat.as_str());
ensure(parsed, Ok(cat), cat.as_str())?;
}
Ok(())
}
#[test]
fn dependency_category_rejects_invalid() {
let result = DependencyCategory::from_str("invalid");
assert!(result.is_err());
}
#[test]
fn dependency_category_accepts_operator_spelling_variants() -> TestResult {
ensure(
DependencyCategory::from_str(" System "),
Ok(DependencyCategory::System),
"trimmed dependency category",
)?;
ensure(
DependencyCategory::from_str("TOOL"),
Ok(DependencyCategory::Tool),
"uppercase dependency category",
)
}
#[test]
fn repro_provenance_builder() -> TestResult {
let mut prov = ReproProvenance::new("2026-04-30T12:00:00Z")
.with_trace_id("trace_00000000000000000000000001")
.with_evidence_id("ev_00000000000000000000000001");
prov.add_source(ProvenanceSource::new(
"git://github.com/user/repo",
"blake3:src123",
"2026-04-30T11:00:00Z",
));
prov.add_event(ProvenanceEvent::new(
ProvenanceEventType::Build,
"2026-04-30T11:30:00Z",
));
prov.add_verification(ProvenanceVerification::new(
"ci-agent",
"2026-04-30T12:00:00Z",
true,
));
ensure(prov.schema, REPRO_PROVENANCE_SCHEMA_V1, "schema")?;
ensure(prov.sources.len(), 1, "source count")?;
ensure(prov.events.len(), 1, "event count")?;
ensure(prov.verifications.len(), 1, "verification count")
}
#[test]
fn provenance_event_type_strings_are_stable() -> TestResult {
ensure(ProvenanceEventType::Capture.as_str(), "capture", "capture")?;
ensure(ProvenanceEventType::Build.as_str(), "build", "build")?;
ensure(ProvenanceEventType::Test.as_str(), "test", "test")?;
ensure(ProvenanceEventType::Pack.as_str(), "pack", "pack")?;
ensure(ProvenanceEventType::Sign.as_str(), "sign", "sign")?;
ensure(ProvenanceEventType::Publish.as_str(), "publish", "publish")?;
ensure(ProvenanceEventType::Custom.as_str(), "custom", "custom")
}
#[test]
fn provenance_event_type_round_trip() -> TestResult {
for et in [
ProvenanceEventType::Capture,
ProvenanceEventType::Build,
ProvenanceEventType::Test,
ProvenanceEventType::Pack,
ProvenanceEventType::Sign,
ProvenanceEventType::Publish,
ProvenanceEventType::Custom,
] {
let parsed = ProvenanceEventType::from_str(et.as_str());
ensure(parsed, Ok(et), et.as_str())?;
}
Ok(())
}
#[test]
fn provenance_event_type_rejects_invalid() {
let result = ProvenanceEventType::from_str("invalid");
assert!(result.is_err());
}
#[test]
fn provenance_event_type_accepts_operator_spelling_variants() -> TestResult {
ensure(
ProvenanceEventType::from_str(" Capture "),
Ok(ProvenanceEventType::Capture),
"trimmed event type",
)?;
ensure(
ProvenanceEventType::from_str("PUBLISH"),
Ok(ProvenanceEventType::Publish),
"uppercase event type",
)
}
#[test]
fn provenance_source_builder() -> TestResult {
let source =
ProvenanceSource::new("file://AGENTS.md", "blake3:abc", "2026-04-30T12:00:00Z")
.with_captured_by("ee-cli");
ensure(source.uri, "file://AGENTS.md".to_string(), "uri")?;
ensure(
source.captured_by,
Some("ee-cli".to_string()),
"captured_by",
)
}
#[test]
fn provenance_event_builder() -> TestResult {
let mut event = ProvenanceEvent::new(ProvenanceEventType::Build, "2026-04-30T12:00:00Z")
.with_actor("ci-agent")
.with_description("Release build");
event.add_input("blake3:in1");
event.add_output("blake3:out1");
ensure(event.event_type, ProvenanceEventType::Build, "type")?;
ensure(event.actor, Some("ci-agent".to_string()), "actor")?;
ensure(event.inputs.len(), 1, "inputs")?;
ensure(event.outputs.len(), 1, "outputs")
}
#[test]
fn provenance_verification_builder() -> TestResult {
let verification = ProvenanceVerification::new("human", "2026-04-30T12:00:00Z", true)
.with_method("manual review")
.with_notes("Looks good");
ensure(verification.verifier, "human".to_string(), "verifier")?;
ensure(verification.passed, true, "passed")?;
ensure(
verification.method,
Some("manual review".to_string()),
"method",
)?;
ensure(verification.notes, Some("Looks good".to_string()), "notes")
}
}