use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};
use crate::advise::AdviseReport;
use crate::lock::SourceLock;
use crate::source::SourceRecord;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CordancePack {
pub schema: String,
pub project: ProjectIdentity,
pub sources: Vec<SourceRecord>,
pub doctrine_pins: Vec<DoctrinePin>,
pub targets: PackTargets,
pub outputs: Vec<PackOutput>,
pub source_lock: SourceLock,
pub advise: AdviseReport,
pub residual_risk: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct ProjectIdentity {
pub name: String,
pub repo_root: Utf8PathBuf,
pub kind: String,
pub host_os: String,
#[serde(default)]
pub axiom_pin: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DoctrinePin {
pub repo: String,
pub commit: String,
pub source_path: Utf8PathBuf,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct PackTargets {
pub claude_code: bool,
pub cursor: bool,
pub codex: bool,
pub axiom_harness_target: bool,
pub cortex_receipt: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ParseTargetsError {
#[error(
"unknown pack target {0:?} — expected one of \
claude-code, cursor, codex, axiom-harness-target, cortex-receipt"
)]
UnknownTarget(String),
}
impl PackTargets {
#[must_use]
pub const fn all() -> Self {
Self {
claude_code: true,
cursor: true,
codex: true,
axiom_harness_target: true,
cortex_receipt: true,
}
}
pub fn from_csv(s: Option<&str>) -> Result<Self, ParseTargetsError> {
let Some(s) = s else {
return Ok(Self::all());
};
if s.trim().is_empty() {
return Ok(Self::all());
}
let mut targets = Self::default();
let mut saw_non_empty_token = false;
for raw in s.split(',') {
let token = raw.trim();
if token.is_empty() {
continue;
}
saw_non_empty_token = true;
match token {
"claude-code" => targets.claude_code = true,
"cursor" => targets.cursor = true,
"codex" => targets.codex = true,
"axiom-harness-target" => targets.axiom_harness_target = true,
"cortex-receipt" => targets.cortex_receipt = true,
other => return Err(ParseTargetsError::UnknownTarget(other.into())),
}
}
if !saw_non_empty_token {
return Ok(Self::all());
}
Ok(targets)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PackOutput {
pub path: Utf8PathBuf,
pub target: String,
pub sha256: String,
pub bytes: u64,
pub managed: bool,
pub source_anchors: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema;
#[test]
fn empty_pack_serialises() {
let pack = CordancePack {
schema: schema::CORDANCE_PACK_V1.into(),
project: ProjectIdentity {
name: "fixture".into(),
repo_root: ".".into(),
kind: "rust-workspace".into(),
host_os: "linux".into(),
axiom_pin: None,
},
sources: vec![],
doctrine_pins: vec![],
targets: PackTargets::default(),
outputs: vec![],
source_lock: SourceLock::empty(),
advise: AdviseReport::empty(),
residual_risk: vec!["v0 pack — claim_ceiling=candidate".into()],
};
let s = serde_json::to_string(&pack).expect("ser");
assert!(s.contains("cordance-pack.v1"));
}
#[test]
fn pack_json_contains_no_generated_at_field() {
let pack = CordancePack {
schema: schema::CORDANCE_PACK_V1.into(),
project: ProjectIdentity::default(),
sources: vec![],
doctrine_pins: vec![],
targets: PackTargets::default(),
outputs: vec![],
source_lock: SourceLock::empty(),
advise: AdviseReport::empty(),
residual_risk: vec![],
};
let s = serde_json::to_string(&pack).expect("ser");
assert!(
!s.contains("generated_at"),
"pack.json must not embed a wall-clock timestamp: {s}"
);
}
#[test]
fn parse_targets_default_when_none() {
let t = PackTargets::from_csv(None).expect("ok");
assert!(t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt);
}
#[test]
fn parse_targets_default_when_empty() {
let t = PackTargets::from_csv(Some("")).expect("ok");
assert!(t.claude_code && t.cursor);
}
#[test]
fn parse_targets_single_token() {
let t = PackTargets::from_csv(Some("cursor")).expect("ok");
assert!(t.cursor);
assert!(!t.claude_code && !t.codex && !t.axiom_harness_target && !t.cortex_receipt);
}
#[test]
fn parse_targets_multiple_tokens() {
let t = PackTargets::from_csv(Some("claude-code,codex")).expect("ok");
assert!(t.claude_code && t.codex);
assert!(!t.cursor && !t.axiom_harness_target);
}
#[test]
fn parse_targets_trims_whitespace() {
let t = PackTargets::from_csv(Some(" claude-code , cursor ")).expect("ok");
assert!(t.claude_code && t.cursor);
}
#[test]
fn parse_targets_rejects_unknown_token() {
let err = PackTargets::from_csv(Some("no-cursor")).expect_err("unknown token must fail");
match err {
ParseTargetsError::UnknownTarget(got) => assert_eq!(got, "no-cursor"),
}
}
#[test]
fn parse_targets_rejects_super_prefix() {
let err = PackTargets::from_csv(Some("supercursor")).expect_err("supercursor must fail");
assert!(matches!(err, ParseTargetsError::UnknownTarget(_)));
}
#[test]
fn parse_targets_skips_empty_tokens() {
let t = PackTargets::from_csv(Some("claude-code,,cursor,")).expect("ok");
assert!(t.claude_code && t.cursor);
}
#[test]
fn parse_targets_comma_only_defaults_to_all() {
let t = PackTargets::from_csv(Some(",")).expect("ok");
assert!(
t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt,
"comma-only input must default to all targets enabled (got {t:?})"
);
}
#[test]
fn parse_targets_whitespace_and_commas_defaults_to_all() {
let t = PackTargets::from_csv(Some(" , , ")).expect("ok");
assert!(
t.claude_code && t.cursor && t.codex && t.axiom_harness_target && t.cortex_receipt,
"whitespace+comma soup must default to all targets enabled (got {t:?})"
);
}
}