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}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Mode {
60 Local,
61 Plugin,
62}
63
64#[derive(Debug, Clone)]
67pub struct GeneratedFile {
68 pub rel_path: String,
70 pub content: String,
72 pub executable: bool,
74 pub never_overwrite: bool,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum FileAction {
81 Created,
83 Regenerated,
85 Overwritten,
87 SkippedModified,
90 SkippedForeign,
92 SkippedPolicy,
94}
95
96impl FileAction {
97 pub fn as_str(self) -> &'static str {
99 match self {
100 FileAction::Created => "created",
101 FileAction::Regenerated => "regenerated",
102 FileAction::Overwritten => "overwritten",
103 FileAction::SkippedModified => "skipped_modified",
104 FileAction::SkippedForeign => "skipped_foreign",
105 FileAction::SkippedPolicy => "skipped_policy",
106 }
107 }
108
109 pub fn wrote(self) -> bool {
111 matches!(
112 self,
113 FileAction::Created | FileAction::Regenerated | FileAction::Overwritten
114 )
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct LockEntry {
125 pub checksum: String,
127 pub ctx_version: String,
129}
130
131#[derive(Debug, Default, Serialize, Deserialize)]
133pub struct LockFile {
134 #[serde(default = "default_lock_version")]
135 pub version: u32,
136 #[serde(default)]
137 pub files: BTreeMap<String, LockEntry>,
138}
139
140fn default_lock_version() -> u32 {
141 1
142}
143
144pub fn read_lock(root: &Path) -> Option<LockFile> {
147 let content = fs::read_to_string(root.join(LOCK_PATH)).ok()?;
148 toml::from_str(&content).ok()
149}
150
151fn write_lock(root: &Path, lock: &LockFile) -> Result<()> {
152 let body = toml::to_string_pretty(lock)
153 .map_err(|e| CtxError::Other(format!("failed to serialize {LOCK_PATH}: {e}")))?;
154 let content = finalize(&body, checksum::HeaderStyle::Toml, templates::CTX_VERSION);
155 let path = root.join(LOCK_PATH);
156 if let Some(parent) = path.parent() {
157 fs::create_dir_all(parent)?;
158 }
159 fs::write(path, content)?;
160 Ok(())
161}
162
163fn generated(rel_path: &str, template: &str, vars: &[(&str, &str)]) -> GeneratedFile {
168 let rendered = templates::render(template, vars);
169 let content = finalize(&rendered, style_for_path(rel_path), templates::CTX_VERSION);
170 GeneratedFile {
171 rel_path: rel_path.to_string(),
172 content,
173 executable: rel_path.ends_with(".sh"),
174 never_overwrite: rel_path == RULES_PATH,
175 }
176}
177
178fn hook_files(dir: &str, vars: &[(&str, &str)]) -> Vec<GeneratedFile> {
179 vec![
180 generated(
181 &format!("{dir}/session-start.sh"),
182 templates::SESSION_START_SH,
183 vars,
184 ),
185 generated(
186 &format!("{dir}/post-tool-use.sh"),
187 templates::POST_TOOL_USE_SH,
188 vars,
189 ),
190 generated(&format!("{dir}/stop.sh"), templates::STOP_SH, vars),
191 ]
192}
193
194pub fn plan_local(root: &Path) -> Vec<GeneratedFile> {
200 let branch = templates::default_branch(root);
201 let author = templates::author_name();
202 let vars = templates::standard_vars(&branch, &author);
203
204 let mut plan = hook_files(LOCAL_HOOKS_DIR, &vars);
205 plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
206 plan
207}
208
209pub fn plan_plugin(root: &Path) -> Vec<GeneratedFile> {
215 let branch = templates::default_branch(root);
216 let author = templates::author_name();
217 let vars = templates::standard_vars(&branch, &author);
218
219 let mut plan = vec![
220 generated(".claude-plugin/plugin.json", templates::PLUGIN_JSON, &vars),
221 generated(
222 ".claude-plugin/marketplace.json",
223 templates::MARKETPLACE_JSON,
224 &vars,
225 ),
226 generated("hooks/hooks.json", templates::HOOKS_JSON, &vars),
227 ];
228 plan.extend(hook_files("hooks", &vars));
229 plan.push(generated(
230 "settings.json",
231 templates::PLUGIN_SETTINGS_JSON,
232 &vars,
233 ));
234 plan.push(generated("skills/ctx/SKILL.md", templates::SKILL_MD, &vars));
235 plan.push(generated("README.md", templates::PLUGIN_README_MD, &vars));
236 if cfg!(feature = "mcp") {
237 plan.push(generated(".mcp.json", templates::MCP_JSON, &vars));
238 }
239 plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
240 plan
241}
242
243pub fn render_settings_snippet() -> String {
245 templates::render(templates::SETTINGS_SNIPPET_JSON, &[])
246}
247
248pub fn render_claude_md_block(root: &Path) -> String {
250 let branch = templates::default_branch(root);
251 templates::render(
252 templates::CLAUDE_MD_BLOCK_MD,
253 &[("DEFAULT_BRANCH", branch.as_str())],
254 )
255}
256
257#[derive(Debug, PartialEq)]
263pub enum SettingsWireAction {
264 Created,
266 Merged,
268 AlreadyWired,
270 SkippedInvalid,
272}
273
274const CTX_HOOKS_MARKER: &str = ".claude/hooks/ctx/";
277
278pub fn wire_local_settings(root: &Path) -> Result<SettingsWireAction> {
288 let snippet = render_settings_snippet();
289 let snippet_value: serde_json::Value = serde_json::from_str(&snippet)
290 .map_err(|e| CtxError::Other(format!("settings snippet is not valid JSON: {e}")))?;
291 let path = root.join(".claude/settings.json");
292
293 let Ok(existing_raw) = fs::read_to_string(&path) else {
294 if let Some(parent) = path.parent() {
296 fs::create_dir_all(parent)?;
297 }
298 let body = serde_json::to_string_pretty(&snippet_value)
299 .map_err(|e| CtxError::Other(format!("failed to serialize settings: {e}")))?;
300 fs::write(&path, format!("{body}\n"))?;
301 return Ok(SettingsWireAction::Created);
302 };
303
304 let Ok(serde_json::Value::Object(existing)) =
305 serde_json::from_str::<serde_json::Value>(&existing_raw)
306 else {
307 return Ok(SettingsWireAction::SkippedInvalid);
309 };
310
311 let mut merged = existing.clone();
312 merge_settings(&mut merged, &snippet_value);
313
314 if serde_json::Value::Object(merged.clone()) == serde_json::Value::Object(existing) {
315 return Ok(SettingsWireAction::AlreadyWired);
316 }
317
318 let body = serde_json::to_string_pretty(&serde_json::Value::Object(merged))
319 .map_err(|e| CtxError::Other(format!("failed to serialize settings: {e}")))?;
320 fs::write(&path, format!("{body}\n"))?;
321 Ok(SettingsWireAction::Merged)
322}
323
324fn merge_settings(
326 target: &mut serde_json::Map<String, serde_json::Value>,
327 snippet: &serde_json::Value,
328) {
329 let Some(snippet_obj) = snippet.as_object() else {
330 return;
331 };
332
333 if let Some(perms) = snippet_obj.get("permissions").and_then(|v| v.as_object()) {
335 let target_perms = target
336 .entry("permissions")
337 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
338 if let serde_json::Value::Object(ref mut target_perms) = target_perms {
339 for key in ["allow", "deny"] {
340 if let Some(add) = perms.get(key).and_then(|v| v.as_array()) {
341 union_array(target_perms, key, add);
342 }
343 }
344 }
345 }
346
347 if let Some(hooks) = snippet_obj.get("hooks").and_then(|v| v.as_object()) {
349 let target_hooks = target
350 .entry("hooks")
351 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
352 if let serde_json::Value::Object(ref mut target_hooks) = target_hooks {
353 for (event, groups) in hooks {
354 if let Some(groups) = groups.as_array() {
355 append_hook_groups_if_absent(target_hooks, event, groups);
356 }
357 }
358 }
359 }
360}
361
362fn union_array(
365 map: &mut serde_json::Map<String, serde_json::Value>,
366 key: &str,
367 additions: &[serde_json::Value],
368) {
369 let entry = map
370 .entry(key)
371 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
372 if let serde_json::Value::Array(ref mut arr) = entry {
373 for add in additions {
374 if !arr.contains(add) {
375 arr.push(add.clone());
376 }
377 }
378 }
379}
380
381fn append_hook_groups_if_absent(
385 hooks: &mut serde_json::Map<String, serde_json::Value>,
386 event: &str,
387 additions: &[serde_json::Value],
388) {
389 let entry = hooks
390 .entry(event)
391 .or_insert_with(|| serde_json::Value::Array(Vec::new()));
392 if let serde_json::Value::Array(ref mut arr) = entry {
393 let already_wired = serde_json::Value::Array(arr.clone())
394 .to_string()
395 .contains(CTX_HOOKS_MARKER);
396 if !already_wired {
397 arr.extend(additions.iter().cloned());
398 }
399 }
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408enum Ownership {
409 Missing,
410 OwnedUnmodified,
411 OwnedModified,
412 Foreign,
413}
414
415fn classify(path: &Path, lock_entry: Option<&LockEntry>) -> Ownership {
418 if !path.exists() {
419 return Ownership::Missing;
420 }
421 let Ok(bytes) = fs::read(path) else {
422 return Ownership::Foreign;
424 };
425 let actual = content_checksum(&bytes);
426
427 if let Some(entry) = lock_entry {
428 let expected = entry
429 .checksum
430 .strip_prefix("sha256:")
431 .unwrap_or(&entry.checksum);
432 return if actual == expected {
433 Ownership::OwnedUnmodified
434 } else {
435 Ownership::OwnedModified
436 };
437 }
438
439 if let Ok(text) = std::str::from_utf8(&bytes) {
440 if let Some(recorded) = recorded_checksum(text) {
441 return if actual == recorded {
442 Ownership::OwnedUnmodified
443 } else {
444 Ownership::OwnedModified
445 };
446 }
447 }
448 Ownership::Foreign
449}
450
451fn write_file(root: &Path, file: &GeneratedFile) -> Result<()> {
452 let path = root.join(&file.rel_path);
453 if let Some(parent) = path.parent() {
454 fs::create_dir_all(parent)?;
455 }
456 fs::write(&path, &file.content)?;
457 #[cfg(unix)]
458 if file.executable {
459 use std::os::unix::fs::PermissionsExt;
460 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
461 }
462 Ok(())
463}
464
465pub fn write_plan(
472 root: &Path,
473 plan: &[GeneratedFile],
474 force: bool,
475) -> Result<Vec<(String, FileAction)>> {
476 let mut lock = read_lock(root).unwrap_or_default();
477 lock.version = 1;
478 let mut actions = Vec::with_capacity(plan.len() + 1);
479
480 for file in plan {
481 let path = root.join(&file.rel_path);
482 let ownership = classify(&path, lock.files.get(&file.rel_path));
483
484 let action = match ownership {
485 Ownership::Missing => FileAction::Created,
486 _ if file.never_overwrite => FileAction::SkippedPolicy,
487 Ownership::OwnedUnmodified => FileAction::Regenerated,
488 Ownership::OwnedModified if force => FileAction::Overwritten,
489 Ownership::OwnedModified => FileAction::SkippedModified,
490 Ownership::Foreign if force => FileAction::Overwritten,
491 Ownership::Foreign => FileAction::SkippedForeign,
492 };
493
494 if action.wrote() {
495 write_file(root, file)?;
496 lock.files.insert(
497 file.rel_path.clone(),
498 LockEntry {
499 checksum: format!("sha256:{}", content_checksum(file.content.as_bytes())),
500 ctx_version: templates::CTX_VERSION.to_string(),
501 },
502 );
503 }
504 actions.push((file.rel_path.clone(), action));
505 }
506
507 let lock_existed = root.join(LOCK_PATH).exists();
508 write_lock(root, &lock)?;
509 actions.push((
510 LOCK_PATH.to_string(),
511 if lock_existed {
512 FileAction::Regenerated
513 } else {
514 FileAction::Created
515 },
516 ));
517
518 Ok(actions)
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use tempfile::TempDir;
525
526 fn plan_and_write(root: &Path, force: bool) -> Vec<(String, FileAction)> {
527 let plan = plan_local(root);
528 write_plan(root, &plan, force).unwrap()
529 }
530
531 fn action_for(actions: &[(String, FileAction)], rel: &str) -> FileAction {
532 actions
533 .iter()
534 .find(|(p, _)| p == rel)
535 .unwrap_or_else(|| panic!("no action for {rel}"))
536 .1
537 }
538
539 #[test]
540 fn test_no_residual_tokens_and_json_parses_in_both_modes() {
541 let temp = TempDir::new().unwrap();
542 for plan in [plan_local(temp.path()), plan_plugin(temp.path())] {
543 for file in &plan {
544 assert!(
545 !file.content.contains("{{"),
546 "unrendered token in {}: {}",
547 file.rel_path,
548 file.content
549 );
550 if file.rel_path.ends_with(".json") {
551 serde_json::from_str::<serde_json::Value>(&file.content)
552 .unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", file.rel_path));
553 }
554 }
555 }
556 assert!(!render_settings_snippet().contains("{{"));
558 assert!(!render_claude_md_block(temp.path()).contains("{{"));
559 serde_json::from_str::<serde_json::Value>(&render_settings_snippet()).unwrap();
560 }
561
562 #[test]
563 fn test_plugin_manifest_fields_and_version() {
564 let temp = TempDir::new().unwrap();
565 let plan = plan_plugin(temp.path());
566 let plugin = plan
567 .iter()
568 .find(|f| f.rel_path == ".claude-plugin/plugin.json")
569 .unwrap();
570 let value: serde_json::Value = serde_json::from_str(&plugin.content).unwrap();
571 assert_eq!(value["name"], "ctx");
572 assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
573 assert!(value["description"].is_string());
574 assert!(value["author"]["name"].is_string());
575
576 let settings = plan.iter().find(|f| f.rel_path == "settings.json").unwrap();
579 let value: serde_json::Value = serde_json::from_str(&settings.content).unwrap();
580 assert_eq!(
581 value["permissions"]["allow"],
582 serde_json::json!(["Bash(ctx *)"])
583 );
584 let deny = value["permissions"]["deny"].as_array().unwrap();
585 assert!(deny.contains(&serde_json::json!("Bash(ctx self-update*)")));
586 assert!(deny.contains(&serde_json::json!("Edit(.ctx/rules.toml)")));
587 assert!(deny.contains(&serde_json::json!("Edit(.claude/hooks/ctx/**)")));
588 assert!(deny.contains(&serde_json::json!("Edit(.claude/settings.json)")));
589 }
590
591 #[test]
592 fn test_headers_carry_crate_version() {
593 let temp = TempDir::new().unwrap();
594 for file in plan_local(temp.path()) {
595 assert!(
596 file.content
597 .contains(&format!("generated by ctx v{}", env!("CARGO_PKG_VERSION"))),
598 "no version header in {}",
599 file.rel_path
600 );
601 assert!(
602 checksum::recorded_checksum(&file.content).is_some(),
603 "no checksum line in {}",
604 file.rel_path
605 );
606 }
607 }
608
609 #[test]
610 fn test_write_plan_ownership_lifecycle() {
611 let temp = TempDir::new().unwrap();
612 let root = temp.path();
613
614 let actions = plan_and_write(root, false);
616 for (rel, action) in &actions {
617 assert_eq!(*action, FileAction::Created, "{rel}");
618 }
619
620 let actions = plan_and_write(root, false);
623 assert_eq!(
624 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
625 FileAction::Regenerated
626 );
627 assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
628
629 let stop = root.join(".claude/hooks/ctx/stop.sh");
631 let modified = fs::read_to_string(&stop).unwrap() + "echo tampered\n";
632 fs::write(&stop, &modified).unwrap();
633 let actions = plan_and_write(root, false);
634 assert_eq!(
635 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
636 FileAction::SkippedModified
637 );
638 assert_eq!(fs::read_to_string(&stop).unwrap(), modified);
639
640 fs::write(root.join(RULES_PATH), "version = 1\n# mine\n").unwrap();
642 let actions = plan_and_write(root, true);
643 assert_eq!(
644 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
645 FileAction::Overwritten
646 );
647 assert!(!fs::read_to_string(&stop).unwrap().contains("tampered"));
648 assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
649 assert_eq!(
650 fs::read_to_string(root.join(RULES_PATH)).unwrap(),
651 "version = 1\n# mine\n"
652 );
653 }
654
655 #[test]
656 fn test_foreign_file_is_skipped_without_force() {
657 let temp = TempDir::new().unwrap();
658 let root = temp.path();
659 let rel = ".claude/hooks/ctx/stop.sh";
660 let path = root.join(rel);
661 fs::create_dir_all(path.parent().unwrap()).unwrap();
662 fs::write(&path, "#!/bin/sh\necho my own hook\n").unwrap();
663
664 let actions = plan_and_write(root, false);
665 assert_eq!(action_for(&actions, rel), FileAction::SkippedForeign);
666 assert!(fs::read_to_string(&path).unwrap().contains("my own hook"));
667
668 let actions = plan_and_write(root, true);
669 assert_eq!(action_for(&actions, rel), FileAction::Overwritten);
670 }
671
672 #[test]
673 fn test_lock_tracks_json_files_in_plugin_mode() {
674 let temp = TempDir::new().unwrap();
675 let root = temp.path();
676 let plan = plan_plugin(root);
677 write_plan(root, &plan, false).unwrap();
678
679 let lock = read_lock(root).unwrap();
680 let entry = lock.files.get(".claude-plugin/plugin.json").unwrap();
682 assert!(entry.checksum.starts_with("sha256:"));
683 assert_eq!(entry.ctx_version, env!("CARGO_PKG_VERSION"));
684
685 let manifest = root.join(".claude-plugin/plugin.json");
687 fs::write(&manifest, "{\"name\": \"evil\"}\n").unwrap();
688 let actions = write_plan(root, &plan, false).unwrap();
689 assert_eq!(
690 action_for(&actions, ".claude-plugin/plugin.json"),
691 FileAction::SkippedModified
692 );
693 }
694
695 #[cfg(unix)]
696 #[test]
697 fn test_hook_scripts_are_executable() {
698 use std::os::unix::fs::PermissionsExt;
699 let temp = TempDir::new().unwrap();
700 let root = temp.path();
701 plan_and_write(root, false);
702 let mode = fs::metadata(root.join(".claude/hooks/ctx/stop.sh"))
703 .unwrap()
704 .permissions()
705 .mode();
706 assert_eq!(mode & 0o111, 0o111, "mode: {:o}", mode);
707 }
708
709 #[test]
710 fn test_wire_creates_settings_when_missing() {
711 let temp = TempDir::new().unwrap();
712 let root = temp.path();
713 let action = wire_local_settings(root).unwrap();
714 assert_eq!(action, SettingsWireAction::Created);
715 let content = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
716 assert!(content.contains(".claude/hooks/ctx/"), "content: {content}");
717 serde_json::from_str::<serde_json::Value>(&content).unwrap();
718 }
719
720 #[test]
721 fn test_wire_merges_additively_preserving_user_settings() {
722 let temp = TempDir::new().unwrap();
723 let root = temp.path();
724 fs::create_dir_all(root.join(".claude")).unwrap();
725 fs::write(
727 root.join(".claude/settings.json"),
728 r#"{
729 "foo": "bar",
730 "permissions": { "allow": ["Bash(git *)"] },
731 "hooks": {
732 "SessionStart": [
733 { "hooks": [{ "type": "command", "command": "/my/own/hook.sh" }] }
734 ]
735 }
736}"#,
737 )
738 .unwrap();
739
740 let action = wire_local_settings(root).unwrap();
741 assert_eq!(action, SettingsWireAction::Merged);
742
743 let content = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
744 let value: serde_json::Value = serde_json::from_str(&content).unwrap();
745 assert_eq!(value["foo"], "bar");
747 let allow = value["permissions"]["allow"].as_array().unwrap();
749 assert!(allow.contains(&serde_json::json!("Bash(git *)")));
750 assert!(allow.contains(&serde_json::json!("Bash(ctx *)")));
751 let session = value["hooks"]["SessionStart"].as_array().unwrap();
753 assert_eq!(session.len(), 2, "user + ctx group: {session:?}");
754 assert!(content.contains("/my/own/hook.sh"));
755 assert!(content.contains(".claude/hooks/ctx/session-start.sh"));
756 }
757
758 #[test]
759 fn test_wire_is_idempotent() {
760 let temp = TempDir::new().unwrap();
761 let root = temp.path();
762 assert_eq!(
763 wire_local_settings(root).unwrap(),
764 SettingsWireAction::Created
765 );
766 let first = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
767
768 assert_eq!(
769 wire_local_settings(root).unwrap(),
770 SettingsWireAction::AlreadyWired
771 );
772 let second = fs::read_to_string(root.join(".claude/settings.json")).unwrap();
773 assert_eq!(first, second, "bytes must be unchanged on re-wire");
774 }
775
776 #[test]
777 fn test_wire_leaves_invalid_json_untouched() {
778 let temp = TempDir::new().unwrap();
779 let root = temp.path();
780 fs::create_dir_all(root.join(".claude")).unwrap();
781 let path = root.join(".claude/settings.json");
782 fs::write(&path, "{not json").unwrap();
783
784 let action = wire_local_settings(root).unwrap();
785 assert_eq!(action, SettingsWireAction::SkippedInvalid);
786 assert_eq!(fs::read_to_string(&path).unwrap(), "{not json");
787 }
788
789 #[test]
790 fn test_starter_rules_toml_parses_and_constrains_nothing() {
791 let temp = TempDir::new().unwrap();
792 let plan = plan_local(temp.path());
793 let rules = plan.iter().find(|f| f.rel_path == RULES_PATH).unwrap();
794 let parsed: crate::rules::RulesFile = toml::from_str(&rules.content).unwrap();
795 assert_eq!(parsed.version, 1);
796 assert!(parsed.layers.is_empty());
797 assert!(parsed.rules.forbidden.is_empty());
798 assert!(parsed.rules.allowed_dependents.is_empty());
799 assert!(parsed.rules.limit.is_empty());
800 assert!(parsed.rules.no_new_dependents.is_empty());
801 }
802}