Skip to main content

coda_core/
state.rs

1//! Feature state types for tracking execution progress.
2//!
3//! Defines `FeatureState` which is persisted to `.coda/<feature>/state.yml`
4//! and supports resuming interrupted runs.
5
6use std::path::{Component, PathBuf};
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Complete state of a feature's execution, persisted to `state.yml`.
12///
13/// Tracks the progress of a feature through all execution phases,
14/// enabling crash recovery by resuming from the last completed phase.
15///
16/// # Examples
17///
18/// ```
19/// use coda_core::state::{FeatureState, FeatureStatus};
20///
21/// let yaml = r#"
22/// feature:
23///   slug: "add-auth"
24///   created_at: "2026-02-10T10:30:00Z"
25///   updated_at: "2026-02-10T10:30:00Z"
26/// status: planned
27/// current_phase: 0
28/// git:
29///   worktree_path: ".trees/add-auth"
30///   branch: "feature/add-auth"
31///   base_branch: "main"
32/// phases: []
33/// total:
34///   turns: 0
35///   cost_usd: 0.0
36///   cost:
37///     input_tokens: 0
38///     output_tokens: 0
39///   duration_secs: 0
40/// "#;
41///
42/// let state: FeatureState = serde_yaml::from_str(yaml).unwrap();
43/// assert_eq!(state.status, FeatureStatus::Planned);
44/// assert_eq!(state.feature.slug, "add-auth");
45/// ```
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FeatureState {
48    /// Basic feature metadata.
49    pub feature: FeatureInfo,
50
51    /// Overall feature execution status.
52    pub status: FeatureStatus,
53
54    /// Index of the current phase being executed (0-based).
55    pub current_phase: u32,
56
57    /// Git branch and worktree information.
58    pub git: GitInfo,
59
60    /// Records for each execution phase.
61    pub phases: Vec<PhaseRecord>,
62
63    /// Pull request information, populated after PR creation.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub pr: Option<PrInfo>,
66
67    /// Cumulative statistics across all phases.
68    pub total: TotalStats,
69}
70
71/// Minimum number of phases: at least 1 dev phase + review + verify.
72const MIN_PHASE_COUNT: usize = 3;
73
74impl FeatureState {
75    /// Validates structural invariants after deserialization.
76    ///
77    /// Checks that `phases` has at least [`MIN_PHASE_COUNT`] entries
78    /// (1+ dev phases + review + verify), `current_phase` is within bounds,
79    /// and `worktree_path` does not contain parent-directory references.
80    ///
81    /// # Errors
82    ///
83    /// Returns a human-readable error description when validation fails.
84    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        // Reject worktree paths that escape the project root via `..`
101        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/// Basic metadata identifying a feature.
115///
116/// The `slug` serves as the unique identifier for the feature
117/// across directory names, branch names, and state files.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct FeatureInfo {
120    /// URL-safe feature slug used as unique identifier (e.g., `"add-user-auth"`).
121    pub slug: String,
122
123    /// When this feature was created.
124    pub created_at: DateTime<Utc>,
125
126    /// When this feature was last updated.
127    pub updated_at: DateTime<Utc>,
128}
129
130/// Git branch and worktree details for a feature.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct GitInfo {
133    /// Relative path to the git worktree.
134    pub worktree_path: PathBuf,
135
136    /// Branch name for this feature.
137    pub branch: String,
138
139    /// Base branch this feature was forked from.
140    pub base_branch: String,
141}
142
143/// Distinguishes development phases (from the design spec) from fixed
144/// quality-assurance phases (review, verify).
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum PhaseKind {
148    /// Development phase derived from the design spec's "Development Phases".
149    Dev,
150    /// Fixed quality-assurance phase (review or verify).
151    Quality,
152}
153
154impl Default for PhaseKind {
155    /// Defaults to `Dev` so that legacy `state.yml` files without a `kind`
156    /// field are treated as development phases.
157    fn default() -> Self {
158        Self::Dev
159    }
160}
161
162/// Record of a single execution phase.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct PhaseRecord {
165    /// Phase name (e.g., `"pub-item-extraction"`, `"review"`, `"verify"`).
166    pub name: String,
167
168    /// Whether this is a development or quality-assurance phase.
169    #[serde(default)]
170    pub kind: PhaseKind,
171
172    /// Current status of this phase.
173    pub status: PhaseStatus,
174
175    /// When execution of this phase started.
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub started_at: Option<DateTime<Utc>>,
178
179    /// When execution of this phase completed.
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub completed_at: Option<DateTime<Utc>>,
182
183    /// Number of agent conversation turns used.
184    pub turns: u32,
185
186    /// Total cost in USD for this phase.
187    pub cost_usd: f64,
188
189    /// Token usage breakdown.
190    pub cost: TokenCost,
191
192    /// Wall-clock duration in seconds.
193    pub duration_secs: u64,
194
195    /// Phase-specific details (flexible schema).
196    pub details: serde_json::Value,
197}
198
199/// Token usage breakdown for cost tracking.
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct TokenCost {
202    /// Number of input tokens consumed.
203    pub input_tokens: u64,
204
205    /// Number of output tokens generated.
206    pub output_tokens: u64,
207}
208
209/// Pull request information for a completed feature.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct PrInfo {
212    /// Full URL to the pull request.
213    pub url: String,
214
215    /// PR number in the repository.
216    pub number: u32,
217
218    /// PR title.
219    pub title: String,
220}
221
222/// Cumulative statistics across all phases.
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct TotalStats {
225    /// Total conversation turns across all phases.
226    pub turns: u32,
227
228    /// Total cost in USD across all phases.
229    pub cost_usd: f64,
230
231    /// Total token usage across all phases.
232    pub cost: TokenCost,
233
234    /// Total wall-clock duration in seconds.
235    pub duration_secs: u64,
236}
237
238/// Overall status of a feature.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241#[non_exhaustive]
242pub enum FeatureStatus {
243    /// Feature has been planned but not started.
244    Planned,
245    /// Feature is currently being executed.
246    InProgress,
247    /// Feature has been completed successfully.
248    Completed,
249    /// Feature execution failed.
250    Failed,
251    /// Feature's PR has been merged and worktree cleaned up.
252    Merged,
253}
254
255/// Status of an individual execution phase.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258#[non_exhaustive]
259pub enum PhaseStatus {
260    /// Phase has not started yet.
261    Pending,
262    /// Phase is currently executing.
263    Running,
264    /// Phase completed successfully.
265    Completed,
266    /// Phase failed.
267    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![], // wrong: need at least 3
524            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        // pr field should be omitted entirely when None
594        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}