Skip to main content

runex_core/
integration_check.rs

1//! Health check for the user-side shell-integration scripts.
2//!
3//! ## Why this module exists
4//!
5//! Most shells (bash/zsh/pwsh/nu) source `runex export <shell>` at every
6//! shell start, so they always see the latest integration template — by
7//! construction, they cannot drift.
8//!
9//! **clink** is the exception: `runex init clink` *copies* the export
10//! output into a standalone lua file under clink's scripts directory.
11//! The user has to re-run `runex init clink` to refresh it. When the
12//! integration template changes (and it has — the hook migration
13//! rewrote it from the ground up), users with a stale `runex.lua`
14//! silently miss out on the new behavior.
15//!
16//! For bash/zsh/pwsh/nu we instead check that the rcfile *contains* the
17//! init marker — i.e. that integration was ever set up at all. That
18//! catches "user never ran `runex init`" but not drift, because drift
19//! can't happen.
20
21use std::path::{Path, PathBuf};
22
23use crate::init::{rc_file_for, RUNEX_INIT_MARKER};
24use crate::sanitize::sanitize_for_display;
25use crate::shell::Shell;
26
27/// Outcome of a single integration check, deliberately small so the
28/// caller can convert it into a `doctor::Check` without coupling this
29/// module to `doctor`.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum IntegrationCheck {
32    /// Integration is in place and (where checkable) up-to-date.
33    Ok { name: String, detail: String },
34    /// Integration is reachable but content has drifted from what
35    /// `runex export <shell>` would produce now (clink only).
36    Outdated {
37        name: String,
38        detail: String,
39        path: PathBuf,
40    },
41    /// Integration could not be located at any of the expected paths.
42    Missing { name: String, detail: String },
43    /// Check did not apply (e.g. user has no rcfile for this shell —
44    /// they probably don't use it).
45    Skipped { name: String, detail: String },
46}
47
48impl IntegrationCheck {
49    pub fn name(&self) -> &str {
50        match self {
51            IntegrationCheck::Ok { name, .. }
52            | IntegrationCheck::Outdated { name, .. }
53            | IntegrationCheck::Missing { name, .. }
54            | IntegrationCheck::Skipped { name, .. } => name,
55        }
56    }
57
58    pub fn detail(&self) -> &str {
59        match self {
60            IntegrationCheck::Ok { detail, .. }
61            | IntegrationCheck::Outdated { detail, .. }
62            | IntegrationCheck::Missing { detail, .. }
63            | IntegrationCheck::Skipped { detail, .. } => detail,
64        }
65    }
66}
67
68/// Compare the user's clink integration script against `current_export`
69/// (= what `runex export clink` produces today).
70///
71/// `search_paths` is the ordered list of file paths to probe. The first
72/// existing file wins; subsequent paths are not consulted. This lets
73/// callers decide policy (env var override, default location, …).
74pub fn check_clink_lua_freshness(current_export: &str, search_paths: &[PathBuf]) -> IntegrationCheck {
75    for candidate in search_paths {
76        if let Ok(on_disk) = std::fs::read_to_string(candidate) {
77            return if normalize_newlines(&on_disk) == normalize_newlines(current_export) {
78                IntegrationCheck::Ok {
79                    name: "integration:clink".into(),
80                    detail: format!(
81                        "up-to-date at {}",
82                        sanitize_for_display(&candidate.display().to_string())
83                    ),
84                }
85            } else {
86                IntegrationCheck::Outdated {
87                    name: "integration:clink".into(),
88                    detail: format!(
89                        "outdated at {} — re-run `runex init clink` to refresh",
90                        sanitize_for_display(&candidate.display().to_string())
91                    ),
92                    path: candidate.clone(),
93                }
94            };
95        }
96    }
97    // No file at any candidate path. Mirror the rcfile-marker policy:
98    // a missing integration script most likely means the user doesn't
99    // run clink. Don't shout about it. (Linux dev boxes hit this on
100    // every `runex doctor` invocation.) Drift is what we actually care
101    // about here — and that's a separate branch above.
102    IntegrationCheck::Skipped {
103        name: "integration:clink".into(),
104        detail: "no clink integration found — assuming clink is not in use".into(),
105    }
106}
107
108/// Confirm that the rcfile for `shell` mentions the runex init marker.
109/// `rcfile_override` is for tests; production callers pass `None` and
110/// fall back to [`crate::init::rc_file_for`].
111pub fn check_rcfile_marker(shell: Shell, rcfile_override: Option<&Path>) -> IntegrationCheck {
112    let name = format!("integration:{}", shell_short_name(shell));
113    let path = match rcfile_override {
114        Some(p) => p.to_path_buf(),
115        None => match rc_file_for(shell) {
116            Some(p) => p,
117            None => {
118                return IntegrationCheck::Skipped {
119                    name,
120                    detail: "no rcfile concept for this shell".into(),
121                }
122            }
123        },
124    };
125    if !path.exists() {
126        return IntegrationCheck::Skipped {
127            name,
128            detail: format!(
129                "rcfile not found at {} — assuming this shell is not in use",
130                sanitize_for_display(&path.display().to_string())
131            ),
132        };
133    }
134    let content = match std::fs::read_to_string(&path) {
135        Ok(s) => s,
136        Err(_) => {
137            return IntegrationCheck::Missing {
138                name,
139                detail: format!(
140                    "could not read {} — `runex init {}` may not have been run",
141                    sanitize_for_display(&path.display().to_string()),
142                    shell_short_name(shell)
143                ),
144            };
145        }
146    };
147    if content.contains(RUNEX_INIT_MARKER) {
148        IntegrationCheck::Ok {
149            name,
150            detail: format!(
151                "marker found in {}",
152                sanitize_for_display(&path.display().to_string())
153            ),
154        }
155    } else {
156        IntegrationCheck::Missing {
157            name,
158            detail: format!(
159                "marker missing in {} — run `runex init {}`",
160                sanitize_for_display(&path.display().to_string()),
161                shell_short_name(shell)
162            ),
163        }
164    }
165}
166
167/// Default ordered list of paths to probe for the clink lua file on
168/// this platform. Callers may extend or override this list.
169pub fn default_clink_lua_paths() -> Vec<PathBuf> {
170    let mut out = Vec::new();
171    if let Ok(p) = std::env::var("RUNEX_CLINK_LUA_PATH") {
172        if !p.is_empty() {
173            out.push(PathBuf::from(p));
174        }
175    }
176    if let Ok(local) = std::env::var("LOCALAPPDATA") {
177        out.push(PathBuf::from(local).join("clink").join("runex.lua"));
178    }
179    if let Some(home) = dirs::home_dir() {
180        // Linux clink fork (rare): keeps state under ~/.local/share/clink.
181        out.push(home.join(".local").join("share").join("clink").join("runex.lua"));
182    }
183    out
184}
185
186fn normalize_newlines(s: &str) -> String {
187    s.replace("\r\n", "\n")
188}
189
190fn shell_short_name(shell: Shell) -> &'static str {
191    match shell {
192        Shell::Bash => "bash",
193        Shell::Zsh => "zsh",
194        Shell::Pwsh => "pwsh",
195        Shell::Clink => "clink",
196        Shell::Nu => "nu",
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::io::Write;
204    use tempfile::TempDir;
205
206    fn write(path: &Path, content: &str) {
207        if let Some(parent) = path.parent() {
208            std::fs::create_dir_all(parent).unwrap();
209        }
210        let mut f = std::fs::File::create(path).unwrap();
211        f.write_all(content.as_bytes()).unwrap();
212    }
213
214    #[test]
215    fn clink_lua_match_returns_ok() {
216        let tmp = TempDir::new().unwrap();
217        let p = tmp.path().join("runex.lua");
218        write(&p, "-- runex shell integration for clink\nlocal RUNEX_BIN = \"r\"\n");
219        let r = check_clink_lua_freshness(
220            "-- runex shell integration for clink\nlocal RUNEX_BIN = \"r\"\n",
221            &[p.clone()],
222        );
223        assert!(
224            matches!(r, IntegrationCheck::Ok { .. }),
225            "expected Ok, got {r:?}"
226        );
227    }
228
229    /// CRLF on disk vs LF in our generated string must NOT count as drift.
230    /// Files saved on Windows often round-trip with CRLF.
231    #[test]
232    fn clink_lua_match_normalises_newlines() {
233        let tmp = TempDir::new().unwrap();
234        let p = tmp.path().join("runex.lua");
235        write(&p, "line1\r\nline2\r\n");
236        let r = check_clink_lua_freshness("line1\nline2\n", &[p.clone()]);
237        assert!(
238            matches!(r, IntegrationCheck::Ok { .. }),
239            "CRLF/LF mismatch must not flag drift; got {r:?}"
240        );
241    }
242
243    #[test]
244    fn clink_lua_drift_returns_outdated() {
245        let tmp = TempDir::new().unwrap();
246        let p = tmp.path().join("runex.lua");
247        write(&p, "old script\n");
248        let r = check_clink_lua_freshness("new script\n", &[p.clone()]);
249        match r {
250            IntegrationCheck::Outdated { path, .. } => assert_eq!(path, p),
251            other => panic!("expected Outdated, got {other:?}"),
252        }
253    }
254
255    /// When the only candidate path doesn't exist, treat it as "user
256    /// doesn't run clink" and skip silently. Linux machines hit this
257    /// branch on every `runex doctor` and don't deserve a warning for
258    /// not having a Windows shell installed.
259    #[test]
260    fn clink_lua_not_found_is_skipped() {
261        let tmp = TempDir::new().unwrap();
262        let p = tmp.path().join("does_not_exist.lua");
263        let r = check_clink_lua_freshness("anything\n", &[p]);
264        assert!(matches!(r, IntegrationCheck::Skipped { .. }), "got {r:?}");
265    }
266
267    #[test]
268    fn rcfile_marker_present_returns_ok() {
269        let tmp = TempDir::new().unwrap();
270        let p = tmp.path().join(".bashrc");
271        write(
272            &p,
273            "alias ll=ls\n\n# runex-init\neval \"$(runex export bash)\"\n",
274        );
275        let r = check_rcfile_marker(Shell::Bash, Some(&p));
276        assert!(matches!(r, IntegrationCheck::Ok { .. }), "got {r:?}");
277    }
278
279    #[test]
280    fn rcfile_marker_absent_returns_missing() {
281        let tmp = TempDir::new().unwrap();
282        let p = tmp.path().join(".bashrc");
283        write(&p, "alias ll=ls\nexport PATH=...\n");
284        let r = check_rcfile_marker(Shell::Bash, Some(&p));
285        assert!(matches!(r, IntegrationCheck::Missing { .. }), "got {r:?}");
286    }
287
288    /// A non-existent rcfile means the user doesn't use this shell.
289    /// That's not an error — just skip the check.
290    #[test]
291    fn rcfile_missing_returns_skipped() {
292        let tmp = TempDir::new().unwrap();
293        let p = tmp.path().join("nonexistent.zshrc");
294        let r = check_rcfile_marker(Shell::Zsh, Some(&p));
295        assert!(matches!(r, IntegrationCheck::Skipped { .. }), "got {r:?}");
296    }
297
298    /// clink has no rcfile concept; passing it without an override must skip.
299    #[test]
300    fn rcfile_check_for_clink_skips_when_no_override() {
301        let r = check_rcfile_marker(Shell::Clink, None);
302        assert!(
303            matches!(r, IntegrationCheck::Skipped { .. }),
304            "clink without override must skip; got {r:?}"
305        );
306    }
307}