use std::path::{Component, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureState {
pub feature: FeatureInfo,
pub status: FeatureStatus,
pub current_phase: u32,
pub git: GitInfo,
pub phases: Vec<PhaseRecord>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pr: Option<PrInfo>,
pub total: TotalStats,
}
const MIN_PHASE_COUNT: usize = 3;
impl FeatureState {
pub fn validate(&self) -> Result<(), String> {
if self.phases.len() < MIN_PHASE_COUNT {
return Err(format!(
"expected at least {MIN_PHASE_COUNT} phases (dev + review + verify), found {}",
self.phases.len(),
));
}
if (self.current_phase as usize) > self.phases.len() {
return Err(format!(
"current_phase {} exceeds phases count {}",
self.current_phase,
self.phases.len(),
));
}
for component in self.git.worktree_path.components() {
if matches!(component, Component::ParentDir) {
return Err(format!(
"worktree_path '{}' contains parent directory traversal",
self.git.worktree_path.display(),
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeatureInfo {
pub slug: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitInfo {
pub worktree_path: PathBuf,
pub branch: String,
pub base_branch: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PhaseKind {
Dev,
Quality,
}
impl Default for PhaseKind {
fn default() -> Self {
Self::Dev
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseRecord {
pub name: String,
#[serde(default)]
pub kind: PhaseKind,
pub status: PhaseStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<DateTime<Utc>>,
pub turns: u32,
pub cost_usd: f64,
pub cost: TokenCost,
pub duration_secs: u64,
pub details: serde_json::Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenCost {
pub input_tokens: u64,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrInfo {
pub url: String,
pub number: u32,
pub title: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TotalStats {
pub turns: u32,
pub cost_usd: f64,
pub cost: TokenCost,
pub duration_secs: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum FeatureStatus {
Planned,
InProgress,
Completed,
Failed,
Merged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum PhaseStatus {
Pending,
Running,
Completed,
Failed,
}
impl std::fmt::Display for FeatureStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Planned => write!(f, "planned"),
Self::InProgress => write!(f, "in progress"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
Self::Merged => write!(f, "merged"),
}
}
}
impl std::fmt::Display for PhaseStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Running => write!(f, "running"),
Self::Completed => write!(f, "completed"),
Self::Failed => write!(f, "failed"),
}
}
}
impl Default for TotalStats {
fn default() -> Self {
Self {
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn test_should_serialize_feature_status() {
let status = FeatureStatus::InProgress;
let yaml = serde_yaml::to_string(&status).unwrap();
assert!(yaml.contains("in_progress"));
}
#[test]
fn test_should_serialize_phase_status() {
let status = PhaseStatus::Running;
let yaml = serde_yaml::to_string(&status).unwrap();
assert!(yaml.contains("running"));
}
#[test]
fn test_should_round_trip_token_cost() {
let cost = TokenCost {
input_tokens: 3000,
output_tokens: 1500,
};
let yaml = serde_yaml::to_string(&cost).unwrap();
let deserialized: TokenCost = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(deserialized.input_tokens, 3000);
assert_eq!(deserialized.output_tokens, 1500);
}
#[test]
fn test_should_round_trip_feature_state() {
let now = chrono::Utc::now();
let state = FeatureState {
feature: FeatureInfo {
slug: "add-user-auth".to_string(),
created_at: now,
updated_at: now,
},
status: FeatureStatus::InProgress,
current_phase: 2,
git: GitInfo {
worktree_path: PathBuf::from(".trees/add-user-auth"),
branch: "feature/add-user-auth".to_string(),
base_branch: "main".to_string(),
},
phases: vec![
PhaseRecord {
name: "auth-types".to_string(),
kind: PhaseKind::Dev,
status: PhaseStatus::Completed,
started_at: Some(now),
completed_at: Some(now),
turns: 3,
cost_usd: 0.12,
cost: TokenCost {
input_tokens: 3000,
output_tokens: 1500,
},
duration_secs: 300,
details: serde_json::json!({"files_created": 4}),
},
PhaseRecord {
name: "auth-middleware".to_string(),
kind: PhaseKind::Dev,
status: PhaseStatus::Completed,
started_at: Some(now),
completed_at: Some(now),
turns: 12,
cost_usd: 1.85,
cost: TokenCost {
input_tokens: 25000,
output_tokens: 12000,
},
duration_secs: 5100,
details: serde_json::json!({"files_changed": 8}),
},
PhaseRecord {
name: "review".to_string(),
kind: PhaseKind::Quality,
status: PhaseStatus::Running,
started_at: Some(now),
completed_at: None,
turns: 5,
cost_usd: 0.52,
cost: TokenCost {
input_tokens: 8000,
output_tokens: 4000,
},
duration_secs: 900,
details: serde_json::json!({}),
},
],
pr: Some(PrInfo {
url: "https://github.com/org/repo/pull/42".to_string(),
number: 42,
title: "feat: add user authentication".to_string(),
}),
total: TotalStats {
turns: 20,
cost_usd: 2.49,
cost: TokenCost {
input_tokens: 36000,
output_tokens: 17500,
},
duration_secs: 6300,
},
};
let yaml = serde_yaml::to_string(&state).unwrap();
let deserialized: FeatureState = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(deserialized.feature.slug, "add-user-auth");
assert_eq!(deserialized.status, FeatureStatus::InProgress);
assert_eq!(deserialized.current_phase, 2);
assert_eq!(deserialized.git.branch, "feature/add-user-auth");
assert_eq!(deserialized.git.base_branch, "main");
assert_eq!(deserialized.phases.len(), 3);
assert_eq!(deserialized.phases[0].kind, PhaseKind::Dev);
assert_eq!(deserialized.phases[0].status, PhaseStatus::Completed);
assert_eq!(deserialized.phases[1].kind, PhaseKind::Dev);
assert_eq!(deserialized.phases[1].turns, 12);
assert_eq!(deserialized.phases[2].kind, PhaseKind::Quality);
assert_eq!(deserialized.phases[2].status, PhaseStatus::Running);
assert!(deserialized.pr.is_some());
assert_eq!(deserialized.pr.as_ref().unwrap().number, 42);
assert_eq!(deserialized.total.turns, 20);
assert!((deserialized.total.cost_usd - 2.49).abs() < f64::EPSILON);
assert_eq!(deserialized.total.cost.input_tokens, 36000);
}
#[test]
fn test_should_create_default_total_stats() {
let stats = TotalStats::default();
assert_eq!(stats.turns, 0);
assert!((stats.cost_usd - 0.0).abs() < f64::EPSILON);
assert_eq!(stats.cost.input_tokens, 0);
assert_eq!(stats.cost.output_tokens, 0);
assert_eq!(stats.duration_secs, 0);
}
#[test]
fn test_should_validate_correct_state() {
let now = chrono::Utc::now();
let state = FeatureState {
feature: FeatureInfo {
slug: "test".to_string(),
created_at: now,
updated_at: now,
},
status: FeatureStatus::Planned,
current_phase: 0,
git: GitInfo {
worktree_path: PathBuf::from(".trees/test"),
branch: "feature/test".to_string(),
base_branch: "main".to_string(),
},
phases: vec![
PhaseRecord {
name: "dev-phase-1".to_string(),
kind: PhaseKind::Dev,
status: PhaseStatus::Pending,
started_at: None,
completed_at: None,
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
details: serde_json::json!({}),
},
PhaseRecord {
name: "review".to_string(),
kind: PhaseKind::Quality,
status: PhaseStatus::Pending,
started_at: None,
completed_at: None,
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
details: serde_json::json!({}),
},
PhaseRecord {
name: "verify".to_string(),
kind: PhaseKind::Quality,
status: PhaseStatus::Pending,
started_at: None,
completed_at: None,
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
details: serde_json::json!({}),
},
],
pr: None,
total: TotalStats::default(),
};
assert!(state.validate().is_ok());
}
#[test]
fn test_should_reject_too_few_phases() {
let now = chrono::Utc::now();
let state = FeatureState {
feature: FeatureInfo {
slug: "test".to_string(),
created_at: now,
updated_at: now,
},
status: FeatureStatus::Planned,
current_phase: 0,
git: GitInfo {
worktree_path: PathBuf::from(".trees/test"),
branch: "feature/test".to_string(),
base_branch: "main".to_string(),
},
phases: vec![], pr: None,
total: TotalStats::default(),
};
let err = state.validate().unwrap_err();
assert!(err.contains("at least 3 phases"));
}
#[test]
fn test_should_reject_path_traversal_in_worktree() {
let now = chrono::Utc::now();
let make_phase = |name: &str, kind: PhaseKind| PhaseRecord {
name: name.to_string(),
kind,
status: PhaseStatus::Pending,
started_at: None,
completed_at: None,
turns: 0,
cost_usd: 0.0,
cost: TokenCost::default(),
duration_secs: 0,
details: serde_json::json!({}),
};
let state = FeatureState {
feature: FeatureInfo {
slug: "test".to_string(),
created_at: now,
updated_at: now,
},
status: FeatureStatus::Planned,
current_phase: 0,
git: GitInfo {
worktree_path: PathBuf::from("../../etc/shadow"),
branch: "feature/test".to_string(),
base_branch: "main".to_string(),
},
phases: vec![
make_phase("dev-1", PhaseKind::Dev),
make_phase("review", PhaseKind::Quality),
make_phase("verify", PhaseKind::Quality),
],
pr: None,
total: TotalStats::default(),
};
let err = state.validate().unwrap_err();
assert!(err.contains("parent directory traversal"));
}
#[test]
fn test_should_serialize_pr_info_as_none() {
let now = chrono::Utc::now();
let state = FeatureState {
feature: FeatureInfo {
slug: "test".to_string(),
created_at: now,
updated_at: now,
},
status: FeatureStatus::Planned,
current_phase: 0,
git: GitInfo {
worktree_path: PathBuf::from(".trees/test"),
branch: "feature/test".to_string(),
base_branch: "main".to_string(),
},
phases: vec![],
pr: None,
total: TotalStats::default(),
};
let yaml = serde_yaml::to_string(&state).unwrap();
assert!(!yaml.contains("pr:"));
let deserialized: FeatureState = serde_yaml::from_str(&yaml).unwrap();
assert!(deserialized.pr.is_none());
assert_eq!(deserialized.status, FeatureStatus::Planned);
}
}