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    Codex,
56}
57
58/// Scaffolding mode.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Mode {
61    Local,
62    Plugin,
63}
64
65/// One file `init` plans to write: final content (header + checksum already
66/// applied) plus write metadata.
67#[derive(Debug, Clone)]
68pub struct GeneratedFile {
69    /// Path relative to the project root, `/`-separated.
70    pub rel_path: String,
71    /// Final on-disk content.
72    pub content: String,
73    /// chmod 0o755 on unix.
74    pub executable: bool,
75    /// Never overwrite once the file exists (user policy files).
76    pub never_overwrite: bool,
77}
78
79/// What `write_plan` did with one planned file.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum FileAction {
82    /// File did not exist and was written.
83    Created,
84    /// File was owned by ctx and unmodified; regenerated in place.
85    Regenerated,
86    /// File was modified or foreign, but `--force` overwrote it.
87    Overwritten,
88    /// File carried a ctx checksum that no longer matches (user-modified);
89    /// skipped.
90    SkippedModified,
91    /// File exists but was not generated by ctx; skipped.
92    SkippedForeign,
93    /// File is never overwritten by policy (`.ctx/rules.toml`); skipped.
94    SkippedPolicy,
95}
96
97impl FileAction {
98    /// Stable identifier used in JSON output.
99    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    /// True when the file was written to disk.
111    pub fn wrote(self) -> bool {
112        matches!(
113            self,
114            FileAction::Created | FileAction::Regenerated | FileAction::Overwritten
115        )
116    }
117}
118
119// ============================================================================
120// Manifest (.ctx/harness.lock)
121// ============================================================================
122
123/// One manifest entry: the checksum and generator version of a file.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct LockEntry {
126    /// `sha256:<hex>` over the file content minus its `ctx:checksum` lines.
127    pub checksum: String,
128    /// ctx version that generated the file.
129    pub ctx_version: String,
130}
131
132/// The `.ctx/harness.lock` manifest: rel_path -> entry.
133#[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
145/// Read and parse the manifest; `None` when missing or unparseable
146/// (a broken manifest falls back to in-file checksum verification).
147pub 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
164// ============================================================================
165// Planning
166// ============================================================================
167
168fn 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
195/// Plan the files for `--mode local`: hook scripts under
196/// `.claude/hooks/ctx/` plus a starter `.ctx/rules.toml`.
197///
198/// The settings snippet and CLAUDE.md block are *printed*, not written; see
199/// [`render_settings_snippet`] and [`render_claude_md_block`].
200pub 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
210/// Plan the files for `--mode plugin`: a full Claude Code plugin scaffold.
211///
212/// `.mcp.json` is included only when this binary was compiled with the
213/// `mcp` feature (release binaries are not); callers should explain how to
214/// enable it otherwise.
215pub 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
318/// Render the settings snippet printed to stdout in local mode.
319pub fn render_settings_snippet() -> String {
320    templates::render(templates::SETTINGS_SNIPPET_JSON, &[])
321}
322
323/// Render the CLAUDE.md guidance block printed to stdout in local mode.
324pub 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// ============================================================================
333// Settings wiring (.claude/settings.json)
334// ============================================================================
335
336/// Outcome of merging the ctx snippet into `.claude/settings.json`.
337#[derive(Debug, PartialEq)]
338pub enum SettingsWireAction {
339    /// No settings file existed; wrote a fresh one from the snippet.
340    Created,
341    /// Merged ctx's entries into an existing settings file.
342    Merged,
343    /// Settings already referenced ctx's hooks; nothing to add.
344    AlreadyWired,
345    /// Settings existed but were not valid JSON (object); left untouched.
346    SkippedInvalid,
347}
348
349/// Idempotency marker: a hook group already wired for ctx references this
350/// substring in its command. Mirrors the doctor's `check_settings_wiring`.
351const CTX_HOOKS_MARKER: &str = ".claude/hooks/ctx/";
352
353/// Merge the ctx hooks + permissions snippet into `.claude/settings.json`.
354///
355/// Additive, idempotent, and never clobbers unrelated user settings:
356/// - a missing file is created from the snippet;
357/// - an existing JSON object has ctx's permission entries unioned (dedup by
358///   value) and ctx's hook groups appended only when no group in that event
359///   already references [`CTX_HOOKS_MARKER`];
360/// - a file that is not valid JSON (or not an object) is left byte-for-byte
361///   untouched.
362pub 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        // Missing (or unreadable): write the snippet fresh.
370        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        // Present but not a JSON object: leave it exactly as-is.
383        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
399/// Deep-merge ctx's snippet into an existing settings object, in place.
400fn 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    // permissions.allow / permissions.deny: union with dedup by value.
409    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    // hooks.<Event>: append ctx's group unless one already references the marker.
423    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
437/// Append `additions` to `map[key]` (an array), keeping existing entries in
438/// order and skipping any addition already present by value.
439fn 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
456/// Append ctx's hook groups for `event` unless a group in that event's array
457/// already references [`CTX_HOOKS_MARKER`] (serialize-and-substring check,
458/// the same idempotency marker the doctor uses).
459fn 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// ============================================================================
478// Ownership + writing
479// ============================================================================
480
481/// How an existing (or missing) on-disk file relates to ctx's generation.
482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
483enum Ownership {
484    Missing,
485    OwnedUnmodified,
486    OwnedModified,
487    Foreign,
488}
489
490/// Classify one on-disk file. Manifest entry wins; in-file `ctx:checksum`
491/// is the fallback; anything else that exists is foreign.
492fn 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        // Unreadable: treat as foreign so we never clobber it silently.
498        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
540/// Write a plan to disk, honoring the ownership model, and update
541/// `.ctx/harness.lock`.
542///
543/// Returns one `(rel_path, action)` per planned file (plus the lock file
544/// itself). This function does not print; callers surface warnings for
545/// skipped files.
546pub 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        // The printed (not written) templates render clean too.
632        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        // Permission block matches the spec (allow ctx, deny self-update and
660        // policy-file edits).
661        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        // First run: everything created.
720        let actions = plan_and_write(root, false);
721        for (rel, action) in &actions {
722            assert_eq!(*action, FileAction::Created, "{rel}");
723        }
724
725        // Second run: unmodified owned files regenerate; rules.toml is
726        // policy-skipped.
727        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        // Modify a hook: skipped without --force, content preserved.
735        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        // --force regenerates it; rules.toml still survives.
746        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        // JSON files have no in-file header; the lock must know them.
786        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        // Detect a JSON tamper via the lock (no in-file checksum exists).
791        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        // Unrelated top-level key + a user-defined hook under a different path.
831        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        // Unrelated key preserved.
851        assert_eq!(value["foo"], "bar");
852        // Existing permission preserved, ctx permission added.
853        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        // User hook preserved, ctx hook appended in the same event.
857        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}