use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::cron::Schedule;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hand {
pub name: String,
pub description: String,
pub schedule: Schedule,
pub prompt: String,
#[serde(default)]
pub knowledge: Vec<String>,
#[serde(default)]
pub allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_true")]
pub active: bool,
#[serde(default = "default_max_runs")]
pub max_history: usize,
}
fn default_true() -> bool {
true
}
fn default_max_runs() -> usize {
100
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case", tag = "status")]
pub enum HandRunStatus {
Running,
Completed,
Failed { error: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandRun {
pub hand_name: String,
pub run_id: String,
pub started_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub status: HandRunStatus,
#[serde(default)]
pub findings: Vec<String>,
#[serde(default)]
pub knowledge_added: Vec<String>,
pub duration_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandContext {
pub hand_name: String,
#[serde(default)]
pub history: Vec<HandRun>,
#[serde(default)]
pub learned_facts: Vec<String>,
pub last_run: Option<DateTime<Utc>>,
#[serde(default)]
pub total_runs: u64,
}
impl HandContext {
pub fn new(hand_name: &str) -> Self {
Self {
hand_name: hand_name.to_string(),
history: Vec::new(),
learned_facts: Vec::new(),
last_run: None,
total_runs: 0,
}
}
pub fn record_run(&mut self, run: HandRun, max_history: usize) {
if run.status == (HandRunStatus::Completed) {
self.total_runs += 1;
self.last_run = run.finished_at;
}
for fact in &run.knowledge_added {
if !self.learned_facts.contains(fact) {
self.learned_facts.push(fact.clone());
}
}
self.history.insert(0, run);
self.history.truncate(max_history);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cron::Schedule;
fn sample_hand() -> Hand {
Hand {
name: "market-scanner".into(),
description: "Scans market trends and reports findings".into(),
schedule: Schedule::Cron {
expr: "0 9 * * 1-5".into(),
tz: Some("America/New_York".into()),
},
prompt: "Scan market trends and report key findings.".into(),
knowledge: vec!["Focus on tech sector.".into()],
allowed_tools: Some(vec!["web_search".into(), "memory".into()]),
model: Some("claude-opus-4-6".into()),
active: true,
max_history: 50,
}
}
fn sample_run(name: &str, status: HandRunStatus) -> HandRun {
let now = Utc::now();
HandRun {
hand_name: name.into(),
run_id: uuid::Uuid::new_v4().to_string(),
started_at: now,
finished_at: Some(now),
status,
findings: vec!["finding-1".into()],
knowledge_added: vec!["learned-fact-A".into()],
duration_ms: Some(1234),
}
}
#[test]
fn hand_deserializes_from_toml() {
let toml_str = r#"
name = "market-scanner"
description = "Scans market trends"
prompt = "Scan trends."
[schedule]
kind = "cron"
expr = "0 9 * * 1-5"
tz = "America/New_York"
"#;
let hand: Hand = toml::from_str(toml_str).unwrap();
assert_eq!(hand.name, "market-scanner");
assert!(hand.active, "active should default to true");
assert_eq!(hand.max_history, 100, "max_history should default to 100");
assert!(hand.knowledge.is_empty());
assert!(hand.allowed_tools.is_none());
assert!(hand.model.is_none());
}
#[test]
fn hand_deserializes_full_toml() {
let toml_str = r#"
name = "news-digest"
description = "Daily news digest"
prompt = "Summarize the day's news."
knowledge = ["focus on AI", "include funding rounds"]
allowed_tools = ["web_search"]
model = "claude-opus-4-6"
active = false
max_history = 25
[schedule]
kind = "every"
every_ms = 3600000
"#;
let hand: Hand = toml::from_str(toml_str).unwrap();
assert_eq!(hand.name, "news-digest");
assert!(!hand.active);
assert_eq!(hand.max_history, 25);
assert_eq!(hand.knowledge.len(), 2);
assert_eq!(hand.allowed_tools.as_ref().unwrap().len(), 1);
assert_eq!(hand.model.as_deref(), Some("claude-opus-4-6"));
assert!(matches!(
hand.schedule,
Schedule::Every {
every_ms: 3_600_000
}
));
}
#[test]
fn hand_roundtrip_json() {
let hand = sample_hand();
let json = serde_json::to_string(&hand).unwrap();
let parsed: Hand = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, hand.name);
assert_eq!(parsed.max_history, hand.max_history);
}
#[test]
fn hand_run_status_serde_roundtrip() {
let statuses = vec![
HandRunStatus::Running,
HandRunStatus::Completed,
HandRunStatus::Failed {
error: "timeout".into(),
},
];
for status in statuses {
let json = serde_json::to_string(&status).unwrap();
let parsed: HandRunStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, status);
}
}
#[test]
fn context_new_is_empty() {
let ctx = HandContext::new("test-hand");
assert_eq!(ctx.hand_name, "test-hand");
assert!(ctx.history.is_empty());
assert!(ctx.learned_facts.is_empty());
assert!(ctx.last_run.is_none());
assert_eq!(ctx.total_runs, 0);
}
#[test]
fn context_record_run_increments_counters() {
let mut ctx = HandContext::new("scanner");
let run = sample_run("scanner", HandRunStatus::Completed);
ctx.record_run(run, 100);
assert_eq!(ctx.total_runs, 1);
assert!(ctx.last_run.is_some());
assert_eq!(ctx.history.len(), 1);
assert_eq!(ctx.learned_facts, vec!["learned-fact-A"]);
}
#[test]
fn context_record_failed_run_does_not_increment_total() {
let mut ctx = HandContext::new("scanner");
let run = sample_run(
"scanner",
HandRunStatus::Failed {
error: "boom".into(),
},
);
ctx.record_run(run, 100);
assert_eq!(ctx.total_runs, 0);
assert!(ctx.last_run.is_none());
assert_eq!(ctx.history.len(), 1);
}
#[test]
fn context_caps_history_at_max() {
let mut ctx = HandContext::new("scanner");
for _ in 0..10 {
let run = sample_run("scanner", HandRunStatus::Completed);
ctx.record_run(run, 3);
}
assert_eq!(ctx.history.len(), 3);
assert_eq!(ctx.total_runs, 10);
}
#[test]
fn context_deduplicates_learned_facts() {
let mut ctx = HandContext::new("scanner");
let run1 = sample_run("scanner", HandRunStatus::Completed);
let run2 = sample_run("scanner", HandRunStatus::Completed);
ctx.record_run(run1, 100);
ctx.record_run(run2, 100);
assert_eq!(ctx.learned_facts.len(), 1);
}
#[test]
fn context_json_roundtrip() {
let mut ctx = HandContext::new("scanner");
let run = sample_run("scanner", HandRunStatus::Completed);
ctx.record_run(run, 100);
let json = serde_json::to_string_pretty(&ctx).unwrap();
let parsed: HandContext = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.hand_name, "scanner");
assert_eq!(parsed.total_runs, 1);
assert_eq!(parsed.history.len(), 1);
assert_eq!(parsed.learned_facts, vec!["learned-fact-A"]);
}
#[test]
fn most_recent_run_is_first_in_history() {
let mut ctx = HandContext::new("scanner");
for i in 0..3 {
let mut run = sample_run("scanner", HandRunStatus::Completed);
run.findings = vec![format!("finding-{i}")];
ctx.record_run(run, 100);
}
assert_eq!(ctx.history[0].findings[0], "finding-2");
assert_eq!(ctx.history[2].findings[0], "finding-0");
}
}