#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use super::flags::InitFlags;
use super::helpers::{
atomic_write_settings, check_mark, confirm_proceed, load_or_create_settings, prompt_choice,
resolve_real_settings_path, HOOK_SCRIPT_NAME, SETTINGS_BACKUP, SETTINGS_FILE,
};
use super::state::{detect_state, has_skim_hook_entry, DetectedState};
use crate::cmd::session::AgentKind;
struct InstallOptions {
project: bool,
install_marketplace: bool,
skip_confirmation: bool,
}
fn prompt_install_options(
flags: &InitFlags,
state: &DetectedState,
) -> anyhow::Result<InstallOptions> {
if flags.yes {
return Ok(InstallOptions {
project: flags.project,
install_marketplace: true,
skip_confirmation: true,
});
}
let mut use_project = flags.project;
let mut skip_confirmation = false;
if !flags.project {
println!(" ? Where should skim install the hook?");
println!(" [1] Global (~/.claude/settings.json) [recommended]");
println!(" [2] Project (.claude/settings.json)");
let choice = prompt_choice(" Choice [1]: ", 1, &[1, 2])?;
if choice == 2 {
println!();
println!(" Tip: use `skim init --project` to skip this prompt next time.");
use_project = true;
skip_confirmation = true;
}
println!();
}
let install_marketplace = if !state.marketplace_installed {
println!(" ? Install the Skimmer plugin? (codebase orientation agent)");
println!(" Adds /skim command and auto-orientation for new codebases");
println!(" [1] Yes [recommended]");
println!(" [2] No");
let choice = prompt_choice(" Choice [1]: ", 1, &[1, 2])?;
println!();
choice == 1
} else {
true
};
Ok(InstallOptions {
project: use_project,
install_marketplace,
skip_confirmation,
})
}
fn verify_agent_installed(state: &DetectedState, flags: &InitFlags) -> anyhow::Result<()> {
if flags.agent == AgentKind::ClaudeCode {
return Ok(());
}
if flags.project {
return Ok(());
}
if !state.config_dir.exists() {
let hint = match flags.agent {
AgentKind::Cursor => "Install Cursor from https://cursor.com",
AgentKind::GeminiCli => "Install Gemini CLI: npm install -g @google/gemini-cli",
AgentKind::CopilotCli => {
"Install GitHub Copilot CLI: gh extension install github/gh-copilot"
}
AgentKind::CodexCli => "Install Codex CLI: npm install -g @openai/codex",
AgentKind::OpenCode => {
"Install OpenCode: go install github.com/opencode-ai/opencode@latest"
}
AgentKind::ClaudeCode => unreachable!("handled above"),
};
anyhow::bail!(
"{} does not appear to be installed (config dir not found: {})\nhint: {}",
flags.agent.display_name(),
state.config_dir.display(),
hint
);
}
Ok(())
}
fn is_guidance_current(flags: &InitFlags, skim_version: &str) -> bool {
if flags.no_guidance {
return true;
}
let global = !flags.project;
flags
.agent
.instruction_file(global)
.map(|p| {
std::fs::read_to_string(&p)
.ok()
.map(|c| c.contains(&format!("{} v{}", GUIDANCE_START, skim_version)))
.unwrap_or(false)
})
.unwrap_or(true) }
pub(super) fn run_install(flags: &InitFlags) -> anyhow::Result<std::process::ExitCode> {
let state = detect_state(flags)?;
verify_agent_installed(&state, flags)?;
println!();
println!(
" skim init -- {} integration setup",
flags.agent.display_name()
);
println!();
print_detected_state(&state);
if !state.existing_bash_hooks.is_empty() {
println!(" WARNING: Other Bash PreToolUse hooks detected:");
for hook_cmd in &state.existing_bash_hooks {
println!(" - {hook_cmd}");
}
println!(" Both hooks will fire on Bash commands. This is usually harmless");
println!(" but may cause unexpected behavior if the other hook also modifies commands.");
println!();
}
let guidance_current = is_guidance_current(flags, &state.skim_version);
if state.hook_installed
&& state.hook_version.as_deref() == Some(&state.skim_version)
&& state.marketplace_installed
&& guidance_current
{
println!(" Already up to date. Nothing to do.");
println!();
return Ok(std::process::ExitCode::SUCCESS);
}
if let Some(ref warning) = state.dual_scope_warning {
println!(" WARNING: {warning}");
println!();
}
let options = prompt_install_options(flags, &state)?;
let flags_override = InitFlags {
project: options.project,
yes: flags.yes,
dry_run: flags.dry_run,
uninstall: false,
force: flags.force,
no_guidance: flags.no_guidance,
agent: flags.agent,
};
let state = detect_state(&flags_override)?;
let hook_script_path = state.config_dir.join("hooks").join(HOOK_SCRIPT_NAME);
println!(" Summary:");
if !state.hook_installed || state.hook_version.as_deref() != Some(&state.skim_version) {
println!(" * Create hook script: {}", hook_script_path.display());
println!(
" * Patch settings: {} (add PreToolUse hook)",
state.settings_path.display()
);
}
if options.install_marketplace && !state.marketplace_installed {
println!(" * Register marketplace: skim (dean0x/skim)");
}
println!();
if !flags.yes && !options.skip_confirmation && !confirm_proceed()? {
println!(" Cancelled.");
return Ok(std::process::ExitCode::SUCCESS);
}
if flags_override.dry_run {
print_dry_run_actions(
&state,
options.install_marketplace,
flags_override.no_guidance,
!flags_override.project,
)?;
return Ok(std::process::ExitCode::SUCCESS);
}
execute_install(
&state,
options.install_marketplace,
flags_override.no_guidance,
!flags_override.project,
)?;
println!();
println!(
" Done! skim is now active in {}.",
flags_override.agent.display_name()
);
println!();
if options.install_marketplace {
println!(
" Next step -- install the Skimmer plugin in {}:",
flags_override.agent.display_name()
);
println!(" /install skimmer@skim");
println!();
}
Ok(std::process::ExitCode::SUCCESS)
}
pub(super) fn print_detected_state(state: &DetectedState) {
println!(" Checking current state...");
println!(
" {} skim binary: {} (v{})",
check_mark(true),
state.skim_binary.display(),
state.skim_version
);
let config_label = if state.settings_exists {
"exists"
} else {
"will be created"
};
println!(
" {} Config: {} ({})",
check_mark(state.settings_exists),
state.settings_path.display(),
config_label
);
let hook_label = if state.hook_installed {
match &state.hook_version {
Some(v) if v == &state.skim_version => format!("installed (v{v})"),
Some(v) => format!("installed (v{v} -> v{} available)", state.skim_version),
None => "installed".to_string(),
}
} else {
"not installed".to_string()
};
println!(
" {} Hook: {}",
check_mark(state.hook_installed),
hook_label
);
println!();
}
fn execute_install(
state: &DetectedState,
install_marketplace: bool,
no_guidance: bool,
global: bool,
) -> anyhow::Result<()> {
create_hook_script(state)?;
patch_settings(state, install_marketplace)?;
if !no_guidance {
let agent = AgentKind::from_str(state.agent_cli_name).ok_or_else(|| {
anyhow::anyhow!(
"unrecognised agent CLI name {:?}; this is a bug in state detection",
state.agent_cli_name
)
})?;
inject_guidance(agent, global)?;
}
Ok(())
}
fn validate_shell_safe_path(path: &str) -> anyhow::Result<()> {
const UNSAFE_CHARS: &[char] = &['"', '`', '$', '\\', '\n', '\0'];
if let Some(bad) = path.chars().find(|c| UNSAFE_CHARS.contains(c)) {
anyhow::bail!(
"binary path contains shell-unsafe character {:?}: {}\n\
hint: reinstall skim to a path without special characters",
bad,
path
);
}
Ok(())
}
fn create_hook_script(state: &DetectedState) -> anyhow::Result<()> {
let hooks_dir = state.config_dir.join("hooks");
let script_path = hooks_dir.join(HOOK_SCRIPT_NAME);
if !hooks_dir.exists() {
std::fs::create_dir_all(&hooks_dir)?;
#[cfg(unix)]
{
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&hooks_dir, perms)?;
}
}
if script_path.exists() {
if let Ok(contents) = std::fs::read_to_string(&script_path) {
let version_line = format!("# skim-hook v{}", state.skim_version);
if contents.contains(&version_line) {
println!(
" {} Skipped: {} (already v{})",
check_mark(true),
script_path.display(),
state.skim_version
);
return Ok(());
}
if let Some(old_ver) = &state.hook_version {
println!(
" {} Updated: {} (v{} -> v{})",
check_mark(true),
script_path.display(),
old_ver,
state.skim_version
);
} else {
println!(" {} Updated: {}", check_mark(true), script_path.display());
}
}
} else {
println!(" {} Created: {}", check_mark(true), script_path.display());
}
let binary_path = state.skim_binary.display().to_string();
validate_shell_safe_path(&binary_path)?;
let agent_flag = if state.agent_cli_name == "claude-code" {
String::new()
} else {
format!(" --agent {}", state.agent_cli_name)
};
let script_content = format!(
"#!/usr/bin/env bash\n\
# skim-hook v{version}\n\
# Generated by: skim init -- do not edit manually\n\
export SKIM_HOOK_VERSION=\"{version}\"\n\
exec \"{binary_path}\" rewrite --hook{agent_flag}\n",
version = state.skim_version,
);
let tmp_path = hooks_dir.join(format!("{HOOK_SCRIPT_NAME}.tmp"));
if let Err(e) = std::fs::write(&tmp_path, script_content) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
#[cfg(unix)]
{
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(&tmp_path, perms) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
}
if let Err(e) = std::fs::rename(&tmp_path, &script_path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
if let Ok(hash) = crate::cmd::integrity::compute_file_hash(&script_path) {
let _ = crate::cmd::integrity::write_hash_manifest(
&state.config_dir,
state.agent_cli_name,
HOOK_SCRIPT_NAME,
&hash,
);
}
Ok(())
}
fn backup_settings(
config_dir: &std::path::Path,
real_path: &std::path::Path,
) -> anyhow::Result<()> {
if real_path.is_symlink() {
anyhow::bail!(
"settings path became a symlink after resolution: {}\n\
hint: this may indicate a symlink race; please verify the path manually",
real_path.display()
);
}
let backup_path = config_dir.join(SETTINGS_BACKUP);
std::fs::copy(real_path, &backup_path)?;
Ok(())
}
fn upsert_hook_entry(
settings: &mut serde_json::Value,
hook_script_path: &str,
) -> anyhow::Result<()> {
let obj = settings
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json root is not an object"))?;
let hooks = obj
.entry("hooks")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json 'hooks' is not an object"))?;
let pre_tool_use = hooks
.entry("PreToolUse")
.or_insert_with(|| serde_json::Value::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json 'hooks.PreToolUse' is not an array"))?;
pre_tool_use.retain(|entry| !has_skim_hook_entry(entry));
pre_tool_use.push(serde_json::json!({
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": hook_script_path,
"timeout": 5
}]
}));
Ok(())
}
fn patch_settings(state: &DetectedState, install_marketplace: bool) -> anyhow::Result<()> {
if !state.config_dir.exists() {
std::fs::create_dir_all(&state.config_dir)?;
}
let real_path = resolve_real_settings_path(&state.settings_path)?;
let mut settings = load_or_create_settings(&real_path)?;
if real_path.exists() {
backup_settings(&state.config_dir, &real_path)?;
println!(
" {} Backed up: {} -> {}",
check_mark(true),
state.settings_path.display(),
SETTINGS_BACKUP
);
}
let hook_script_path = state.config_dir.join("hooks").join(HOOK_SCRIPT_NAME);
upsert_hook_entry(&mut settings, &hook_script_path.display().to_string())?;
if install_marketplace {
let obj = settings
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json root is not an object"))?;
let marketplaces = obj
.entry("extraKnownMarketplaces")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
.as_object_mut()
.ok_or_else(|| {
anyhow::anyhow!("settings.json 'extraKnownMarketplaces' is not an object")
})?;
marketplaces.insert(
"skim".to_string(),
serde_json::json!({"source": {"source": "github", "repo": "dean0x/skim"}}),
);
}
atomic_write_settings(&settings, &real_path)?;
println!(
" {} Patched: {} (PreToolUse hook added)",
check_mark(true),
state.settings_path.display()
);
if install_marketplace {
println!(
" {} Registered: skim marketplace in {}",
check_mark(true),
SETTINGS_FILE
);
}
Ok(())
}
const GUIDANCE_START: &str = "<!-- skim-start";
const GUIDANCE_END: &str = "<!-- skim-end -->";
const MAX_INSTRUCTION_FILE_SIZE: u64 = 1_048_576;
fn find_skim_section(content: &str) -> Option<(usize, usize)> {
let start = content.find(GUIDANCE_START)?;
let end_marker = content.find(GUIDANCE_END)?;
if start >= end_marker {
return None; }
Some((start, end_marker + GUIDANCE_END.len()))
}
fn resolve_instruction_path(agent: AgentKind, global: bool) -> anyhow::Result<std::path::PathBuf> {
match agent.instruction_file(global) {
Some(p) => Ok(p),
None if global => {
eprintln!(
" {} does not support global guidance. Using project scope.",
agent.display_name()
);
agent
.instruction_file(false)
.ok_or_else(|| anyhow::anyhow!("No instruction file for {}", agent.display_name()))
}
None => anyhow::bail!("No instruction file for {}", agent.display_name()),
}
}
fn read_existing_safely(path: &std::path::Path) -> anyhow::Result<Option<String>> {
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_INSTRUCTION_FILE_SIZE {
eprintln!(
" warning: {} is too large ({} bytes), skipping guidance",
path.display(),
meta.len()
);
return Ok(None);
}
}
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s)),
Err(e) => {
eprintln!(
" warning: could not read {}: {} (skipping guidance)",
path.display(),
e
);
Ok(None)
}
}
}
fn guidance_create(path: &std::path::Path, new_content: &str) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
atomic_write_stripped(path, &format!("{new_content}\n"))
}
fn guidance_update(
path: &std::path::Path,
existing: &str,
start: usize,
end: usize,
new_content: &str,
) -> anyhow::Result<()> {
let updated = format!("{}{}{}", &existing[..start], new_content, &existing[end..]);
atomic_write_stripped(path, &updated)
}
fn guidance_append(
path: &std::path::Path,
existing: &str,
new_content: &str,
) -> anyhow::Result<()> {
let mut content = existing.to_owned();
if !content.ends_with('\n') {
content.push('\n');
}
content.push('\n');
content.push_str(new_content);
content.push('\n');
atomic_write_stripped(path, &content)
}
pub(super) fn inject_guidance(agent: AgentKind, global: bool) -> anyhow::Result<()> {
let path = resolve_instruction_path(agent, global)?;
let path = super::helpers::resolve_real_settings_path(&path)?;
let version = env!("CARGO_PKG_VERSION");
let is_mdc = path.extension().is_some_and(|ext| ext == "mdc");
let new_content = if is_mdc {
super::helpers::guidance_content_mdc(version)
} else {
super::helpers::guidance_content(version)
};
if path.exists() {
let existing = match read_existing_safely(&path)? {
Some(s) => s,
None => return Ok(()), };
if find_skim_section(&existing).is_none() && existing.contains(GUIDANCE_START) {
eprintln!(
" warning: skim markers in {} appear corrupted (skipping guidance update)",
path.display()
);
return Ok(());
}
if let Some((start, end)) = find_skim_section(&existing) {
if existing[start..end].contains(&format!("v{version}")) {
println!(
" {} Guidance already current (v{})",
check_mark(true),
version
);
return Ok(());
}
guidance_update(&path, &existing, start, end, &new_content)?;
println!(
" {} Updated guidance in {} (-> v{})",
check_mark(true),
path.display(),
version
);
return Ok(());
}
guidance_append(&path, &existing, &new_content)?;
} else {
guidance_create(&path, &new_content)?;
}
if is_mdc && path.to_string_lossy().contains("skim.mdc") {
clean_legacy_cursorrules()?;
}
println!(
" {} Installed guidance in {}",
check_mark(true),
path.display()
);
if !global {
println!(
" Note: guidance added to {} — commit to share with your team.",
path.display()
);
}
Ok(())
}
pub(super) fn remove_guidance(agent: AgentKind, global: bool) -> anyhow::Result<()> {
let path = match agent.instruction_file(global) {
Some(p) if p.exists() => p,
_ => {
if agent == AgentKind::Cursor {
clean_legacy_cursorrules()?;
}
return Ok(());
}
};
let path = super::helpers::resolve_real_settings_path(&path)?;
if let Ok(meta) = std::fs::metadata(&path) {
if meta.len() > MAX_INSTRUCTION_FILE_SIZE {
eprintln!(
" warning: {} is too large ({} bytes), skipping guidance",
path.display(),
meta.len()
);
return Ok(());
}
}
let content = std::fs::read_to_string(&path)?;
if let Some((start, end)) = find_skim_section(&content) {
if path.extension().is_some_and(|ext| ext == "mdc") {
std::fs::remove_file(&path)?;
} else {
let mut updated = format!(
"{}{}",
content[..start].trim_end_matches('\n'),
&content[end..]
);
updated = updated.trim().to_string();
if !updated.is_empty() {
updated.push('\n');
}
if updated.trim().is_empty() {
std::fs::remove_file(&path)?;
} else {
atomic_write_stripped(&path, &updated)?;
}
}
println!(
" {} Removed guidance from {}",
check_mark(true),
path.display()
);
}
if agent == AgentKind::Cursor {
clean_legacy_cursorrules()?;
}
Ok(())
}
fn strip_skim_section(content: &str) -> Option<String> {
let (start, end) = find_skim_section(content)?;
let trimmed = format!(
"{}{}",
content[..start].trim_end_matches('\n'),
&content[end..]
)
.trim()
.to_string();
let final_content = if trimmed.is_empty() {
String::new()
} else {
trimmed + "\n"
};
Some(final_content)
}
fn atomic_write_stripped(path: &std::path::Path, content: &str) -> anyhow::Result<()> {
let tmp_ext = match path.extension().and_then(|e| e.to_str()) {
Some(ext) => format!("{ext}.tmp"),
None => "tmp".to_string(),
};
let tmp_path = path.with_extension(&tmp_ext);
if let Err(e) = std::fs::write(&tmp_path, content) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
if let Err(e) = std::fs::rename(&tmp_path, path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e.into());
}
Ok(())
}
fn clean_legacy_cursorrules() -> anyhow::Result<()> {
let legacy = std::path::PathBuf::from(".cursorrules");
if !legacy.exists() {
return Ok(());
}
let legacy = super::helpers::resolve_real_settings_path(&legacy)?;
if let Ok(content) = std::fs::read_to_string(&legacy) {
if let Some(cleaned) = strip_skim_section(&content) {
atomic_write_stripped(&legacy, &cleaned)?;
println!(" {} Cleaned legacy .cursorrules markers", check_mark(true));
}
}
Ok(())
}
pub(super) fn print_dry_run_actions(
state: &DetectedState,
install_marketplace: bool,
no_guidance: bool,
global: bool,
) -> anyhow::Result<()> {
let hook_script_path = state.config_dir.join("hooks").join(HOOK_SCRIPT_NAME);
println!(" [dry-run] Would create: {}", hook_script_path.display());
if state.settings_exists {
println!(
" [dry-run] Would back up: {} -> {}",
state.settings_path.display(),
SETTINGS_BACKUP
);
}
println!(
" [dry-run] Would patch: {} (add PreToolUse hook)",
state.settings_path.display()
);
if install_marketplace {
println!(
" [dry-run] Would register: skim marketplace in {}",
SETTINGS_FILE
);
}
if !no_guidance {
let agent = AgentKind::from_str(state.agent_cli_name).ok_or_else(|| {
anyhow::anyhow!(
"unrecognised agent CLI name {:?}; this is a bug in state detection",
state.agent_cli_name
)
})?;
if let Some(path) = agent.instruction_file(global) {
println!(" [dry-run] Would inject guidance into {}", path.display());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cmd::init::helpers::guidance_content;
#[test]
fn test_upsert_hook_entry_idempotent() {
let mut settings = serde_json::json!({});
upsert_hook_entry(&mut settings, "/path/to/skim-rewrite.sh").unwrap();
upsert_hook_entry(&mut settings, "/path/to/skim-rewrite.sh").unwrap();
let entries = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(
entries.len(),
1,
"running upsert twice should produce exactly one entry, not a duplicate"
);
}
#[test]
fn test_find_skim_section_normal_case() {
let content =
"Before\n<!-- skim-start v1.0.0 -->\nsome guidance\n<!-- skim-end -->\nAfter\n";
let result = find_skim_section(content);
assert!(result.is_some(), "Should find section with both markers");
let (start, end) = result.unwrap();
assert_eq!(
&content[start..],
"<!-- skim-start v1.0.0 -->\nsome guidance\n<!-- skim-end -->\nAfter\n"
);
assert_eq!(
&content[..end],
"Before\n<!-- skim-start v1.0.0 -->\nsome guidance\n<!-- skim-end -->"
);
}
#[test]
fn test_find_skim_section_markers_in_wrong_order() {
let content = "<!-- skim-end -->\nsome content\n<!-- skim-start v1.0.0 -->\n";
assert!(
find_skim_section(content).is_none(),
"Should return None when end marker precedes start marker"
);
}
#[test]
fn test_find_skim_section_only_start_marker() {
let content = "<!-- skim-start v1.0.0 -->\nsome guidance\nno end marker\n";
assert!(
find_skim_section(content).is_none(),
"Should return None when only start marker is present"
);
}
#[test]
fn test_find_skim_section_only_end_marker() {
let content = "some content\n<!-- skim-end -->\nmore content\n";
assert!(
find_skim_section(content).is_none(),
"Should return None when only end marker is present"
);
}
#[test]
fn test_find_skim_section_empty_input() {
assert!(
find_skim_section("").is_none(),
"Should return None for empty input"
);
}
#[test]
fn test_find_skim_section_adjacent_markers() {
let content = "prefix\n<!-- skim-start v2.0.0 --><!-- skim-end -->\nsuffix\n";
let result = find_skim_section(content);
assert!(
result.is_some(),
"Should find section when markers are adjacent"
);
let (start, end) = result.unwrap();
assert!(content[start..].starts_with("<!-- skim-start"));
assert!(content[..end].ends_with("<!-- skim-end -->"));
}
#[test]
fn test_inject_guidance_appends_to_existing() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
let existing = "# Existing Content\n\nSome rules here.\n";
std::fs::write(&path, existing).unwrap();
let version = "2.1.0";
let guidance = guidance_content(version);
guidance_append(&path, existing, &guidance).unwrap();
let result = std::fs::read_to_string(&path).unwrap();
assert!(result.starts_with("# Existing Content"));
assert!(result.contains("<!-- skim-start v2.1.0 -->"));
}
#[test]
fn test_inject_guidance_updates_stale_version() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
let old_guidance = guidance_content("1.0.0");
let existing = format!("# Header\n\n{}\n\n# Footer\n", old_guidance);
std::fs::write(&path, &existing).unwrap();
let new_guidance = guidance_content("2.1.0");
let (start, end) = find_skim_section(&existing).expect("markers should be present");
guidance_update(&path, &existing, start, end, &new_guidance).unwrap();
let result = std::fs::read_to_string(&path).unwrap();
assert!(result.contains("v2.1.0"));
assert!(!result.contains("v1.0.0"));
assert!(result.contains("# Header"));
assert!(result.contains("# Footer"));
}
#[test]
fn test_remove_guidance_strips_section() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
let guidance = guidance_content("2.1.0");
let existing = format!("# Header\n\n{}\n\n# Footer\n", guidance);
std::fs::write(&path, &existing).unwrap();
let stripped = strip_skim_section(&existing).expect("should find and strip skim section");
atomic_write_stripped(&path, &stripped).unwrap();
let result = std::fs::read_to_string(&path).unwrap();
assert!(!result.contains("skim-start"));
assert!(result.contains("# Header"));
assert!(result.contains("# Footer"));
}
#[test]
fn test_remove_guidance_deletes_empty_file() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("CLAUDE.md");
let guidance = guidance_content("2.1.0");
std::fs::write(&path, format!("{}\n", guidance)).unwrap();
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
let stripped = strip_skim_section(&content).expect("should find skim section");
if stripped.trim().is_empty() {
std::fs::remove_file(&path).unwrap();
} else {
std::fs::write(&path, &stripped).unwrap();
}
assert!(!path.exists(), "Empty file should be deleted");
}
#[test]
fn test_validate_shell_safe_path_normal_paths() {
assert!(validate_shell_safe_path("/usr/local/bin/skim").is_ok());
assert!(validate_shell_safe_path("/home/user/.cargo/bin/skim").is_ok());
assert!(validate_shell_safe_path("/path/with spaces/skim").is_ok());
}
#[test]
fn test_validate_shell_safe_path_rejects_double_quote() {
let result = validate_shell_safe_path("/path/with\"quote/skim");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("shell-unsafe"));
}
#[test]
fn test_validate_shell_safe_path_rejects_backtick() {
assert!(validate_shell_safe_path("/path/with`cmd`/skim").is_err());
}
#[test]
fn test_validate_shell_safe_path_rejects_dollar() {
assert!(validate_shell_safe_path("/path/$HOME/skim").is_err());
}
#[test]
fn test_validate_shell_safe_path_rejects_backslash() {
assert!(validate_shell_safe_path("/path/with\\escape/skim").is_err());
}
#[test]
fn test_validate_shell_safe_path_rejects_newline() {
assert!(validate_shell_safe_path("/path/with\nnewline/skim").is_err());
}
#[test]
fn test_validate_shell_safe_path_rejects_null_byte() {
assert!(validate_shell_safe_path("/path/with\0null/skim").is_err());
}
}