Skip to main content

scud/commands/
convert.rs

1//! Convert between task storage formats
2
3use anyhow::{Context, Result};
4use colored::Colorize;
5use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8
9use crate::formats::{parse_scg, serialize_scg, Format};
10use crate::models::Phase;
11use crate::storage::Storage;
12
13pub fn run(
14    project_root: Option<PathBuf>,
15    from_format: &str,
16    to_format: &str,
17    backup: bool,
18) -> Result<()> {
19    let from = Format::from_extension(from_format)
20        .ok_or_else(|| anyhow::anyhow!("Unknown format: {}", from_format))?;
21    let to = Format::from_extension(to_format)
22        .ok_or_else(|| anyhow::anyhow!("Unknown format: {}", to_format))?;
23
24    if from == to {
25        println!("{}", "Source and target formats are the same".yellow());
26        return Ok(());
27    }
28
29    // SCG → JSON conversion is blocked because the CLI only reads tasks.scg
30    // Converting would break all storage operations
31    if from == Format::Scg && to == Format::Json {
32        anyhow::bail!(
33            "SCG to JSON conversion is not supported.\n\
34             The SCUD CLI requires tasks.scg for storage.\n\
35             Use 'scud show' or 'scud list' to view tasks."
36        );
37    }
38
39    let storage = Storage::new(project_root);
40    let taskmaster_dir = storage.scud_dir();
41    let tasks_dir = taskmaster_dir.join("tasks");
42
43    // Determine source file
44    let source_file = tasks_dir.join(format!("tasks.{}", from.extension()));
45    let target_file = tasks_dir.join(format!("tasks.{}", to.extension()));
46
47    if !source_file.exists() {
48        anyhow::bail!(
49            "Source file not found: {}\nExpected format: {}",
50            source_file.display(),
51            from_format
52        );
53    }
54
55    println!(
56        "{} {} -> {}",
57        "Converting".blue(),
58        source_file.display(),
59        target_file.display()
60    );
61
62    // Read source
63    let content = fs::read_to_string(&source_file)
64        .with_context(|| format!("Failed to read {}", source_file.display()))?;
65
66    // Parse based on source format
67    let phases: HashMap<String, Phase> = match from {
68        Format::Json => serde_json::from_str(&content).with_context(|| "Failed to parse JSON")?,
69        Format::Scg => {
70            // Parse multi-phase SCG
71            parse_multi_phase_scg(&content)?
72        }
73    };
74
75    println!("  {} phase(s) found", phases.len());
76    for (tag, phase) in &phases {
77        println!("    {} {} tasks", tag.cyan(), phase.tasks.len());
78    }
79
80    // Serialize to target format
81    let output = match to {
82        Format::Json => {
83            serde_json::to_string_pretty(&phases).with_context(|| "Failed to serialize to JSON")?
84        }
85        Format::Scg => {
86            let mut out = String::new();
87            let mut sorted_tags: Vec<_> = phases.keys().collect();
88            sorted_tags.sort();
89
90            for (i, tag) in sorted_tags.iter().enumerate() {
91                if i > 0 {
92                    out.push_str("\n---\n\n");
93                }
94                let phase = phases.get(*tag).unwrap();
95                out.push_str(&serialize_scg(phase));
96            }
97            out
98        }
99    };
100
101    // Backup if requested
102    if backup && source_file.exists() {
103        let backup_file = tasks_dir.join(format!("tasks.{}.backup", from.extension()));
104        fs::copy(&source_file, &backup_file)
105            .with_context(|| format!("Failed to create backup at {}", backup_file.display()))?;
106        println!(
107            "  {} Backup created: {}",
108            "✓".green(),
109            backup_file.display()
110        );
111    }
112
113    // Write target
114    fs::write(&target_file, &output)
115        .with_context(|| format!("Failed to write {}", target_file.display()))?;
116
117    // Remove source if different file
118    if source_file != target_file {
119        fs::remove_file(&source_file)
120            .with_context(|| format!("Failed to remove old file {}", source_file.display()))?;
121    }
122
123    println!();
124    println!("{}", "Conversion complete!".green().bold());
125    println!();
126    println!("{}", "Verify with:".blue());
127    println!("  scud list");
128    println!("  scud stats");
129
130    Ok(())
131}
132
133fn parse_multi_phase_scg(content: &str) -> Result<HashMap<String, Phase>> {
134    let mut phases = HashMap::new();
135
136    // Empty content returns empty map
137    if content.trim().is_empty() {
138        return Ok(phases);
139    }
140
141    // Split by phase separator (---)
142    let sections: Vec<&str> = content.split("\n---\n").collect();
143
144    for section in sections {
145        let section = section.trim();
146        if section.is_empty() {
147            continue;
148        }
149
150        let phase = parse_scg(section).with_context(|| "Failed to parse SCG section")?;
151
152        phases.insert(phase.name.clone(), phase);
153    }
154
155    Ok(phases)
156}