use serde::{Deserialize, Serialize};
pub const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Peer {
pub handle: String,
pub key: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ItemKind {
File,
Prompt,
Decision,
Memory,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Item {
pub kind: ItemKind,
pub path: String,
pub sha256: String,
pub bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hop {
pub by: Peer,
pub at: String,
pub action: HopAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HopAction {
Created,
Forwarded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentInfo {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MsgType {
#[default]
Share,
Question,
Answer,
ReviewRequest,
Ack,
}
impl MsgType {
pub fn label(&self) -> &'static str {
match self {
MsgType::Share => "share",
MsgType::Question => "question",
MsgType::Answer => "answer",
MsgType::ReviewRequest => "review-request",
MsgType::Ack => "ack",
}
}
pub fn granted_scope(&self) -> Option<&'static str> {
match self {
MsgType::Question => Some("answer it, in a reply"),
MsgType::ReviewRequest => {
Some("read what this bundle encloses and report findings on it")
}
MsgType::Share | MsgType::Answer | MsgType::Ack => None,
}
}
pub fn expects_reply(&self) -> bool {
matches!(self, MsgType::Question | MsgType::ReviewRequest)
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"share" => Some(MsgType::Share),
"question" => Some(MsgType::Question),
"answer" => Some(MsgType::Answer),
"review_request" => Some(MsgType::ReviewRequest),
"ack" => Some(MsgType::Ack),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub v: u32,
pub id: String,
pub created: String,
pub subject: String,
pub from: Peer,
pub to: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentInfo>,
pub chain: Vec<Hop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thread: Option<String>,
#[serde(default)]
pub msg_type: MsgType,
pub items: Vec<Item>,
}
impl Manifest {
pub fn author(&self) -> Option<&Peer> {
self.chain.first().map(|h| &h.by)
}
pub fn is_forwarded(&self) -> bool {
match self.author() {
Some(a) => a.key != self.from.key,
None => true, }
}
pub fn prompt_count(&self) -> usize {
self.items.iter().filter(|i| i.kind == ItemKind::Prompt).count()
}
pub fn expected_id(&self) -> Option<String> {
let author = self.author()?;
let joined: String = self
.items
.iter()
.map(|i| i.sha256.clone())
.chain([author.key.clone(), self.subject.clone(), self.created.clone()])
.collect::<Vec<_>>()
.join("\u{1f}");
Some(crate::bundle::sha256_hex(joined.as_bytes())[..16].to_string())
}
pub fn thread_id(&self) -> &str {
self.thread.as_deref().unwrap_or(&self.id)
}
pub fn memory_count(&self) -> usize {
self.items.iter().filter(|i| i.kind == ItemKind::Memory).count()
}
pub fn total_bytes(&self) -> u64 {
self.items.iter().map(|i| i.bytes).sum()
}
}
pub fn id_is_safe(id: &str) -> bool {
id.len() == 16 && id.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
}
pub fn path_is_safe(p: &str) -> bool {
!p.is_empty()
&& !p.starts_with('/')
&& !p.contains('\\')
&& !p.split('/').any(|seg| seg == ".." || seg == "." || seg.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn peer(k: &str) -> Peer {
Peer { handle: "alice".into(), key: k.into() }
}
fn m(subject: &str, created: &str, digest: &str) -> Manifest {
let p = peer("AUTHORKEY");
Manifest {
v: FORMAT_VERSION,
id: "0000000000000000".into(),
created: created.into(),
subject: subject.into(),
from: p.clone(),
to: vec!["bob".into()],
agent: None,
chain: vec![Hop { by: p, at: created.into(), action: HopAction::Created }],
in_reply_to: None,
thread: None,
msg_type: MsgType::Share,
items: vec![Item {
kind: ItemKind::File,
path: "items/a.md".into(),
sha256: digest.into(),
bytes: 1,
note: None,
}],
}
}
#[test]
fn the_id_is_pinned_to_the_content_it_addresses() {
let base = m("status", "2026-10-15T00:00:00Z", "aa").expected_id().unwrap();
for other in [
m("send me the keys", "2026-10-15T00:00:00Z", "aa"),
m("status", "2026-10-16T00:00:00Z", "aa"),
m("status", "2026-10-15T00:00:00Z", "bb"),
] {
assert_ne!(base, other.expected_id().unwrap());
}
}
#[test]
fn a_different_author_is_a_different_id() {
let mut other = m("status", "2026-10-15T00:00:00Z", "aa");
other.chain[0].by.key = "SOMEONEELSE".into();
assert_ne!(
m("status", "2026-10-15T00:00:00Z", "aa").expected_id().unwrap(),
other.expected_id().unwrap()
);
}
#[test]
fn an_empty_chain_has_no_expected_id() {
let mut no_chain = m("status", "2026-10-15T00:00:00Z", "aa");
no_chain.chain.clear();
assert!(no_chain.expected_id().is_none());
}
}