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, Clone, Copy, PartialEq, Eq)]
263enum Ownership {
264 Missing,
265 OwnedUnmodified,
266 OwnedModified,
267 Foreign,
268}
269
270fn classify(path: &Path, lock_entry: Option<&LockEntry>) -> Ownership {
273 if !path.exists() {
274 return Ownership::Missing;
275 }
276 let Ok(bytes) = fs::read(path) else {
277 return Ownership::Foreign;
279 };
280 let actual = content_checksum(&bytes);
281
282 if let Some(entry) = lock_entry {
283 let expected = entry
284 .checksum
285 .strip_prefix("sha256:")
286 .unwrap_or(&entry.checksum);
287 return if actual == expected {
288 Ownership::OwnedUnmodified
289 } else {
290 Ownership::OwnedModified
291 };
292 }
293
294 if let Ok(text) = std::str::from_utf8(&bytes) {
295 if let Some(recorded) = recorded_checksum(text) {
296 return if actual == recorded {
297 Ownership::OwnedUnmodified
298 } else {
299 Ownership::OwnedModified
300 };
301 }
302 }
303 Ownership::Foreign
304}
305
306fn write_file(root: &Path, file: &GeneratedFile) -> Result<()> {
307 let path = root.join(&file.rel_path);
308 if let Some(parent) = path.parent() {
309 fs::create_dir_all(parent)?;
310 }
311 fs::write(&path, &file.content)?;
312 #[cfg(unix)]
313 if file.executable {
314 use std::os::unix::fs::PermissionsExt;
315 fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
316 }
317 Ok(())
318}
319
320pub fn write_plan(
327 root: &Path,
328 plan: &[GeneratedFile],
329 force: bool,
330) -> Result<Vec<(String, FileAction)>> {
331 let mut lock = read_lock(root).unwrap_or_default();
332 lock.version = 1;
333 let mut actions = Vec::with_capacity(plan.len() + 1);
334
335 for file in plan {
336 let path = root.join(&file.rel_path);
337 let ownership = classify(&path, lock.files.get(&file.rel_path));
338
339 let action = match ownership {
340 Ownership::Missing => FileAction::Created,
341 _ if file.never_overwrite => FileAction::SkippedPolicy,
342 Ownership::OwnedUnmodified => FileAction::Regenerated,
343 Ownership::OwnedModified if force => FileAction::Overwritten,
344 Ownership::OwnedModified => FileAction::SkippedModified,
345 Ownership::Foreign if force => FileAction::Overwritten,
346 Ownership::Foreign => FileAction::SkippedForeign,
347 };
348
349 if action.wrote() {
350 write_file(root, file)?;
351 lock.files.insert(
352 file.rel_path.clone(),
353 LockEntry {
354 checksum: format!("sha256:{}", content_checksum(file.content.as_bytes())),
355 ctx_version: templates::CTX_VERSION.to_string(),
356 },
357 );
358 }
359 actions.push((file.rel_path.clone(), action));
360 }
361
362 let lock_existed = root.join(LOCK_PATH).exists();
363 write_lock(root, &lock)?;
364 actions.push((
365 LOCK_PATH.to_string(),
366 if lock_existed {
367 FileAction::Regenerated
368 } else {
369 FileAction::Created
370 },
371 ));
372
373 Ok(actions)
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use tempfile::TempDir;
380
381 fn plan_and_write(root: &Path, force: bool) -> Vec<(String, FileAction)> {
382 let plan = plan_local(root);
383 write_plan(root, &plan, force).unwrap()
384 }
385
386 fn action_for(actions: &[(String, FileAction)], rel: &str) -> FileAction {
387 actions
388 .iter()
389 .find(|(p, _)| p == rel)
390 .unwrap_or_else(|| panic!("no action for {rel}"))
391 .1
392 }
393
394 #[test]
395 fn test_no_residual_tokens_and_json_parses_in_both_modes() {
396 let temp = TempDir::new().unwrap();
397 for plan in [plan_local(temp.path()), plan_plugin(temp.path())] {
398 for file in &plan {
399 assert!(
400 !file.content.contains("{{"),
401 "unrendered token in {}: {}",
402 file.rel_path,
403 file.content
404 );
405 if file.rel_path.ends_with(".json") {
406 serde_json::from_str::<serde_json::Value>(&file.content)
407 .unwrap_or_else(|e| panic!("{} is not valid JSON: {e}", file.rel_path));
408 }
409 }
410 }
411 assert!(!render_settings_snippet().contains("{{"));
413 assert!(!render_claude_md_block(temp.path()).contains("{{"));
414 serde_json::from_str::<serde_json::Value>(&render_settings_snippet()).unwrap();
415 }
416
417 #[test]
418 fn test_plugin_manifest_fields_and_version() {
419 let temp = TempDir::new().unwrap();
420 let plan = plan_plugin(temp.path());
421 let plugin = plan
422 .iter()
423 .find(|f| f.rel_path == ".claude-plugin/plugin.json")
424 .unwrap();
425 let value: serde_json::Value = serde_json::from_str(&plugin.content).unwrap();
426 assert_eq!(value["name"], "ctx");
427 assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
428 assert!(value["description"].is_string());
429 assert!(value["author"]["name"].is_string());
430
431 let settings = plan.iter().find(|f| f.rel_path == "settings.json").unwrap();
434 let value: serde_json::Value = serde_json::from_str(&settings.content).unwrap();
435 assert_eq!(
436 value["permissions"]["allow"],
437 serde_json::json!(["Bash(ctx *)"])
438 );
439 let deny = value["permissions"]["deny"].as_array().unwrap();
440 assert!(deny.contains(&serde_json::json!("Bash(ctx self-update*)")));
441 assert!(deny.contains(&serde_json::json!("Edit(.ctx/rules.toml)")));
442 assert!(deny.contains(&serde_json::json!("Edit(.claude/hooks/ctx/**)")));
443 assert!(deny.contains(&serde_json::json!("Edit(.claude/settings.json)")));
444 }
445
446 #[test]
447 fn test_headers_carry_crate_version() {
448 let temp = TempDir::new().unwrap();
449 for file in plan_local(temp.path()) {
450 assert!(
451 file.content
452 .contains(&format!("generated by ctx v{}", env!("CARGO_PKG_VERSION"))),
453 "no version header in {}",
454 file.rel_path
455 );
456 assert!(
457 checksum::recorded_checksum(&file.content).is_some(),
458 "no checksum line in {}",
459 file.rel_path
460 );
461 }
462 }
463
464 #[test]
465 fn test_write_plan_ownership_lifecycle() {
466 let temp = TempDir::new().unwrap();
467 let root = temp.path();
468
469 let actions = plan_and_write(root, false);
471 for (rel, action) in &actions {
472 assert_eq!(*action, FileAction::Created, "{rel}");
473 }
474
475 let actions = plan_and_write(root, false);
478 assert_eq!(
479 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
480 FileAction::Regenerated
481 );
482 assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
483
484 let stop = root.join(".claude/hooks/ctx/stop.sh");
486 let modified = fs::read_to_string(&stop).unwrap() + "echo tampered\n";
487 fs::write(&stop, &modified).unwrap();
488 let actions = plan_and_write(root, false);
489 assert_eq!(
490 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
491 FileAction::SkippedModified
492 );
493 assert_eq!(fs::read_to_string(&stop).unwrap(), modified);
494
495 fs::write(root.join(RULES_PATH), "version = 1\n# mine\n").unwrap();
497 let actions = plan_and_write(root, true);
498 assert_eq!(
499 action_for(&actions, ".claude/hooks/ctx/stop.sh"),
500 FileAction::Overwritten
501 );
502 assert!(!fs::read_to_string(&stop).unwrap().contains("tampered"));
503 assert_eq!(action_for(&actions, RULES_PATH), FileAction::SkippedPolicy);
504 assert_eq!(
505 fs::read_to_string(root.join(RULES_PATH)).unwrap(),
506 "version = 1\n# mine\n"
507 );
508 }
509
510 #[test]
511 fn test_foreign_file_is_skipped_without_force() {
512 let temp = TempDir::new().unwrap();
513 let root = temp.path();
514 let rel = ".claude/hooks/ctx/stop.sh";
515 let path = root.join(rel);
516 fs::create_dir_all(path.parent().unwrap()).unwrap();
517 fs::write(&path, "#!/bin/sh\necho my own hook\n").unwrap();
518
519 let actions = plan_and_write(root, false);
520 assert_eq!(action_for(&actions, rel), FileAction::SkippedForeign);
521 assert!(fs::read_to_string(&path).unwrap().contains("my own hook"));
522
523 let actions = plan_and_write(root, true);
524 assert_eq!(action_for(&actions, rel), FileAction::Overwritten);
525 }
526
527 #[test]
528 fn test_lock_tracks_json_files_in_plugin_mode() {
529 let temp = TempDir::new().unwrap();
530 let root = temp.path();
531 let plan = plan_plugin(root);
532 write_plan(root, &plan, false).unwrap();
533
534 let lock = read_lock(root).unwrap();
535 let entry = lock.files.get(".claude-plugin/plugin.json").unwrap();
537 assert!(entry.checksum.starts_with("sha256:"));
538 assert_eq!(entry.ctx_version, env!("CARGO_PKG_VERSION"));
539
540 let manifest = root.join(".claude-plugin/plugin.json");
542 fs::write(&manifest, "{\"name\": \"evil\"}\n").unwrap();
543 let actions = write_plan(root, &plan, false).unwrap();
544 assert_eq!(
545 action_for(&actions, ".claude-plugin/plugin.json"),
546 FileAction::SkippedModified
547 );
548 }
549
550 #[cfg(unix)]
551 #[test]
552 fn test_hook_scripts_are_executable() {
553 use std::os::unix::fs::PermissionsExt;
554 let temp = TempDir::new().unwrap();
555 let root = temp.path();
556 plan_and_write(root, false);
557 let mode = fs::metadata(root.join(".claude/hooks/ctx/stop.sh"))
558 .unwrap()
559 .permissions()
560 .mode();
561 assert_eq!(mode & 0o111, 0o111, "mode: {:o}", mode);
562 }
563
564 #[test]
565 fn test_starter_rules_toml_parses_and_constrains_nothing() {
566 let temp = TempDir::new().unwrap();
567 let plan = plan_local(temp.path());
568 let rules = plan.iter().find(|f| f.rel_path == RULES_PATH).unwrap();
569 let parsed: crate::rules::RulesFile = toml::from_str(&rules.content).unwrap();
570 assert_eq!(parsed.version, 1);
571 assert!(parsed.layers.is_empty());
572 assert!(parsed.rules.forbidden.is_empty());
573 assert!(parsed.rules.allowed_dependents.is_empty());
574 assert!(parsed.rules.limit.is_empty());
575 assert!(parsed.rules.no_new_dependents.is_empty());
576 }
577}