use anyhow::{Context, Result};
use clap::Args;
use std::fs;
use std::path::Path;
#[derive(Args, Debug)]
pub struct MigrateArgs {
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long, default_value = ".task-graph")]
pub from: String,
#[arg(long, default_value = "task-graph")]
pub to: String,
}
pub fn run_migrate(args: &MigrateArgs) -> Result<()> {
let from = Path::new(&args.from);
let to = Path::new(&args.to);
if !from.exists() {
println!("No migration needed: '{}' does not exist.", args.from);
return Ok(());
}
if to.exists() {
println!(
"Target directory '{}' already exists. Cannot migrate.",
args.to
);
println!("Options:");
println!(" 1. Remove '{}' and run migrate again", args.to);
println!(" 2. Manually merge the directories");
println!(" 3. Use --to to specify a different target");
return Ok(());
}
println!("Migration plan:");
println!(" From: {}", from.display());
println!(" To: {}", to.display());
println!();
let entries = list_directory_contents(from)?;
if entries.is_empty() {
println!(" (empty directory)");
} else {
for entry in &entries {
println!(" {}", entry);
}
}
println!();
if args.dry_run {
println!("Dry run: No changes made.");
return Ok(());
}
if !args.yes {
println!("This will move '{}' to '{}'.", args.from, args.to);
print!("Continue? [y/N] ");
use std::io::Write;
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Migration cancelled.");
return Ok(());
}
}
println!("Migrating...");
fs::rename(from, to).context("Failed to rename directory")?;
println!("Migration complete!");
println!();
println!("Your configuration has been moved to '{}'.", args.to);
println!();
println!("If you have any scripts or configurations that reference '.task-graph/',");
println!("please update them to use 'task-graph/' instead.");
Ok(())
}
fn list_directory_contents(dir: &Path) -> Result<Vec<String>> {
let mut entries = Vec::new();
list_directory_recursive(dir, dir, &mut entries)?;
entries.sort();
Ok(entries)
}
fn list_directory_recursive(base: &Path, dir: &Path, entries: &mut Vec<String>) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let relative = path.strip_prefix(base).unwrap_or(&path);
if path.is_dir() {
entries.push(format!("{}/", relative.display()));
list_directory_recursive(base, &path, entries)?;
} else {
entries.push(relative.display().to_string());
}
}
Ok(())
}
pub fn check_and_warn_deprecated() {
let deprecated = Path::new(".task-graph");
let new_location = Path::new("task-graph");
if deprecated.exists() && !new_location.exists() {
eprintln!();
eprintln!("Warning: Using deprecated directory '.task-graph/'.");
eprintln!("Run 'task-graph migrate' to move to 'task-graph/'.");
eprintln!();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_test_structure(temp: &TempDir) -> PathBuf {
let base = temp.path().to_path_buf();
let deprecated = base.join(".task-graph");
fs::create_dir_all(deprecated.join("skills")).unwrap();
fs::create_dir_all(deprecated.join("media")).unwrap();
fs::write(
deprecated.join("config.yaml"),
"server:\n claim_limit: 10\n",
)
.unwrap();
fs::write(deprecated.join("tasks.db"), "fake-db-content").unwrap();
fs::write(deprecated.join("skills/custom.md"), "# Custom Skill").unwrap();
base
}
#[test]
fn test_migrate_dry_run_no_changes() {
let temp = TempDir::new().unwrap();
let base = create_test_structure(&temp);
let args = MigrateArgs {
yes: false,
dry_run: true,
from: base.join(".task-graph").to_string_lossy().to_string(),
to: base.join("task-graph").to_string_lossy().to_string(),
};
run_migrate(&args).unwrap();
assert!(base.join(".task-graph").exists());
assert!(!base.join("task-graph").exists());
}
#[test]
fn test_migrate_moves_directory() {
let temp = TempDir::new().unwrap();
let base = create_test_structure(&temp);
let args = MigrateArgs {
yes: true, dry_run: false,
from: base.join(".task-graph").to_string_lossy().to_string(),
to: base.join("task-graph").to_string_lossy().to_string(),
};
run_migrate(&args).unwrap();
assert!(!base.join(".task-graph").exists());
assert!(base.join("task-graph").exists());
assert!(base.join("task-graph/config.yaml").exists());
assert!(base.join("task-graph/tasks.db").exists());
assert!(base.join("task-graph/skills/custom.md").exists());
let config = fs::read_to_string(base.join("task-graph/config.yaml")).unwrap();
assert!(config.contains("claim_limit: 10"));
}
#[test]
fn test_migrate_no_source() {
let temp = TempDir::new().unwrap();
let base = temp.path();
let args = MigrateArgs {
yes: true,
dry_run: false,
from: base.join(".task-graph").to_string_lossy().to_string(),
to: base.join("task-graph").to_string_lossy().to_string(),
};
run_migrate(&args).unwrap();
}
#[test]
fn test_migrate_target_exists() {
let temp = TempDir::new().unwrap();
let base = create_test_structure(&temp);
fs::create_dir_all(base.join("task-graph")).unwrap();
let args = MigrateArgs {
yes: true,
dry_run: false,
from: base.join(".task-graph").to_string_lossy().to_string(),
to: base.join("task-graph").to_string_lossy().to_string(),
};
run_migrate(&args).unwrap();
assert!(base.join(".task-graph").exists());
assert!(base.join("task-graph").exists());
}
}