Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/hooks/init/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,9 @@ fn patch_settings_json_command(

insert_hook_entry(&mut root, hook_command)?;

if !dry_run {
ensure_parent_dir(&settings_path)?;
}
update_json_file(
&settings_path,
&root,
Expand Down Expand Up @@ -1016,6 +1019,57 @@ mod tests {
});
}

#[test]
fn test_global_default_mode_creates_missing_claude_dir() {
let tmp = TempDir::new().unwrap();
with_missing_claude_dir_override(&tmp, |claude_dir| {
run_default_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap();

assert!(
claude_dir.exists(),
"missing Claude config dir must be created"
);
assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created");
assert!(
claude_dir.join(CLAUDE_MD).exists(),
"CLAUDE.md must be created"
);
assert!(
claude_dir.join(SETTINGS_JSON).exists(),
"settings.json must be created"
);
});
}

/// `--opencode` is installed after RTK.md. A missing Claude dir must not
/// abort before that install. Unix only: OpenCode resolves through
/// `dirs::home_dir()`, which follows `$HOME` on Unix and ignores it on Windows.
#[cfg(unix)]
#[test]
fn test_global_opencode_installs_when_claude_dir_missing() {
use crate::hooks::constants::{
CONFIG_DIR, OPENCODE_PLUGIN_FILE, OPENCODE_SUBDIR, PLUGIN_SUBDIR,
};

let tmp = TempDir::new().unwrap();
with_missing_claude_dir_override(&tmp, |claude_dir| {
run_default_mode(true, PatchMode::Auto, true, InitContext::default()).unwrap();

assert!(claude_dir.join(RTK_MD).exists(), "RTK.md must be created");
let plugin = tmp
.path()
.join("home")
.join(CONFIG_DIR)
.join(OPENCODE_SUBDIR)
.join(PLUGIN_SUBDIR)
.join(OPENCODE_PLUGIN_FILE);
assert!(
plugin.exists(),
"OpenCode plugin must be installed when ~/.claude was missing"
);
});
}

#[test]
fn test_patch_settings_json_tolerates_utf8_bom() {
let tmp = TempDir::new().unwrap();
Expand Down Expand Up @@ -1171,6 +1225,28 @@ mod tests {
});
}

#[test]
fn test_global_hook_only_mode_creates_missing_claude_dir() {
let tmp = TempDir::new().unwrap();
with_missing_claude_dir_override(&tmp, |claude_dir| {
run_hook_only_mode(true, PatchMode::Auto, false, InitContext::default()).unwrap();

assert!(
claude_dir.exists(),
"missing Claude config dir must be created"
);
assert!(
!claude_dir.join(RTK_MD).exists(),
"RTK.md must NOT be created in hook-only mode"
);
let settings = fs::read_to_string(claude_dir.join(SETTINGS_JSON)).unwrap();
assert!(
settings.contains(CLAUDE_HOOK_COMMAND),
"settings.json must contain hook command"
);
});
}

#[test]
fn test_run_default_mode_dry_run_writes_nothing() {
let tmp = TempDir::new().unwrap();
Expand All @@ -1196,6 +1272,23 @@ mod tests {
});
}

#[test]
fn test_run_default_mode_dry_run_does_not_create_missing_claude_dir() {
let tmp = TempDir::new().unwrap();
with_missing_claude_dir_override(&tmp, |claude_dir| {
let dry = InitContext {
dry_run: true,
..Default::default()
};
run_default_mode(true, PatchMode::Auto, false, dry).unwrap();

assert!(
!claude_dir.exists(),
"dry-run must not create missing Claude config dir"
);
});
}

#[test]
fn test_uninstall_dry_run_preserves_artifacts() {
let tmp = TempDir::new().unwrap();
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/init/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ fn write_if_changed_internal(
println!("[dry-run] content:\n{}", content);
}
} else {
ensure_parent_dir(path)?;
atomic_write(path, content)
.with_context(|| format!("Failed to write {}: {}", name, path.display()))?;
if verbose > 0 {
Expand All @@ -525,6 +526,19 @@ fn write_if_changed_internal(
}
}

pub(super) fn ensure_parent_dir(path: &Path) -> Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};

if parent.as_os_str().is_empty() {
return Ok(());
}

fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))
}

/// Resolve the final write target: if `path` is a symlink, follow it so
/// the atomic rename lands on the real file and the symlink is preserved.
fn resolve_atomic_target(path: &Path) -> PathBuf {
Expand Down Expand Up @@ -1686,6 +1700,24 @@ pub(super) fn with_claude_dir_override<F: FnOnce(&Path)>(tmp: &TempDir, f: F) {
temp_env::with_var("CLAUDE_CONFIG_DIR", Some(&claude_dir), || f(&claude_dir));
}

#[cfg(test)]
pub(super) fn with_missing_claude_dir_override<F: FnOnce(&Path)>(tmp: &TempDir, f: F) {
let claude_dir = tmp.path().join(CLAUDE_DIR);
let home_dir = tmp.path().join("home");
assert!(
!claude_dir.exists(),
"test precondition: Claude config dir must be missing"
);

temp_env::with_vars(
[
("CLAUDE_CONFIG_DIR", Some(claude_dir.as_os_str())),
("HOME", Some(home_dir.as_os_str())),
],
|| f(&claude_dir),
);
}

#[cfg(test)]
mod tests {
use super::claude::{remove_hook_from_json, remove_legacy_hook_entries_from_json};
Expand Down
Loading