Skip to main content

pushkin_core/
legacy.rs

1//! Legacy (pre-rename) Sentinel naming — the ONE place old names live
2//! (remediation pass 3, PART B2). The 2026-08-14 naming directive retired
3//! the sentinel names; every remaining old-name string in the codebase is
4//! a constant here, kept solely to RECOGNIZE or REMOVE artifacts a
5//! sentinel-era binary left behind. The naming sweep's exclusion list is
6//! auditable against this module: any `sentinel` outside it (and the
7//! append-only history) is a straggler.
8
9use std::borrow::Cow;
10use std::path::{Path, PathBuf};
11use thiserror::Error;
12
13/// Marker key a sentinel-era install wrote into agent hook configs.
14/// Any entry carrying it — whatever the value — is OURS.
15pub const MARKER_KEY: &str = "_sentinel";
16
17/// Repo-local state dir a sentinel-era binary owned (events, consent,
18/// instructions, board, daemon socket).
19pub const STATE_DIR: &str = ".sentinel";
20
21/// Git-tracked waiver dir a sentinel-era binary owned. Only treated as
22/// ours when it actually carries the waiver record below.
23pub const WAIVER_DIR: &str = "sentinel";
24
25/// The waiver record inside [`WAIVER_DIR`].
26pub const WAIVERS_FILE: &str = "sentinel/waivers.toml";
27
28/// Rule-id prefix of events and waivers recorded pre-rename. Historical
29/// rows keep it (the log is append-only); aggregation and waiver matching
30/// normalize through [`modern_rule_id`].
31pub const RULE_PREFIX: &str = "sentinel.";
32
33/// The prefix new events carry.
34const MODERN_RULE_PREFIX: &str = "pushkin.";
35
36/// Adapter artifacts a sentinel-era install wrote; `--remove-agent`
37/// strips these alongside their pushkin-named successors.
38pub const CODEX_RULES: &str = ".codex/rules/sentinel.rules";
39pub const AUGGIE_SCRIPT: &str = ".augment/hooks/sentinel.sh";
40pub const OPENCODE_PLUGIN: &str = ".opencode/plugin/sentinel.ts";
41pub const HERMES_HOOK: &str = ".hermes/hooks/sentinel.json";
42
43/// Hermes plugin dir name a sentinel-era install created under
44/// `$HERMES_HOME/plugins/`.
45pub const HERMES_PLUGIN_DIR: &str = "sentinel-gate";
46
47/// The manifest-less Phase 2 draft location (predates even the plugin
48/// manifest), cleaned up best-effort on every hermes install.
49pub const HERMES_DRAFT_DIR: &str = "sentinel_gate";
50
51/// Maps a legacy `sentinel.*` rule id to its `pushkin.*` spelling;
52/// anything else passes through. Both sides of a comparison go through
53/// this so mixed-population aggregation groups one rule, not two.
54#[must_use]
55pub fn modern_rule_id(rule: &str) -> Cow<'_, str> {
56    match rule.strip_prefix(RULE_PREFIX) {
57        Some(tail) => Cow::Owned(format!("{MODERN_RULE_PREFIX}{tail}")),
58        None => Cow::Borrowed(rule),
59    }
60}
61
62/// The legacy spelling of a modern rule id (for SQL `IN` filters over the
63/// mixed population); non-`pushkin.*` ids pass through unchanged.
64#[must_use]
65pub fn legacy_rule_id(rule: &str) -> Cow<'_, str> {
66    match rule.strip_prefix(MODERN_RULE_PREFIX) {
67        Some(tail) => Cow::Owned(format!("{RULE_PREFIX}{tail}")),
68        None => Cow::Borrowed(rule),
69    }
70}
71
72#[derive(Debug, Error)]
73pub enum MigrationError {
74    #[error(
75        "both {old} and {new} exist — refusing to guess which is current. \
76         A human must reconcile them (keep one, remove or merge the other), \
77         then re-run."
78    )]
79    BothExist { old: String, new: String },
80    #[error("cannot rename {old} to {new}: {source}")]
81    Rename {
82        old: String,
83        new: String,
84        source: std::io::Error,
85    },
86}
87
88/// One directory rename performed by [`migrate_dirs`].
89#[derive(Debug)]
90pub struct MigratedDir {
91    pub old: PathBuf,
92    pub new: PathBuf,
93}
94
95/// One-time footprint migration: `.sentinel/` → `.pushkin/` and
96/// `sentinel/` → `pushkin/` (the waiver dir, recognized by its
97/// `waivers.toml` — signed history survives the move). Old and new both
98/// present is a loud refusal, never a guess. Idempotent: with no legacy
99/// dirs this is a no-op.
100///
101/// # Errors
102/// Returns [`MigrationError`] when a pair coexists or a rename fails.
103pub fn migrate_dirs(root: &Path) -> Result<Vec<MigratedDir>, MigrationError> {
104    let mut renamed = Vec::new();
105    let state_pair = (root.join(STATE_DIR), root.join(".pushkin"));
106    if state_pair.0.is_dir() {
107        rename_pair(&state_pair.0, &state_pair.1, &mut renamed)?;
108    }
109    let waiver_pair = (root.join(WAIVER_DIR), root.join("pushkin"));
110    if root.join(WAIVERS_FILE).is_file() {
111        rename_pair(&waiver_pair.0, &waiver_pair.1, &mut renamed)?;
112    }
113    Ok(renamed)
114}
115
116fn rename_pair(
117    old: &Path,
118    new: &Path,
119    renamed: &mut Vec<MigratedDir>,
120) -> Result<(), MigrationError> {
121    if new.exists() {
122        // Trailing slashes: these are directories, and the refusal must
123        // name them unambiguously.
124        return Err(MigrationError::BothExist {
125            old: format!("{}/", old.display()),
126            new: format!("{}/", new.display()),
127        });
128    }
129    std::fs::rename(old, new).map_err(|source| MigrationError::Rename {
130        old: old.display().to_string(),
131        new: new.display().to_string(),
132        source,
133    })?;
134    renamed.push(MigratedDir {
135        old: old.to_path_buf(),
136        new: new.to_path_buf(),
137    });
138    Ok(())
139}