Skip to main content

ta_changeset/
milestone_draft.rs

1// milestone_draft.rs — MilestoneDraft: aggregated multi-phase draft (v0.15.14).
2//
3// A MilestoneDraft collects draft IDs from multiple phase runs into a single
4// review unit. Stored as `.ta/milestones/<milestone-id>.json`.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// A summary of one phase's draft contribution to a milestone.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct PhaseSummary {
12    /// The draft ID produced by the phase run.
13    pub draft_id: String,
14    /// Optional plan phase ID (e.g. "v0.15.14").
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub phase_id: Option<String>,
17    /// Optional human-readable phase title.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub phase_title: Option<String>,
20    /// Number of artifacts in this draft.
21    pub artifact_count: usize,
22}
23
24/// An aggregated milestone draft collecting multiple per-phase drafts.
25///
26/// Stored at `.ta/milestones/<milestone_id>.json`.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct MilestoneDraft {
29    /// Unique milestone identifier (UUID).
30    pub milestone_id: String,
31    /// Human-readable title for the milestone.
32    pub milestone_title: String,
33    /// Ordered list of draft IDs included in this milestone.
34    pub source_drafts: Vec<String>,
35    /// Optional branch name for milestone-branch application mode.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub milestone_branch: Option<String>,
38    /// Per-phase summaries, one per source draft.
39    pub phase_summaries: Vec<PhaseSummary>,
40    /// When this milestone was created.
41    pub created_at: DateTime<Utc>,
42}
43
44impl MilestoneDraft {
45    /// Persist the milestone to `.ta/milestones/<milestone_id>.json`.
46    pub fn save(&self, workspace_root: &std::path::Path) -> anyhow::Result<()> {
47        let milestones_dir = workspace_root.join(".ta").join("milestones");
48        std::fs::create_dir_all(&milestones_dir)?;
49        let path = milestones_dir.join(format!("{}.json", self.milestone_id));
50        let content = serde_json::to_string_pretty(self)?;
51        std::fs::write(&path, content).map_err(|e| {
52            anyhow::anyhow!(
53                "Failed to write milestone draft to {}: {}",
54                path.display(),
55                e
56            )
57        })
58    }
59
60    /// Load a milestone from `.ta/milestones/<milestone_id>.json`.
61    pub fn load(workspace_root: &std::path::Path, milestone_id: &str) -> anyhow::Result<Self> {
62        let path = workspace_root
63            .join(".ta")
64            .join("milestones")
65            .join(format!("{}.json", milestone_id));
66        let content = std::fs::read_to_string(&path).map_err(|e| {
67            anyhow::anyhow!(
68                "Milestone draft not found at {}: {}\n\
69                 List milestones with: ls .ta/milestones/",
70                path.display(),
71                e
72            )
73        })?;
74        serde_json::from_str(&content).map_err(|e| {
75            anyhow::anyhow!("Failed to parse milestone draft {}: {}", path.display(), e)
76        })
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use tempfile::tempdir;
84
85    fn make_milestone(id: &str, title: &str) -> MilestoneDraft {
86        MilestoneDraft {
87            milestone_id: id.to_string(),
88            milestone_title: title.to_string(),
89            source_drafts: vec!["draft-aaa".to_string(), "draft-bbb".to_string()],
90            milestone_branch: Some("feature/milestone-1".to_string()),
91            phase_summaries: vec![
92                PhaseSummary {
93                    draft_id: "draft-aaa".to_string(),
94                    phase_id: Some("v0.15.14".to_string()),
95                    phase_title: Some("Phase title A".to_string()),
96                    artifact_count: 3,
97                },
98                PhaseSummary {
99                    draft_id: "draft-bbb".to_string(),
100                    phase_id: Some("v0.15.15".to_string()),
101                    phase_title: Some("Phase title B".to_string()),
102                    artifact_count: 2,
103                },
104            ],
105            created_at: Utc::now(),
106        }
107    }
108
109    #[test]
110    fn milestone_draft_save_load() {
111        let dir = tempdir().unwrap();
112        let milestone = make_milestone("test-milestone-id", "Test Milestone");
113        milestone.save(dir.path()).unwrap();
114
115        let loaded = MilestoneDraft::load(dir.path(), "test-milestone-id").unwrap();
116        assert_eq!(loaded.milestone_id, "test-milestone-id");
117        assert_eq!(loaded.milestone_title, "Test Milestone");
118        assert_eq!(loaded.source_drafts.len(), 2);
119        assert_eq!(loaded.phase_summaries.len(), 2);
120        assert_eq!(loaded.phase_summaries[0].artifact_count, 3);
121        assert_eq!(
122            loaded.milestone_branch.as_deref(),
123            Some("feature/milestone-1")
124        );
125    }
126
127    #[test]
128    fn milestone_draft_roundtrip_json() {
129        let milestone = make_milestone("roundtrip-id", "Roundtrip Milestone");
130        let json = serde_json::to_string_pretty(&milestone).unwrap();
131        let restored: MilestoneDraft = serde_json::from_str(&json).unwrap();
132        assert_eq!(restored.milestone_id, "roundtrip-id");
133        assert_eq!(restored.source_drafts, milestone.source_drafts);
134        assert_eq!(restored.phase_summaries[1].draft_id, "draft-bbb");
135    }
136
137    #[test]
138    fn milestone_draft_load_missing_returns_error() {
139        let dir = tempdir().unwrap();
140        let err = MilestoneDraft::load(dir.path(), "nonexistent-id").unwrap_err();
141        assert!(
142            err.to_string().contains("not found") || err.to_string().contains("No such file"),
143            "Expected not-found error, got: {}",
144            err
145        );
146    }
147}