pub mod checksum;
pub mod compat;
pub mod doctor;
pub mod templates;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{CtxError, Result};
use checksum::{content_checksum, finalize, recorded_checksum, style_for_path};
pub const LOCK_PATH: &str = ".ctx/harness.lock";
pub const RULES_PATH: &str = ".ctx/rules.toml";
pub const LOCAL_HOOKS_DIR: &str = ".claude/hooks/ctx";
pub const HOOK_NAMES: [&str; 3] = ["session-start", "post-tool-use", "stop"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Claude,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Local,
Plugin,
}
#[derive(Debug, Clone)]
pub struct GeneratedFile {
pub rel_path: String,
pub content: String,
pub executable: bool,
pub never_overwrite: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileAction {
Created,
Regenerated,
Overwritten,
SkippedModified,
SkippedForeign,
SkippedPolicy,
}
impl FileAction {
pub fn as_str(self) -> &'static str {
match self {
FileAction::Created => "created",
FileAction::Regenerated => "regenerated",
FileAction::Overwritten => "overwritten",
FileAction::SkippedModified => "skipped_modified",
FileAction::SkippedForeign => "skipped_foreign",
FileAction::SkippedPolicy => "skipped_policy",
}
}
pub fn wrote(self) -> bool {
matches!(
self,
FileAction::Created | FileAction::Regenerated | FileAction::Overwritten
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockEntry {
pub checksum: String,
pub ctx_version: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LockFile {
#[serde(default = "default_lock_version")]
pub version: u32,
#[serde(default)]
pub files: BTreeMap<String, LockEntry>,
}
fn default_lock_version() -> u32 {
1
}
pub fn read_lock(root: &Path) -> Option<LockFile> {
let content = fs::read_to_string(root.join(LOCK_PATH)).ok()?;
toml::from_str(&content).ok()
}
fn write_lock(root: &Path, lock: &LockFile) -> Result<()> {
let body = toml::to_string_pretty(lock)
.map_err(|e| CtxError::Other(format!("failed to serialize {LOCK_PATH}: {e}")))?;
let content = finalize(&body, checksum::HeaderStyle::Toml, templates::CTX_VERSION);
let path = root.join(LOCK_PATH);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(())
}
fn generated(rel_path: &str, template: &str, vars: &[(&str, &str)]) -> GeneratedFile {
let rendered = templates::render(template, vars);
let content = finalize(&rendered, style_for_path(rel_path), templates::CTX_VERSION);
GeneratedFile {
rel_path: rel_path.to_string(),
content,
executable: rel_path.ends_with(".sh"),
never_overwrite: rel_path == RULES_PATH,
}
}
fn hook_files(dir: &str, vars: &[(&str, &str)]) -> Vec<GeneratedFile> {
vec![
generated(
&format!("{dir}/session-start.sh"),
templates::SESSION_START_SH,
vars,
),
generated(
&format!("{dir}/post-tool-use.sh"),
templates::POST_TOOL_USE_SH,
vars,
),
generated(&format!("{dir}/stop.sh"), templates::STOP_SH, vars),
]
}
pub fn plan_local(root: &Path) -> Vec<GeneratedFile> {
let branch = templates::default_branch(root);
let author = templates::author_name();
let vars = templates::standard_vars(&branch, &author);
let mut plan = hook_files(LOCAL_HOOKS_DIR, &vars);
plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
plan
}
pub fn plan_plugin(root: &Path) -> Vec<GeneratedFile> {
let branch = templates::default_branch(root);
let author = templates::author_name();
let vars = templates::standard_vars(&branch, &author);
let mut plan = vec![
generated(".claude-plugin/plugin.json", templates::PLUGIN_JSON, &vars),
generated(
".claude-plugin/marketplace.json",
templates::MARKETPLACE_JSON,
&vars,
),
generated("hooks/hooks.json", templates::HOOKS_JSON, &vars),
];
plan.extend(hook_files("hooks", &vars));
plan.push(generated(
"settings.json",
templates::PLUGIN_SETTINGS_JSON,
&vars,
));
plan.push(generated("skills/ctx/SKILL.md", templates::SKILL_MD, &vars));
plan.push(generated("README.md", templates::PLUGIN_README_MD, &vars));
if cfg!(feature = "mcp") {
plan.push(generated(".mcp.json", templates::MCP_JSON, &vars));
}
plan.push(generated(RULES_PATH, templates::RULES_TOML, &vars));
plan
}
pub fn render_settings_snippet() -> String {
templates::render(templates::SETTINGS_SNIPPET_JSON, &[])
}
pub fn render_claude_md_block(root: &Path) -> String {
let branch = templates::default_branch(root);
templates::render(
templates::CLAUDE_MD_BLOCK_MD,
&[("DEFAULT_BRANCH", branch.as_str())],
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Ownership {
Missing,
OwnedUnmodified,
OwnedModified,
Foreign,
}
fn classify(path: &Path, lock_entry: Option<&LockEntry>) -> Ownership {
if !path.exists() {
return Ownership::Missing;
}
let Ok(bytes) = fs::read(path) else {
return Ownership::Foreign;
};
let actual = content_checksum(&bytes);
if let Some(entry) = lock_entry {
let expected = entry
.checksum
.strip_prefix("sha256:")
.unwrap_or(&entry.checksum);
return if actual == expected {
Ownership::OwnedUnmodified
} else {
Ownership::OwnedModified
};
}
if let Ok(text) = std::str::from_utf8(&bytes) {
if let Some(recorded) = recorded_checksum(text) {
return if actual == recorded {
Ownership::OwnedUnmodified
} else {
Ownership::OwnedModified
};
}
}
Ownership::Foreign
}
fn write_file(root: &Path, file: &GeneratedFile) -> Result<()> {
let path = root.join(&file.rel_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, &file.content)?;
#[cfg(unix)]
if file.executable {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
pub fn write_plan(
root: &Path,
plan: &[GeneratedFile],
force: bool,
) -> Result<Vec<(String, FileAction)>> {
let mut lock = read_lock(root).unwrap_or_default();
lock.version = 1;
let mut actions = Vec::with_capacity(plan.len() + 1);
for file in plan {
let path = root.join(&file.rel_path);
let ownership = classify(&path, lock.files.get(&file.rel_path));
let action = match ownership {
Ownership::Missing => FileAction::Created,
_ if file.never_overwrite => FileAction::SkippedPolicy,
Ownership::OwnedUnmodified => FileAction::Regenerated,
Ownership::OwnedModified if force => FileAction::Overwritten,
Ownership::OwnedModified => FileAction::SkippedModified,
Ownership::Foreign if force => FileAction::Overwritten,
Ownership::Foreign => FileAction::SkippedForeign,
};
if action.wrote() {
write_file(root, file)?;
lock.files.insert(
file.rel_path.clone(),
LockEntry {
checksum: format!("sha256:{}", content_checksum(file.content.as_bytes())),
ctx_version: templates::CTX_VERSION.to_string(),
},
);
}
actions.push((file.rel_path.clone(), action));
}
let lock_existed = root.join(LOCK_PATH).exists();
write_lock(root, &lock)?;
actions.push((
LOCK_PATH.to_string(),
if lock_existed {
FileAction::Regenerated
} else {
FileAction::Created
},
));
Ok(actions)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn plan_and_write(root: &Path, force: bool) -> Vec<(String, FileAction)> {
let plan = plan_local(root);
write_plan(root, &plan, force).unwrap()
}
fn action_for(actions: &[(String, FileAction)], rel: &str) -> FileAction {
actions
.iter()
.find(|(p, _)| p == rel)
.unwrap_or_else(|| panic!("no action for {rel}"))
.1
}
#[test]
fn test_no_residual_tokens_and_json_parses_in_both_modes() {
let temp = TempDir::new().unwrap();
for plan in [plan_local(temp.path()), plan_plugin(temp.path())] {
for file in &plan {
assert!(
!file.content.contains("{{"),
"unrendered token in {}: {}",
file.rel_path,
file.content
);
if file.rel_path.ends_with(".json") {
serde_json::from_str::<serde_json::Value>(&file.content)
.unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", file.rel_path));
}
}
}
assert!(!render_settings_snippet().contains("{{"));
assert!(!render_claude_md_block(temp.path()).contains("{{"));
serde_json::from_str::<serde_json::Value>(&render_settings_snippet()).unwrap();
}
#[test]
fn test_plugin_manifest_fields_and_version() {
let temp = TempDir::new().unwrap();
let plan = plan_plugin(temp.path());
let plugin = plan
.iter()
.find(|f| f.rel_path == ".claude-plugin/plugin.json")
.unwrap();
let value: serde_json::Value = serde_json::from_str(&plugin.content).unwrap();
assert_eq!(value["name"], "ctx");
assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
assert!(value["description"].is_string());
assert!(value["author"]["name"].is_string());
let settings = plan.iter().find(|f| f.rel_path == "settings.json").unwrap();
let value: serde_json::Value = serde_json::from_str(&settings.content).unwrap();
assert_eq!(
value["permissions"]["allow"],
serde_json::json!(["Bash(ctx *)"])
);
let deny = value["permissions"]["deny"].as_array().unwrap();
assert!(deny.contains(&serde_json::json!("Bash(ctx self-update*)")));
assert!(deny.contains(&serde_json::json!("Edit(.ctx/rules.toml)")));
assert!(deny.contains(&serde_json::json!("Edit(.claude/hooks/ctx/**)")));
assert!(deny.contains(&serde_json::json!("Edit(.claude/settings.json)")));
}
#[test]
fn test_headers_carry_crate_version() {
let temp = TempDir::new().unwrap();
for file in plan_local(temp.path()) {
assert!(
file.content
.contains(&format!("generated by ctx v{}", env!("CARGO_PKG_VERSION"))),
"no version header in {}",
file.rel_path
);
assert!(
checksum::recorded_checksum(&file.content).is_some(),
"no checksum line in {}",
file.rel_path
);
}
}
#[test]
fn test_write_plan_ownership_lifecycle() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let actions = plan_and_write(root, false);
for (rel, action) in &actions {
assert_eq!(*action, FileAction::Created, "{rel}");
}
let actions = plan_and_write(root, false);
assert_eq!(
action_for(&actions, ".claude/hooks/ctx/stop.sh"),
FileAction::Regenerated
);
assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
let stop = root.join(".claude/hooks/ctx/stop.sh");
let modified = fs::read_to_string(&stop).unwrap() + "echo tampered\n";
fs::write(&stop, &modified).unwrap();
let actions = plan_and_write(root, false);
assert_eq!(
action_for(&actions, ".claude/hooks/ctx/stop.sh"),
FileAction::SkippedModified
);
assert_eq!(fs::read_to_string(&stop).unwrap(), modified);
fs::write(root.join(RULES_PATH), "version = 1\n# mine\n").unwrap();
let actions = plan_and_write(root, true);
assert_eq!(
action_for(&actions, ".claude/hooks/ctx/stop.sh"),
FileAction::Overwritten
);
assert!(!fs::read_to_string(&stop).unwrap().contains("tampered"));
assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
assert_eq!(
fs::read_to_string(root.join(RULES_PATH)).unwrap(),
"version = 1\n# mine\n"
);
}
#[test]
fn test_foreign_file_is_skipped_without_force() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let rel = ".claude/hooks/ctx/stop.sh";
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, "#!/bin/sh\necho my own hook\n").unwrap();
let actions = plan_and_write(root, false);
assert_eq!(action_for(&actions, rel), FileAction::SkippedForeign);
assert!(fs::read_to_string(&path).unwrap().contains("my own hook"));
let actions = plan_and_write(root, true);
assert_eq!(action_for(&actions, rel), FileAction::Overwritten);
}
#[test]
fn test_lock_tracks_json_files_in_plugin_mode() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let plan = plan_plugin(root);
write_plan(root, &plan, false).unwrap();
let lock = read_lock(root).unwrap();
let entry = lock.files.get(".claude-plugin/plugin.json").unwrap();
assert!(entry.checksum.starts_with("sha256:"));
assert_eq!(entry.ctx_version, env!("CARGO_PKG_VERSION"));
let manifest = root.join(".claude-plugin/plugin.json");
fs::write(&manifest, "{\"name\": \"evil\"}\n").unwrap();
let actions = write_plan(root, &plan, false).unwrap();
assert_eq!(
action_for(&actions, ".claude-plugin/plugin.json"),
FileAction::SkippedModified
);
}
#[cfg(unix)]
#[test]
fn test_hook_scripts_are_executable() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let root = temp.path();
plan_and_write(root, false);
let mode = fs::metadata(root.join(".claude/hooks/ctx/stop.sh"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "mode: {:o}", mode);
}
#[test]
fn test_starter_rules_toml_parses_and_constrains_nothing() {
let temp = TempDir::new().unwrap();
let plan = plan_local(temp.path());
let rules = plan.iter().find(|f| f.rel_path == RULES_PATH).unwrap();
let parsed: crate::rules::RulesFile = toml::from_str(&rules.content).unwrap();
assert_eq!(parsed.version, 1);
assert!(parsed.layers.is_empty());
assert!(parsed.rules.forbidden.is_empty());
assert!(parsed.rules.allowed_dependents.is_empty());
assert!(parsed.rules.limit.is_empty());
assert!(parsed.rules.no_new_dependents.is_empty());
}
}