use serde::{Deserialize, Serialize};
use crate::error::CoreError;
use crate::policy::SpendPolicy;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Delegation {
pub id: String,
pub owner: String,
pub agent: String,
pub budget_cap_cents: u64,
pub valid_from: u64,
pub valid_until: u64,
pub nonce_scope: String,
#[serde(default, skip_serializing_if = "SpendPolicy::is_empty")]
pub policy: SpendPolicy,
}
impl Delegation {
pub fn new(
id: impl Into<String>,
owner: impl Into<String>,
agent: impl Into<String>,
budget_cap_cents: u64,
valid_from: u64,
valid_until: u64,
nonce_scope: impl Into<String>,
) -> Self {
Self {
id: id.into(),
owner: owner.into(),
agent: agent.into(),
budget_cap_cents,
valid_from,
valid_until,
nonce_scope: nonce_scope.into(),
policy: SpendPolicy::default(),
}
}
pub fn with_policy(mut self, policy: SpendPolicy) -> Self {
self.policy = policy;
self
}
pub fn validate(&self) -> Result<(), CoreError> {
if self.id.trim().is_empty() {
return Err(CoreError::InvalidDelegation("id 不能为空".into()));
}
if self.owner.trim().is_empty() {
return Err(CoreError::InvalidDelegation("owner 不能为空".into()));
}
if self.agent.trim().is_empty() {
return Err(CoreError::InvalidDelegation("agent 不能为空".into()));
}
if self.nonce_scope.trim().is_empty() {
return Err(CoreError::InvalidDelegation("nonce_scope 不能为空".into()));
}
if self.budget_cap_cents == 0 {
return Err(CoreError::InvalidDelegation(
"budget_cap_cents 不能为 0(单位是分,¥10 = 1000)".into(),
));
}
if self.valid_until <= self.valid_from {
return Err(CoreError::InvalidDelegation(format!(
"有效期倒挂:valid_until({}) 必须 > valid_from({})",
self.valid_until, self.valid_from
)));
}
self.policy.validate()?;
Ok(())
}
pub fn not_yet_valid(&self, now: u64) -> bool {
now < self.valid_from
}
pub fn is_expired(&self, now: u64) -> bool {
now >= self.valid_until
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Delegation {
Delegation::new(
"d1",
"boss",
"claude-code",
1000,
1000,
2000,
"agent:claude-code",
)
}
#[test]
fn validate_accepts_well_formed_delegation() {
assert_eq!(sample().validate(), Ok(()));
}
#[test]
fn validate_rejects_empty_fields() {
for bad in [
Delegation {
id: " ".into(),
..sample()
},
Delegation {
owner: "".into(),
..sample()
},
Delegation {
agent: "".into(),
..sample()
},
Delegation {
nonce_scope: "".into(),
..sample()
},
] {
assert!(
matches!(bad.validate(), Err(CoreError::InvalidDelegation(_))),
"应拒收空字段: {bad:?}"
);
}
}
#[test]
fn validate_rejects_zero_budget() {
let d = Delegation {
budget_cap_cents: 0,
..sample()
};
assert!(matches!(d.validate(), Err(CoreError::InvalidDelegation(_))));
}
#[test]
fn validate_rejects_inverted_window() {
let d = Delegation::new("d1", "boss", "agent", 1000, 2000, 2000, "s1");
assert!(matches!(d.validate(), Err(CoreError::InvalidDelegation(_))));
let d = Delegation::new("d1", "boss", "agent", 1000, 3000, 2000, "s1");
assert!(matches!(d.validate(), Err(CoreError::InvalidDelegation(_))));
}
#[test]
fn expiry_boundary_is_half_open_fail_closed() {
let d = sample();
assert!(!d.is_expired(1999), "valid_until 前一秒仍在有效期内");
assert!(d.is_expired(2000), "恰在 valid_until 时刻必须按过期处理");
assert!(d.is_expired(5000), "过期之后仍是过期");
}
#[test]
fn not_yet_valid_boundary() {
let d = sample();
assert!(d.not_yet_valid(999), "valid_from 前一秒尚未生效");
assert!(!d.not_yet_valid(1000), "恰在 valid_from 时刻已生效(含端点)");
}
#[test]
fn serde_roundtrip() {
let d = sample();
let json = serde_json::to_string(&d).expect("序列化");
let back: Delegation = serde_json::from_str(&json).expect("反序列化");
assert_eq!(back, d);
assert!(json.contains("\"budget_cap_cents\":1000"));
assert!(json.contains("\"nonce_scope\":\"agent:claude-code\""));
}
}