1use std::path::PathBuf;
15
16use serde_json::Value;
17
18use crate::config::Paths;
19use crate::error::{Error, Result};
20
21pub(crate) mod backup;
22pub(crate) mod hooks;
23pub(crate) mod mcp;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum InstallScope {
30 User,
32 Project,
34}
35
36#[derive(Debug, Clone)]
38pub struct InstallPlan {
39 pub scope: InstallScope,
41 pub dotenv: Option<PathBuf>,
44 pub with_hooks: bool,
46 pub force: bool,
48 pub apply: bool,
51}
52
53#[derive(Debug, Clone)]
55pub struct UninstallPlan {
56 pub scope: InstallScope,
58 pub keep_mcp: bool,
60 pub keep_hooks: bool,
62 pub apply: bool,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum ChangeKind {
69 Added,
71 Updated,
73 Removed,
75 NoOp,
77}
78
79#[derive(Debug, Clone)]
81pub struct MutationChange {
82 pub target: PathBuf,
84 pub kind: ChangeKind,
86 pub description: String,
88}
89
90#[derive(Debug, Clone)]
92pub struct InstallReport {
93 pub mcp_change: Option<MutationChange>,
95 pub hooks_changes: Vec<MutationChange>,
97 pub backups: Vec<PathBuf>,
100 pub applied: bool,
102}
103
104pub fn install(paths: &Paths, plan: &InstallPlan) -> Result<InstallReport> {
112 let mcp_path = paths.user_home.join(".claude.json");
113 let hooks_path = paths.user_home.join(".claude/settings.json");
114
115 let mut mcp_root = load_json_or_empty(&mcp_path)?;
117 let mcp_kind = mcp::add_mcp_entry(
118 &mut mcp_root,
119 plan.scope,
120 plan.dotenv.as_deref(),
121 plan.force,
122 );
123 let mcp_change = Some(MutationChange {
124 target: mcp_path.clone(),
125 kind: mcp_kind.clone(),
126 description: format!("MCP entry (scope={:?}): {:?}", plan.scope, mcp_kind),
127 });
128
129 let mut hooks_root = if plan.with_hooks {
131 load_json_or_empty(&hooks_path)?
132 } else {
133 Value::Null
134 };
135 let raw_hook_kinds = if plan.with_hooks {
136 hooks::add_hooks(&mut hooks_root, plan.force)
137 } else {
138 vec![]
139 };
140 let hook_names = ["user-prompt-submit", "session-start"];
141 let hooks_changes: Vec<MutationChange> = raw_hook_kinds
142 .into_iter()
143 .enumerate()
144 .map(|(i, kind)| {
145 let name = hook_names.get(i).copied().unwrap_or("unknown");
146 MutationChange {
147 target: hooks_path.clone(),
148 kind: kind.clone(),
149 description: format!("hook `agentsec hook {name}`: {kind:?}"),
150 }
151 })
152 .collect();
153
154 let mcp_dirty = mcp_kind != ChangeKind::NoOp;
161 let hooks_dirty = hooks_changes.iter().any(|c| c.kind != ChangeKind::NoOp);
162
163 let mut backups = Vec::new();
164 if plan.apply {
165 if mcp_dirty {
167 let bak = backup::backup(&mcp_path)?;
168 if !bak.as_os_str().is_empty() {
169 backups.push(bak);
170 }
171 write_json(&mcp_path, &mcp_root)?;
172 }
173
174 if plan.with_hooks && hooks_dirty {
176 if let Some(parent) = hooks_path.parent() {
178 std::fs::create_dir_all(parent)?;
179 }
180 let bak = backup::backup(&hooks_path)?;
181 if !bak.as_os_str().is_empty() {
182 backups.push(bak);
183 }
184 write_json(&hooks_path, &hooks_root)?;
185 }
186 }
187
188 Ok(InstallReport {
189 mcp_change,
190 hooks_changes,
191 backups,
192 applied: plan.apply,
193 })
194}
195
196pub fn uninstall(paths: &Paths, plan: &UninstallPlan) -> Result<InstallReport> {
201 let mcp_path = paths.user_home.join(".claude.json");
202 let hooks_path = paths.user_home.join(".claude/settings.json");
203
204 let mcp_change = if plan.keep_mcp {
206 None
207 } else {
208 let mut mcp_root = load_json_or_empty(&mcp_path)?;
209 let kind = mcp::remove_mcp_entry(&mut mcp_root, plan.scope);
210 Some((
211 mcp_root,
212 MutationChange {
213 target: mcp_path.clone(),
214 kind: kind.clone(),
215 description: format!("MCP entry (scope={:?}): {:?}", plan.scope, kind),
216 },
217 ))
218 };
219
220 let hooks_result = if plan.keep_hooks {
222 None
223 } else {
224 let mut hooks_root = load_json_or_empty(&hooks_path)?;
225 let raw_kinds = hooks::remove_hooks(&mut hooks_root);
226 Some((hooks_root, raw_kinds))
227 };
228
229 let hook_names = ["user-prompt-submit", "session-start"];
230 let hooks_changes: Vec<MutationChange> = hooks_result
231 .as_ref()
232 .map(|(_, kinds)| {
233 kinds
234 .iter()
235 .enumerate()
236 .map(|(i, kind)| {
237 let name = hook_names.get(i).copied().unwrap_or("unknown");
238 MutationChange {
239 target: hooks_path.clone(),
240 kind: kind.clone(),
241 description: format!("hook `agentsec hook {name}`: {kind:?}"),
242 }
243 })
244 .collect()
245 })
246 .unwrap_or_default();
247
248 let mcp_change_report = mcp_change.as_ref().map(|(_, c)| c.clone());
249
250 let mcp_dirty = mcp_change
257 .as_ref()
258 .is_some_and(|(_, c)| c.kind != ChangeKind::NoOp);
259 let hooks_dirty = hooks_changes.iter().any(|c| c.kind != ChangeKind::NoOp);
260
261 let mut backups = Vec::new();
262 if plan.apply {
263 if mcp_dirty && let Some((mcp_root, _)) = &mcp_change {
264 let bak = backup::backup(&mcp_path)?;
265 if !bak.as_os_str().is_empty() {
266 backups.push(bak);
267 }
268 write_json(&mcp_path, mcp_root)?;
269 }
270 if hooks_dirty && let Some((hooks_root, _)) = &hooks_result {
271 let bak = backup::backup(&hooks_path)?;
272 if !bak.as_os_str().is_empty() {
273 backups.push(bak);
274 }
275 write_json(&hooks_path, hooks_root)?;
276 }
277 }
278
279 Ok(InstallReport {
280 mcp_change: mcp_change_report,
281 hooks_changes,
282 backups,
283 applied: plan.apply,
284 })
285}
286
287fn load_json_or_empty(path: &std::path::Path) -> Result<Value> {
290 if !path.exists() {
291 return Ok(Value::Object(serde_json::Map::new()));
292 }
293 let body = std::fs::read_to_string(path)?;
294 if body.trim().is_empty() {
295 return Ok(Value::Object(serde_json::Map::new()));
296 }
297 serde_json::from_str(&body)
298 .map_err(|e| Error::Installer(format!("JSON parse error in {}: {e}", path.display())))
299}
300
301fn write_json(path: &std::path::Path, value: &Value) -> Result<()> {
302 if let Some(parent) = path.parent() {
303 std::fs::create_dir_all(parent)?;
304 }
305 let content = serde_json::to_string_pretty(value)?;
306 std::fs::write(path, content)?;
307 Ok(())
308}
309
310#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::LlmConfig;
316 use crate::{Config, Paths};
317 use tempfile::TempDir;
318
319 fn test_paths(tmp: &TempDir) -> Paths {
320 Paths {
321 home: tmp.path().to_path_buf(),
322 user_home: tmp.path().to_path_buf(),
323 }
324 }
325
326 fn test_cfg(tmp: &TempDir) -> Config {
327 Config {
328 paths: test_paths(tmp),
329 llm: LlmConfig {
330 api_key: None,
331 model: "claude-sonnet-4-5".to_string(),
332 },
333 paste: crate::config::PasteConfig::default(),
334 web: crate::config::WebConfig::default(),
335 dotenv_path: None,
336 }
337 }
338
339 #[test]
340 fn install_dry_run_no_file_mutation() {
341 let tmp = TempDir::new().unwrap();
342 let paths = test_paths(&tmp);
343 let plan = InstallPlan {
344 scope: InstallScope::User,
345 dotenv: None,
346 with_hooks: true,
347 force: false,
348 apply: false,
349 };
350 let report = install(&paths, &plan).unwrap();
351 assert!(!report.applied);
352 assert!(report.backups.is_empty());
353 assert!(!tmp.path().join(".claude.json").exists());
355 assert!(!tmp.path().join(".claude/settings.json").exists());
356 }
357
358 #[test]
359 fn install_apply_creates_files() {
360 let tmp = TempDir::new().unwrap();
361 let paths = test_paths(&tmp);
362 let plan = InstallPlan {
363 scope: InstallScope::User,
364 dotenv: None,
365 with_hooks: true,
366 force: false,
367 apply: true,
368 };
369 let report = install(&paths, &plan).unwrap();
370 assert!(report.applied);
371 assert!(tmp.path().join(".claude.json").exists());
372 assert!(tmp.path().join(".claude/settings.json").exists());
373 let mcp_content = std::fs::read_to_string(tmp.path().join(".claude.json")).unwrap();
375 let mcp_json: Value = serde_json::from_str(&mcp_content).unwrap();
376 assert!(mcp_json["mcpServers"]["agentsec"].is_object());
377 }
378
379 #[test]
380 fn install_twice_is_idempotent() {
381 let tmp = TempDir::new().unwrap();
382 let paths = test_paths(&tmp);
383 let plan = InstallPlan {
384 scope: InstallScope::User,
385 dotenv: None,
386 with_hooks: true,
387 force: false,
388 apply: true,
389 };
390 install(&paths, &plan).unwrap();
391 install(&paths, &plan).unwrap();
392
393 let hooks_content =
394 std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
395 let hooks_json: Value = serde_json::from_str(&hooks_content).unwrap();
396 let ups_len = hooks_json["hooks"]["UserPromptSubmit"]
398 .as_array()
399 .map_or(0, Vec::len);
400 assert_eq!(ups_len, 1, "should not duplicate hook entries");
401 }
402
403 #[test]
404 fn install_apply_creates_backup_when_file_existed() {
405 let tmp = TempDir::new().unwrap();
406 let paths = test_paths(&tmp);
407 std::fs::write(tmp.path().join(".claude.json"), "{}").unwrap();
409 std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
410 std::fs::write(tmp.path().join(".claude/settings.json"), "{}").unwrap();
411
412 let plan = InstallPlan {
413 scope: InstallScope::User,
414 dotenv: None,
415 with_hooks: true,
416 force: false,
417 apply: true,
418 };
419 let report = install(&paths, &plan).unwrap();
420 assert_eq!(report.backups.len(), 2, "should create 2 backups");
421 for bak in &report.backups {
422 assert!(bak.exists());
423 }
424 }
425
426 #[test]
427 fn install_apply_skips_backup_when_all_changes_are_noop() {
428 let tmp = TempDir::new().unwrap();
434 let paths = test_paths(&tmp);
435 let plan = InstallPlan {
436 scope: InstallScope::User,
437 dotenv: None,
438 with_hooks: true,
439 force: false,
440 apply: true,
441 };
442 let first = install(&paths, &plan).unwrap();
445 assert!(first.applied);
446 let second = install(&paths, &plan).unwrap();
448 assert!(second.applied);
449 assert_eq!(
450 second.mcp_change.as_ref().unwrap().kind,
451 super::ChangeKind::NoOp
452 );
453 assert!(
454 second
455 .hooks_changes
456 .iter()
457 .all(|c| c.kind == super::ChangeKind::NoOp),
458 "all hooks must report NoOp on second apply"
459 );
460 assert_eq!(
461 second.backups.len(),
462 0,
463 "second apply must not create backups when nothing changes"
464 );
465 }
466
467 #[test]
468 fn uninstall_apply_skips_backup_when_nothing_to_remove() {
469 let tmp = TempDir::new().unwrap();
472 let paths = test_paths(&tmp);
473 std::fs::write(tmp.path().join(".claude.json"), "{}").unwrap();
476 std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
477 std::fs::write(tmp.path().join(".claude/settings.json"), "{}").unwrap();
478
479 let plan = UninstallPlan {
480 scope: InstallScope::User,
481 keep_mcp: false,
482 keep_hooks: false,
483 apply: true,
484 };
485 let report = uninstall(&paths, &plan).unwrap();
486 assert!(report.applied);
487 assert_eq!(
488 report.mcp_change.as_ref().unwrap().kind,
489 super::ChangeKind::NoOp
490 );
491 assert!(
492 report
493 .hooks_changes
494 .iter()
495 .all(|c| c.kind == super::ChangeKind::NoOp)
496 );
497 assert_eq!(
498 report.backups.len(),
499 0,
500 "uninstall on clean host must not create backups"
501 );
502 }
503
504 #[allow(dead_code)]
506 fn _use_cfg(tmp: &TempDir) {
507 let _ = test_cfg(tmp);
508 }
509}