Skip to main content

ctx/harness/
mod.rs

1//! Harness packaging for `ctx harness` (Claude Code integration).
2//!
3//! This module scaffolds hook scripts, settings, skills, and plugin
4//! manifests from templates embedded in the binary ([`templates`]), stamps
5//! every generated file with a version header and checksum ([`checksum`]),
6//! guards generated hooks against version skew ([`compat`]), and diagnoses
7//! the whole integration ([`doctor`]).
8//!
9//! # Ownership model
10//!
11//! `init` records a checksum for every file it writes -- in the file itself
12//! (comment header) where the format allows comments, and always in the
13//! `.ctx/harness.lock` manifest. On re-run, each planned file is classified:
14//!
15//! - **Missing**: written.
16//! - **Owned, unmodified** (checksum matches): regenerated in place.
17//! - **Owned, modified** (checksum mismatch): warned about and skipped;
18//!   `--force` overwrites.
19//! - **Foreign** (exists but no ctx checksum anywhere): warned about and
20//!   skipped; `--force` overwrites.
21//!
22//! Exception: `.ctx/rules.toml` encodes *user* policy and is never
23//! overwritten once it exists, not even with `--force`.
24
25pub 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
39/// Relative path of the harness manifest.
40pub const LOCK_PATH: &str = ".ctx/harness.lock";
41
42/// Relative path of the starter rules file.
43pub const RULES_PATH: &str = ".ctx/rules.toml";
44
45/// Directory holding the local-mode hook scripts.
46pub const LOCAL_HOOKS_DIR: &str = ".claude/hooks/ctx";
47
48/// The three hook script basenames (shared by local and plugin modes).
49pub const HOOK_NAMES: [&str; 3] = ["session-start", "post-tool-use", "stop"];
50
51/// Harness targets (mirrors the CLI enum; only Claude Code for now).
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Target {
54    Claude,
55}
56
57/// Scaffolding mode.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Mode {
60    Local,
61    Plugin,
62}
63
64/// One file `init` plans to write: final content (header + checksum already
65/// applied) plus write metadata.
66#[derive(Debug, Clone)]
67pub struct GeneratedFile {
68    /// Path relative to the project root, `/`-separated.
69    pub rel_path: String,
70    /// Final on-disk content.
71    pub content: String,
72    /// chmod 0o755 on unix.
73    pub executable: bool,
74    /// Never overwrite once the file exists (user policy files).
75    pub never_overwrite: bool,
76}
77
78/// What `write_plan` did with one planned file.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum FileAction {
81    /// File did not exist and was written.
82    Created,
83    /// File was owned by ctx and unmodified; regenerated in place.
84    Regenerated,
85    /// File was modified or foreign, but `--force` overwrote it.
86    Overwritten,
87    /// File carried a ctx checksum that no longer matches (user-modified);
88    /// skipped.
89    SkippedModified,
90    /// File exists but was not generated by ctx; skipped.
91    SkippedForeign,
92    /// File is never overwritten by policy (`.ctx/rules.toml`); skipped.
93    SkippedPolicy,
94}
95
96impl FileAction {
97    /// Stable identifier used in JSON output.
98    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    /// True when the file was written to disk.
110    pub fn wrote(self) -> bool {
111        matches!(
112            self,
113            FileAction::Created | FileAction::Regenerated | FileAction::Overwritten
114        )
115    }
116}
117
118// ============================================================================
119// Manifest (.ctx/harness.lock)
120// ============================================================================
121
122/// One manifest entry: the checksum and generator version of a file.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct LockEntry {
125    /// `sha256:<hex>` over the file content minus its `ctx:checksum` lines.
126    pub checksum: String,
127    /// ctx version that generated the file.
128    pub ctx_version: String,
129}
130
131/// The `.ctx/harness.lock` manifest: rel_path -> entry.
132#[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
144/// Read and parse the manifest; `None` when missing or unparseable
145/// (a broken manifest falls back to in-file checksum verification).
146pub 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
163// ============================================================================
164// Planning
165// ============================================================================
166
167fn 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
194/// Plan the files for `--mode local`: hook scripts under
195/// `.claude/hooks/ctx/` plus a starter `.ctx/rules.toml`.
196///
197/// The settings snippet and CLAUDE.md block are *printed*, not written; see
198/// [`render_settings_snippet`] and [`render_claude_md_block`].
199pub 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
209/// Plan the files for `--mode plugin`: a full Claude Code plugin scaffold.
210///
211/// `.mcp.json` is included only when this binary was compiled with the
212/// `mcp` feature (release binaries are not); callers should explain how to
213/// enable it otherwise.
214pub 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
243/// Render the settings snippet printed to stdout in local mode.
244pub fn render_settings_snippet() -> String {
245    templates::render(templates::SETTINGS_SNIPPET_JSON, &[])
246}
247
248/// Render the CLAUDE.md guidance block printed to stdout in local mode.
249pub 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// ============================================================================
258// Settings wiring (.claude/settings.json)
259// ============================================================================
260
261/// Outcome of merging the ctx snippet into `.claude/settings.json`.
262#[derive(Debug, PartialEq)]
263pub enum SettingsWireAction {
264    /// No settings file existed; wrote a fresh one from the snippet.
265    Created,
266    /// Merged ctx's entries into an existing settings file.
267    Merged,
268    /// Settings already referenced ctx's hooks; nothing to add.
269    AlreadyWired,
270    /// Settings existed but were not valid JSON (object); left untouched.
271    SkippedInvalid,
272}
273
274/// Idempotency marker: a hook group already wired for ctx references this
275/// substring in its command. Mirrors the doctor's `check_settings_wiring`.
276const CTX_HOOKS_MARKER: &str = ".claude/hooks/ctx/";
277
278/// Merge the ctx hooks + permissions snippet into `.claude/settings.json`.
279///
280/// Additive, idempotent, and never clobbers unrelated user settings:
281/// - a missing file is created from the snippet;
282/// - an existing JSON object has ctx's permission entries unioned (dedup by
283///   value) and ctx's hook groups appended only when no group in that event
284///   already references [`CTX_HOOKS_MARKER`];
285/// - a file that is not valid JSON (or not an object) is left byte-for-byte
286///   untouched.
287pub 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        // Missing (or unreadable): write the snippet fresh.
295        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        // Present but not a JSON object: leave it exactly as-is.
308        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
324/// Deep-merge ctx's snippet into an existing settings object, in place.
325fn 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    // permissions.allow / permissions.deny: union with dedup by value.
334    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    // hooks.<Event>: append ctx's group unless one already references the marker.
348    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
362/// Append `additions` to `map[key]` (an array), keeping existing entries in
363/// order and skipping any addition already present by value.
364fn 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
381/// Append ctx's hook groups for `event` unless a group in that event's array
382/// already references [`CTX_HOOKS_MARKER`] (serialize-and-substring check,
383/// the same idempotency marker the doctor uses).
384fn 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// ============================================================================
403// Ownership + writing
404// ============================================================================
405
406/// How an existing (or missing) on-disk file relates to ctx's generation.
407#[derive(Debug, Clone, Copy, PartialEq, Eq)]
408enum Ownership {
409    Missing,
410    OwnedUnmodified,
411    OwnedModified,
412    Foreign,
413}
414
415/// Classify one on-disk file. Manifest entry wins; in-file `ctx:checksum`
416/// is the fallback; anything else that exists is foreign.
417fn 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        // Unreadable: treat as foreign so we never clobber it silently.
423        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
465/// Write a plan to disk, honoring the ownership model, and update
466/// `.ctx/harness.lock`.
467///
468/// Returns one `(rel_path, action)` per planned file (plus the lock file
469/// itself). This function does not print; callers surface warnings for
470/// skipped files.
471pub 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        // The printed (not written) templates render clean too.
557        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        // Permission block matches the spec (allow ctx, deny self-update and
577        // policy-file edits).
578        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        // First run: everything created.
615        let actions = plan_and_write(root, false);
616        for (rel, action) in &actions {
617            assert_eq!(*action, FileAction::Created, "{rel}");
618        }
619
620        // Second run: unmodified owned files regenerate; rules.toml is
621        // policy-skipped.
622        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        // Modify a hook: skipped without --force, content preserved.
630        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        // --force regenerates it; rules.toml still survives.
641        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        // JSON files have no in-file header; the lock must know them.
681        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        // Detect a JSON tamper via the lock (no in-file checksum exists).
686        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        // Unrelated top-level key + a user-defined hook under a different path.
726        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        // Unrelated key preserved.
746        assert_eq!(value["foo"], "bar");
747        // Existing permission preserved, ctx permission added.
748        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        // User hook preserved, ctx hook appended in the same event.
752        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}