1use std::fs;
6use std::path::PathBuf;
7use std::time::{SystemTime, UNIX_EPOCH};
8use uuid::Uuid;
9
10use super::types::*;
11
12fn get_plans_dir() -> PathBuf {
14 dirs::home_dir()
15 .unwrap_or_else(|| PathBuf::from("."))
16 .join(".aster")
17 .join("plans")
18}
19
20fn get_templates_dir() -> PathBuf {
22 dirs::home_dir()
23 .unwrap_or_else(|| PathBuf::from("."))
24 .join(".aster")
25 .join("plan-templates")
26}
27
28fn get_versions_dir() -> PathBuf {
30 dirs::home_dir()
31 .unwrap_or_else(|| PathBuf::from("."))
32 .join(".aster")
33 .join("plan-versions")
34}
35
36const PLAN_EXPIRY_DAYS: u64 = 90;
38
39pub struct PlanPersistenceManager;
41
42impl PlanPersistenceManager {
43 fn ensure_dirs() {
45 for dir in [get_plans_dir(), get_templates_dir(), get_versions_dir()] {
46 if !dir.exists() {
47 let _ = fs::create_dir_all(&dir);
48 }
49 }
50 }
51
52 pub fn generate_plan_id() -> String {
54 let timestamp = SystemTime::now()
55 .duration_since(UNIX_EPOCH)
56 .unwrap_or_default()
57 .as_millis();
58 let uuid_str = Uuid::new_v4().to_string();
59 let random = uuid_str.get(..8).unwrap_or(&uuid_str);
60 format!("plan-{:x}-{}", timestamp, random)
61 }
62
63 fn get_plan_file_path(id: &str) -> PathBuf {
65 get_plans_dir().join(format!("{}.json", id))
66 }
67
68 fn get_version_file_path(plan_id: &str, version: u32) -> PathBuf {
70 get_versions_dir().join(format!("{}-v{}.json", plan_id, version))
71 }
72
73 pub fn save_plan(plan: &mut SavedPlan, create_version: bool) -> Result<(), String> {
75 Self::ensure_dirs();
76
77 let now = current_timestamp();
78 plan.metadata.updated_at = now;
79
80 if plan.metadata.created_at == 0 {
81 plan.metadata.created_at = now;
82 plan.metadata.version = 1;
83 }
84
85 let file_path = Self::get_plan_file_path(&plan.metadata.id);
86
87 if create_version && file_path.exists() {
89 if let Ok(old_plan) = Self::load_plan(&plan.metadata.id) {
90 let _ = Self::save_version(&old_plan);
91 plan.metadata.version = old_plan.metadata.version + 1;
92 }
93 }
94
95 let data = serde_json::to_string_pretty(plan)
96 .map_err(|e| format!("Failed to serialize plan: {}", e))?;
97
98 fs::write(&file_path, data).map_err(|e| format!("Failed to write plan file: {}", e))?;
99
100 Ok(())
101 }
102
103 pub fn load_plan(id: &str) -> Result<SavedPlan, String> {
105 let file_path = Self::get_plan_file_path(id);
106
107 if !file_path.exists() {
108 return Err(format!("Plan not found: {}", id));
109 }
110
111 let data = fs::read_to_string(&file_path)
112 .map_err(|e| format!("Failed to read plan file: {}", e))?;
113
114 let plan: SavedPlan =
115 serde_json::from_str(&data).map_err(|e| format!("Failed to parse plan: {}", e))?;
116
117 if Self::is_expired(&plan) {
118 return Err("Plan has expired".to_string());
119 }
120
121 Ok(plan)
122 }
123
124 pub fn delete_plan(id: &str, delete_versions: bool) -> Result<(), String> {
126 let file_path = Self::get_plan_file_path(id);
127
128 if file_path.exists() {
129 fs::remove_file(&file_path).map_err(|e| format!("Failed to delete plan: {}", e))?;
130 }
131
132 if delete_versions {
133 if let Ok(versions) = Self::list_versions(id) {
134 for version in versions {
135 let version_path = Self::get_version_file_path(id, version.version);
136 let _ = fs::remove_file(version_path);
137 }
138 }
139 }
140
141 Ok(())
142 }
143
144 pub fn list_plans(options: &PlanListOptions) -> Vec<SavedPlan> {
146 Self::ensure_dirs();
147
148 let plans_dir = get_plans_dir();
149 let mut plans = Vec::new();
150
151 if let Ok(entries) = fs::read_dir(&plans_dir) {
152 for entry in entries.flatten() {
153 let path = entry.path();
154 if path.extension().map(|e| e == "json").unwrap_or(false) {
155 if let Some(id) = path.file_stem().and_then(|s| s.to_str()) {
156 if let Ok(plan) = Self::load_plan(id) {
157 plans.push(plan);
158 }
159 }
160 }
161 }
162 }
163
164 plans = Self::apply_filters(plans, options);
166 plans = Self::apply_sorting(plans, options);
167
168 let offset = options.offset.unwrap_or(0);
170 let limit = options.limit.unwrap_or(plans.len());
171 plans.into_iter().skip(offset).take(limit).collect()
172 }
173
174 fn apply_filters(mut plans: Vec<SavedPlan>, options: &PlanListOptions) -> Vec<SavedPlan> {
176 if let Some(ref search) = options.search {
178 let search_lower = search.to_lowercase();
179 plans.retain(|p| {
180 p.metadata.title.to_lowercase().contains(&search_lower)
181 || p.metadata
182 .description
183 .to_lowercase()
184 .contains(&search_lower)
185 || p.summary.to_lowercase().contains(&search_lower)
186 });
187 }
188
189 if let Some(ref tags) = options.tags {
191 plans.retain(|p| {
192 p.metadata
193 .tags
194 .as_ref()
195 .is_some_and(|plan_tags| tags.iter().any(|t| plan_tags.contains(t)))
196 });
197 }
198
199 if let Some(ref statuses) = options.status {
201 plans.retain(|p| statuses.contains(&p.metadata.status));
202 }
203
204 if let Some(ref priorities) = options.priority {
206 plans.retain(|p| {
207 p.metadata
208 .priority
209 .as_ref()
210 .is_some_and(|pr| priorities.contains(pr))
211 });
212 }
213
214 if let Some(ref wd) = options.working_directory {
216 plans.retain(|p| p.metadata.working_directory.starts_with(wd));
217 }
218
219 plans
220 }
221
222 fn apply_sorting(mut plans: Vec<SavedPlan>, options: &PlanListOptions) -> Vec<SavedPlan> {
224 let sort_by = options.sort_by.unwrap_or(SortField::UpdatedAt);
225 let sort_order = options.sort_order.unwrap_or(SortOrder::Desc);
226
227 plans.sort_by(|a, b| {
228 let cmp = match sort_by {
229 SortField::CreatedAt => a.metadata.created_at.cmp(&b.metadata.created_at),
230 SortField::UpdatedAt => a.metadata.updated_at.cmp(&b.metadata.updated_at),
231 SortField::Title => a.metadata.title.cmp(&b.metadata.title),
232 SortField::Priority => {
233 let pa = priority_to_num(a.metadata.priority.as_ref());
234 let pb = priority_to_num(b.metadata.priority.as_ref());
235 pa.cmp(&pb)
236 }
237 SortField::Status => {
238 format!("{:?}", a.metadata.status).cmp(&format!("{:?}", b.metadata.status))
239 }
240 };
241
242 match sort_order {
243 SortOrder::Asc => cmp,
244 SortOrder::Desc => cmp.reverse(),
245 }
246 });
247
248 plans
249 }
250
251 fn is_expired(plan: &SavedPlan) -> bool {
253 let now = current_timestamp();
254 let age_ms = now.saturating_sub(plan.metadata.created_at);
255 let expiry_ms = PLAN_EXPIRY_DAYS * 24 * 60 * 60 * 1000;
256 age_ms > expiry_ms
257 }
258
259 pub fn save_version(plan: &SavedPlan) -> Result<(), String> {
261 Self::ensure_dirs();
262
263 let version = plan.metadata.version;
264 let version_path = Self::get_version_file_path(&plan.metadata.id, version);
265
266 let data = serde_json::to_string_pretty(plan)
267 .map_err(|e| format!("Failed to serialize version: {}", e))?;
268
269 fs::write(&version_path, data)
270 .map_err(|e| format!("Failed to write version file: {}", e))?;
271
272 Ok(())
273 }
274
275 pub fn list_versions(plan_id: &str) -> Result<Vec<PlanVersion>, String> {
277 let versions_dir = get_versions_dir();
278 let mut versions = Vec::new();
279
280 let current_plan = Self::load_plan(plan_id).ok();
281 let current_version = current_plan
282 .as_ref()
283 .map(|p| p.metadata.version)
284 .unwrap_or(1);
285
286 if let Ok(entries) = fs::read_dir(&versions_dir) {
287 for entry in entries.flatten() {
288 let path = entry.path();
289 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
290
291 if !filename.starts_with(plan_id) || !filename.ends_with(".json") {
292 continue;
293 }
294
295 if let Some(version) = extract_version_number(filename) {
296 let metadata = fs::metadata(&path).ok();
297 let created_at = metadata
298 .and_then(|m| m.modified().ok())
299 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
300 .map(|d| d.as_millis() as u64)
301 .unwrap_or(0);
302
303 versions.push(PlanVersion {
304 version,
305 plan_id: plan_id.to_string(),
306 created_at,
307 change_summary: format!("Version {}", version),
308 author: None,
309 is_current: version == current_version,
310 });
311 }
312 }
313 }
314
315 versions.sort_by(|a, b| b.version.cmp(&a.version));
316 Ok(versions)
317 }
318
319 pub fn restore_version(plan_id: &str, version: u32) -> Result<(), String> {
321 let version_path = Self::get_version_file_path(plan_id, version);
322
323 if !version_path.exists() {
324 return Err(format!("Version {} not found", version));
325 }
326
327 let data = fs::read_to_string(&version_path)
328 .map_err(|e| format!("Failed to read version: {}", e))?;
329
330 let mut plan: SavedPlan =
331 serde_json::from_str(&data).map_err(|e| format!("Failed to parse version: {}", e))?;
332
333 if let Ok(current) = Self::load_plan(plan_id) {
335 let _ = Self::save_version(¤t);
336 }
337
338 plan.metadata.updated_at = current_timestamp();
339 Self::save_plan(&mut plan, false)
340 }
341
342 pub fn update_plan_status(
344 id: &str,
345 status: PlanStatus,
346 approved_by: Option<&str>,
347 rejection_reason: Option<&str>,
348 ) -> Result<(), String> {
349 let mut plan = Self::load_plan(id)?;
350
351 plan.metadata.status = status;
352 plan.metadata.updated_at = current_timestamp();
353
354 if matches!(status, PlanStatus::Approved) {
355 if let Some(by) = approved_by {
356 plan.metadata.approved_by = Some(by.to_string());
357 plan.metadata.approved_at = Some(current_timestamp());
358 }
359 }
360
361 if matches!(status, PlanStatus::Rejected) {
362 if let Some(reason) = rejection_reason {
363 plan.metadata.rejection_reason = Some(reason.to_string());
364 }
365 }
366
367 if matches!(status, PlanStatus::Completed) {
368 plan.completed_at = Some(current_timestamp());
369 }
370
371 Self::save_plan(&mut plan, true)
372 }
373
374 pub fn export_plan(plan_id: &str, options: &PlanExportOptions) -> Result<String, String> {
376 let plan = Self::load_plan(plan_id)?;
377
378 match options.format {
379 ExportFormat::Json => Self::export_as_json(&plan, options),
380 ExportFormat::Markdown => Ok(Self::export_as_markdown(&plan, options)),
381 ExportFormat::Html => Ok(Self::export_as_html(&plan, options)),
382 }
383 }
384
385 fn export_as_json(plan: &SavedPlan, _options: &PlanExportOptions) -> Result<String, String> {
386 serde_json::to_string_pretty(plan).map_err(|e| format!("Failed to export as JSON: {}", e))
387 }
388
389 fn export_as_markdown(plan: &SavedPlan, options: &PlanExportOptions) -> String {
390 let mut lines = Vec::new();
391
392 lines.push(format!("# {}", plan.metadata.title));
393 lines.push(String::new());
394
395 if options.include_metadata {
396 lines.push("## Metadata".to_string());
397 lines.push(format!("- Status: {:?}", plan.metadata.status));
398 lines.push(format!("- Priority: {:?}", plan.metadata.priority));
399 lines.push(String::new());
400 }
401
402 lines.push("## Summary".to_string());
403 lines.push(plan.summary.clone());
404 lines.push(String::new());
405
406 lines.push("## Implementation Steps".to_string());
407 for step in &plan.steps {
408 lines.push(format!("### Step {}: {}", step.step, step.description));
409 lines.push(format!("- Complexity: {:?}", step.complexity));
410 lines.push(format!("- Files: {}", step.files.join(", ")));
411 lines.push(String::new());
412 }
413
414 if options.include_risks && !plan.risks.is_empty() {
415 lines.push("## Risks".to_string());
416 for risk in &plan.risks {
417 lines.push(format!("- **[{:?}]** {}", risk.level, risk.description));
418 }
419 lines.push(String::new());
420 }
421
422 lines.join("\n")
423 }
424
425 fn export_as_html(plan: &SavedPlan, options: &PlanExportOptions) -> String {
426 let markdown = Self::export_as_markdown(plan, options);
427 format!(
428 r#"<!DOCTYPE html>
429<html><head><title>{}</title></head>
430<body><pre>{}</pre></body></html>"#,
431 plan.metadata.title, markdown
432 )
433 }
434}
435
436fn current_timestamp() -> u64 {
439 SystemTime::now()
440 .duration_since(UNIX_EPOCH)
441 .unwrap_or_default()
442 .as_millis() as u64
443}
444
445fn priority_to_num(priority: Option<&Priority>) -> u8 {
446 match priority {
447 Some(Priority::Low) => 1,
448 Some(Priority::Medium) => 2,
449 Some(Priority::High) => 3,
450 Some(Priority::Critical) => 4,
451 None => 0,
452 }
453}
454
455fn extract_version_number(filename: &str) -> Option<u32> {
456 let re = regex::Regex::new(r"-v(\d+)\.json$").ok()?;
457 let caps = re.captures(filename)?;
458 caps.get(1)?.as_str().parse().ok()
459}