1pub mod checksum;
26pub mod compat;
27pub mod doctor;
28pub mod templates;
29
30use std::collections::BTreeMap;
31use std::fs;
32use std::path::Path;
33
34use serde::{Deserialize, Serialize};
35
36use crate::error::{CtxError, Result};
37use checksum::{content_checksum, finalize, recorded_checksum, style_for_path};
38
39pub const LOCK_PATH: &str = ".ctx/harness.lock";
41
42pub const RULES_PATH: &str = ".ctx/rules.toml";
44
45pub const LOCAL_HOOKS_DIR: &str = ".claude/hooks/ctx";
47
48pub const HOOK_NAMES: [&str; 3] = ["session-start", "post-tool-use", "stop"];
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Target {
54 Claude,
55 Codex,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Mode {
61 Local,
62 Plugin,
63}
64
65#[derive(Debug, Clone)]
68pub struct GeneratedFile {
69 pub rel_path: String,
71 pub content: String,
73 pub executable: bool,
75 pub never_overwrite: bool,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum FileAction {
82 Created,
84 Regenerated,
86 Overwritten,
88 SkippedModified,
91 SkippedForeign,
93 SkippedPolicy,
95}
96
97impl FileAction {
98 pub fn as_str(self) -> &'static str {
100 match self {
101 FileAction::Created => "created",
102 FileAction::Regenerated => "regenerated",
103 FileAction::Overwritten => "overwritten",
104 FileAction::SkippedModified => "skipped_modified",
105 FileAction::SkippedForeign => "skipped_foreign",
106 FileAction::SkippedPolicy => "skipped_policy",
107 }
108 }
109
110 pub fn wrote(self) -> bool {
112 matches!(
113 self,
114 FileAction::Created | FileAction::Regenerated | FileAction::Overwritten
115 )
116 }
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct LockEntry {
126 pub checksum: String,
128 pub ctx_version: String,
130}
131
132#[derive(Debug, Default, Serialize, Deserialize)]
134pub struct LockFile {
135 #[serde(default = "default_lock_version")]
136 pub version: u32,
137 #[serde(default)]
138 pub files: BTreeMap<String, LockEntry>,
139}
140
141fn default_lock_version() -> u32 {
142 1
143}
144
145pub fn read_lock(root: &Path) -> Option<LockFile> {
148 let content = fs::read_to_string(root.join(LOCK_PATH)).ok()?;
149 toml::from_str(&content).ok()
150}
151
152fn write_lock(root: &Path, lock: &LockFile) -> Result<()> {
153 let body = toml::to_string_pretty(lock)
154 .map_err(|e| CtxError::Other(format!("failed to serialize {LOCK_PATH}: {e}")))?;
155 let content = finalize(&body, checksum::HeaderStyle::Toml, templates::CTX_VERSION);
156 let path = root.join(LOCK_PATH);
157 if let Some(parent) = path.parent() {
158 fs::create_dir_all(parent)?;
159 }
160 fs::write(path, content)?;
161 Ok(())
162}
163
164fn generated(rel_path: &str, template: &str, vars: &[(&str, &str)]) -> GeneratedFile {
169 let rendered = templates::render(template, vars);
170 let content = finalize(&rendered, style_for_path(rel_path), templates::CTX_VERSION);
171 GeneratedFile {
172 rel_path: rel_path.to_string(),
173 content,
174 executable: rel_path.ends_with(".sh"),
175 never_overwrite: rel_path == RULES_PATH,
176 }
177}
178
179fn hook_files(dir: &str, vars: &[(&str, &str)]) -> Vec<GeneratedFile> {
180 vec![
181 generated(
182 &format!("{dir}/session-start.sh"),
183 templates::SESSION_START_SH,
184 vars,
185 ),
186 generated(
187 &format!("{dir}/post-tool-use.sh"),
188 templates::POST_TOOL_USE_SH,
189 vars,
190 ),
191 generated(&format!("{dir}/stop.sh"), templates::STOP_SH, vars),
192 ]
193}
194
195pub fn plan_local(root: &Path) -> Vec<GeneratedFile> {
201 let branch = templates::default_branch(root);
202 let author = templates::author_name();
203 let vars = templates::standard_vars(&branch, &author);
204
205 let mut plan = hook_files(LOCAL_HOOKS_DIR, &vars);
206 plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
207 plan
208}
209
210pub fn plan_plugin(root: &Path) -> Vec<GeneratedFile> {
216 let branch = templates::default_branch(root);
217 let author = templates::author_name();
218 let vars = templates::standard_vars(&branch, &author);
219
220 let mut plan = vec![
221 generated(".claude-plugin/plugin.json", templates::PLUGIN_JSON, &vars),
222 generated(
223 ".claude-plugin/marketplace.json",
224 templates::MARKETPLACE_JSON,
225 &vars,
226 ),
227 generated("hooks/hooks.json", templates::HOOKS_JSON, &vars),
228 ];
229 plan.extend(hook_files("hooks", &vars));
230 plan.push(generated(
231 "settings.json",
232 templates::PLUGIN_SETTINGS_JSON,
233 &vars,
234 ));
235 plan.push(generated("skills/ctx/SKILL.md", templates::SKILL_MD, &vars));
236 plan.push(generated("README.md", templates::PLUGIN_README_MD, &vars));
237 if cfg!(feature = "mcp") {
238 plan.push(generated(".mcp.json", templates::MCP_JSON, &vars));
239 }
240 plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
241 plan
242}
243
244fn codex_hook_files(dir: &str, vars: &[(&str, &str)]) -> Vec<GeneratedFile> {
245 vec![
246 generated(
247 &format!("{dir}/session-start.sh"),
248 templates::CODEX_SESSION_START_SH,
249 vars,
250 ),
251 generated(
252 &format!("{dir}/post-tool-use.sh"),
253 templates::CODEX_POST_TOOL_USE_SH,
254 vars,
255 ),
256 generated(&format!("{dir}/stop.sh"), templates::CODEX_STOP_SH, vars),
257 ]
258}
259
260pub fn plan_codex_local(root: &Path) -> Vec<GeneratedFile> {
261 let branch = templates::default_branch(root);
262 let author = templates::author_name();
263 let vars = templates::standard_vars(&branch, &author);
264 let mut plan = vec![generated(
265 ".codex/hooks.json",
266 templates::CODEX_HOOKS_JSON,
267 &vars,
268 )];
269 plan.extend(codex_hook_files(".codex/hooks/ctx", &vars));
270 plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
271 plan
272}
273
274pub fn plan_codex_plugin(root: &Path) -> Vec<GeneratedFile> {
275 let branch = templates::default_branch(root);
276 let author = templates::author_name();
277 let vars = templates::standard_vars(&branch, &author);
278 let manifest = if cfg!(feature = "mcp") {
279 templates::CODEX_PLUGIN_MCP_JSON
280 } else {
281 templates::CODEX_PLUGIN_JSON
282 };
283 let mut plan = vec![
284 generated(".codex-plugin/plugin.json", manifest, &vars),
285 generated(
286 ".agents/plugins/marketplace.json",
287 templates::CODEX_MARKETPLACE_JSON,
288 &vars,
289 ),
290 generated(
291 "hooks/hooks.json",
292 templates::CODEX_PLUGIN_HOOKS_JSON,
293 &vars,
294 ),
295 ];
296 plan.extend(codex_hook_files("hooks", &vars));
297 plan.push(generated("skills/ctx/SKILL.md", templates::SKILL_MD, &vars));
298 plan.push(generated(
299 "README.md",
300 templates::CODEX_PLUGIN_README_MD,
301 &vars,
302 ));
303 if cfg!(feature = "mcp") {
304 plan.push(generated(".mcp.json", templates::MCP_JSON, &vars));
305 }
306 plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
307 plan
308}
309
310pub fn render_agents_md_block(root: &Path) -> String {
311 let branch = templates::default_branch(root);
312 templates::render(
313 templates::AGENTS_MD_BLOCK_MD,
314 &[("DEFAULT_BRANCH", branch.as_str())],
315 )
316}
317
318pub fn render_settings_snippet() -> String {
320 templates::render(templates::SETTINGS_SNIPPET_JSON, &[])
321}
322
323pub fn render_claude_md_block(root: &Path) -> String {
325 let branch = templates::default_branch(root);
326 templates::render(
327 templates::CLAUDE_MD_BLOCK_MD,
328 &[("DEFAULT_BRANCH", branch.as_str())],
329 )
330}
331
332#[derive(Debug, PartialEq)]
338pub enum SettingsWireAction {
339 Created,
341 Merged,
343 AlreadyWired,
345 SkippedInvalid,
347}
348
349const CTX_HOOKS_MARKER: &str = ".claude/hooks/ctx/";
352
353pub fn wire_local_settings(root: &Path) -> Result<SettingsWireAction> {
363 let snippet = render_settings_snippet();
364 let snippet_value: serde_json::Value = serde_json::from_str(&snippet)
365 .map_err(|e| CtxError::Other(format!("settings snippet is not valid JSON: {e}")))?;
366 let path = root.join(".claude/settings.json");
367
368 let Ok(existing_raw) = fs::read_to_string(&path) else {
369 if let Some(parent) = path.parent() {
371 fs::create_dir_all(parent)?;
372 }
373 let body = serde_json::to_string_pretty(&snippet_value)
374 .map_err(|e| CtxError::Other(format!("failed to serialize settings: {e}")))?;
375 fs::write(&path, format!("{body}\n"))?;
376 return Ok(SettingsWireAction::Created);
377 };
378
379 let Ok(serde_json::Value::Object(existing)) =
380 serde_json::from_str::<serde_json::Value>(&existing_raw)
381 else {
382 return Ok(SettingsWireAction::SkippedInvalid);
384 };
385
386 let mut merged = existing.clone();
387 merge_settings(&mut merged, &snippet_value);
388
389 if serde_json::Value::Object(merged.clone()) == serde_json::Value::Object(existing) {
390 return Ok(SettingsWireAction::AlreadyWired);
391 }
392
393 let body = serde_json::to_string_pretty(&serde_json::Value::Object(merged))
394 .map_err(|e| CtxError::Other(format!("failed to serialize settings: {e}")))?;
395 fs::write(&path, format!("{body}\n"))?;
396 Ok(SettingsWireAction::Merged)
397}
398
399fn merge_settings(
401 target: &mut serde_json::Map<String, serde_json::Value>,
402 snippet: &serde_json::Value,
403) {
404 let Some(snippet_obj) = snippet.as_object() else {
405 return;
406 };
407
408 if let Some(perms) = snippet_obj.get("permissions").and_then(|v| v.as_object()) {
410 let target_perms = target
411 .entry("permissions")
412 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
413 if let serde_json::Value::Object(ref mut target_perms) = target_perms {
414 for key in ["allow", "deny"] {
415 if let Some(add) = perms.get(key).and_then(|v| v.as_array()) {
416 union_array(target_perms, key, add);
417 }
418 }
419 }
420 }
421
422 if let Some(hooks) = snippet_obj.get("hooks").and_then(|v| v.as_object()) {
424 let target_hooks = target
425 .entry("hooks")
426 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
427 if let serde_json::Value::Object(ref mut target_hooks) = target_hooks {
428 for (event, groups) in hooks {
429 if let Some(groups) = groups.as_array() {
430 append_hook_groups_if_absent(target_hooks, event, groups);
431 }
432 }
433 }
434 }
435}
436
437fn union_array(
440 map: &mut serde_json::Map<String, serde_json::Value>,
441 key: &str,
442 additions: &[serde_json::Value],
443) {
444 let entry = map
445 .entry(key)
446 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
447 if let serde_json::Value::Array(ref mut arr) = entry {
448 for add in additions {
449 if !arr.contains(add) {
450 arr.push(add.clone());
451 }
452 }
453 }
454}
455
456fn append_hook_groups_if_absent(
460 hooks: &mut serde_json::Map<String, serde_json::Value>,
461 event: &str,
462 additions: &[serde_json::Value],
463) {
464 let entry = hooks
465 .entry(event)
466 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
467 if let serde_json::Value::Array(ref mut arr) = entry {
468 let already_wired = serde_json::Value::Array(arr.clone())
469 .to_string()
470 .contains(CTX_HOOKS_MARKER);
471 if !already_wired {
472 arr.extend(additions.iter().cloned());
473 }
474 }
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483enum Ownership {
484 Missing,
485 OwnedUnmodified,
486 OwnedModified,
487 Foreign,
488}
489
490fn classify(path: &Path, lock_entry: Option<&LockEntry>) -> Ownership {
493 if !path.exists() {
494 return Ownership::Missing;
495 }
496 let Ok(bytes) = fs::read(path) else {
497 return Ownership::Foreign;
499 };
500 let actual = content_checksum(&bytes);
501
502 if let Some(entry) = lock_entry {
503 let expected = entry
504 .checksum
505 .strip_prefix("sha256:")
506 .unwrap_or(&entry.checksum);
507 return if actual == expected {
508 Ownership::OwnedUnmodified
509 } else {
510 Ownership::OwnedModified
511 };
512 }
513
514 if let Ok(text) = std::str::from_utf8(&bytes) {
515 if let Some(recorded) = recorded_checksum(text) {
516 return if actual == recorded {
517 Ownership::OwnedUnmodified
518 } else {
519 Ownership::OwnedModified
520 };
521 }
522 }
523 Ownership::Foreign
524}
525
526fn write_file(root: &Path, file: &GeneratedFile) -> Result<()> {
527 let path = root.join(&file.rel_path);
528 if let Some(parent) = path.parent() {
529 fs::create_dir_all(parent)?;
530 }
531 fs::write(&path, &file.content)?;
532 #[cfg(unix)]
533 if file.executable {
534 use std::os::unix::fs::PermissionsExt;
535 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
536 }
537 Ok(())
538}
539
540pub fn write_plan(
547 root: &Path,
548 plan: &[GeneratedFile],
549 force: bool,
550) -> Result<Vec<(String, FileAction)>> {
551 let mut lock = read_lock(root).unwrap_or_default();
552 lock.version = 1;
553 let mut actions = Vec::with_capacity(plan.len() + 1);
554
555 for file in plan {
556 let path = root.join(&file.rel_path);
557 let ownership = classify(&path, lock.files.get(&file.rel_path));
558
559 let action = match ownership {
560 Ownership::Missing => FileAction::Created,
561 _ if file.never_overwrite => FileAction::SkippedPolicy,
562 Ownership::OwnedUnmodified => FileAction::Regenerated,
563 Ownership::OwnedModified if force => FileAction::Overwritten,
564 Ownership::OwnedModified => FileAction::SkippedModified,
565 Ownership::Foreign if force => FileAction::Overwritten,
566 Ownership::Foreign => FileAction::SkippedForeign,
567 };
568
569 if action.wrote() {
570 write_file(root, file)?;
571 lock.files.insert(
572 file.rel_path.clone(),
573 LockEntry {
574 checksum: format!("sha256:{}", content_checksum(file.content.as_bytes())),
575 ctx_version: templates::CTX_VERSION.to_string(),
576 },
577 );
578 }
579 actions.push((file.rel_path.clone(), action));
580 }
581
582 let lock_existed = root.join(LOCK_PATH).exists();
583 write_lock(root, &lock)?;
584 actions.push((
585 LOCK_PATH.to_string(),
586 if lock_existed {
587 FileAction::Regenerated
588 } else {
589 FileAction::Created
590 },
591 ));
592
593 Ok(actions)
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use tempfile::TempDir;
600
601 fn plan_and_write(root: &Path, force: bool) -> Vec<(String, FileAction)> {
602 let plan = plan_local(root);
603 write_plan(root, &plan, force).unwrap()
604 }
605
606 fn action_for(actions: &[(String, FileAction)], rel: &str) -> FileAction {
607 actions
608 .iter()
609 .find(|(p, _)| p == rel)
610 .unwrap_or_else(|| panic!("no action for {rel}"))
611 .1
612 }
613
614 #[test]
615 fn test_no_residual_tokens_and_json_parses_in_both_modes() {
616 let temp = TempDir::new().unwrap();
617 for plan in [plan_local(temp.path()), plan_plugin(temp.path())] {
618 for file in &plan {
619 assert!(
620 !file.content.contains("{{"),
621 "unrendered token in {}: {}",
622 file.rel_path,
623 file.content
624 );
625 if file.rel_path.ends_with(".json") {
626 serde_json::from_str::<serde_json::Value>(&file.content)
627 .unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", file.rel_path));
628 }
629 }
630 }
631 assert!(!render_settings_snippet().contains("{{"));
633 assert!(!render_claude_md_block(temp.path()).contains("{{"));
634 serde_json::from_str::<serde_json::Value>(&render_settings_snippet()).unwrap();
635 }
636
637 #[test]
638 fn test_plugin_manifest_fields_and_version() {
639 let temp = TempDir::new().unwrap();
640 let plan = plan_plugin(temp.path());
641 let plugin = plan
642 .iter()
643 .find(|f| f.rel_path == ".claude-plugin/plugin.json")
644 .unwrap();
645 let value: serde_json::Value = serde_json::from_str(&plugin.content).unwrap();
646 assert_eq!(value["name"], "ctx");
647 assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
648 assert!(value["description"].is_string());
649 assert!(value["author"]["name"].is_string());
650 assert!(value["author"]["email"].is_string());
651 assert_eq!(value["license"], "MIT OR Apache-2.0");
652 assert!(value["homepage"].is_string());
653 assert!(value["repository"]
654 .as_str()
655 .unwrap()
656 .contains("/plugins/claude/ctx"));
657 assert!(value["keywords"].as_array().unwrap().len() >= 4);
658
659 let settings = plan.iter().find(|f| f.rel_path == "settings.json").unwrap();
662 let value: serde_json::Value = serde_json::from_str(&settings.content).unwrap();
663 assert_eq!(
664 value["permissions"]["allow"],
665 serde_json::json!(["Bash(ctx *)"])
666 );
667 let deny = value["permissions"]["deny"].as_array().unwrap();
668 assert!(deny.contains(&serde_json::json!("Bash(ctx self-update*)")));
669 assert!(deny.contains(&serde_json::json!("Edit(.ctx/rules.toml)")));
670 assert!(deny.contains(&serde_json::json!("Edit(.claude/hooks/ctx/**)")));
671 assert!(deny.contains(&serde_json::json!("Edit(.claude/settings.json)")));
672 }
673
674 #[test]
675 fn test_codex_plans_render_valid_files() {
676 let temp = TempDir::new().unwrap();
677 for plan in [
678 plan_codex_local(temp.path()),
679 plan_codex_plugin(temp.path()),
680 ] {
681 for file in plan {
682 assert!(
683 !file.content.contains("{{"),
684 "unrendered token in {}",
685 file.rel_path
686 );
687 if file.rel_path.ends_with(".json") {
688 serde_json::from_str::<serde_json::Value>(&file.content)
689 .unwrap_or_else(|e| panic!("{}: {e}", file.rel_path));
690 }
691 }
692 }
693 assert!(render_agents_md_block(temp.path()).contains("ctx map"));
694 }
695
696 #[test]
697 fn test_headers_carry_crate_version() {
698 let temp = TempDir::new().unwrap();
699 for file in plan_local(temp.path()) {
700 assert!(
701 file.content
702 .contains(&format!("generated by ctx v{}", env!("CARGO_PKG_VERSION"))),
703 "no version header in {}",
704 file.rel_path
705 );
706 assert!(
707 checksum::recorded_checksum(&file.content).is_some(),
708 "no checksum line in {}",
709 file.rel_path
710 );
711 }
712 }
713
714 #[test]
715 fn test_write_plan_ownership_lifecycle() {
716 let temp = TempDir::new().unwrap();
717 let root = temp.path();
718
719 let actions = plan_and_write(root, false);
721 for (rel, action) in &actions {
722 assert_eq!(*action, FileAction::Created, "{rel}");
723 }
724
725 let actions = plan_and_write(root, false);
728 assert_eq!(
729 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
730 FileAction::Regenerated
731 );
732 assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
733
734 let stop = root.join(".claude/hooks/ctx/stop.sh");
736 let modified = fs::read_to_string(&stop).unwrap() + "echo tampered\n";
737 fs::write(&stop, &modified).unwrap();
738 let actions = plan_and_write(root, false);
739 assert_eq!(
740 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
741 FileAction::SkippedModified
742 );
743 assert_eq!(fs::read_to_string(&stop).unwrap(), modified);
744
745 fs::write(root.join(RULES_PATH), "version = 1\n# mine\n").unwrap();
747 let actions = plan_and_write(root, true);
748 assert_eq!(
749 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
750 FileAction::Overwritten
751 );
752 assert!(!fs::read_to_string(&stop).unwrap().contains("tampered"));
753 assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
754 assert_eq!(
755 fs::read_to_string(root.join(RULES_PATH)).unwrap(),
756 "version = 1\n# mine\n"
757 );
758 }
759
760 #[test]
761 fn test_foreign_file_is_skipped_without_force() {
762 let temp = TempDir::new().unwrap();
763 let root = temp.path();
764 let rel = ".claude/hooks/ctx/stop.sh";
765 let path = root.join(rel);
766 fs::create_dir_all(path.parent().unwrap()).unwrap();
767 fs::write(&path, "#!/bin/sh\necho my own hook\n").unwrap();
768
769 let actions = plan_and_write(root, false);
770 assert_eq!(action_for(&actions, rel), FileAction::SkippedForeign);
771 assert!(fs::read_to_string(&path).unwrap().contains("my own hook"));
772
773 let actions = plan_and_write(root, true);
774 assert_eq!(action_for(&actions, rel), FileAction::Overwritten);
775 }
776
777 #[test]
778 fn test_lock_tracks_json_files_in_plugin_mode() {
779 let temp = TempDir::new().unwrap();
780 let root = temp.path();
781 let plan = plan_plugin(root);
782 write_plan(root, &plan, false).unwrap();
783
784 let lock = read_lock(root).unwrap();
785 let entry = lock.files.get(".claude-plugin/plugin.json").unwrap();
787 assert!(entry.checksum.starts_with("sha256:"));
788 assert_eq!(entry.ctx_version, env!("CARGO_PKG_VERSION"));
789
790 let manifest = root.join(".claude-plugin/plugin.json");
792 fs::write(&manifest, "{\"name\": \"evil\"}\n").unwrap();
793 let actions = write_plan(root, &plan, false).unwrap();
794 assert_eq!(
795 action_for(&actions, ".claude-plugin/plugin.json"),
796 FileAction::SkippedModified
797 );
798 }
799
800 #[cfg(unix)]
801 #[test]
802 fn test_hook_scripts_are_executable() {
803 use std::os::unix::fs::PermissionsExt;
804 let temp = TempDir::new().unwrap();
805 let root = temp.path();
806 plan_and_write(root, false);
807 let mode = fs::metadata(root.join(".claude/hooks/ctx/stop.sh"))
808 .unwrap()
809 .permissions()
810 .mode();
811 assert_eq!(mode & 0o111, 0o111, "mode: {:o}", mode);
812 }
813
814 #[test]
815 fn test_wire_creates_settings_when_missing() {
816 let temp = TempDir::new().unwrap();
817 let root = temp.path();
818 let action = wire_local_settings(root).unwrap();
819 assert_eq!(action, SettingsWireAction::Created);
820 let content = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
821 assert!(content.contains(".claude/hooks/ctx/"), "content: {content}");
822 serde_json::from_str::<serde_json::Value>(&content).unwrap();
823 }
824
825 #[test]
826 fn test_wire_merges_additively_preserving_user_settings() {
827 let temp = TempDir::new().unwrap();
828 let root = temp.path();
829 fs::create_dir_all(root.join(".claude")).unwrap();
830 fs::write(
832 root.join(".claude/settings.json"),
833 r#"{
834 "foo": "bar",
835 "permissions": { "allow": ["Bash(git *)"] },
836 "hooks": {
837 "SessionStart": [
838 { "hooks": [{ "type": "command", "command": "/my/own/hook.sh" }] }
839 ]
840 }
841}"#,
842 )
843 .unwrap();
844
845 let action = wire_local_settings(root).unwrap();
846 assert_eq!(action, SettingsWireAction::Merged);
847
848 let content = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
849 let value: serde_json::Value = serde_json::from_str(&content).unwrap();
850 assert_eq!(value["foo"], "bar");
852 let allow = value["permissions"]["allow"].as_array().unwrap();
854 assert!(allow.contains(&serde_json::json!("Bash(git *)")));
855 assert!(allow.contains(&serde_json::json!("Bash(ctx *)")));
856 let session = value["hooks"]["SessionStart"].as_array().unwrap();
858 assert_eq!(session.len(), 2, "user + ctx group: {session:?}");
859 assert!(content.contains("/my/own/hook.sh"));
860 assert!(content.contains(".claude/hooks/ctx/session-start.sh"));
861 }
862
863 #[test]
864 fn test_wire_is_idempotent() {
865 let temp = TempDir::new().unwrap();
866 let root = temp.path();
867 assert_eq!(
868 wire_local_settings(root).unwrap(),
869 SettingsWireAction::Created
870 );
871 let first = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
872
873 assert_eq!(
874 wire_local_settings(root).unwrap(),
875 SettingsWireAction::AlreadyWired
876 );
877 let second = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
878 assert_eq!(first, second, "bytes must be unchanged on re-wire");
879 }
880
881 #[test]
882 fn test_wire_leaves_invalid_json_untouched() {
883 let temp = TempDir::new().unwrap();
884 let root = temp.path();
885 fs::create_dir_all(root.join(".claude")).unwrap();
886 let path = root.join(".claude/settings.json");
887 fs::write(&path, "{not json").unwrap();
888
889 let action = wire_local_settings(root).unwrap();
890 assert_eq!(action, SettingsWireAction::SkippedInvalid);
891 assert_eq!(fs::read_to_string(&path).unwrap(), "{not json");
892 }
893
894 #[test]
895 fn test_starter_rules_toml_parses_and_constrains_nothing() {
896 let temp = TempDir::new().unwrap();
897 let plan = plan_local(temp.path());
898 let rules = plan.iter().find(|f| f.rel_path == RULES_PATH).unwrap();
899 let parsed: crate::rules::RulesFile = toml::from_str(&rules.content).unwrap();
900 assert_eq!(parsed.version, 1);
901 assert!(parsed.layers.is_empty());
902 assert!(parsed.rules.forbidden.is_empty());
903 assert!(parsed.rules.allowed_dependents.is_empty());
904 assert!(parsed.rules.limit.is_empty());
905 assert!(parsed.rules.no_new_dependents.is_empty());
906 }
907}