From 0713aa7cabde4a838d7f6c1e09cc0e7c0f8225bb Mon Sep 17 00:00:00 2001 From: Marcin Jahn <10273406+marcinjahn@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:21:11 +0100 Subject: [PATCH] Migrate configs to XDG_DATA_HOME/projects Closes #1. There's a migration automatically executed for existing users. Projects get moved to the new directory. --- src/app_init.rs | 43 ++++--- src/cli_args.rs | 6 +- src/commands/add_command.rs | 44 ++++--- src/config/locations.rs | 34 +++--- src/config/projects.rs | 72 +++++++---- src/main.rs | 25 +++- src/migration.rs | 221 ++++++++++++++++++++++++++++++++++ tests/e2e/add.bats | 12 +- tests/e2e/forget_file.bats | 8 +- tests/e2e/forget_project.bats | 2 +- tests/e2e/helpers.bash | 4 +- tests/e2e/init.bats | 20 +-- tests/e2e/list.bats | 6 +- tests/e2e/migration.bats | 67 +++++++++++ 14 files changed, 465 insertions(+), 99 deletions(-) create mode 100644 src/migration.rs create mode 100644 tests/e2e/migration.bats diff --git a/src/app_init.rs b/src/app_init.rs index 9f8e8f0..9a5af09 100644 --- a/src/app_init.rs +++ b/src/app_init.rs @@ -1,4 +1,5 @@ use crate::config::{app_config::AppConfig, locations::LocationsProvider}; +use crate::migration; use anyhow::Result; use std::{ fs::{self, File}, @@ -14,10 +15,11 @@ pub struct AppInitializer<'a> { impl<'a> AppInitializer<'a> { /// Creates config dir and file if they don't exist pub fn init(&self) -> Result<()> { + migration::migrate_projects_path_if_needed(self.locations_provider)?; + let base_config_dir = self.locations_provider.get_base_config_path()?; if !base_config_dir.exists() { - let base_config_dir = self.locations_provider.get_configs_config_path(); - fs::create_dir_all(base_config_dir)?; + fs::create_dir_all(&base_config_dir)?; } let config_file_path = self.locations_provider.get_config_file_path(); @@ -25,9 +27,9 @@ impl<'a> AppInitializer<'a> { self.create_config_file(&config_file_path)?; } - let configs_dir_path = self.locations_provider.get_configs_config_path(); - if !configs_dir_path.exists() { - fs::create_dir_all(configs_dir_path)?; + let projects_dir_path = self.locations_provider.get_projects_data_path(); + if !projects_dir_path.exists() { + fs::create_dir_all(projects_dir_path)?; } Ok(()) @@ -35,7 +37,7 @@ impl<'a> AppInitializer<'a> { fn create_config_file(&self, file_path: &Path) -> Result<()> { let file_content = AppConfig::default().to_string()?; - let mut file = File::create(&file_path)?; + let mut file = File::create(file_path)?; Ok(file.write_all(file_content.as_bytes())?) } } @@ -49,7 +51,8 @@ mod tests { #[test] fn init_when_not_initialized_yet_files_get_created() { - let (locations_provider, base_path, _temp_dir) = get_fake_locationsprovider(false); + let (locations_provider, _config_path, data_path, _temp_dirs) = + get_fake_locationsprovider(false); let init = AppInitializer { locations_provider: &locations_provider, }; @@ -59,12 +62,12 @@ mod tests { get_config_without_whitespace(locations_provider.get_config_file_path()); assert_eq!("{\"projects\":[]}", config_file_content); - assert!(base_path.join("configs").exists()) + assert!(data_path.join("projects").exists()) } #[test] fn init_when_base_dir_exists_but_config_file_doesnt_then_config_file_gets_created() { - let (locations_provider, _, _temp_dir) = get_fake_locationsprovider(true); + let (locations_provider, _, _, _temp_dirs) = get_fake_locationsprovider(true); let init = AppInitializer { locations_provider: &locations_provider, }; @@ -78,13 +81,25 @@ mod tests { fn get_fake_locationsprovider( base_dir_should_exist: bool, - ) -> (LocationsProvider, PathBuf, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let mut path = dir.path().to_path_buf(); + ) -> ( + LocationsProvider, + PathBuf, + PathBuf, + (tempfile::TempDir, tempfile::TempDir), + ) { + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let mut config_path = config_dir.path().to_path_buf(); + let data_path = data_dir.path().to_path_buf(); if !base_dir_should_exist { - path = path.join("non-existing-base-dir"); + config_path = config_path.join("non-existing-base-dir"); } - (LocationsProvider::new(path.to_path_buf()), path, dir) + ( + LocationsProvider::new(config_path.clone(), data_path.clone()), + config_path, + data_path, + (config_dir, data_dir), + ) } fn get_config_without_whitespace(path: PathBuf) -> String { diff --git a/src/cli_args.rs b/src/cli_args.rs index 6c883aa..9edb04f 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -14,10 +14,14 @@ pub struct AppArgs { #[arg(short = 'v', long, action = clap::ArgAction::Version)] version: (), - /// The path that conamn will treat as a base path for all its data storage (configs, projects) + /// The base path for puff's configuration (config.json) #[arg(long, default_value = "default", env = "PUFF_CONFIG_PATH", hide = true)] pub config_path: String, + /// The base path for puff's data storage (projects) + #[arg(long, default_value = "default", env = "PUFF_DATA_PATH", hide = true)] + pub data_path: String, + #[command(subcommand)] pub command: Command, } diff --git a/src/commands/add_command.rs b/src/commands/add_command.rs index 7ceb91b..946dd3c 100644 --- a/src/commands/add_command.rs +++ b/src/commands/add_command.rs @@ -153,13 +153,15 @@ mod tests { #[test] fn add_file_when_project_does_not_exist() { - let puff_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); let current_dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(puff_dir.path().join("configs/proj1")).unwrap(); - let locations_provider = LocationsProvider::new(puff_dir.path().to_path_buf()); + fs::create_dir_all(data_dir.path().join("projects/proj1")).unwrap(); + let locations_provider = + LocationsProvider::new(config_dir.path().to_path_buf(), data_dir.path().to_path_buf()); let user_file = current_dir.path().join("file"); - let config_file = puff_dir.path().join("config.json"); + let config_file = config_dir.path().join("config.json"); let mut file = File::create(&config_file).unwrap(); write!(file, "{{\"projects\":[]}}").unwrap(); @@ -176,13 +178,15 @@ mod tests { #[test] fn add_file_fresh_scenario() { - let puff_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); let current_dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(puff_dir.path().join("configs/proj1")).unwrap(); - let locations_provider = LocationsProvider::new(puff_dir.path().to_path_buf()); + fs::create_dir_all(data_dir.path().join("projects/proj1")).unwrap(); + let locations_provider = + LocationsProvider::new(config_dir.path().to_path_buf(), data_dir.path().to_path_buf()); let user_file = current_dir.path().join("file"); - let config_file = puff_dir.path().join("config.json"); + let config_file = config_dir.path().join("config.json"); let mut file = File::create(&config_file).unwrap(); write!( file, @@ -198,12 +202,14 @@ mod tests { #[test] fn add_file_in_subdirectory() { - let puff_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); let project_root = tempfile::tempdir().unwrap(); - fs::create_dir_all(puff_dir.path().join("configs/proj1")).unwrap(); - let locations_provider = LocationsProvider::new(puff_dir.path().to_path_buf()); + fs::create_dir_all(data_dir.path().join("projects/proj1")).unwrap(); + let locations_provider = + LocationsProvider::new(config_dir.path().to_path_buf(), data_dir.path().to_path_buf()); - let config_file = puff_dir.path().join("config.json"); + let config_file = config_dir.path().join("config.json"); let mut file = File::create(&config_file).unwrap(); write!( file, @@ -219,18 +225,20 @@ mod tests { let sut = AddCommand::new(&locations_provider); sut.add_file(user_file, project_root.path(), false).unwrap(); - let managed_file = puff_dir.path().join("configs/proj1/config/secrets.env"); + let managed_file = data_dir.path().join("projects/proj1/config/secrets.env"); assert!(managed_file.exists()); } #[test] fn add_file_from_subdirectory_cwd() { - let puff_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); let project_root = tempfile::tempdir().unwrap(); - fs::create_dir_all(puff_dir.path().join("configs/proj1")).unwrap(); - let locations_provider = LocationsProvider::new(puff_dir.path().to_path_buf()); + fs::create_dir_all(data_dir.path().join("projects/proj1")).unwrap(); + let locations_provider = + LocationsProvider::new(config_dir.path().to_path_buf(), data_dir.path().to_path_buf()); - let config_file = puff_dir.path().join("config.json"); + let config_file = config_dir.path().join("config.json"); let mut file = File::create(&config_file).unwrap(); write!( file, @@ -248,7 +256,7 @@ mod tests { sut.add_file(std::path::PathBuf::from("secrets.env"), &subdir, false) .unwrap(); - let managed_file = puff_dir.path().join("configs/proj1/config/secrets.env"); + let managed_file = data_dir.path().join("projects/proj1/config/secrets.env"); assert!(managed_file.exists()); let symlink_target = fs::read_link(&user_file).unwrap(); diff --git a/src/config/locations.rs b/src/config/locations.rs index 8a2b08f..7c6a1fe 100644 --- a/src/config/locations.rs +++ b/src/config/locations.rs @@ -8,12 +8,14 @@ const APP_NAME: &str = "puff"; pub struct LocationsProvider { config_base_path: PathBuf, + data_base_path: PathBuf, } impl LocationsProvider { - pub fn new(base_config_path: PathBuf) -> LocationsProvider { + pub fn new(config_base_path: PathBuf, data_base_path: PathBuf) -> LocationsProvider { LocationsProvider { - config_base_path: base_config_path, + config_base_path, + data_base_path, } } @@ -21,7 +23,16 @@ impl LocationsProvider { Ok(self.config_base_path.clone()) } - pub fn get_configs_config_path(&self) -> PathBuf { + pub fn get_base_data_path(&self) -> Result { + Ok(self.data_base_path.clone()) + } + + pub fn get_projects_data_path(&self) -> PathBuf { + self.data_base_path.join(Path::new("projects")) + } + + /// Legacy path used before the config/data split. Used for migration. + pub fn get_legacy_configs_path(&self) -> PathBuf { self.config_base_path.join(Path::new("configs")) } @@ -30,7 +41,7 @@ impl LocationsProvider { } pub fn get_managed_dir(&self, name: &str) -> PathBuf { - self.get_configs_config_path().join(Path::new(name)) + self.get_projects_data_path().join(Path::new(name)) } /// Walks up from `path` through its ancestors and returns the first @@ -53,18 +64,11 @@ impl LocationsProvider { impl Default for LocationsProvider { fn default() -> Self { + let dirs = ProjectDirs::from("com", "marcinjahn", APP_NAME) + .expect("The default configuration path of puff could not be retrieved"); Self { - config_base_path: get_base_config_path() - .expect("The default configuration path of puff could not be retrieved"), + config_base_path: dirs.config_dir().to_owned(), + data_base_path: dirs.data_dir().to_owned(), } } } - -fn get_base_config_path() -> Result { - match ProjectDirs::from("com", "marcinjahn", APP_NAME) { - Some(dirs) => Ok(dirs.config_dir().to_owned()), - None => Err(anyhow!( - "Could not determine the configuration directory for this system." - )), - } -} diff --git a/src/config/projects.rs b/src/config/projects.rs index e3f1ab0..45f672f 100644 --- a/src/config/projects.rs +++ b/src/config/projects.rs @@ -81,7 +81,7 @@ impl<'a> ProjectsRetriever<'a> { /// Returns names of all the projects that puff stores (some of them might /// not be associated yet) fn get_all_projects(&self) -> Result> { - let location = self.locations_provider.get_configs_config_path(); + let location = self.locations_provider.get_projects_data_path(); let paths = fs::read_dir(location)?; let mut projects = vec![]; @@ -163,8 +163,12 @@ mod tests { #[test] fn is_associated_when_associated_project_is_provided_true_is_returned() { let checked_dir = tempfile::tempdir().unwrap(); - let base_dir = tempfile::tempdir().unwrap(); - let locations_provider = LocationsProvider::new(base_dir.path().to_path_buf()); + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let locations_provider = LocationsProvider::new( + config_dir.path().to_path_buf(), + data_dir.path().to_path_buf(), + ); let app_config = AppConfig { projects: vec![Project { name: String::from("proj"), @@ -182,8 +186,12 @@ mod tests { #[test] fn is_associated_when_not_associated_project_is_provided_false_is_returned() { - let base_dir = tempfile::tempdir().unwrap(); - let locations_provider = LocationsProvider::new(base_dir.path().to_path_buf()); + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let locations_provider = LocationsProvider::new( + config_dir.path().to_path_buf(), + data_dir.path().to_path_buf(), + ); let app_config = AppConfig { projects: vec![] }; let sut = ProjectsRetriever::new(app_config, &locations_provider); @@ -200,12 +208,16 @@ mod tests { let proj_2_dir = tempfile::tempdir().unwrap(); let proj_3_dir = tempfile::tempdir().unwrap(); - let base_dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj1")).unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj2")).unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj3")).unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj1")).unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj2")).unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj3")).unwrap(); - let locations_provider = LocationsProvider::new(base_dir.path().to_path_buf()); + let locations_provider = LocationsProvider::new( + config_dir.path().to_path_buf(), + data_dir.path().to_path_buf(), + ); let app_config = AppConfig { projects: vec![ Project { @@ -238,12 +250,16 @@ mod tests { let proj_1_dir = tempfile::tempdir().unwrap(); let proj_2_dir = tempfile::tempdir().unwrap(); - let base_dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj1")).unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj2")).unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj3")).unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj1")).unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj2")).unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj3")).unwrap(); - let locations_provider = LocationsProvider::new(base_dir.path().to_path_buf()); + let locations_provider = LocationsProvider::new( + config_dir.path().to_path_buf(), + data_dir.path().to_path_buf(), + ); let app_config = AppConfig { projects: vec![ Project { @@ -272,12 +288,16 @@ mod tests { let proj_1_dir = tempfile::tempdir().unwrap(); let proj_2_dir = tempfile::tempdir().unwrap(); - let base_dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj1")).unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj2")).unwrap(); - fs::create_dir_all(base_dir.path().join("configs/proj3")).unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj1")).unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj2")).unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj3")).unwrap(); - let locations_provider = LocationsProvider::new(base_dir.path().to_path_buf()); + let locations_provider = LocationsProvider::new( + config_dir.path().to_path_buf(), + data_dir.path().to_path_buf(), + ); let app_config = AppConfig { projects: vec![ Project { @@ -306,10 +326,14 @@ mod tests { #[test] fn get_all_projects_when_there_are_no_projects_then_empty_vector_is_returned() { - let base_dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(base_dir.path().join("configs")).unwrap(); - - let locations_provider = LocationsProvider::new(base_dir.path().to_path_buf()); + let data_dir = tempfile::tempdir().unwrap(); + let config_dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(data_dir.path().join("projects")).unwrap(); + + let locations_provider = LocationsProvider::new( + config_dir.path().to_path_buf(), + data_dir.path().to_path_buf(), + ); let app_config = AppConfig { projects: vec![] }; let sut = ProjectsRetriever::new(app_config, &locations_provider); diff --git a/src/main.rs b/src/main.rs index 63cbf7b..619a7b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod config; mod fs_utils; mod git_ignore; mod io_utils; +mod migration; mod project_init; fn main() { @@ -38,9 +39,27 @@ fn run() -> Result<()> { return Ok(()); } - let locations_provider = match args.config_path.as_str() { - "default" => LocationsProvider::default(), - other => LocationsProvider::new(Path::new(other).to_path_buf()), + let locations_provider = { + let config_path = match args.config_path.as_str() { + "default" => None, + other => Some(Path::new(other).to_path_buf()), + }; + let data_path = match args.data_path.as_str() { + "default" => None, + other => Some(Path::new(other).to_path_buf()), + }; + match (config_path, data_path) { + (None, None) => LocationsProvider::default(), + (Some(c), None) => { + let default = LocationsProvider::default(); + LocationsProvider::new(c, default.get_base_data_path()?) + } + (None, Some(d)) => { + let default = LocationsProvider::default(); + LocationsProvider::new(default.get_base_config_path()?, d) + } + (Some(c), Some(d)) => LocationsProvider::new(c, d), + } }; AppInitializer { diff --git a/src/migration.rs b/src/migration.rs new file mode 100644 index 0000000..aa01d3f --- /dev/null +++ b/src/migration.rs @@ -0,0 +1,221 @@ +use crate::config::{app_config::AppConfigManager, locations::LocationsProvider}; +use anyhow::{Result, bail}; +use std::fs; +use std::path::Path; + +/// Migrates the legacy `{config_path}/configs/` directory to `{data_path}/projects/`. +/// Returns `Ok(true)` if migration was performed, `Ok(false)` if not needed. +pub fn migrate_projects_path_if_needed(locations_provider: &LocationsProvider) -> Result { + let legacy_path = locations_provider.get_legacy_configs_path(); + if !legacy_path.exists() { + return Ok(false); + } + + let new_path = locations_provider.get_projects_data_path(); + if new_path.exists() { + bail!( + "Both legacy '{}' and new '{}' directories exist. \ + Please remove one of them manually to resolve the ambiguity.", + legacy_path.display(), + new_path.display() + ); + } + + // a bit unrealistic to check this, but why not be exxxxtra safe? + if let Some(parent) = new_path.parent() { + fs::create_dir_all(parent)?; + } + + // Try atomic rename first (works on same filesystem) + if fs::rename(&legacy_path, &new_path).is_err() { + copy_dir_recursive(&legacy_path, &new_path)?; + fs::remove_dir_all(&legacy_path)?; + } + + repoint_symlinks(locations_provider, &legacy_path, &new_path); + + println!( + "Managed projects have been migrated to '{}'. You're all set!", + new_path.display() + ); + + Ok(true) +} + +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +/// Best-effort: walk each associated project's user directory and repoint +/// symlinks that targeted the old configs/ path to the new projects/ path. +fn repoint_symlinks(locations_provider: &LocationsProvider, old_base: &Path, new_base: &Path) { + let config_file = locations_provider.get_config_file_path(); + let Ok(config_manager) = AppConfigManager::new(config_file) else { + return; + }; + let Ok(config) = config_manager.get_config() else { + return; + }; + + for project in &config.projects { + repoint_project_symlinks(&project.path, &project.name, old_base, new_base); + } +} + +fn repoint_project_symlinks(user_dir: &Path, project_name: &str, old_base: &Path, new_base: &Path) { + let old_managed = old_base.join(project_name); + let new_managed = new_base.join(project_name); + + let Ok(entries) = walk_files(user_dir) else { + return; + }; + + for file_path in entries { + let Ok(target) = fs::read_link(&file_path) else { + continue; + }; + if let Ok(relative) = target.strip_prefix(&old_managed) { + let new_target = new_managed.join(relative); + + let _ = fs::remove_file(&file_path); + #[cfg(unix)] + let _ = std::os::unix::fs::symlink(&new_target, &file_path); + #[cfg(windows)] + let _ = std::os::windows::fs::symlink_file(&new_target, &file_path); + } + } +} + +fn walk_files(dir: &Path) -> Result> { + let mut files = vec![]; + let mut dirs = vec![dir.to_owned()]; + + while let Some(current) = dirs.pop() { + let Ok(entries) = fs::read_dir(¤t) else { + continue; + }; + + for entry in entries { + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if path.is_dir() && !path.is_symlink() { + dirs.push(path); + } else { + files.push(path); + } + } + } + + Ok(files) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::locations::LocationsProvider; + use std::fs; + use std::io::Write; + + fn setup_locations(config_dir: &Path, data_dir: &Path) -> LocationsProvider { + LocationsProvider::new(config_dir.to_path_buf(), data_dir.to_path_buf()) + } + + fn write_config(config_dir: &Path, json: &str) { + fs::create_dir_all(config_dir).unwrap(); + let config_file = config_dir.join("config.json"); + let mut f = fs::File::create(config_file).unwrap(); + f.write_all(json.as_bytes()).unwrap(); + } + + #[test] + fn no_migration_when_no_legacy_dir() { + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + write_config(config_dir.path(), r#"{"projects":[]}"#); + + let lp = setup_locations(config_dir.path(), data_dir.path()); + let result = migrate_projects_path_if_needed(&lp).unwrap(); + + assert!(!result); + } + + #[test] + fn successful_migration() { + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + write_config(config_dir.path(), r#"{"projects":[]}"#); + + let legacy = config_dir.path().join("configs"); + fs::create_dir_all(legacy.join("myproject")).unwrap(); + fs::write(legacy.join("myproject/.env"), "SECRET=1").unwrap(); + + let lp = setup_locations(config_dir.path(), data_dir.path()); + let result = migrate_projects_path_if_needed(&lp).unwrap(); + + assert!(result); + assert!(!legacy.exists()); + let new_path = data_dir.path().join("projects/myproject/.env"); + assert!(new_path.exists()); + assert_eq!(fs::read_to_string(new_path).unwrap(), "SECRET=1"); + } + + #[test] + fn fails_when_both_dirs_exist() { + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + write_config(config_dir.path(), r#"{"projects":[]}"#); + + fs::create_dir_all(config_dir.path().join("configs/proj")).unwrap(); + fs::create_dir_all(data_dir.path().join("projects/proj")).unwrap(); + + let lp = setup_locations(config_dir.path(), data_dir.path()); + let result = migrate_projects_path_if_needed(&lp); + + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("Both legacy")); + } + + #[cfg(unix)] + #[test] + fn symlinks_repointed_after_migration() { + let config_dir = tempfile::tempdir().unwrap(); + let data_dir = tempfile::tempdir().unwrap(); + let user_dir = tempfile::tempdir().unwrap(); + + write_config( + config_dir.path(), + &format!( + r#"{{"projects":[{{"name":"proj","id":"1","path":"{}"}}]}}"#, + user_dir.path().display() + ), + ); + + let legacy = config_dir.path().join("configs"); + fs::create_dir_all(legacy.join("proj")).unwrap(); + fs::write(legacy.join("proj/.env"), "SECRET=1").unwrap(); + + // Create symlink in user dir pointing to old location + let symlink_path = user_dir.path().join(".env"); + std::os::unix::fs::symlink(legacy.join("proj/.env"), &symlink_path).unwrap(); + + let lp = setup_locations(config_dir.path(), data_dir.path()); + migrate_projects_path_if_needed(&lp).unwrap(); + + // Symlink should now point to new location + let target = fs::read_link(&symlink_path).unwrap(); + assert!(target.starts_with(data_dir.path().join("projects"))); + assert_eq!(fs::read_to_string(&symlink_path).unwrap(), "SECRET=1"); + } +} diff --git a/tests/e2e/add.bats b/tests/e2e/add.bats index 9b6b539..7799e99 100644 --- a/tests/e2e/add.bats +++ b/tests/e2e/add.bats @@ -9,7 +9,7 @@ teardown() { teardown_puff_env; } run puff add .env assert_success assert_symlink "$PROJECT_DIR/.env" - assert_file_exists "$PUFF_CONFIG_PATH/configs/myproject/.env" + assert_file_exists "$PUFF_DATA_PATH/projects/myproject/.env" } @test "add: existing file copies content to managed dir" { @@ -17,7 +17,7 @@ teardown() { teardown_puff_env; } echo "secret=123" >.env run puff add .env assert_success - assert_file_content "$PUFF_CONFIG_PATH/configs/myproject/.env" "secret=123" + assert_file_content "$PUFF_DATA_PATH/projects/myproject/.env" "secret=123" } @test "add: existing file is replaced with symlink" { @@ -60,13 +60,13 @@ teardown() { teardown_puff_env; } run puff add config/database.env assert_success assert_symlink "$PROJECT_DIR/config/database.env" - assert_file_exists "$PUFF_CONFIG_PATH/configs/myproject/config/database.env" + assert_file_exists "$PUFF_DATA_PATH/projects/myproject/config/database.env" } @test "add: file already in managed dir creates symlink without error" { puff_init "myproject" # Simulate managed file already existing (e.g. copied from another machine) - echo "token=xyz" >"$PUFF_CONFIG_PATH/configs/myproject/.env" + echo "token=xyz" >"$PUFF_DATA_PATH/projects/myproject/.env" run puff add .env assert_success assert_symlink "$PROJECT_DIR/.env" @@ -86,8 +86,8 @@ teardown() { teardown_puff_env; } assert_success assert_symlink "$PROJECT_DIR/.env" assert_symlink "$PROJECT_DIR/.env.local" - assert_file_exists "$PUFF_CONFIG_PATH/configs/myproject/.env" - assert_file_exists "$PUFF_CONFIG_PATH/configs/myproject/.env.local" + assert_file_exists "$PUFF_DATA_PATH/projects/myproject/.env" + assert_file_exists "$PUFF_DATA_PATH/projects/myproject/.env.local" } @test "add: partial failure exits with code 1 and processes remaining files" { diff --git a/tests/e2e/forget_file.bats b/tests/e2e/forget_file.bats index 1bc6196..4289264 100644 --- a/tests/e2e/forget_file.bats +++ b/tests/e2e/forget_file.bats @@ -27,7 +27,7 @@ teardown() { teardown_puff_env; } echo "secret=123" >.env puff add .env puff forget .env - assert_not_exists "$PUFF_CONFIG_PATH/configs/myproject/.env" + assert_not_exists "$PUFF_DATA_PATH/projects/myproject/.env" } @test "forget file: with --delete removes file without restoring" { @@ -52,7 +52,7 @@ teardown() { teardown_puff_env; } echo "val=1" >config/settings.env puff add config/settings.env puff forget config/settings.env - assert_not_exists "$PUFF_CONFIG_PATH/configs/myproject/config" + assert_not_exists "$PUFF_DATA_PATH/projects/myproject/config" } @test "forget file: multiple files are all forgotten" { @@ -66,8 +66,8 @@ teardown() { teardown_puff_env; } assert_not_symlink "$PROJECT_DIR/.env.local" assert_file_exists "$PROJECT_DIR/.env" assert_file_exists "$PROJECT_DIR/.env.local" - assert_not_exists "$PUFF_CONFIG_PATH/configs/myproject/.env" - assert_not_exists "$PUFF_CONFIG_PATH/configs/myproject/.env.local" + assert_not_exists "$PUFF_DATA_PATH/projects/myproject/.env" + assert_not_exists "$PUFF_DATA_PATH/projects/myproject/.env.local" } @test "forget file: partial failure exits with code 1 and processes remaining files" { diff --git a/tests/e2e/forget_project.bats b/tests/e2e/forget_project.bats index 32dabd3..ec3f382 100644 --- a/tests/e2e/forget_project.bats +++ b/tests/e2e/forget_project.bats @@ -30,7 +30,7 @@ teardown() { teardown_puff_env; } echo "secret=123" >.env puff add .env puff project forget -y myproject - assert_not_exists "$PUFF_CONFIG_PATH/configs/myproject" + assert_not_exists "$PUFF_DATA_PATH/projects/myproject" } @test "project forget: removes project from config" { diff --git a/tests/e2e/helpers.bash b/tests/e2e/helpers.bash index 4063e32..c058db5 100644 --- a/tests/e2e/helpers.bash +++ b/tests/e2e/helpers.bash @@ -4,13 +4,15 @@ export PATH="$REPO_ROOT/target/release:$PATH" setup_puff_env() { export PUFF_CONFIG_PATH PUFF_CONFIG_PATH="$(mktemp -d)" + export PUFF_DATA_PATH + PUFF_DATA_PATH="$(mktemp -d)" export PROJECT_DIR PROJECT_DIR="$(mktemp -d)" cd "$PROJECT_DIR" || return 1 } teardown_puff_env() { - rm -rf "$PUFF_CONFIG_PATH" "$PROJECT_DIR" + rm -rf "$PUFF_CONFIG_PATH" "$PUFF_DATA_PATH" "$PROJECT_DIR" } # Initializes current directory as a puff project with the given name. diff --git a/tests/e2e/init.bats b/tests/e2e/init.bats index 0cf5f12..b5c92ed 100644 --- a/tests/e2e/init.bats +++ b/tests/e2e/init.bats @@ -14,7 +14,7 @@ teardown() { teardown_puff_env; } @test "init: creates managed directory" { puff_init "myproject" - assert_file_exists "$PUFF_CONFIG_PATH/configs/myproject" + assert_file_exists "$PUFF_DATA_PATH/projects/myproject" } @test "init: project appears in puff list" { @@ -34,7 +34,7 @@ teardown() { teardown_puff_env; } local named_dir named_dir="$(mktemp -d)" # Send empty line — puff should fall back to using the directory's basename - run bash -c "echo '' | PUFF_CONFIG_PATH='$PUFF_CONFIG_PATH' puff init" + run bash -c "echo '' | PUFF_CONFIG_PATH='$PUFF_CONFIG_PATH' PUFF_DATA_PATH='$PUFF_DATA_PATH' puff init" assert_success rm -rf "$named_dir" } @@ -45,20 +45,22 @@ teardown() { teardown_puff_env; } echo "secret=abc" >.env puff add .env - # Simulate new machine: copy puff home but wipe config associations - local new_puff_home - new_puff_home="$(mktemp -d)" - cp -r "$PUFF_CONFIG_PATH/." "$new_puff_home/" - echo '{"projects":[]}' >"$new_puff_home/config.json" + # Simulate new machine: copy puff config and data but wipe config associations + local new_config_home new_data_home + new_config_home="$(mktemp -d)" + new_data_home="$(mktemp -d)" + cp -r "$PUFF_CONFIG_PATH/." "$new_config_home/" + cp -r "$PUFF_DATA_PATH/." "$new_data_home/" + echo '{"projects":[]}' >"$new_config_home/config.json" # Remove symlink (simulates fresh checkout, no local symlink yet) rm "$PROJECT_DIR/.env" # Init: should detect unassociated project and ask to select; send "1" to pick it - run bash -c "echo '1' | PUFF_CONFIG_PATH='$new_puff_home' puff init" + run bash -c "echo '1' | PUFF_CONFIG_PATH='$new_config_home' PUFF_DATA_PATH='$new_data_home' puff init" assert_success assert_symlink "$PROJECT_DIR/.env" assert_file_content "$PROJECT_DIR/.env" "secret=abc" - rm -rf "$new_puff_home" + rm -rf "$new_config_home" "$new_data_home" } diff --git a/tests/e2e/list.bats b/tests/e2e/list.bats index bdf3da5..e73536f 100644 --- a/tests/e2e/list.bats +++ b/tests/e2e/list.bats @@ -32,7 +32,7 @@ teardown() { teardown_puff_env; } @test "list: shows unassociated project" { puff_init "myproject" - mkdir -p "$PUFF_CONFIG_PATH/configs/orphan" + mkdir -p "$PUFF_DATA_PATH/projects/orphan" run puff list assert_success assert_output_contains "orphan" @@ -40,7 +40,7 @@ teardown() { teardown_puff_env; } @test "list -a: shows only associated projects" { puff_init "myproject" - mkdir -p "$PUFF_CONFIG_PATH/configs/orphan" + mkdir -p "$PUFF_DATA_PATH/projects/orphan" run puff list -a assert_success assert_output_contains "myproject" @@ -49,7 +49,7 @@ teardown() { teardown_puff_env; } @test "list -u: shows only unassociated projects" { puff_init "myproject" - mkdir -p "$PUFF_CONFIG_PATH/configs/orphan" + mkdir -p "$PUFF_DATA_PATH/projects/orphan" run puff list -u assert_success assert_output_contains "orphan" diff --git a/tests/e2e/migration.bats b/tests/e2e/migration.bats new file mode 100644 index 0000000..f4cdf04 --- /dev/null +++ b/tests/e2e/migration.bats @@ -0,0 +1,67 @@ +#!/usr/bin/env bats +load helpers + +setup() { setup_puff_env; } +teardown() { teardown_puff_env; } + +@test "migration: old configs/ is moved to projects/" { + # Manually set up old-style layout + mkdir -p "$PUFF_CONFIG_PATH/configs/myproject" + echo "SECRET=1" >"$PUFF_CONFIG_PATH/configs/myproject/.env" + echo '{"projects":[]}' >"$PUFF_CONFIG_PATH/config.json" + + run puff list + assert_success + + # Old dir should be gone, new dir should have the file + assert_not_exists "$PUFF_CONFIG_PATH/configs" + assert_file_exists "$PUFF_DATA_PATH/projects/myproject/.env" + assert_file_content "$PUFF_DATA_PATH/projects/myproject/.env" "SECRET=1" +} + +@test "migration: migration message printed" { + mkdir -p "$PUFF_CONFIG_PATH/configs/myproject" + echo '{"projects":[]}' >"$PUFF_CONFIG_PATH/config.json" + + run puff list + assert_success + assert_output_contains "Managed projects have been migrated" +} + +@test "migration: no migration when no legacy dir" { + run puff list + assert_success + assert_output_not_contains "migrated" +} + +@test "migration: fails when both dirs exist" { + mkdir -p "$PUFF_CONFIG_PATH/configs/myproject" + mkdir -p "$PUFF_DATA_PATH/projects/myproject" + echo '{"projects":[]}' >"$PUFF_CONFIG_PATH/config.json" + + run puff list + assert_failure + assert_output_contains "Both legacy" +} + +@test "migration: symlinks updated after migration" { + # Set up old-style layout with an associated project + symlink + mkdir -p "$PUFF_CONFIG_PATH/configs/myproject" + echo "SECRET=1" >"$PUFF_CONFIG_PATH/configs/myproject/.env" + cat >"$PUFF_CONFIG_PATH/config.json" <