Skip to main content

code_system_graph_hooks/
install.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value, json};
7
8use crate::managed_root::{MAX_HOST_FILE_BYTES, ManagedRoot};
9use crate::templates::{
10    ANTIGRAVITY_LIMITATION, CURSOR_LIMITATION, CURSOR_RULE, GEMINI_HOOK_DESCRIPTION, HOOK_STATUS_MESSAGE, STATIC_ROUTING_CODEGRAPH, STATIC_ROUTING_NATIVE, STATIC_RULE, STRICT_GATE
11};
12use crate::types::{
13    HookError, HookMode, HookStatus, HostKind, InstallReport, InstallRequest, UninstallReport
14};
15
16const PRODUCT_MARKER: &str = "code-system-graph-hooks:v1";
17const STATE_DIRECTORY: &str = ".code-system-graph/hooks";
18const GENERATED_STATE_IGNORE_RULE: &[u8] = b".code-system-graph/";
19
20#[derive(Debug, Clone, Copy)]
21enum HostProtocol {
22    Json { event: &'static str },
23    Guidance,
24}
25
26#[derive(Debug)]
27struct HostSpec {
28    path: PathBuf,
29    protocol: HostProtocol,
30    limitation: Option<&'static str>,
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34struct InstallState {
35    marker: String,
36    host: HostKind,
37    mode: HookMode,
38    host_file: PathBuf,
39    #[serde(default)]
40    codegraph_enabled: bool,
41}
42
43/// Installs or updates one host integration with atomic, marker-scoped writes.
44///
45/// Existing host configuration is merged rather than replaced. A timestamped backup is created
46/// before each changed existing file. Repeating an identical installation is a no-op.
47///
48/// # Errors
49///
50/// Returns [`HookError`] for invalid roots or host configuration and for failed filesystem,
51/// serialization, backup, or atomic-write operations.
52pub fn install(request: &InstallRequest) -> Result<InstallReport, HookError> {
53    validate_request(request)?;
54    let managed = ManagedRoot::open(&request.root)?;
55    let spec = host_spec(request);
56    let state_relative = state_relative_path(request);
57    let mut backups = Vec::new();
58    let mut warnings = duplicate_warnings(&managed, request, &spec)?;
59    let runtime = request.code_system_graph_binary.with_file_name(format!(
60        "code-system-graph-hooks{}",
61        std::env::consts::EXE_SUFFIX
62    ));
63
64    let routing_changed = match spec.protocol {
65        HostProtocol::Json { event } => {
66            install_json_hook(&managed, request, &spec.path, event, &runtime, &mut backups)?
67        }
68        HostProtocol::Guidance => install_guidance(&managed, request, &spec.path, &mut backups)?,
69    };
70    let strict_changed = if request.mode == HookMode::Strict {
71        install_strict_gate(&managed, request, &mut backups, &mut warnings)?
72    } else {
73        remove_strict_gate(&managed, request, &mut backups, &mut Vec::new())?
74    };
75    let state = InstallState {
76        marker: PRODUCT_MARKER.to_owned(),
77        host: request.host,
78        mode: request.mode,
79        host_file: managed.absolute(&spec.path),
80        codegraph_enabled: request.codegraph_enabled,
81    };
82    let state_changed =
83        write_json_if_changed(&managed, &state_relative, &state, false, &mut backups)?;
84    restrict_file(&managed.absolute(&state_relative))?;
85    let (gitignore_path, gitignore_updated) = configure_generated_state_ignore(&managed)?;
86
87    Ok(InstallReport {
88        changed: routing_changed || strict_changed || state_changed || gitignore_updated,
89        host_file: managed.absolute(&spec.path),
90        state_file: managed.absolute(&state_relative),
91        gitignore_path,
92        gitignore_updated,
93        backups,
94        warnings,
95        limitation: spec.limitation.map(str::to_owned),
96    })
97}
98
99/// Inspects marker-owned host and strict-gate content without changing files.
100///
101/// # Errors
102///
103/// Returns [`HookError`] when existing host configuration cannot be read or parsed.
104pub fn status(request: &InstallRequest) -> Result<HookStatus, HookError> {
105    validate_request(request)?;
106    let managed = ManagedRoot::open(&request.root)?;
107    let spec = host_spec(request);
108    let state_relative = state_relative_path(request);
109    let installed_state = read_install_state(&managed, &state_relative)?;
110    let policy = installed_state
111        .as_ref()
112        .map_or(request.codegraph_enabled, |state| state.codegraph_enabled);
113    let runtime = request.code_system_graph_binary.with_file_name(format!(
114        "code-system-graph-hooks{}",
115        std::env::consts::EXE_SUFFIX
116    ));
117    let routing_installed = match spec.protocol {
118        HostProtocol::Json { event } => {
119            json_hook_installed(&managed, request, &spec.path, event, &runtime, policy)?
120        }
121        HostProtocol::Guidance => {
122            file_contains(&managed, &spec.path, &guidance_block(request.host, policy))?
123        }
124    };
125    let strict_gate_installed = if request.mode == HookMode::Strict {
126        file_contains(
127            &managed,
128            &git_pre_commit(&managed)?,
129            &begin_marker(request.host),
130        )?
131    } else if let Some(path) = optional_git_pre_commit(&managed)? {
132        file_contains(&managed, &path, &begin_marker(request.host))?
133    } else {
134        false
135    };
136    let policy_matches = installed_state
137        .as_ref()
138        .is_none_or(|state| state.codegraph_enabled == request.codegraph_enabled);
139    let installed = routing_installed
140        && (request.mode == HookMode::Advisory || strict_gate_installed)
141        && installed_state.is_some()
142        && policy_matches;
143    let warnings = duplicate_warnings(&managed, request, &spec)?;
144
145    Ok(HookStatus {
146        installed,
147        routing_installed,
148        strict_gate_installed,
149        host_file: managed.absolute(&spec.path),
150        state_file: managed.absolute(&state_relative),
151        warnings,
152        limitation: spec.limitation.map(str::to_owned),
153    })
154}
155
156/// Removes only marker-owned content and merges around unrelated host configuration.
157///
158/// Existing files are backed up immediately before a changed merge. Backups are not blindly
159/// restored because doing so could discard edits made after installation.
160///
161/// # Errors
162///
163/// Returns [`HookError`] when host configuration cannot be parsed or a backup, merge, deletion,
164/// permission change, or atomic write fails.
165pub fn uninstall(request: &InstallRequest) -> Result<UninstallReport, HookError> {
166    validate_request(request)?;
167    let managed = ManagedRoot::open(&request.root)?;
168    let spec = host_spec(request);
169    let state_relative = state_relative_path(request);
170    let mut backups = Vec::new();
171    let mut removed_files = Vec::new();
172    let mut warnings = Vec::new();
173
174    let routing_changed = match spec.protocol {
175        HostProtocol::Json { event } => {
176            uninstall_json_hook(&managed, &spec.path, event, &mut backups)?
177        }
178        HostProtocol::Guidance => remove_guidance(
179            &managed,
180            request,
181            &spec.path,
182            &mut backups,
183            &mut removed_files,
184        )?,
185    };
186    let strict_changed = remove_strict_gate(&managed, request, &mut backups, &mut removed_files)?;
187    let state_changed = if managed.remove_file_if_exists(&state_relative)? {
188        removed_files.push(managed.absolute(&state_relative));
189        true
190    } else {
191        false
192    };
193
194    warnings.extend(duplicate_warnings(&managed, request, &spec)?);
195    Ok(UninstallReport {
196        changed: routing_changed || strict_changed || state_changed,
197        backups,
198        removed_files,
199        warnings,
200    })
201}
202
203fn validate_request(request: &InstallRequest) -> Result<(), HookError> {
204    if request.workspace.trim().is_empty() {
205        return Err(HookError::InvalidConfiguration {
206            path: request.root.clone(),
207            message: "workspace name must not be empty".to_owned(),
208        });
209    }
210    if request.repository.trim().is_empty() {
211        return Err(HookError::InvalidConfiguration {
212            path: request.root.clone(),
213            message: "repository alias must not be empty".to_owned(),
214        });
215    }
216    Ok(())
217}
218
219fn host_spec(request: &InstallRequest) -> HostSpec {
220    let (relative, protocol, limitation) = match request.host {
221        HostKind::ClaudeCode => (
222            ".claude/settings.local.json",
223            HostProtocol::Json {
224                event: "UserPromptSubmit",
225            },
226            None,
227        ),
228        HostKind::Codex => (
229            ".codex/hooks.json",
230            HostProtocol::Json {
231                event: "UserPromptSubmit",
232            },
233            None,
234        ),
235        HostKind::Gemini => (
236            ".gemini/settings.json",
237            HostProtocol::Json {
238                event: "BeforeAgent",
239            },
240            None,
241        ),
242        HostKind::Antigravity => (
243            ".agents/rules/code-system-graph-routing.md",
244            HostProtocol::Guidance,
245            Some(ANTIGRAVITY_LIMITATION.trim_end()),
246        ),
247        HostKind::Cursor => (
248            ".cursor/rules/code-system-graph-routing.mdc",
249            HostProtocol::Guidance,
250            Some(CURSOR_LIMITATION.trim_end()),
251        ),
252    };
253    HostSpec {
254        path: PathBuf::from(relative),
255        protocol,
256        limitation,
257    }
258}
259
260fn state_relative_path(request: &InstallRequest) -> PathBuf {
261    PathBuf::from(STATE_DIRECTORY).join(format!("install-{}.json", request.host.as_str()))
262}
263
264fn read_install_state(
265    managed: &ManagedRoot,
266    relative: &Path,
267) -> Result<Option<InstallState>, HookError> {
268    let Some(content) = managed.read_optional_utf8_bounded(relative, MAX_HOST_FILE_BYTES)? else {
269        return Ok(None);
270    };
271    let state =
272        serde_json::from_str(&content).map_err(|error| HookError::InvalidConfiguration {
273            path: managed.absolute(relative),
274            message: error.to_string(),
275        })?;
276    Ok(Some(state))
277}
278
279fn configure_generated_state_ignore(
280    managed: &ManagedRoot,
281) -> Result<(Option<PathBuf>, bool), HookError> {
282    let canonical_root = managed.root();
283    if !belongs_to_git_worktree(canonical_root) {
284        return Ok((None, false));
285    }
286    let (relative, updated) = ensure_generated_state_ignored(managed)?;
287    Ok((Some(managed.absolute(&relative)), updated))
288}
289
290fn belongs_to_git_worktree(root: &Path) -> bool {
291    root.ancestors()
292        .any(|ancestor| valid_git_worktree_marker(&ancestor.join(".git")))
293}
294
295fn valid_git_worktree_marker(marker: &Path) -> bool {
296    let Ok(metadata) = fs::symlink_metadata(marker) else {
297        return false;
298    };
299    if metadata.file_type().is_dir() {
300        return fs::symlink_metadata(marker.join("HEAD"))
301            .is_ok_and(|head| head.file_type().is_file());
302    }
303    if !metadata.file_type().is_file() || metadata.len() > 4_096 {
304        return false;
305    }
306    let Ok(source) = fs::read_to_string(marker) else {
307        return false;
308    };
309    source
310        .lines()
311        .next()
312        .is_some_and(|line| line.trim_start().starts_with("gitdir:"))
313}
314
315fn ensure_generated_state_ignored(managed: &ManagedRoot) -> Result<(PathBuf, bool), HookError> {
316    let relative = PathBuf::from(".gitignore");
317    let content = managed
318        .read_optional_bytes_bounded(&relative, MAX_HOST_FILE_BYTES)?
319        .unwrap_or_default();
320    if generated_state_is_ignored(&content) {
321        return Ok((relative, false));
322    }
323    let mut updated = content;
324    if !updated.is_empty() && !updated.ends_with(b"\n") {
325        updated.push(b'\n');
326    }
327    updated.extend_from_slice(GENERATED_STATE_IGNORE_RULE);
328    updated.push(b'\n');
329    managed.atomic_write(&relative, &updated)?;
330    Ok((relative, true))
331}
332
333fn generated_state_is_ignored(content: &[u8]) -> bool {
334    content
335        .split(|byte| *byte == b'\n')
336        .map(|line| line.strip_suffix(b"\r").map_or(line, |trimmed| trimmed))
337        .fold(None, |state, line| match line {
338            b".code-system-graph/"
339            | b"/.code-system-graph/"
340            | b".code-system-graph"
341            | b"/.code-system-graph" => Some(true),
342            b"!.code-system-graph/"
343            | b"!/.code-system-graph/"
344            | b"!.code-system-graph"
345            | b"!/.code-system-graph" => Some(false),
346            _ => state,
347        })
348        .unwrap_or(false)
349}
350
351fn install_json_hook(
352    managed: &ManagedRoot,
353    request: &InstallRequest,
354    relative: &Path,
355    event: &str,
356    runtime: &Path,
357    backups: &mut Vec<PathBuf>,
358) -> Result<bool, HookError> {
359    let (mut root, existed) = read_json_object(managed, relative)?;
360    let hooks = object_field_mut(&mut root, "hooks", managed, relative)?;
361    let entries = array_field_mut(hooks, event, managed, relative)?;
362    let owned = owned_json_entry(request, runtime);
363    if entries.iter().any(is_owned_json) {
364        if entries.iter().any(|entry| entry == &owned) {
365            return Ok(false);
366        }
367        entries.retain(|entry| !is_owned_json(entry));
368    }
369    entries.push(owned);
370    write_value(managed, relative, &root, existed, backups)
371}
372
373fn owned_json_entry(request: &InstallRequest, runtime: &Path) -> Value {
374    owned_json_entry_with_policy(request, runtime, request.codegraph_enabled)
375}
376
377fn owned_json_entry_with_policy(
378    request: &InstallRequest,
379    runtime: &Path,
380    codegraph_enabled: bool,
381) -> Value {
382    let command = format!(
383        "{} route --host {} --root {} --codegraph-enabled {} --marker {}",
384        shell_quote(runtime.as_os_str().to_string_lossy().as_ref()),
385        request.host.as_str(),
386        shell_quote(request.root.as_os_str().to_string_lossy().as_ref()),
387        codegraph_enabled,
388        PRODUCT_MARKER
389    );
390    match request.host {
391        HostKind::ClaudeCode | HostKind::Codex => json!({
392            "hooks": [{
393                "type": "command",
394                "command": command,
395                "timeout": 5,
396                "statusMessage": HOOK_STATUS_MESSAGE.trim_end()
397            }]
398        }),
399        HostKind::Gemini => json!({
400            "matcher": "*",
401            "hooks": [{
402                "name": PRODUCT_MARKER,
403                "type": "command",
404                "command": command,
405                "timeout": 5000,
406                "description": GEMINI_HOOK_DESCRIPTION.trim_end()
407            }]
408        }),
409        HostKind::Antigravity | HostKind::Cursor => Value::Null,
410    }
411}
412
413fn uninstall_json_hook(
414    managed: &ManagedRoot,
415    relative: &Path,
416    event: &str,
417    backups: &mut Vec<PathBuf>,
418) -> Result<bool, HookError> {
419    if !managed.regular_file_exists(relative)? {
420        return Ok(false);
421    }
422    let (mut root, _) = read_json_object(managed, relative)?;
423    let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
424        return Ok(false);
425    };
426    let Some(entries) = hooks.get_mut(event).and_then(Value::as_array_mut) else {
427        return Ok(false);
428    };
429    let original_len = entries.len();
430    entries.retain(|entry| !is_owned_json(entry));
431    if entries.len() == original_len {
432        return Ok(false);
433    }
434    if entries.is_empty() {
435        hooks.remove(event);
436    }
437    if hooks.is_empty() {
438        root.as_object_mut().map(|object| object.remove("hooks"));
439    }
440    write_value(managed, relative, &root, true, backups)
441}
442
443fn json_hook_installed(
444    managed: &ManagedRoot,
445    request: &InstallRequest,
446    relative: &Path,
447    event: &str,
448    runtime: &Path,
449    policy: bool,
450) -> Result<bool, HookError> {
451    if !managed.regular_file_exists(relative)? {
452        return Ok(false);
453    }
454    let owned = owned_json_entry_with_policy(request, runtime, policy);
455    let (root, _) = read_json_object(managed, relative)?;
456    Ok(root
457        .get("hooks")
458        .and_then(|hooks| hooks.get(event))
459        .and_then(Value::as_array)
460        .is_some_and(|entries| entries.iter().any(|entry| entry == &owned)))
461}
462
463fn install_guidance(
464    managed: &ManagedRoot,
465    request: &InstallRequest,
466    relative: &Path,
467    backups: &mut Vec<PathBuf>,
468) -> Result<bool, HookError> {
469    let existing = read_optional_string(managed, relative)?;
470    let marker = begin_marker(request.host);
471    let block = guidance_block(request.host, request.codegraph_enabled);
472    if existing
473        .as_deref()
474        .is_some_and(|content| content.contains(&block))
475    {
476        return Ok(false);
477    }
478    let existing = match existing {
479        Some(content) if content.contains(&marker) => {
480            let without_owned = remove_marked_block(&content, request.host).ok_or_else(|| {
481                HookError::InvalidConfiguration {
482                    path: managed.absolute(relative),
483                    message: "managed guidance has an incomplete marker block".to_owned(),
484                }
485            })?;
486            (!without_owned.trim().is_empty()).then_some(without_owned)
487        }
488        other => other,
489    };
490    let updated = match existing {
491        Some(mut content) => {
492            if !content.ends_with('\n') {
493                content.push('\n');
494            }
495            content.push('\n');
496            content.push_str(&block);
497            content
498        }
499        None => guidance_scaffold(request.host, &block),
500    };
501    write_string(
502        managed,
503        relative,
504        &updated,
505        managed.regular_file_exists(relative)?,
506        backups,
507    )
508}
509
510fn guidance_scaffold(host: HostKind, block: &str) -> String {
511    if host == HostKind::Cursor {
512        render_embedded_template(
513            CURSOR_RULE,
514            &[("PRODUCT_MARKER", PRODUCT_MARKER), ("BLOCK", block)],
515        )
516    } else {
517        block.to_owned()
518    }
519}
520
521fn guidance_block(host: HostKind, codegraph_enabled: bool) -> String {
522    let routing = if codegraph_enabled {
523        STATIC_ROUTING_CODEGRAPH.trim_end()
524    } else {
525        STATIC_ROUTING_NATIVE.trim_end()
526    };
527    render_embedded_template(
528        STATIC_RULE,
529        &[
530            ("BEGIN_MARKER", &begin_marker(host)),
531            ("ROUTING", routing),
532            ("END_MARKER", &end_marker(host)),
533        ],
534    )
535}
536
537fn remove_guidance(
538    managed: &ManagedRoot,
539    request: &InstallRequest,
540    relative: &Path,
541    backups: &mut Vec<PathBuf>,
542    removed_files: &mut Vec<PathBuf>,
543) -> Result<bool, HookError> {
544    let Some(content) = read_optional_string(managed, relative)? else {
545        return Ok(false);
546    };
547    let Some(updated) = remove_marked_block(&content, request.host) else {
548        return Ok(false);
549    };
550    let generated_cursor_scaffold = request.host == HostKind::Cursor
551        && updated.trim() == guidance_scaffold(HostKind::Cursor, "").trim();
552    backup(managed, relative, backups)?;
553    if updated.trim().is_empty() || generated_cursor_scaffold {
554        managed.remove_file_if_exists(relative)?;
555        removed_files.push(managed.absolute(relative));
556    } else {
557        managed.atomic_write(relative, updated.as_bytes())?;
558    }
559    Ok(true)
560}
561
562fn install_strict_gate(
563    managed: &ManagedRoot,
564    request: &InstallRequest,
565    backups: &mut Vec<PathBuf>,
566    warnings: &mut Vec<String>,
567) -> Result<bool, HookError> {
568    let relative = git_pre_commit(managed)?;
569    let existing = read_optional_string(managed, &relative)?;
570    let marker = begin_marker(request.host);
571    if existing
572        .as_deref()
573        .is_some_and(|content| content.contains(&marker))
574    {
575        return Ok(false);
576    }
577    if existing.as_deref().is_some_and(contains_product_reference) {
578        warnings.push(format!(
579            "another Code System Graph pre-commit hook exists in `{}`; it was preserved",
580            managed.absolute(&relative).display()
581        ));
582    }
583    let block = strict_gate_block(request);
584    let updated = match existing {
585        Some(mut content) => {
586            if !content.ends_with('\n') {
587                content.push('\n');
588            }
589            content.push('\n');
590            content.push_str(&block);
591            content
592        }
593        None => format!("#!/bin/sh\n\n{block}"),
594    };
595    let changed = write_string(
596        managed,
597        &relative,
598        &updated,
599        managed.regular_file_exists(&relative)?,
600        backups,
601    )?;
602    if changed {
603        make_executable(&managed.absolute(&relative))?;
604    }
605    Ok(changed)
606}
607
608fn strict_gate_block(request: &InstallRequest) -> String {
609    let binary = shell_quote(
610        request
611            .code_system_graph_binary
612            .as_os_str()
613            .to_string_lossy()
614            .as_ref(),
615    );
616    let database = shell_quote(request.database.as_os_str().to_string_lossy().as_ref());
617    let workspace = shell_quote(&request.workspace);
618    let repository = shell_quote(&request.repository);
619    render_embedded_template(
620        STRICT_GATE,
621        &[
622            ("BEGIN_MARKER", &begin_marker(request.host)),
623            ("BINARY", &binary),
624            ("DATABASE", &database),
625            ("WORKSPACE", &workspace),
626            ("REPOSITORY", &repository),
627            ("END_MARKER", &end_marker(request.host)),
628        ],
629    )
630}
631
632fn render_embedded_template(template: &str, variables: &[(&str, &str)]) -> String {
633    variables
634        .iter()
635        .fold(template.to_owned(), |rendered, (name, value)| {
636            rendered.replace(&format!("{{{{{name}}}}}"), value)
637        })
638}
639
640fn remove_strict_gate(
641    managed: &ManagedRoot,
642    request: &InstallRequest,
643    backups: &mut Vec<PathBuf>,
644    removed_files: &mut Vec<PathBuf>,
645) -> Result<bool, HookError> {
646    let Some(relative) = optional_git_pre_commit(managed)? else {
647        return Ok(false);
648    };
649    let Some(content) = read_optional_string(managed, &relative)? else {
650        return Ok(false);
651    };
652    let Some(updated) = remove_marked_block(&content, request.host) else {
653        return Ok(false);
654    };
655    backup(managed, &relative, backups)?;
656    if updated.trim() == "#!/bin/sh" || updated.trim().is_empty() {
657        managed.remove_file_if_exists(&relative)?;
658        removed_files.push(managed.absolute(&relative));
659    } else {
660        managed.atomic_write(&relative, updated.as_bytes())?;
661        make_executable(&managed.absolute(&relative))?;
662    }
663    Ok(true)
664}
665
666fn git_pre_commit(managed: &ManagedRoot) -> Result<PathBuf, HookError> {
667    optional_git_pre_commit(managed)?.ok_or_else(|| HookError::InvalidConfiguration {
668        path: managed.root().to_path_buf(),
669        message: "strict mode requires a Git repository worktree".to_owned(),
670    })
671}
672
673fn optional_git_pre_commit(managed: &ManagedRoot) -> Result<Option<PathBuf>, HookError> {
674    let dot_git = Path::new(".git");
675    if managed.is_directory(dot_git)? {
676        return Ok(Some(PathBuf::from(".git/hooks/pre-commit")));
677    }
678    if !managed.entry_exists(dot_git)? {
679        return Ok(None);
680    }
681    let content = managed.read_utf8_bounded(dot_git, 4_096)?;
682    let relative = content
683        .trim()
684        .strip_prefix("gitdir:")
685        .map(str::trim)
686        .ok_or_else(|| HookError::InvalidConfiguration {
687            path: managed.absolute(dot_git),
688            message: "expected a Git directory or `gitdir:` pointer".to_owned(),
689        })?;
690    let git_dir = Path::new(relative);
691    let git_dir = if git_dir.is_absolute() {
692        let canonical = fs::canonicalize(git_dir).map_err(|source| HookError::Io {
693            path: git_dir.to_path_buf(),
694            source,
695        })?;
696        if !canonical.starts_with(managed.root()) {
697            return Err(HookError::InvalidConfiguration {
698                path: canonical,
699                message: "Git metadata escapes the authorized repository root".to_owned(),
700            });
701        }
702        match canonical.strip_prefix(managed.root()) {
703            Ok(path) => path.to_path_buf(),
704            Err(_) => {
705                return Err(HookError::InvalidConfiguration {
706                    path: canonical,
707                    message: "Git metadata escapes the authorized repository root".to_owned(),
708                });
709            }
710        }
711    } else {
712        git_dir.to_path_buf()
713    };
714    Ok(Some(git_dir.join("hooks/pre-commit")))
715}
716
717fn duplicate_warnings(
718    managed: &ManagedRoot,
719    request: &InstallRequest,
720    spec: &HostSpec,
721) -> Result<Vec<String>, HookError> {
722    let mut warnings = Vec::new();
723    match spec.protocol {
724        HostProtocol::Json { event } if managed.regular_file_exists(&spec.path)? => {
725            let (root, _) = read_json_object(managed, &spec.path)?;
726            if root
727                .get("hooks")
728                .and_then(|hooks| hooks.get(event))
729                .and_then(Value::as_array)
730                .is_some_and(|entries| {
731                    entries
732                        .iter()
733                        .any(|entry| !is_owned_json(entry) && json_mentions_product(entry))
734                })
735            {
736                warnings.push(format!(
737                    "another Code System Graph hook exists in `{}` and was preserved",
738                    managed.absolute(&spec.path).display()
739                ));
740            }
741        }
742        HostProtocol::Guidance if managed.regular_file_exists(&spec.path)? => {
743            if let Some(content) = read_optional_string(managed, &spec.path)?
744                && !content.contains(&begin_marker(request.host))
745                && contains_product_reference(&content)
746            {
747                warnings.push(format!(
748                    "another Code System Graph guidance file exists in `{}` and was preserved",
749                    managed.absolute(&spec.path).display()
750                ));
751            }
752        }
753        HostProtocol::Json { .. } | HostProtocol::Guidance => {}
754    }
755    Ok(warnings)
756}
757
758fn read_json_object(managed: &ManagedRoot, relative: &Path) -> Result<(Value, bool), HookError> {
759    if !managed.regular_file_exists(relative)? {
760        return Ok((Value::Object(Map::new()), false));
761    }
762    let content = managed.read_utf8_bounded(relative, MAX_HOST_FILE_BYTES)?;
763    let value: Value =
764        serde_json::from_str(&content).map_err(|source| HookError::InvalidConfiguration {
765            path: managed.absolute(relative),
766            message: source.to_string(),
767        })?;
768    if !value.is_object() {
769        return Err(HookError::InvalidConfiguration {
770            path: managed.absolute(relative),
771            message: "top-level JSON value must be an object".to_owned(),
772        });
773    }
774    Ok((value, true))
775}
776
777fn object_field_mut<'a>(
778    root: &'a mut Value,
779    key: &str,
780    managed: &ManagedRoot,
781    relative: &Path,
782) -> Result<&'a mut Map<String, Value>, HookError> {
783    let object = root
784        .as_object_mut()
785        .ok_or_else(|| HookError::InvalidConfiguration {
786            path: managed.absolute(relative),
787            message: "top-level JSON value must be an object".to_owned(),
788        })?;
789    let value = object
790        .entry(key.to_owned())
791        .or_insert_with(|| Value::Object(Map::new()));
792    value
793        .as_object_mut()
794        .ok_or_else(|| HookError::InvalidConfiguration {
795            path: managed.absolute(relative),
796            message: format!("`{key}` must be an object"),
797        })
798}
799
800fn array_field_mut<'a>(
801    object: &'a mut Map<String, Value>,
802    key: &str,
803    managed: &ManagedRoot,
804    relative: &Path,
805) -> Result<&'a mut Vec<Value>, HookError> {
806    let value = object
807        .entry(key.to_owned())
808        .or_insert_with(|| Value::Array(Vec::new()));
809    value
810        .as_array_mut()
811        .ok_or_else(|| HookError::InvalidConfiguration {
812            path: managed.absolute(relative),
813            message: format!("hook event `{key}` must be an array"),
814        })
815}
816
817fn is_owned_json(value: &Value) -> bool {
818    json_strings(value).any(|text| text.contains(PRODUCT_MARKER))
819}
820
821fn json_mentions_product(value: &Value) -> bool {
822    json_strings(value).any(contains_product_reference)
823}
824
825fn json_strings(value: &Value) -> Box<dyn Iterator<Item = &str> + '_> {
826    match value {
827        Value::String(text) => Box::new(std::iter::once(text.as_str())),
828        Value::Array(values) => Box::new(values.iter().flat_map(json_strings)),
829        Value::Object(values) => Box::new(values.values().flat_map(json_strings)),
830        Value::Null | Value::Bool(_) | Value::Number(_) => Box::new(std::iter::empty()),
831    }
832}
833
834fn contains_product_reference(value: &str) -> bool {
835    let value = value.to_ascii_lowercase();
836    value.contains("code-system-graph") || value.contains("code system graph")
837}
838
839fn begin_marker(host: HostKind) -> String {
840    format!("# BEGIN {PRODUCT_MARKER}:{}", host.as_str())
841}
842
843fn end_marker(host: HostKind) -> String {
844    format!("# END {PRODUCT_MARKER}:{}", host.as_str())
845}
846
847fn remove_marked_block(content: &str, host: HostKind) -> Option<String> {
848    let begin = begin_marker(host);
849    let end = end_marker(host);
850    let start = content.find(&begin)?;
851    let relative_end = content[start..].find(&end)?;
852    let mut finish = start + relative_end + end.len();
853    if content.as_bytes().get(finish) == Some(&b'\n') {
854        finish += 1;
855    }
856    let mut updated = String::with_capacity(content.len() - (finish - start));
857    updated.push_str(&content[..start]);
858    updated.push_str(&content[finish..]);
859    Some(updated.trim_end().to_owned() + "\n")
860}
861
862fn file_contains(managed: &ManagedRoot, relative: &Path, needle: &str) -> Result<bool, HookError> {
863    Ok(read_optional_string(managed, relative)?
864        .as_deref()
865        .is_some_and(|content| content.contains(needle)))
866}
867
868fn read_optional_string(
869    managed: &ManagedRoot,
870    relative: &Path,
871) -> Result<Option<String>, HookError> {
872    managed.read_optional_utf8_bounded(relative, MAX_HOST_FILE_BYTES)
873}
874
875fn write_json_if_changed<T: Serialize>(
876    managed: &ManagedRoot,
877    relative: &Path,
878    value: &T,
879    backup_existing: bool,
880    backups: &mut Vec<PathBuf>,
881) -> Result<bool, HookError> {
882    let bytes = serde_json::to_vec_pretty(value)?;
883    write_bytes_if_changed(managed, relative, &bytes, backup_existing, backups)
884}
885
886fn write_value(
887    managed: &ManagedRoot,
888    relative: &Path,
889    value: &Value,
890    existed: bool,
891    backups: &mut Vec<PathBuf>,
892) -> Result<bool, HookError> {
893    let mut bytes = serde_json::to_vec_pretty(value)?;
894    bytes.push(b'\n');
895    write_bytes_if_changed(managed, relative, &bytes, existed, backups)
896}
897
898fn write_string(
899    managed: &ManagedRoot,
900    relative: &Path,
901    content: &str,
902    existed: bool,
903    backups: &mut Vec<PathBuf>,
904) -> Result<bool, HookError> {
905    write_bytes_if_changed(managed, relative, content.as_bytes(), existed, backups)
906}
907
908fn write_bytes_if_changed(
909    managed: &ManagedRoot,
910    relative: &Path,
911    bytes: &[u8],
912    backup_existing: bool,
913    backups: &mut Vec<PathBuf>,
914) -> Result<bool, HookError> {
915    if managed
916        .read_optional_utf8_bounded(relative, MAX_HOST_FILE_BYTES)?
917        .is_some_and(|existing| existing.as_bytes() == bytes)
918    {
919        return Ok(false);
920    }
921    if backup_existing && managed.regular_file_exists(relative)? {
922        backup(managed, relative, backups)?;
923    }
924    managed.atomic_write(relative, bytes)?;
925    Ok(true)
926}
927
928fn backup(
929    managed: &ManagedRoot,
930    relative: &Path,
931    backups: &mut Vec<PathBuf>,
932) -> Result<(), HookError> {
933    let stamp = SystemTime::now()
934        .duration_since(UNIX_EPOCH)
935        .map_err(|_| HookError::InvalidSystemTime)?;
936    let file_name = relative
937        .file_name()
938        .ok_or_else(|| HookError::InvalidConfiguration {
939            path: managed.absolute(relative),
940            message: "backup source has no file name".to_owned(),
941        })?
942        .to_string_lossy();
943    let backup_relative = relative.with_file_name(format!(
944        "{file_name}.bak.code-system-graph.{}-{}",
945        stamp.as_secs(),
946        stamp.subsec_nanos()
947    ));
948    let content = managed.read_utf8_bounded(relative, MAX_HOST_FILE_BYTES)?;
949    managed.atomic_write(&backup_relative, content.as_bytes())?;
950    backups.push(managed.absolute(&backup_relative));
951    Ok(())
952}
953
954fn shell_quote(value: &str) -> String {
955    format!("'{}'", value.replace('\'', "'\"'\"'"))
956}
957
958#[cfg(unix)]
959fn restrict_file(path: &Path) -> Result<(), HookError> {
960    use std::os::unix::fs::PermissionsExt;
961
962    fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| HookError::Io {
963        path: path.to_path_buf(),
964        source,
965    })
966}
967
968#[cfg(not(unix))]
969fn restrict_file(_path: &Path) -> Result<(), HookError> {
970    Ok(())
971}
972
973#[cfg(unix)]
974fn make_executable(path: &Path) -> Result<(), HookError> {
975    use std::os::unix::fs::PermissionsExt;
976
977    let mut permissions = fs::metadata(path)
978        .map_err(|source| HookError::Io {
979            path: path.to_path_buf(),
980            source,
981        })?
982        .permissions();
983    permissions.set_mode(permissions.mode() | 0o700);
984    fs::set_permissions(path, permissions).map_err(|source| HookError::Io {
985        path: path.to_path_buf(),
986        source,
987    })
988}
989
990#[cfg(not(unix))]
991fn make_executable(_path: &Path) -> Result<(), HookError> {
992    Ok(())
993}
994
995#[cfg(test)]
996mod tests {
997    use std::path::PathBuf;
998
999    use super::{guidance_block, guidance_scaffold, owned_json_entry, strict_gate_block};
1000    use crate::types::{HookMode, HostKind, InstallRequest};
1001
1002    fn request(host: HostKind, codegraph_enabled: bool) -> InstallRequest {
1003        InstallRequest {
1004            root: PathBuf::from("/workspace/api"),
1005            host,
1006            mode: HookMode::Advisory,
1007            code_system_graph_binary: PathBuf::from("/bin/csgraph"),
1008            database: PathBuf::from("/workspace/graph.db"),
1009            workspace: "commerce".to_owned(),
1010            repository: "api".to_owned(),
1011            codegraph_enabled,
1012        }
1013    }
1014
1015    #[test]
1016    fn generated_routing_should_follow_codegraph_policy() {
1017        let native = guidance_block(HostKind::Cursor, false);
1018        let enriched = guidance_block(HostKind::Cursor, true);
1019        assert!(!native.contains("explore"));
1020        assert!(enriched.contains("explore"));
1021        assert!(!native.contains("{{"));
1022        assert!(!enriched.contains("{{"));
1023        assert!(!guidance_scaffold(HostKind::Cursor, &native).contains("{{"));
1024        assert!(!strict_gate_block(&request(HostKind::Codex, false)).contains("{{"));
1025
1026        let runtime = PathBuf::from("/bin/code-system-graph-hooks");
1027        let native_hook = owned_json_entry(&request(HostKind::Codex, false), &runtime).to_string();
1028        let enriched_hook = owned_json_entry(&request(HostKind::Codex, true), &runtime).to_string();
1029        assert!(native_hook.contains("--codegraph-enabled false"));
1030        assert!(enriched_hook.contains("--codegraph-enabled true"));
1031    }
1032}