1use 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#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum IntegrationCheck {
32 Ok { name: String, detail: String },
34 Outdated {
37 name: String,
38 detail: String,
39 path: PathBuf,
40 },
41 Missing { name: String, detail: String },
43 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
68pub 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 IntegrationCheck::Skipped {
103 name: "integration:clink".into(),
104 detail: "no clink integration found — assuming clink is not in use".into(),
105 }
106}
107
108pub 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
167pub 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 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 #[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 #[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 #[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 #[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}