use std::error::Error;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use frigg::settings::FriggConfig;
use frigg::storage::resolve_workspace_relative_write_path;
use crate::cli_args::AdoptTarget;
use crate::cli_runtime::{
CliOutput, OutputField, OutputLevel, field, format_output_event_line, reported_error,
};
mod json_merge;
mod managed_block;
mod plan;
mod targets;
use plan::{AdoptPlan, AdoptPlanAction, AdoptPlanEntry};
use targets::select_targets;
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_adopt_command_with_output(
config: &FriggConfig,
requested_targets: Vec<AdoptTarget>,
all: bool,
uninstall: bool,
check: bool,
dry_run: bool,
force: bool,
output: &CliOutput,
) -> Result<(), Box<dyn Error>> {
output.progress_event(
OutputLevel::Info,
"adopt",
"start",
&[
field("status", "starting"),
field("repos", config.repositories().len()),
field("requested_targets", requested_targets.len()),
field("all", all),
field("dry_run", dry_run),
field("check", check),
field("uninstall", uninstall),
field("force", force),
],
None,
)?;
let plan = build_adopt_plan(config, &requested_targets, all, uninstall, force)?;
let pending_changes = plan.pending_changes();
let status = if plan.is_empty() {
"noop"
} else if check && pending_changes > 0 {
"pending"
} else {
"planned"
};
if dry_run || check {
output.result_event(
adopt_level_for_status(status),
"adopt",
"complete",
&adopt_summary_fields(
status,
config.repositories().len(),
&plan,
pending_changes,
dry_run,
check,
uninstall,
force,
0,
),
None,
)?;
for entry in &plan.entries {
output.result_event(
adopt_level_for_action(entry.action),
"adopt",
"plan",
&adopt_plan_fields(entry),
Some(&entry.path.display().to_string()),
)?;
}
}
if check && pending_changes > 0 {
let message = format!("adopt check failed: {pending_changes} pending change(s)");
output.error_event(
"adopt",
"failed",
&[
field("status", "failed"),
field("mode", "check"),
field("pending", pending_changes),
field("error", &message),
],
None,
)?;
return Err(reported_error(message));
}
if plan.action_count(AdoptPlanAction::Error) > 0 {
let message = "adopt failed: plan contains target error(s)";
output.error_event(
"adopt",
"failed",
&[
field("status", "failed"),
field("targets", plan.len()),
field("error", message),
],
None,
)?;
return Err(reported_error(message));
}
if dry_run || check {
return Ok(());
}
for entry in &plan.entries {
output.progress_event(
adopt_level_for_action(entry.action),
"adopt",
"plan",
&adopt_plan_fields(entry),
Some(&entry.path.display().to_string()),
)?;
}
let writes = apply_plan_entries(&plan, uninstall, force)?;
output.summary_event(
adopt_level_for_status(status),
"adopt",
"complete",
&adopt_summary_fields(
status,
config.repositories().len(),
&plan,
pending_changes,
dry_run,
check,
uninstall,
force,
writes,
),
None,
)?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn adopt_summary_fields(
status: &str,
repositories: usize,
plan: &AdoptPlan,
pending_changes: usize,
dry_run: bool,
check: bool,
uninstall: bool,
force: bool,
writes: usize,
) -> Vec<OutputField> {
vec![
field("status", status),
field("repos", repositories),
field("targets", plan.len()),
field("create", plan.action_count(AdoptPlanAction::Create)),
field("update", plan.action_count(AdoptPlanAction::Update)),
field("unchanged", plan.action_count(AdoptPlanAction::Unchanged)),
field("remove", plan.action_count(AdoptPlanAction::Remove)),
field("skipped", plan.action_count(AdoptPlanAction::Skipped)),
field("error", plan.action_count(AdoptPlanAction::Error)),
field("pending", pending_changes),
field("dry_run", dry_run),
field("check", check),
field("uninstall", uninstall),
field("force", force),
field("writes", writes),
]
}
fn adopt_plan_fields(entry: &AdoptPlanEntry) -> Vec<OutputField> {
vec![
field("repo", &entry.repository_id),
field("target", format!("{:?}", entry.target)),
field("action", entry.action.as_str()),
field("reason", entry.reason.as_deref().unwrap_or("-")),
field("writes", 0),
field("root", entry.root.display()),
]
}
fn adopt_level_for_status(status: &str) -> OutputLevel {
match status {
"pending" => OutputLevel::Warn,
"noop" => OutputLevel::Skip,
_ => OutputLevel::Ok,
}
}
fn adopt_level_for_action(action: AdoptPlanAction) -> OutputLevel {
match action {
AdoptPlanAction::Create | AdoptPlanAction::Update | AdoptPlanAction::Remove => {
OutputLevel::Info
}
AdoptPlanAction::Unchanged | AdoptPlanAction::Skipped => OutputLevel::Skip,
AdoptPlanAction::Error => OutputLevel::Error,
}
}
fn build_adopt_plan(
config: &FriggConfig,
requested_targets: &[AdoptTarget],
all: bool,
uninstall: bool,
force: bool,
) -> io::Result<AdoptPlan> {
let repositories = config.repositories();
let mut entries = Vec::new();
for repo in &repositories {
let root = config
.root_by_repository_id(&repo.repository_id.0)
.ok_or_else(|| {
io::Error::other(format_output_event_line(
OutputLevel::Error,
"adopt",
"failed",
&[
field("status", "failed"),
field("repo", &repo.repository_id.0),
field("error", "workspace root lookup failed"),
],
None,
))
})?;
for target in select_targets(root, requested_targets, all) {
let (action, reason) = classify_target_action(root, target, uninstall, force);
entries.push(AdoptPlanEntry {
repository_id: repo.repository_id.0.clone(),
root: root.to_path_buf(),
target,
path: root.join(target.path()),
action,
reason,
});
}
}
Ok(AdoptPlan::new(entries))
}
fn classify_target_action(
root: &Path,
target: AdoptTarget,
uninstall: bool,
force: bool,
) -> (AdoptPlanAction, Option<String>) {
let path = root.join(target.path());
if !path.exists() {
return if uninstall {
(
AdoptPlanAction::Unchanged,
Some("target-missing".to_owned()),
)
} else {
(AdoptPlanAction::Create, Some("target-missing".to_owned()))
};
};
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) => return (AdoptPlanAction::Error, Some(format!("read-error:{err}"))),
};
if matches!(target, AdoptTarget::Hook) {
classify_claude_hook_target(&contents, uninstall)
} else if matches!(target, AdoptTarget::McpProject | AdoptTarget::McpCursor) {
classify_mcp_target(&contents, uninstall, force)
} else {
classify_markdown_target(&contents, uninstall)
}
}
fn classify_markdown_target(contents: &str, uninstall: bool) -> (AdoptPlanAction, Option<String>) {
let desired = managed_block::desired_markdown();
if uninstall {
return match managed_block::remove_managed_block(contents) {
Ok(managed_block::ManagedBlockEdit::Changed(_)) => (
AdoptPlanAction::Remove,
Some("managed-block-present".to_owned()),
),
Ok(managed_block::ManagedBlockEdit::Unchanged) => (
AdoptPlanAction::Unchanged,
Some("managed-block-absent".to_owned()),
),
Err(err) => (
AdoptPlanAction::Error,
Some(format!("invalid-managed-block:{err}")),
),
};
}
match managed_block::upsert_managed_block(contents, &desired) {
Ok(managed_block::ManagedBlockEdit::Unchanged) => (
AdoptPlanAction::Unchanged,
Some("managed-block-current".to_owned()),
),
Ok(managed_block::ManagedBlockEdit::Changed(_)) => {
if managed_block::has_managed_block(contents) {
(
AdoptPlanAction::Update,
Some("managed-block-drifted".to_owned()),
)
} else {
(
AdoptPlanAction::Update,
Some("managed-block-absent".to_owned()),
)
}
}
Err(err) => (
AdoptPlanAction::Error,
Some(format!("invalid-managed-block:{err}")),
),
}
}
fn apply_plan_entries(
plan: &AdoptPlan,
uninstall: bool,
force: bool,
) -> Result<usize, Box<dyn Error>> {
let mut writes = 0;
for entry in &plan.entries {
if !matches!(
entry.action,
AdoptPlanAction::Create | AdoptPlanAction::Update | AdoptPlanAction::Remove
) {
continue;
}
let write_path = resolve_entry_write_path(entry)?;
let contents = match fs::read_to_string(&entry.path) {
Ok(contents) => Some(contents),
Err(err) if err.kind() == io::ErrorKind::NotFound => None,
Err(err) => return Err(Box::new(err)),
};
let edit = if matches!(entry.target, AdoptTarget::Hook) {
apply_claude_hook_edit(contents.as_deref(), uninstall)
} else if matches!(
entry.target,
AdoptTarget::McpProject | AdoptTarget::McpCursor
) {
apply_mcp_json_edit(contents.as_deref(), uninstall, force)
} else {
apply_markdown_edit(contents.as_deref(), uninstall)
}?;
match edit {
AdoptApplyEdit::Write(updated) => {
fs::write(&write_path, updated)?;
writes += 1;
}
AdoptApplyEdit::Delete => {
match fs::remove_file(&write_path) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Err(err) => return Err(Box::new(err)),
}
writes += 1;
}
AdoptApplyEdit::Unchanged => {}
}
}
Ok(writes)
}
#[cfg(test)]
fn apply_mcp_json_entries(plan: &AdoptPlan, uninstall: bool, force: bool) -> io::Result<usize> {
apply_plan_entries(plan, uninstall, force).map_err(|err| io::Error::other(err.to_string()))
}
fn resolve_entry_write_path(entry: &AdoptPlanEntry) -> Result<PathBuf, Box<dyn Error>> {
resolve_workspace_relative_write_path(&entry.root, Path::new(entry.target.path()))
.map_err(|err| Box::new(err) as Box<dyn Error>)
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AdoptApplyEdit {
Write(String),
Delete,
Unchanged,
}
fn apply_markdown_edit(
contents: Option<&str>,
uninstall: bool,
) -> Result<AdoptApplyEdit, Box<dyn Error>> {
if uninstall {
let Some(contents) = contents else {
return Ok(AdoptApplyEdit::Unchanged);
};
return match managed_block::remove_managed_block(contents) {
Ok(managed_block::ManagedBlockEdit::Changed(updated)) => {
if updated.trim().is_empty() {
Ok(AdoptApplyEdit::Delete)
} else {
Ok(AdoptApplyEdit::Write(updated))
}
}
Ok(managed_block::ManagedBlockEdit::Unchanged) => Ok(AdoptApplyEdit::Unchanged),
Err(err) => Err(Box::new(err)),
};
}
let desired = managed_block::desired_markdown();
let edit = managed_block::upsert_managed_block(contents.unwrap_or_default(), &desired)?;
Ok(match edit {
managed_block::ManagedBlockEdit::Changed(updated) => AdoptApplyEdit::Write(updated),
managed_block::ManagedBlockEdit::Unchanged => AdoptApplyEdit::Unchanged,
})
}
fn apply_mcp_json_edit(
contents: Option<&str>,
uninstall: bool,
force: bool,
) -> Result<AdoptApplyEdit, Box<dyn Error>> {
let edit = if uninstall {
match contents {
Some(contents) => json_merge::remove_mcp_server(contents, force),
None => Ok(json_merge::McpJsonEdit::Unchanged),
}
} else {
json_merge::upsert_mcp_server(contents, force)
}?;
Ok(match edit {
json_merge::McpJsonEdit::Changed(updated) => AdoptApplyEdit::Write(updated),
json_merge::McpJsonEdit::Unchanged | json_merge::McpJsonEdit::Skipped => {
AdoptApplyEdit::Unchanged
}
})
}
fn apply_claude_hook_edit(
contents: Option<&str>,
uninstall: bool,
) -> Result<AdoptApplyEdit, Box<dyn Error>> {
let edit = if uninstall {
match contents {
Some(contents) => json_merge::remove_claude_hook(contents),
None => Ok(json_merge::McpJsonEdit::Unchanged),
}
} else {
json_merge::upsert_claude_hook(contents)
}?;
Ok(match edit {
json_merge::McpJsonEdit::Changed(updated) => AdoptApplyEdit::Write(updated),
json_merge::McpJsonEdit::Unchanged | json_merge::McpJsonEdit::Skipped => {
AdoptApplyEdit::Unchanged
}
})
}
fn classify_mcp_target(
contents: &str,
uninstall: bool,
force: bool,
) -> (AdoptPlanAction, Option<String>) {
let state = match json_merge::classify_mcp_entry(contents) {
Ok(state) => state,
Err(err) => {
return (AdoptPlanAction::Error, Some(format!("invalid-json:{err}")));
}
};
match (uninstall, state, force) {
(true, json_merge::McpEntryState::Desired, _) => (
AdoptPlanAction::Remove,
Some("frigg-entry-present".to_owned()),
),
(true, json_merge::McpEntryState::Diverged, false) => (
AdoptPlanAction::Skipped,
Some("frigg-entry-diverged".to_owned()),
),
(true, json_merge::McpEntryState::Diverged, true) => (
AdoptPlanAction::Remove,
Some("force-diverged-frigg-entry".to_owned()),
),
(true, json_merge::McpEntryState::Missing, _) => (
AdoptPlanAction::Unchanged,
Some("frigg-entry-absent".to_owned()),
),
(false, json_merge::McpEntryState::Desired, _) => (
AdoptPlanAction::Unchanged,
Some("frigg-entry-current".to_owned()),
),
(false, json_merge::McpEntryState::Missing, _) => (
AdoptPlanAction::Update,
Some("frigg-entry-absent".to_owned()),
),
(false, json_merge::McpEntryState::Diverged, false) => (
AdoptPlanAction::Skipped,
Some("frigg-entry-diverged".to_owned()),
),
(false, json_merge::McpEntryState::Diverged, true) => (
AdoptPlanAction::Update,
Some("force-diverged-frigg-entry".to_owned()),
),
}
}
fn classify_claude_hook_target(
contents: &str,
uninstall: bool,
) -> (AdoptPlanAction, Option<String>) {
let state = match json_merge::classify_claude_hook(contents) {
Ok(state) => state,
Err(err) => return (AdoptPlanAction::Error, Some(format!("invalid-json:{err}"))),
};
match (uninstall, state) {
(true, json_merge::ClaudeHookState::Desired) => (
AdoptPlanAction::Remove,
Some("frigg-hook-present".to_owned()),
),
(true, json_merge::ClaudeHookState::Missing) => (
AdoptPlanAction::Unchanged,
Some("frigg-hook-absent".to_owned()),
),
(false, json_merge::ClaudeHookState::Desired) => (
AdoptPlanAction::Unchanged,
Some("frigg-hook-current".to_owned()),
),
(false, json_merge::ClaudeHookState::Missing) => (
AdoptPlanAction::Update,
Some("frigg-hook-absent".to_owned()),
),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use frigg::settings::FriggConfig;
use super::{apply_mcp_json_entries, apply_plan_entries, build_adopt_plan};
use crate::cli_args::AdoptTarget;
#[test]
fn adopt_plan_uses_workspace_roots_and_targets() {
let root = temp_dir("adopt-plan");
fs::create_dir_all(&root).expect("create temp root");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::McpProject], false, false, false)
.expect("build adopt plan");
assert_eq!(plan.len(), 1);
assert_eq!(plan.entries[0].repository_id, "repo-001");
assert_eq!(plan.entries[0].path, root.join(".mcp.json"));
assert_eq!(plan.entries[0].action, super::AdoptPlanAction::Create);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_plan_reports_unchanged_markdown_when_block_is_current() {
let root = temp_dir("adopt-plan-current-markdown");
fs::create_dir_all(&root).expect("create temp root");
fs::write(
root.join("AGENTS.md"),
super::managed_block::desired_markdown(),
)
.expect("write agents");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::AgentsMd], false, false, false)
.expect("build adopt plan");
assert_eq!(plan.entries[0].action, super::AdoptPlanAction::Unchanged);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_plan_skips_diverged_mcp_without_force() {
let root = temp_dir("adopt-plan-diverged-mcp");
fs::create_dir_all(&root).expect("create temp root");
fs::write(
root.join(".mcp.json"),
r#"{"mcpServers":{"frigg":{"command":"frigg"}}}"#,
)
.expect("write mcp");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::McpProject], false, false, false)
.expect("build adopt plan");
assert_eq!(plan.entries[0].action, super::AdoptPlanAction::Skipped);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_creates_missing_mcp_config() {
let root = temp_dir("adopt-apply-create-mcp");
fs::create_dir_all(&root).expect("create temp root");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::McpProject], false, false, false)
.expect("build adopt plan");
assert_eq!(
apply_mcp_json_entries(&plan, false, false).expect("apply mcp"),
1
);
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(root.join(".mcp.json")).expect("read mcp"))
.expect("parse mcp");
assert_eq!(
value["mcpServers"]["frigg"],
super::json_merge::desired_mcp_server()
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_creates_missing_markdown_with_managed_block() {
let root = temp_dir("adopt-apply-create-markdown");
fs::create_dir_all(&root).expect("create temp root");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::AgentsMd], false, false, false)
.expect("build adopt plan");
assert_eq!(
apply_plan_entries(&plan, false, false).expect("apply markdown"),
1
);
assert_eq!(
fs::read_to_string(root.join("AGENTS.md")).expect("read agents"),
super::managed_block::desired_markdown()
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_removes_owned_markdown_file_on_uninstall() {
let root = temp_dir("adopt-apply-remove-owned-markdown");
fs::create_dir_all(&root).expect("create temp root");
fs::write(
root.join("AGENTS.md"),
super::managed_block::desired_markdown(),
)
.expect("write agents");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::AgentsMd], false, true, false)
.expect("build adopt plan");
assert_eq!(
apply_plan_entries(&plan, true, false).expect("apply uninstall"),
1
);
assert!(!root.join("AGENTS.md").exists());
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_preserves_sibling_mcp_servers() {
let root = temp_dir("adopt-apply-preserve-mcp");
fs::create_dir_all(&root).expect("create temp root");
fs::write(
root.join(".mcp.json"),
r#"{"mcpServers":{"other":{"command":"other"}},"unrelated":true}"#,
)
.expect("write mcp");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::McpProject], false, false, false)
.expect("build adopt plan");
assert_eq!(
apply_mcp_json_entries(&plan, false, false).expect("apply mcp"),
1
);
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(root.join(".mcp.json")).expect("read mcp"))
.expect("parse mcp");
assert_eq!(value["mcpServers"]["other"]["command"], "other");
assert_eq!(value["unrelated"], true);
assert_eq!(
value["mcpServers"]["frigg"],
super::json_merge::desired_mcp_server()
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_removes_only_frigg_mcp_server_on_uninstall() {
let root = temp_dir("adopt-apply-remove-mcp");
fs::create_dir_all(&root).expect("create temp root");
fs::write(
root.join(".mcp.json"),
r#"{"mcpServers":{"frigg":{"type":"http","url":"http://127.0.0.1:37444/mcp"},"other":{"command":"other"}},"unrelated":true}"#,
)
.expect("write mcp");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::McpProject], false, true, false)
.expect("build adopt plan");
assert_eq!(
apply_mcp_json_entries(&plan, true, false).expect("apply mcp"),
1
);
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(root.join(".mcp.json")).expect("read mcp"))
.expect("parse mcp");
assert!(value["mcpServers"].get("frigg").is_none());
assert_eq!(value["mcpServers"]["other"]["command"], "other");
assert_eq!(value["unrelated"], true);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_installs_claude_hook_idempotently() {
let root = temp_dir("adopt-apply-install-hook");
fs::create_dir_all(&root).expect("create temp root");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::Hook], false, false, false)
.expect("build adopt plan");
assert_eq!(plan.entries[0].action, super::AdoptPlanAction::Create);
assert_eq!(
apply_plan_entries(&plan, false, false).expect("apply hook"),
1
);
let value: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".claude/settings.json")).expect("read settings"),
)
.expect("parse settings");
assert_eq!(
value["hooks"]["PreToolUse"][0]["hooks"][0],
super::json_merge::desired_claude_hook_command()
);
let plan = build_adopt_plan(&config, &[AdoptTarget::Hook], false, false, false)
.expect("build adopt plan again");
assert_eq!(plan.entries[0].action, super::AdoptPlanAction::Unchanged);
assert_eq!(
apply_plan_entries(&plan, false, false).expect("reapply hook"),
0
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_all_with_explicit_hook_installs_claude_hook() {
let root = temp_dir("adopt-apply-all-install-hook");
fs::create_dir_all(&root).expect("create temp root");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::Hook], true, false, false)
.expect("build adopt plan");
assert!(
plan.entries
.iter()
.any(|entry| entry.target == AdoptTarget::Hook)
);
apply_plan_entries(&plan, false, false).expect("apply all with hook");
let value: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".claude/settings.json")).expect("read settings"),
)
.expect("parse settings");
assert_eq!(
value["hooks"]["PreToolUse"][0]["hooks"][0],
super::json_merge::desired_claude_hook_command()
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_preserves_sibling_claude_settings_and_hooks() {
let root = temp_dir("adopt-apply-preserve-hook");
fs::create_dir_all(root.join(".claude")).expect("create claude dir");
fs::write(
root.join(".claude/settings.json"),
r#"{"theme":"dark","hooks":{"PreToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"other"}]}],"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"post"}]}]}}"#,
)
.expect("write settings");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::Hook], false, false, false)
.expect("build adopt plan");
assert_eq!(
apply_plan_entries(&plan, false, false).expect("apply hook"),
1
);
let value: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".claude/settings.json")).expect("read settings"),
)
.expect("parse settings");
assert_eq!(value["theme"], "dark");
assert_eq!(
value["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
"post"
);
assert_eq!(
value["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"other"
);
assert_eq!(
value["hooks"]["PreToolUse"][1]["hooks"][0],
super::json_merge::desired_claude_hook_command()
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_apply_removes_only_frigg_claude_hook_on_uninstall() {
let root = temp_dir("adopt-apply-remove-hook");
fs::create_dir_all(root.join(".claude")).expect("create claude dir");
fs::write(
root.join(".claude/settings.json"),
r#"{"hooks":{"PreToolUse":[{"matcher":"Grep|Bash|Read","hooks":[{"type":"command","command":"other"},{"type":"command","command":"frigg hook pretooluse","timeout":5}]},{"matcher":"Write","hooks":[{"type":"command","command":"frigg hook pretooluse","timeout":5},{"type":"command","command":"write-hook"}]}]},"unrelated":true}"#,
)
.expect("write settings");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::Hook], false, true, false)
.expect("build adopt plan");
assert_eq!(
apply_plan_entries(&plan, true, false).expect("apply hook uninstall"),
1
);
let value: serde_json::Value = serde_json::from_str(
&fs::read_to_string(root.join(".claude/settings.json")).expect("read settings"),
)
.expect("parse settings");
assert_eq!(value["unrelated"], true);
assert_eq!(
value["hooks"]["PreToolUse"][0]["hooks"][0]["command"],
"other"
);
assert_eq!(value["hooks"]["PreToolUse"][1]["matcher"], "Write");
assert_eq!(
value["hooks"]["PreToolUse"][1]["hooks"][0],
super::json_merge::desired_claude_hook_command()
);
assert_eq!(
value["hooks"]["PreToolUse"][1]["hooks"][1]["command"],
"write-hook"
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[test]
fn adopt_plan_reports_malformed_claude_settings_without_clobbering() {
let root = temp_dir("adopt-plan-malformed-hook");
fs::create_dir_all(root.join(".claude")).expect("create claude dir");
let settings_path = root.join(".claude/settings.json");
fs::write(&settings_path, "{not json").expect("write malformed settings");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::Hook], false, false, false)
.expect("build adopt plan");
assert_eq!(plan.entries[0].action, super::AdoptPlanAction::Error);
let err = super::apply_claude_hook_edit(Some("{not json"), false)
.expect_err("apply should reject malformed JSON");
assert!(err.to_string().contains("invalid JSON"));
assert_eq!(
fs::read_to_string(&settings_path).expect("read settings"),
"{not json"
);
fs::remove_dir_all(root).expect("remove temp root");
}
#[cfg(unix)]
#[test]
fn adopt_apply_rejects_symlink_parent_escape_before_write() {
let root = temp_dir("adopt-apply-symlink-root");
let outside = temp_dir("adopt-apply-symlink-outside");
fs::create_dir_all(&root).expect("create temp root");
fs::create_dir_all(&outside).expect("create outside root");
std::os::unix::fs::symlink(&outside, root.join(".cursor")).expect("create cursor symlink");
let config = FriggConfig::from_workspace_roots(vec![root.clone()])
.expect("config should accept workspace root");
let plan = build_adopt_plan(&config, &[AdoptTarget::Cursor], false, false, false)
.expect("build adopt plan");
let err = apply_plan_entries(&plan, false, false)
.expect_err("symlinked parent escape should fail");
assert!(err.to_string().contains("escapes canonical workspace root"));
assert!(!outside.join("rules/frigg.mdc").exists());
fs::remove_dir_all(root).expect("remove temp root");
fs::remove_dir_all(outside).expect("remove outside root");
}
fn temp_dir(stem: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("{stem}-{unique}"))
}
}