use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseSummary {
pub draft_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase_title: Option<String>,
pub artifact_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MilestoneDraft {
pub milestone_id: String,
pub milestone_title: String,
pub source_drafts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub milestone_branch: Option<String>,
pub phase_summaries: Vec<PhaseSummary>,
pub created_at: DateTime<Utc>,
}
impl MilestoneDraft {
pub fn save(&self, workspace_root: &std::path::Path) -> anyhow::Result<()> {
let milestones_dir = workspace_root.join(".ta").join("milestones");
std::fs::create_dir_all(&milestones_dir)?;
let path = milestones_dir.join(format!("{}.json", self.milestone_id));
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content).map_err(|e| {
anyhow::anyhow!(
"Failed to write milestone draft to {}: {}",
path.display(),
e
)
})
}
pub fn load(workspace_root: &std::path::Path, milestone_id: &str) -> anyhow::Result<Self> {
let path = workspace_root
.join(".ta")
.join("milestones")
.join(format!("{}.json", milestone_id));
let content = std::fs::read_to_string(&path).map_err(|e| {
anyhow::anyhow!(
"Milestone draft not found at {}: {}\n\
List milestones with: ls .ta/milestones/",
path.display(),
e
)
})?;
serde_json::from_str(&content).map_err(|e| {
anyhow::anyhow!("Failed to parse milestone draft {}: {}", path.display(), e)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn make_milestone(id: &str, title: &str) -> MilestoneDraft {
MilestoneDraft {
milestone_id: id.to_string(),
milestone_title: title.to_string(),
source_drafts: vec!["draft-aaa".to_string(), "draft-bbb".to_string()],
milestone_branch: Some("feature/milestone-1".to_string()),
phase_summaries: vec![
PhaseSummary {
draft_id: "draft-aaa".to_string(),
phase_id: Some("v0.15.14".to_string()),
phase_title: Some("Phase title A".to_string()),
artifact_count: 3,
},
PhaseSummary {
draft_id: "draft-bbb".to_string(),
phase_id: Some("v0.15.15".to_string()),
phase_title: Some("Phase title B".to_string()),
artifact_count: 2,
},
],
created_at: Utc::now(),
}
}
#[test]
fn milestone_draft_save_load() {
let dir = tempdir().unwrap();
let milestone = make_milestone("test-milestone-id", "Test Milestone");
milestone.save(dir.path()).unwrap();
let loaded = MilestoneDraft::load(dir.path(), "test-milestone-id").unwrap();
assert_eq!(loaded.milestone_id, "test-milestone-id");
assert_eq!(loaded.milestone_title, "Test Milestone");
assert_eq!(loaded.source_drafts.len(), 2);
assert_eq!(loaded.phase_summaries.len(), 2);
assert_eq!(loaded.phase_summaries[0].artifact_count, 3);
assert_eq!(
loaded.milestone_branch.as_deref(),
Some("feature/milestone-1")
);
}
#[test]
fn milestone_draft_roundtrip_json() {
let milestone = make_milestone("roundtrip-id", "Roundtrip Milestone");
let json = serde_json::to_string_pretty(&milestone).unwrap();
let restored: MilestoneDraft = serde_json::from_str(&json).unwrap();
assert_eq!(restored.milestone_id, "roundtrip-id");
assert_eq!(restored.source_drafts, milestone.source_drafts);
assert_eq!(restored.phase_summaries[1].draft_id, "draft-bbb");
}
#[test]
fn milestone_draft_load_missing_returns_error() {
let dir = tempdir().unwrap();
let err = MilestoneDraft::load(dir.path(), "nonexistent-id").unwrap_err();
assert!(
err.to_string().contains("not found") || err.to_string().contains("No such file"),
"Expected not-found error, got: {}",
err
);
}
}