1use std::path::{Component, PathBuf};
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FeatureState {
48 pub feature: FeatureInfo,
50
51 pub status: FeatureStatus,
53
54 pub current_phase: u32,
56
57 pub git: GitInfo,
59
60 pub phases: Vec<PhaseRecord>,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub pr: Option<PrInfo>,
66
67 pub total: TotalStats,
69}
70
71const MIN_PHASE_COUNT: usize = 3;
73
74impl FeatureState {
75 pub fn validate(&self) -> Result<(), String> {
85 if self.phases.len() < MIN_PHASE_COUNT {
86 return Err(format!(
87 "expected at least {MIN_PHASE_COUNT} phases (dev + review + verify), found {}",
88 self.phases.len(),
89 ));
90 }
91
92 if (self.current_phase as usize) > self.phases.len() {
93 return Err(format!(
94 "current_phase {} exceeds phases count {}",
95 self.current_phase,
96 self.phases.len(),
97 ));
98 }
99
100 for component in self.git.worktree_path.components() {
102 if matches!(component, Component::ParentDir) {
103 return Err(format!(
104 "worktree_path '{}' contains parent directory traversal",
105 self.git.worktree_path.display(),
106 ));
107 }
108 }
109
110 Ok(())
111 }
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct FeatureInfo {
120 pub slug: String,
122
123 pub created_at: DateTime<Utc>,
125
126 pub updated_at: DateTime<Utc>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct GitInfo {
133 pub worktree_path: PathBuf,
135
136 pub branch: String,
138
139 pub base_branch: String,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum PhaseKind {
148 Dev,
150 Quality,
152}
153
154impl Default for PhaseKind {
155 fn default() -> Self {
158 Self::Dev
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct PhaseRecord {
165 pub name: String,
167
168 #[serde(default)]
170 pub kind: PhaseKind,
171
172 pub status: PhaseStatus,
174
175 #[serde(skip_serializing_if = "Option::is_none")]
177 pub started_at: Option<DateTime<Utc>>,
178
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub completed_at: Option<DateTime<Utc>>,
182
183 pub turns: u32,
185
186 pub cost_usd: f64,
188
189 pub cost: TokenCost,
191
192 pub duration_secs: u64,
194
195 pub details: serde_json::Value,
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct TokenCost {
202 pub input_tokens: u64,
204
205 pub output_tokens: u64,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct PrInfo {
212 pub url: String,
214
215 pub number: u32,
217
218 pub title: String,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct TotalStats {
225 pub turns: u32,
227
228 pub cost_usd: f64,
230
231 pub cost: TokenCost,
233
234 pub duration_secs: u64,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241#[non_exhaustive]
242pub enum FeatureStatus {
243 Planned,
245 InProgress,
247 Completed,
249 Failed,
251 Merged,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258#[non_exhaustive]
259pub enum PhaseStatus {
260 Pending,
262 Running,
264 Completed,
266 Failed,
268}
269
270impl std::fmt::Display for FeatureStatus {
271 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272 match self {
273 Self::Planned => write!(f, "planned"),
274 Self::InProgress => write!(f, "in progress"),
275 Self::Completed => write!(f, "completed"),
276 Self::Failed => write!(f, "failed"),
277 Self::Merged => write!(f, "merged"),
278 }
279 }
280}
281
282impl std::fmt::Display for PhaseStatus {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 match self {
285 Self::Pending => write!(f, "pending"),
286 Self::Running => write!(f, "running"),
287 Self::Completed => write!(f, "completed"),
288 Self::Failed => write!(f, "failed"),
289 }
290 }
291}
292
293impl Default for TotalStats {
294 fn default() -> Self {
295 Self {
296 turns: 0,
297 cost_usd: 0.0,
298 cost: TokenCost::default(),
299 duration_secs: 0,
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use std::path::PathBuf;
307
308 use super::*;
309
310 #[test]
311 fn test_should_serialize_feature_status() {
312 let status = FeatureStatus::InProgress;
313 let yaml = serde_yaml::to_string(&status).unwrap();
314 assert!(yaml.contains("in_progress"));
315 }
316
317 #[test]
318 fn test_should_serialize_phase_status() {
319 let status = PhaseStatus::Running;
320 let yaml = serde_yaml::to_string(&status).unwrap();
321 assert!(yaml.contains("running"));
322 }
323
324 #[test]
325 fn test_should_round_trip_token_cost() {
326 let cost = TokenCost {
327 input_tokens: 3000,
328 output_tokens: 1500,
329 };
330 let yaml = serde_yaml::to_string(&cost).unwrap();
331 let deserialized: TokenCost = serde_yaml::from_str(&yaml).unwrap();
332 assert_eq!(deserialized.input_tokens, 3000);
333 assert_eq!(deserialized.output_tokens, 1500);
334 }
335
336 #[test]
337 fn test_should_round_trip_feature_state() {
338 let now = chrono::Utc::now();
339 let state = FeatureState {
340 feature: FeatureInfo {
341 slug: "add-user-auth".to_string(),
342 created_at: now,
343 updated_at: now,
344 },
345 status: FeatureStatus::InProgress,
346 current_phase: 2,
347 git: GitInfo {
348 worktree_path: PathBuf::from(".trees/add-user-auth"),
349 branch: "feature/add-user-auth".to_string(),
350 base_branch: "main".to_string(),
351 },
352 phases: vec![
353 PhaseRecord {
354 name: "auth-types".to_string(),
355 kind: PhaseKind::Dev,
356 status: PhaseStatus::Completed,
357 started_at: Some(now),
358 completed_at: Some(now),
359 turns: 3,
360 cost_usd: 0.12,
361 cost: TokenCost {
362 input_tokens: 3000,
363 output_tokens: 1500,
364 },
365 duration_secs: 300,
366 details: serde_json::json!({"files_created": 4}),
367 },
368 PhaseRecord {
369 name: "auth-middleware".to_string(),
370 kind: PhaseKind::Dev,
371 status: PhaseStatus::Completed,
372 started_at: Some(now),
373 completed_at: Some(now),
374 turns: 12,
375 cost_usd: 1.85,
376 cost: TokenCost {
377 input_tokens: 25000,
378 output_tokens: 12000,
379 },
380 duration_secs: 5100,
381 details: serde_json::json!({"files_changed": 8}),
382 },
383 PhaseRecord {
384 name: "review".to_string(),
385 kind: PhaseKind::Quality,
386 status: PhaseStatus::Running,
387 started_at: Some(now),
388 completed_at: None,
389 turns: 5,
390 cost_usd: 0.52,
391 cost: TokenCost {
392 input_tokens: 8000,
393 output_tokens: 4000,
394 },
395 duration_secs: 900,
396 details: serde_json::json!({}),
397 },
398 ],
399 pr: Some(PrInfo {
400 url: "https://github.com/org/repo/pull/42".to_string(),
401 number: 42,
402 title: "feat: add user authentication".to_string(),
403 }),
404 total: TotalStats {
405 turns: 20,
406 cost_usd: 2.49,
407 cost: TokenCost {
408 input_tokens: 36000,
409 output_tokens: 17500,
410 },
411 duration_secs: 6300,
412 },
413 };
414
415 let yaml = serde_yaml::to_string(&state).unwrap();
416 let deserialized: FeatureState = serde_yaml::from_str(&yaml).unwrap();
417
418 assert_eq!(deserialized.feature.slug, "add-user-auth");
419 assert_eq!(deserialized.status, FeatureStatus::InProgress);
420 assert_eq!(deserialized.current_phase, 2);
421 assert_eq!(deserialized.git.branch, "feature/add-user-auth");
422 assert_eq!(deserialized.git.base_branch, "main");
423 assert_eq!(deserialized.phases.len(), 3);
424 assert_eq!(deserialized.phases[0].kind, PhaseKind::Dev);
425 assert_eq!(deserialized.phases[0].status, PhaseStatus::Completed);
426 assert_eq!(deserialized.phases[1].kind, PhaseKind::Dev);
427 assert_eq!(deserialized.phases[1].turns, 12);
428 assert_eq!(deserialized.phases[2].kind, PhaseKind::Quality);
429 assert_eq!(deserialized.phases[2].status, PhaseStatus::Running);
430 assert!(deserialized.pr.is_some());
431 assert_eq!(deserialized.pr.as_ref().unwrap().number, 42);
432 assert_eq!(deserialized.total.turns, 20);
433 assert!((deserialized.total.cost_usd - 2.49).abs() < f64::EPSILON);
434 assert_eq!(deserialized.total.cost.input_tokens, 36000);
435 }
436
437 #[test]
438 fn test_should_create_default_total_stats() {
439 let stats = TotalStats::default();
440 assert_eq!(stats.turns, 0);
441 assert!((stats.cost_usd - 0.0).abs() < f64::EPSILON);
442 assert_eq!(stats.cost.input_tokens, 0);
443 assert_eq!(stats.cost.output_tokens, 0);
444 assert_eq!(stats.duration_secs, 0);
445 }
446
447 #[test]
448 fn test_should_validate_correct_state() {
449 let now = chrono::Utc::now();
450 let state = FeatureState {
451 feature: FeatureInfo {
452 slug: "test".to_string(),
453 created_at: now,
454 updated_at: now,
455 },
456 status: FeatureStatus::Planned,
457 current_phase: 0,
458 git: GitInfo {
459 worktree_path: PathBuf::from(".trees/test"),
460 branch: "feature/test".to_string(),
461 base_branch: "main".to_string(),
462 },
463 phases: vec![
464 PhaseRecord {
465 name: "dev-phase-1".to_string(),
466 kind: PhaseKind::Dev,
467 status: PhaseStatus::Pending,
468 started_at: None,
469 completed_at: None,
470 turns: 0,
471 cost_usd: 0.0,
472 cost: TokenCost::default(),
473 duration_secs: 0,
474 details: serde_json::json!({}),
475 },
476 PhaseRecord {
477 name: "review".to_string(),
478 kind: PhaseKind::Quality,
479 status: PhaseStatus::Pending,
480 started_at: None,
481 completed_at: None,
482 turns: 0,
483 cost_usd: 0.0,
484 cost: TokenCost::default(),
485 duration_secs: 0,
486 details: serde_json::json!({}),
487 },
488 PhaseRecord {
489 name: "verify".to_string(),
490 kind: PhaseKind::Quality,
491 status: PhaseStatus::Pending,
492 started_at: None,
493 completed_at: None,
494 turns: 0,
495 cost_usd: 0.0,
496 cost: TokenCost::default(),
497 duration_secs: 0,
498 details: serde_json::json!({}),
499 },
500 ],
501 pr: None,
502 total: TotalStats::default(),
503 };
504 assert!(state.validate().is_ok());
505 }
506
507 #[test]
508 fn test_should_reject_too_few_phases() {
509 let now = chrono::Utc::now();
510 let state = FeatureState {
511 feature: FeatureInfo {
512 slug: "test".to_string(),
513 created_at: now,
514 updated_at: now,
515 },
516 status: FeatureStatus::Planned,
517 current_phase: 0,
518 git: GitInfo {
519 worktree_path: PathBuf::from(".trees/test"),
520 branch: "feature/test".to_string(),
521 base_branch: "main".to_string(),
522 },
523 phases: vec![], pr: None,
525 total: TotalStats::default(),
526 };
527 let err = state.validate().unwrap_err();
528 assert!(err.contains("at least 3 phases"));
529 }
530
531 #[test]
532 fn test_should_reject_path_traversal_in_worktree() {
533 let now = chrono::Utc::now();
534 let make_phase = |name: &str, kind: PhaseKind| PhaseRecord {
535 name: name.to_string(),
536 kind,
537 status: PhaseStatus::Pending,
538 started_at: None,
539 completed_at: None,
540 turns: 0,
541 cost_usd: 0.0,
542 cost: TokenCost::default(),
543 duration_secs: 0,
544 details: serde_json::json!({}),
545 };
546 let state = FeatureState {
547 feature: FeatureInfo {
548 slug: "test".to_string(),
549 created_at: now,
550 updated_at: now,
551 },
552 status: FeatureStatus::Planned,
553 current_phase: 0,
554 git: GitInfo {
555 worktree_path: PathBuf::from("../../etc/shadow"),
556 branch: "feature/test".to_string(),
557 base_branch: "main".to_string(),
558 },
559 phases: vec![
560 make_phase("dev-1", PhaseKind::Dev),
561 make_phase("review", PhaseKind::Quality),
562 make_phase("verify", PhaseKind::Quality),
563 ],
564 pr: None,
565 total: TotalStats::default(),
566 };
567 let err = state.validate().unwrap_err();
568 assert!(err.contains("parent directory traversal"));
569 }
570
571 #[test]
572 fn test_should_serialize_pr_info_as_none() {
573 let now = chrono::Utc::now();
574 let state = FeatureState {
575 feature: FeatureInfo {
576 slug: "test".to_string(),
577 created_at: now,
578 updated_at: now,
579 },
580 status: FeatureStatus::Planned,
581 current_phase: 0,
582 git: GitInfo {
583 worktree_path: PathBuf::from(".trees/test"),
584 branch: "feature/test".to_string(),
585 base_branch: "main".to_string(),
586 },
587 phases: vec![],
588 pr: None,
589 total: TotalStats::default(),
590 };
591
592 let yaml = serde_yaml::to_string(&state).unwrap();
593 assert!(!yaml.contains("pr:"));
595
596 let deserialized: FeatureState = serde_yaml::from_str(&yaml).unwrap();
597 assert!(deserialized.pr.is_none());
598 assert_eq!(deserialized.status, FeatureStatus::Planned);
599 }
600}