use serde::{Deserialize, Serialize};
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(String);
impl SessionId {
pub fn from_string(s: String) -> Self {
Self(s)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for SessionId {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(s.to_string()))
}
}
impl From<&str> for SessionId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ItemRef(String);
impl ItemRef {
#[allow(
dead_code,
reason = "per-kind code (step 5+) constructs indexed refs; CLI handlers take opaque strings"
)]
#[aristo::intent(
"ItemRef uses `#` as the id↔index separator rather than `:` \
because annotation ids in this project can legitimately \
contain `:` (e.g. `aristos:foo`). A refactor that switches to \
`:` would silently break ref parsing the moment any session \
touched an `aristos:`-namespaced id; `#` is safe because it's \
a reserved character in annotation ids by design.",
verify = "neural",
id = "item_ref_separator_is_hash_not_colon"
)]
pub fn new(id: &str, index: usize) -> Self {
Self(format!("{id}#{index}"))
}
pub fn from_opaque(s: impl Into<String>) -> Self {
Self(s.into())
}
#[allow(
dead_code,
reason = "consumed by per-kind code in step 5+ and by unit tests"
)]
pub fn as_str(&self) -> &str {
&self.0
}
#[allow(
dead_code,
reason = "per-kind code (step 5+) decodes refs back into (id, index) for index lookup"
)]
pub fn split_indexed(&self) -> Option<(&str, usize)> {
let (id, idx) = self.0.rsplit_once('#')?;
let idx = idx.parse().ok()?;
Some((id, idx))
}
}
impl fmt::Display for ItemRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ItemStatus {
Open,
Accepted,
Rejected,
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SessionState {
Active,
Closed,
Aborted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ExitKind {
Exit,
ExitDeferUndecided,
Abort,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NestingPolicy {
Disallow,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Item {
#[serde(rename = "ref")]
pub item_ref: ItemRef,
pub status: ItemStatus,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub closed_at: Option<String>,
}
impl Item {
#[allow(
dead_code,
reason = "per-kind code (step 5+) seeds open items at session start; CLI's decide handler builds Item inline"
)]
pub fn open(item_ref: ItemRef) -> Self {
Self {
item_ref,
status: ItemStatus::Open,
note: None,
closed_at: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub schema_version: u32,
pub id: SessionId,
pub kind: String,
pub subject: String,
pub started_at: String,
pub started_by: String,
pub nesting_policy: NestingPolicy,
pub state: SessionState,
#[serde(default)]
pub items: Vec<Item>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub closed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub exit_kind: Option<ExitKind>,
}
impl Session {
pub fn bucket_counts(&self) -> BucketCounts {
let mut counts = BucketCounts::default();
for item in &self.items {
match item.status {
ItemStatus::Open => counts.open += 1,
ItemStatus::Accepted => counts.accepted += 1,
ItemStatus::Rejected => counts.rejected += 1,
ItemStatus::Pending => counts.pending += 1,
}
}
counts
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct BucketCounts {
pub open: usize,
pub accepted: usize,
pub rejected: usize,
pub pending: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn item_ref_encodes_id_and_index_with_hash() {
let r = ItemRef::new("foo_bar", 3);
assert_eq!(r.as_str(), "foo_bar#3");
let (id, idx) = r.split_indexed().unwrap();
assert_eq!(id, "foo_bar");
assert_eq!(idx, 3);
}
#[test]
fn item_ref_handles_colon_in_annotation_id() {
let r = ItemRef::new("aristos:foo", 7);
assert_eq!(r.as_str(), "aristos:foo#7");
let (id, idx) = r.split_indexed().unwrap();
assert_eq!(id, "aristos:foo");
assert_eq!(idx, 7);
}
#[test]
fn item_ref_split_returns_none_for_opaque() {
let r = ItemRef::from_opaque("verdict");
assert!(r.split_indexed().is_none());
}
#[test]
fn item_status_serializes_kebab_case() {
let s = serde_json::to_string(&ItemStatus::Accepted).unwrap();
assert_eq!(s, "\"accepted\"");
let exit = serde_json::to_string(&ExitKind::ExitDeferUndecided).unwrap();
assert_eq!(exit, "\"exit-defer-undecided\"");
}
#[test]
fn session_round_trips_through_toml() {
let s = Session {
schema_version: 1,
id: SessionId::from_string("01J5K9N7CRITIQUEREVIEW00000".into()),
kind: "critique-review".into(),
subject: "src/critique/pending.rs".into(),
started_at: "2026-05-18T13:00:00Z".into(),
started_by: "aristo-critique skill".into(),
nesting_policy: NestingPolicy::Disallow,
state: SessionState::Active,
items: vec![
Item::open(ItemRef::new("foo", 0)),
Item {
item_ref: ItemRef::new("foo", 1),
status: ItemStatus::Accepted,
note: Some("will tighten next commit".into()),
closed_at: Some("2026-05-18T13:05:23Z".into()),
},
],
closed_at: None,
exit_kind: None,
};
let text = toml::to_string(&s).unwrap();
let parsed: Session = toml::from_str(&text).unwrap();
assert_eq!(parsed, s);
}
#[test]
fn bucket_counts_partition_items() {
let mk = |st| Item {
item_ref: ItemRef::new("x", 0),
status: st,
note: None,
closed_at: None,
};
let s = Session {
schema_version: 1,
id: SessionId::from_string("test".into()),
kind: "critique-review".into(),
subject: String::new(),
started_at: String::new(),
started_by: String::new(),
nesting_policy: NestingPolicy::Disallow,
state: SessionState::Active,
items: vec![
mk(ItemStatus::Open),
mk(ItemStatus::Open),
mk(ItemStatus::Accepted),
mk(ItemStatus::Rejected),
mk(ItemStatus::Pending),
mk(ItemStatus::Pending),
],
closed_at: None,
exit_kind: None,
};
let counts = s.bucket_counts();
assert_eq!(counts.open, 2);
assert_eq!(counts.accepted, 1);
assert_eq!(counts.rejected, 1);
assert_eq!(counts.pending, 2);
}
}