diff --git a/.oxrls/hard-seam.md b/.oxrls/hard-seam.md new file mode 100644 index 0000000..16a16ac --- /dev/null +++ b/.oxrls/hard-seam.md @@ -0,0 +1,5 @@ +--- +'@bdbchgg/oxrls': 'patch' +--- + +Add guard to `oxrls init` to detect already-initialized projects and exit early with an informational message. Use `--force` to re-run the wizard and overwrite the existing config. diff --git a/README.md b/README.md index 90de810..37271d0 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ I'd love to get some eyes from the Rust community on best practices, patterns, a | Command | Description | | --------------- | ------------------------------------------------------------ | -| `oxrls init` | Create `oxrls.json` config and `.oxrls/` directory | +| `oxrls init` | Create `oxrls.json` config and `.oxrls/` directory (use `--force` to re-run) | | `oxrls new` | Create a release file (interactive or `--package --summary`) | | `oxrls status` | Show pending release files and calculated bumps | | `oxrls bump` | Apply version bumps, update deps, generate changelogs | diff --git a/packages/oxrls/src/commands/init.rs b/packages/oxrls/src/commands/init.rs index 3ed1352..dd8468c 100644 --- a/packages/oxrls/src/commands/init.rs +++ b/packages/oxrls/src/commands/init.rs @@ -7,7 +7,7 @@ use std::path::Path; use glob::Pattern; -use crate::config::OxrlsConfig; +use crate::config::{OxrlsConfig, find_existing_config}; use crate::error::{OxrlsError, Result}; use crate::init_wizard::run_init_wizard; use crate::workspace::{Workspace, find_workspace_root, load_workspace}; @@ -68,8 +68,16 @@ pub(crate) fn resolve_package_patterns( pub fn cmd_init(force: bool, release_dir: Option<&str>, non_interactive: bool) -> Result<()> { let cwd = std::env::current_dir().map_err(OxrlsError::Io)?; + cmd_init_at(&cwd, force, release_dir, non_interactive) +} - let root = find_workspace_root(Path::new(".")).map_err(|e| { +fn cmd_init_at( + cwd: &Path, + force: bool, + release_dir: Option<&str>, + non_interactive: bool, +) -> Result<()> { + let root = find_workspace_root(cwd).map_err(|e| { OxrlsError::Config(format!( "No workspace found: {}. Run from a repo with a package.json.", e @@ -78,6 +86,14 @@ pub fn cmd_init(force: bool, release_dir: Option<&str>, non_interactive: bool) - let workspace = load_workspace(&root)?; let is_monorepo = workspace.packages.len() > 1; + if !force && let Some(existing_path) = find_existing_config(cwd) { + println!( + "oxrls is already initialized.\nConfig: {}\nEdit that file to change settings, or use --force to re-run the wizard.", + existing_path.display() + ); + return Ok(()); + } + let config_path = cwd.join(".oxrls").join("config.json"); let mut config = OxrlsConfig::default(); @@ -112,3 +128,130 @@ pub fn cmd_init(force: bool, release_dir: Option<&str>, non_interactive: bool) - println!("\noxrls is ready! Use `oxrls new` to create a release file."); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn write_package_json(dir: &TempDir) { + let pkg = r#"{"name": "test", "version": "1.0.0"}"#; + fs::write(dir.path().join("package.json"), pkg).unwrap(); + } + + #[test] + fn test_init_returns_early_when_config_exists() { + let dir = TempDir::new().unwrap(); + write_package_json(&dir); + + let config_dir = dir.path().join(".oxrls"); + fs::create_dir_all(&config_dir).unwrap(); + let config_content = r#"{"release_dir": ".release", "version": "1.0.0"}"#; + fs::write(config_dir.join("config.json"), config_content).unwrap(); + + let result = cmd_init_at(dir.path(), false, None, true); + assert!(result.is_ok()); + + let actual = fs::read_to_string(config_dir.join("config.json")).unwrap(); + assert_eq!(actual, config_content); + } + + #[test] + fn test_init_force_overwrites_existing_config() { + let dir = TempDir::new().unwrap(); + write_package_json(&dir); + + let config_dir = dir.path().join(".oxrls"); + fs::create_dir_all(&config_dir).unwrap(); + let custom_config = r#"{"release_dir": ".custom-release", "version": "1.0.0"}"#; + fs::write(config_dir.join("config.json"), custom_config).unwrap(); + + let result = cmd_init_at(dir.path(), true, None, true); + assert!(result.is_ok()); + + let actual = fs::read_to_string(config_dir.join("config.json")).unwrap(); + assert!(!actual.contains(".custom-release")); + } + + #[test] + fn test_init_creates_config_when_none_exists() { + let dir = TempDir::new().unwrap(); + write_package_json(&dir); + + let result = cmd_init_at(dir.path(), false, None, true); + assert!(result.is_ok()); + + let config_path = dir.path().join(".oxrls").join("config.json"); + assert!(config_path.exists()); + + let release_dir = dir.path().join(".oxrls"); + assert!(release_dir.exists()); + } + + #[test] + fn test_find_existing_config_finds_all_three_names() { + let cases: [(&str, fn(&std::path::Path) -> std::path::PathBuf); 3] = [ + (".oxrls/config.json", |p| { + let d = p.join(".oxrls"); + let _ = fs::create_dir_all(&d); + fs::write(d.join("config.json"), "{}").unwrap(); + p.join(".oxrls").join("config.json") + }), + ("oxrls.json", |p| { + fs::write(p.join("oxrls.json"), "{}").unwrap(); + p.join("oxrls.json") + }), + (".oxrls.json", |p| { + fs::write(p.join(".oxrls.json"), "{}").unwrap(); + p.join(".oxrls.json") + }), + ]; + + for (name, setup) in &cases { + let dir = TempDir::new().unwrap(); + let expected = setup(dir.path()); + let found = find_existing_config(dir.path()); + assert!(found.is_some(), "Expected to find config named {}", name); + assert_eq!(found.unwrap(), expected); + } + } + + #[test] + fn test_find_existing_config_returns_none_when_no_config() { + let dir = TempDir::new().unwrap(); + let found = find_existing_config(dir.path()); + assert!(found.is_none()); + } + + #[test] + fn test_find_existing_config_searches_parent_dirs() { + let dir = TempDir::new().unwrap(); + let child = dir.path().join("subdir"); + fs::create_dir_all(&child).unwrap(); + + let config_dir = dir.path().join(".oxrls"); + fs::create_dir_all(&config_dir).unwrap(); + fs::write(config_dir.join("config.json"), "{}").unwrap(); + + let found = find_existing_config(&child); + assert!(found.is_some()); + } + + #[test] + fn test_init_non_interactive_returns_early_when_config_exists() { + let dir = TempDir::new().unwrap(); + write_package_json(&dir); + + let config_dir = dir.path().join(".oxrls"); + fs::create_dir_all(&config_dir).unwrap(); + let config_content = r#"{"release_dir": ".release", "version": "1.0.0"}"#; + fs::write(config_dir.join("config.json"), config_content).unwrap(); + + let result = cmd_init_at(dir.path(), false, None, true); + assert!(result.is_ok()); + + let actual = fs::read_to_string(config_dir.join("config.json")).unwrap(); + assert_eq!(actual, config_content); + } +} diff --git a/packages/oxrls/src/config.rs b/packages/oxrls/src/config.rs index 726e970..514757c 100644 --- a/packages/oxrls/src/config.rs +++ b/packages/oxrls/src/config.rs @@ -196,10 +196,23 @@ impl OxrlsConfig { } } -const CONFIG_FILE_NAMES: &[&str] = &[".oxrls/config.json", "oxrls.json", ".oxrls.json"]; +pub(crate) const CONFIG_FILE_NAMES: &[&str] = &[".oxrls/config.json", "oxrls.json", ".oxrls.json"]; + +pub fn find_existing_config(start_dir: &std::path::Path) -> Option { + let mut current = Some(start_dir); + while let Some(dir) = current { + for name in CONFIG_FILE_NAMES { + let path = dir.join(name); + if path.exists() { + return Some(path); + } + } + current = dir.parent(); + } + None +} impl OxrlsConfig { - /// Find and load config from the given directory or one of its parents. pub fn load(start_dir: &Path) -> Result<(Self, PathBuf)> { let cwd = std::env::current_dir().map_err(OxrlsError::Io)?; let search_start = if start_dir.is_absolute() { diff --git a/packages/oxrls/src/init_wizard.rs b/packages/oxrls/src/init_wizard.rs index 14242ca..47cea6e 100644 --- a/packages/oxrls/src/init_wizard.rs +++ b/packages/oxrls/src/init_wizard.rs @@ -36,11 +36,12 @@ pub fn run_init_wizard( .with_default(true) .prompt() .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; - config.generate_global_changelog = - Confirm::new(" Create a global CHANGELOG.md in the project root (aggregating all changes)?") - .with_default(false) - .prompt() - .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; + config.generate_global_changelog = Confirm::new( + " Create a global CHANGELOG.md in the project root (aggregating all changes)?", + ) + .with_default(false) + .prompt() + .map_err(|e| OxrlsError::Other(format!("Input failed: {}", e)))?; } else { config.generate_packages_changelog = false; config.generate_global_changelog = true; diff --git a/packages/oxrls/tests/megatest.rs b/packages/oxrls/tests/megatest.rs index 51cdce6..5c7d7a1 100644 --- a/packages/oxrls/tests/megatest.rs +++ b/packages/oxrls/tests/megatest.rs @@ -417,7 +417,10 @@ fn test_solo_repo_always_creates_changelog() { ); let changelog_content = std::fs::read_to_string(&changelog_path).unwrap(); - assert!(changelog_content.contains("# Changelog"), "Should have changelog header"); + assert!( + changelog_content.contains("# Changelog"), + "Should have changelog header" + ); // Solo repo uses version heading (not date), no package prefix assert!( changelog_content.contains("## v1.0.1"), @@ -429,8 +432,14 @@ fn test_solo_repo_always_creates_changelog() { "Solo repo should NOT have package prefix, got:\n{}", changelog_content ); - assert!(changelog_content.contains("Fix login bug"), "Should contain the release summary"); - assert!(changelog_content.contains("### Patch Changes"), "Should group changes by type"); + assert!( + changelog_content.contains("Fix login bug"), + "Should contain the release summary" + ); + assert!( + changelog_content.contains("### Patch Changes"), + "Should group changes by type" + ); // Phase 7: Verify release files were consumed assert_eq!(