Skip to main content

task_graph_mcp/cli/
migrate.rs

1//! Migration command for moving from deprecated `.task-graph/` to `task-graph/`.
2
3use anyhow::{Context, Result};
4use clap::Args;
5use std::fs;
6use std::path::Path;
7
8/// Arguments for the migrate command.
9#[derive(Args, Debug)]
10pub struct MigrateArgs {
11    /// Perform migration without prompting for confirmation.
12    #[arg(short = 'y', long)]
13    pub yes: bool,
14
15    /// Show what would be migrated without making changes.
16    #[arg(long)]
17    pub dry_run: bool,
18
19    /// Source directory (default: .task-graph)
20    #[arg(long, default_value = ".task-graph")]
21    pub from: String,
22
23    /// Target directory (default: task-graph)
24    #[arg(long, default_value = "task-graph")]
25    pub to: String,
26}
27
28/// Run the migration command.
29pub fn run_migrate(args: &MigrateArgs) -> Result<()> {
30    let from = Path::new(&args.from);
31    let to = Path::new(&args.to);
32
33    // Check if source exists
34    if !from.exists() {
35        println!("No migration needed: '{}' does not exist.", args.from);
36        return Ok(());
37    }
38
39    // Check if target already exists
40    if to.exists() {
41        println!(
42            "Target directory '{}' already exists. Cannot migrate.",
43            args.to
44        );
45        println!("Options:");
46        println!("  1. Remove '{}' and run migrate again", args.to);
47        println!("  2. Manually merge the directories");
48        println!("  3. Use --to to specify a different target");
49        return Ok(());
50    }
51
52    // Show what will be migrated
53    println!("Migration plan:");
54    println!("  From: {}", from.display());
55    println!("  To:   {}", to.display());
56    println!();
57
58    // List contents
59    let entries = list_directory_contents(from)?;
60    if entries.is_empty() {
61        println!("  (empty directory)");
62    } else {
63        for entry in &entries {
64            println!("  {}", entry);
65        }
66    }
67    println!();
68
69    if args.dry_run {
70        println!("Dry run: No changes made.");
71        return Ok(());
72    }
73
74    // Confirm unless --yes
75    if !args.yes {
76        println!("This will move '{}' to '{}'.", args.from, args.to);
77        print!("Continue? [y/N] ");
78        use std::io::Write;
79        std::io::stdout().flush()?;
80
81        let mut input = String::new();
82        std::io::stdin().read_line(&mut input)?;
83
84        if !input.trim().eq_ignore_ascii_case("y") {
85            println!("Migration cancelled.");
86            return Ok(());
87        }
88    }
89
90    // Perform the migration
91    println!("Migrating...");
92    fs::rename(from, to).context("Failed to rename directory")?;
93
94    println!("Migration complete!");
95    println!();
96    println!("Your configuration has been moved to '{}'.", args.to);
97    println!();
98    println!("If you have any scripts or configurations that reference '.task-graph/',");
99    println!("please update them to use 'task-graph/' instead.");
100
101    Ok(())
102}
103
104/// List directory contents recursively (for display).
105fn list_directory_contents(dir: &Path) -> Result<Vec<String>> {
106    let mut entries = Vec::new();
107    list_directory_recursive(dir, dir, &mut entries)?;
108    entries.sort();
109    Ok(entries)
110}
111
112fn list_directory_recursive(base: &Path, dir: &Path, entries: &mut Vec<String>) -> Result<()> {
113    if !dir.is_dir() {
114        return Ok(());
115    }
116
117    for entry in fs::read_dir(dir)? {
118        let entry = entry?;
119        let path = entry.path();
120        let relative = path.strip_prefix(base).unwrap_or(&path);
121
122        if path.is_dir() {
123            entries.push(format!("{}/", relative.display()));
124            list_directory_recursive(base, &path, entries)?;
125        } else {
126            entries.push(relative.display().to_string());
127        }
128    }
129
130    Ok(())
131}
132
133/// Check if migration is recommended and print a warning.
134pub fn check_and_warn_deprecated() {
135    let deprecated = Path::new(".task-graph");
136    let new_location = Path::new("task-graph");
137
138    if deprecated.exists() && !new_location.exists() {
139        eprintln!();
140        eprintln!("Warning: Using deprecated directory '.task-graph/'.");
141        eprintln!("Run 'task-graph migrate' to move to 'task-graph/'.");
142        eprintln!();
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::path::PathBuf;
150    use tempfile::TempDir;
151
152    fn create_test_structure(temp: &TempDir) -> PathBuf {
153        let base = temp.path().to_path_buf();
154        let deprecated = base.join(".task-graph");
155
156        // Create deprecated directory structure
157        fs::create_dir_all(deprecated.join("skills")).unwrap();
158        fs::create_dir_all(deprecated.join("media")).unwrap();
159        fs::write(
160            deprecated.join("config.yaml"),
161            "server:\n  claim_limit: 10\n",
162        )
163        .unwrap();
164        fs::write(deprecated.join("tasks.db"), "fake-db-content").unwrap();
165        fs::write(deprecated.join("skills/custom.md"), "# Custom Skill").unwrap();
166
167        base
168    }
169
170    #[test]
171    fn test_migrate_dry_run_no_changes() {
172        let temp = TempDir::new().unwrap();
173        let base = create_test_structure(&temp);
174
175        let args = MigrateArgs {
176            yes: false,
177            dry_run: true,
178            from: base.join(".task-graph").to_string_lossy().to_string(),
179            to: base.join("task-graph").to_string_lossy().to_string(),
180        };
181
182        run_migrate(&args).unwrap();
183
184        // Source should still exist
185        assert!(base.join(".task-graph").exists());
186        // Target should not exist
187        assert!(!base.join("task-graph").exists());
188    }
189
190    #[test]
191    fn test_migrate_moves_directory() {
192        let temp = TempDir::new().unwrap();
193        let base = create_test_structure(&temp);
194
195        let args = MigrateArgs {
196            yes: true, // Skip confirmation
197            dry_run: false,
198            from: base.join(".task-graph").to_string_lossy().to_string(),
199            to: base.join("task-graph").to_string_lossy().to_string(),
200        };
201
202        run_migrate(&args).unwrap();
203
204        // Source should no longer exist
205        assert!(!base.join(".task-graph").exists());
206        // Target should exist with all contents
207        assert!(base.join("task-graph").exists());
208        assert!(base.join("task-graph/config.yaml").exists());
209        assert!(base.join("task-graph/tasks.db").exists());
210        assert!(base.join("task-graph/skills/custom.md").exists());
211
212        // Verify content
213        let config = fs::read_to_string(base.join("task-graph/config.yaml")).unwrap();
214        assert!(config.contains("claim_limit: 10"));
215    }
216
217    #[test]
218    fn test_migrate_no_source() {
219        let temp = TempDir::new().unwrap();
220        let base = temp.path();
221
222        let args = MigrateArgs {
223            yes: true,
224            dry_run: false,
225            from: base.join(".task-graph").to_string_lossy().to_string(),
226            to: base.join("task-graph").to_string_lossy().to_string(),
227        };
228
229        // Should succeed with "no migration needed" message
230        run_migrate(&args).unwrap();
231    }
232
233    #[test]
234    fn test_migrate_target_exists() {
235        let temp = TempDir::new().unwrap();
236        let base = create_test_structure(&temp);
237
238        // Create target directory
239        fs::create_dir_all(base.join("task-graph")).unwrap();
240
241        let args = MigrateArgs {
242            yes: true,
243            dry_run: false,
244            from: base.join(".task-graph").to_string_lossy().to_string(),
245            to: base.join("task-graph").to_string_lossy().to_string(),
246        };
247
248        // Should succeed but not migrate (target exists)
249        run_migrate(&args).unwrap();
250
251        // Both should still exist
252        assert!(base.join(".task-graph").exists());
253        assert!(base.join("task-graph").exists());
254    }
255}