1use std::path::PathBuf;
2
3use crate::config::xdg_config_home;
4use crate::sanitize::{double_quote_escape, is_nu_drop_char};
5use crate::shell::{bash_quote_string, lua_quote_string, nu_quote_string, pwsh_quote_string, Shell};
6
7fn nu_quote_path(path: &str) -> String {
14 let mut out = String::from("\"");
15 for ch in path.chars() {
16 if let Some(esc) = double_quote_escape(ch) {
17 out.push_str(esc);
18 } else if ch == '$' {
19 out.push_str("\\$");
20 } else if is_nu_drop_char(ch) {
21 } else {
22 out.push(ch);
23 }
24 }
25 out.push('"');
26 out
27}
28
29pub const RUNEX_INIT_MARKER: &str = "# runex-init";
31
32pub fn default_config_content() -> &'static str {
45 r#"version = 1
46
47[keybind.trigger]
48default = "space"
49
50# Sample abbreviation. After restarting your shell, type `gst<Space>`
51# and it will expand to `git status `.
52[[abbr]]
53key = "gst"
54expand = "git status"
55
56# Add your own below. For more recipes (per-shell commands, fallback
57# chains, cursor placeholders, etc.) see:
58# https://github.com/ShortArrow/runex/blob/main/docs/recipes.md
59"#
60}
61
62pub fn integration_line(shell: Shell, bin: &str) -> String {
79 match shell {
80 Shell::Bash => format!("eval \"$({} export bash)\"", bash_quote_string(bin)),
81 Shell::Zsh => format!("eval \"$({} export zsh)\"", bash_quote_string(bin)),
82 Shell::Pwsh => format!(
83 "Invoke-Expression (& {} export pwsh | Out-String)",
84 pwsh_quote_string(bin)
85 ),
86 Shell::Nu => {
87 let cfg_dir = xdg_config_home()
88 .map(|p| p.display().to_string())
89 .unwrap_or_else(|| "~/.config".to_string());
90 let nu_bin = nu_quote_string(bin);
91 let nu_path = nu_quote_path(&format!("{cfg_dir}/runex/runex.nu"));
92 format!(
93 "{nu_bin} export nu | save --force {nu_path}\nsource {nu_path}"
94 )
95 }
96 Shell::Clink => format!(
97 "-- add {} export clink output to your clink scripts directory",
98 lua_quote_string(bin)
99 ),
100 }
101}
102
103pub fn default_clink_lua_install_path() -> std::path::PathBuf {
119 clink_lua_install_path_with(|k| std::env::var(k).ok(), dirs::home_dir)
120}
121
122pub(crate) fn clink_lua_install_path_with<E, H>(env_get: E, home_dir: H) -> std::path::PathBuf
126where
127 E: Fn(&str) -> Option<String>,
128 H: Fn() -> Option<std::path::PathBuf>,
129{
130 if let Some(p) = env_get("RUNEX_CLINK_LUA_PATH") {
131 if !p.is_empty() {
132 return std::path::PathBuf::from(p);
133 }
134 }
135 if let Some(local) = env_get("LOCALAPPDATA") {
136 if !local.is_empty() {
137 return std::path::PathBuf::from(local).join("clink").join("runex.lua");
138 }
139 }
140 if let Some(home) = home_dir() {
141 return home.join(".local").join("share").join("clink").join("runex.lua");
142 }
143 std::path::PathBuf::from("runex.lua")
144}
145
146pub fn next_steps_message(shell: Shell, rc_path: Option<&std::path::Path>) -> String {
155 let reload = match shell {
156 Shell::Bash | Shell::Zsh => match rc_path {
157 Some(p) => format!("Reload your shell: `source {}` (or `exec $SHELL`)", p.display()),
158 None => "Reload your shell: `exec $SHELL`".to_string(),
159 },
160 Shell::Pwsh => match rc_path {
161 Some(p) => format!("Reload your profile: `. $PROFILE` (resolves to {})", p.display()),
162 None => "Reload your profile: `. $PROFILE`".to_string(),
163 },
164 Shell::Nu => "Reload nushell: open a new shell (or run `exec nu`)".to_string(),
165 Shell::Clink => "Open a new cmd window — clink loads the lua at startup.".to_string(),
166 };
167 format!(
168 "Next steps:\n 1. {reload}\n 2. Try `gst<Space>` — it should expand to `git status `.\n 3. Add your own abbreviations: see https://github.com/ShortArrow/runex/blob/main/docs/recipes.md\n 4. Verify any time with: `runex doctor`"
169 )
170}
171
172pub fn rc_file_for(shell: Shell) -> Option<PathBuf> {
177 let home = dirs::home_dir()?;
178 match shell {
179 Shell::Bash => Some(home.join(".bashrc")),
180 Shell::Zsh => Some(home.join(".zshrc")),
181 Shell::Pwsh => {
182 let base = if cfg!(windows) {
183 home.join("Documents").join("PowerShell")
184 } else {
185 home.join(".config").join("powershell")
186 };
187 Some(base.join("Microsoft.PowerShell_profile.ps1"))
188 }
189 Shell::Nu => {
190 let cfg = xdg_config_home().unwrap_or_else(|| home.join(".config"));
191 Some(cfg.join("nushell").join("env.nu"))
192 }
193 Shell::Clink => None,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 mod integration_line {
202 use super::*;
203
204 #[test]
205 fn default_config_content_has_version() {
206 assert!(default_config_content().contains("version = 1"));
207 }
208
209 #[test]
215 fn default_config_content_includes_default_trigger() {
216 let s = default_config_content();
217 assert!(s.contains("[keybind.trigger]"), "missing [keybind.trigger]: {s}");
218 assert!(s.contains("default = \"space\""), "missing default trigger: {s}");
219 }
220
221 #[test]
224 fn default_config_content_includes_sample_abbr_gst() {
225 let s = default_config_content();
226 assert!(s.contains("key = \"gst\""), "missing gst sample: {s}");
227 assert!(s.contains("expand = \"git status\""), "missing gst expand: {s}");
228 }
229
230 #[test]
236 fn next_steps_for_bash_mentions_source_command() {
237 let msg = next_steps_message(Shell::Bash, Some(std::path::Path::new("/home/u/.bashrc")));
238 assert!(msg.contains("source /home/u/.bashrc") || msg.contains("exec"),
239 "bash next_steps must explain how to reload: {msg}");
240 assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
241 assert!(msg.contains("recipes"), "must point at recipes: {msg}");
242 }
243
244 #[test]
245 fn next_steps_for_clink_mentions_new_cmd_window() {
246 let msg = next_steps_message(Shell::Clink, None);
247 assert!(msg.to_lowercase().contains("cmd"),
248 "clink next_steps must mention opening a new cmd window: {msg}");
249 assert!(msg.contains("runex doctor"), "must suggest doctor: {msg}");
250 }
251
252 #[test]
253 fn next_steps_for_pwsh_mentions_dot_profile() {
254 let msg = next_steps_message(
255 Shell::Pwsh,
256 Some(std::path::Path::new("/u/Microsoft.PowerShell_profile.ps1")),
257 );
258 assert!(msg.contains("$PROFILE") || msg.contains(". /"),
259 "pwsh next_steps must explain reload: {msg}");
260 }
261
262 #[test]
268 fn clink_install_path_honors_env_override() {
269 let p = clink_lua_install_path_with(
270 |k| match k {
271 "RUNEX_CLINK_LUA_PATH" => Some("/tmp/runex_test_clink.lua".into()),
272 _ => None,
273 },
274 || None,
275 );
276 assert_eq!(p, std::path::PathBuf::from("/tmp/runex_test_clink.lua"));
277 }
278
279 #[test]
280 fn clink_install_path_uses_localappdata_when_set() {
281 let p = clink_lua_install_path_with(
282 |k| match k {
283 "LOCALAPPDATA" => Some("/tmp/local_appdata_test".into()),
284 _ => None,
285 },
286 || None,
287 );
288 assert_eq!(
289 p,
290 std::path::PathBuf::from("/tmp/local_appdata_test/clink/runex.lua")
291 );
292 }
293
294 #[test]
295 fn clink_install_path_falls_back_to_home() {
296 let p = clink_lua_install_path_with(
297 |_| None,
298 || Some(std::path::PathBuf::from("/home/user")),
299 );
300 assert_eq!(
301 p,
302 std::path::PathBuf::from("/home/user/.local/share/clink/runex.lua")
303 );
304 }
305
306 #[test]
310 fn clink_install_path_treats_empty_env_as_unset() {
311 let p = clink_lua_install_path_with(
312 |k| match k {
313 "RUNEX_CLINK_LUA_PATH" | "LOCALAPPDATA" => Some(String::new()),
314 _ => None,
315 },
316 || Some(std::path::PathBuf::from("/home/u")),
317 );
318 assert!(p.starts_with("/home/u"), "expected home fallback, got {p:?}");
319 }
320
321 #[test]
322 fn integration_line_bash() {
323 assert_eq!(
324 integration_line(Shell::Bash, "runex"),
325 r#"eval "$('runex' export bash)""#
326 );
327 }
328
329 #[test]
330 fn integration_line_pwsh() {
331 let line = integration_line(Shell::Pwsh, "runex");
332 assert!(line.contains("Invoke-Expression"));
333 assert!(line.contains("'runex' export pwsh"));
334 }
335
336 #[test]
338 fn integration_line_bash_escapes_single_quote_in_bin() {
339 let line = integration_line(Shell::Bash, "run'ex");
340 assert!(!line.contains("run'ex"), "unescaped quote in bash line: {line}");
341 assert!(line.contains(r"run'\''ex"), "expected bash-escaped form: {line}");
342 }
343
344 #[test]
345 fn integration_line_zsh_escapes_single_quote_in_bin() {
346 let line = integration_line(Shell::Zsh, "run'ex");
347 assert!(!line.contains("run'ex"), "unescaped quote in zsh line: {line}");
348 assert!(line.contains(r"run'\''ex"), "expected zsh-escaped form: {line}");
349 }
350
351 #[test]
353 fn integration_line_pwsh_escapes_single_quote_in_bin() {
354 let line = integration_line(Shell::Pwsh, "run'ex");
355 assert!(!line.contains("run'ex"), "unescaped quote in pwsh line: {line}");
356 assert!(line.contains("run''ex"), "expected pwsh-escaped form: {line}");
357 }
358
359 #[test]
362 fn integration_line_bash_semicolon_does_not_inject() {
363 let line = integration_line(Shell::Bash, "app; echo PWNED");
364 assert!(
365 line.contains("'app; echo PWNED'"),
366 "bin must be single-quoted in bash line: {line}"
367 );
368 }
369
370 #[test]
372 fn integration_line_pwsh_semicolon_does_not_inject() {
373 let line = integration_line(Shell::Pwsh, "app; Write-Host PWNED");
374 assert!(
375 line.contains("'app; Write-Host PWNED'"),
376 "bin must be single-quoted in pwsh line: {line}"
377 );
378 }
379
380 #[test]
382 fn integration_line_nu_uses_caret_external_command_syntax() {
383 let line = integration_line(Shell::Nu, "runex");
384 assert!(
385 line.contains("^\"runex\""),
386 "nu integration line must use ^\"...\" syntax: {line}"
387 );
388 }
389
390 #[test]
391 fn integration_line_nu_escapes_special_chars_in_bin() {
392 let line = integration_line(Shell::Nu, "my\"app");
393 assert!(line.contains("^\"my\\\"app\""), "nu: special chars must be escaped: {line}");
394 }
395
396 #[test]
398 fn integration_line_nu_quotes_cfg_dir_with_spaces() {
399 let quoted = nu_quote_path("/home/my user/.config");
400 assert_eq!(quoted, "\"/home/my user/.config\"");
401 assert!(!quoted.starts_with('/'), "path must be quoted, not raw");
402 }
403
404 #[test]
406 fn integration_line_nu_quotes_cfg_dir_with_backslash() {
407 let quoted = nu_quote_path(r"C:\Users\my user\AppData");
408 assert_eq!(quoted, r#""C:\\Users\\my user\\AppData""#);
409 }
410
411 #[test]
414 fn integration_line_nu_save_path_is_quoted() {
415 let line = integration_line(Shell::Nu, "runex");
416 for fragment in ["save --force \"", "source \""] {
417 assert!(
418 line.contains(fragment),
419 "nu line must contain `{fragment}`: {line}"
420 );
421 }
422 }
423
424 #[test]
427 fn integration_line_clink_single_quote_in_bin_is_lua_quoted() {
428 let line = integration_line(Shell::Clink, "run'ex");
429 assert!(
430 line.contains("\"run'ex\""),
431 "bin must be lua-quoted in clink line: {line}"
432 );
433 }
434
435 #[test]
437 fn integration_line_clink_newline_in_bin_does_not_inject() {
438 let line = integration_line(Shell::Clink, "runex\nos.execute('evil')");
439 assert!(
440 !line.contains('\n'),
441 "literal newline must be escaped in clink line: {line:?}"
442 );
443 assert!(
444 line.contains("\\n"),
445 "expected \\n escape sequence in clink line: {line:?}"
446 );
447 }
448
449 } mod nu_quote_path_escaping {
457 use super::*;
458
459 #[test]
460 fn nu_quote_path_escapes_newline() {
461 let quoted = nu_quote_path("/home/user/.config\nevil");
462 assert!(!quoted.contains('\n'), "nu_quote_path must escape newline: {quoted}");
463 assert!(quoted.contains("\\n"), "expected \\n escape: {quoted}");
464 }
465
466 #[test]
467 fn nu_quote_path_escapes_carriage_return() {
468 let quoted = nu_quote_path("/path\r/evil");
469 assert!(!quoted.contains('\r'), "nu_quote_path must escape CR: {quoted}");
470 assert!(quoted.contains("\\r"), "expected \\r escape: {quoted}");
471 }
472
473 #[test]
475 fn integration_line_nu_newline_in_xdg_does_not_inject() {
476 let quoted = nu_quote_path("/home/user/.config\nsource /tmp/evil.nu\n#");
477 assert!(!quoted.contains('\n'), "newline injection must be escaped in nu path: {quoted}");
478 }
479
480 #[test]
481 fn nu_quote_path_escapes_nul() {
482 let quoted = nu_quote_path("path\x00evil");
483 assert!(!quoted.contains('\0'), "nu_quote_path must not produce literal NUL: {quoted:?}");
484 assert!(quoted.contains("path"), "path prefix must be preserved: {quoted:?}");
485 }
486
487 #[test]
488 fn nu_quote_path_escapes_tab() {
489 let quoted = nu_quote_path("path\t/evil");
490 assert!(!quoted.contains('\t'), "nu_quote_path must escape tab: {quoted:?}");
491 assert!(quoted.contains("\\t"), "expected \\t escape: {quoted:?}");
492 }
493
494 #[test]
495 fn nu_quote_path_drops_del() {
496 let quoted = nu_quote_path("path\x7fend");
497 assert!(!quoted.contains('\x7f'), "nu_quote_path must drop DEL: {quoted:?}");
498 }
499
500 #[test]
501 fn nu_quote_path_drops_unicode_line_separators() {
502 for ch in ['\u{0085}', '\u{2028}', '\u{2029}'] {
503 let input = format!("path{ch}end");
504 let quoted = nu_quote_path(&input);
505 assert!(!quoted.contains(ch), "nu_quote_path must drop U+{:04X}: {quoted:?}", ch as u32);
506 }
507 }
508
509 #[test]
510 fn rc_file_for_bash_ends_with_bashrc() {
511 if let Some(path) = rc_file_for(Shell::Bash) {
512 assert!(path.to_str().unwrap().ends_with(".bashrc"));
513 }
514 }
515
516 #[test]
518 fn nu_quote_path_drops_remaining_c0_control_chars() {
519 let dangerous_c0: &[char] = &[
520 '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
521 '\x08', '\x0b', '\x0c', '\x0e', '\x0f',
522 '\x10', '\x11', '\x12', '\x13', '\x14', '\x15', '\x16', '\x17',
523 '\x18', '\x19', '\x1a', '\x1b',
524 '\x1c', '\x1d', '\x1e', '\x1f',
525 ];
526 for &ch in dangerous_c0 {
527 let input = format!("path{}end", ch);
528 let quoted = nu_quote_path(&input);
529 assert!(
530 !quoted.contains(ch),
531 "nu_quote_path must drop C0 control U+{:04X}: {quoted:?}",
532 ch as u32
533 );
534 }
535 }
536
537 } mod nu_quote_path_deceptive {
545 use super::*;
546
547 #[test]
549 fn nu_quote_path_drops_rlo() {
550 let quoted = nu_quote_path("/home/user\u{202E}/.config");
551 assert!(
552 !quoted.contains('\u{202E}'),
553 "nu_quote_path must drop U+202E (RLO): {quoted:?}"
554 );
555 }
556
557 #[test]
559 fn nu_quote_path_drops_bom() {
560 let quoted = nu_quote_path("/home/user\u{FEFF}/.config");
561 assert!(
562 !quoted.contains('\u{FEFF}'),
563 "nu_quote_path must drop U+FEFF (BOM): {quoted:?}"
564 );
565 }
566
567 #[test]
569 fn nu_quote_path_drops_zwsp() {
570 let quoted = nu_quote_path("/home/user\u{200B}/.config");
571 assert!(
572 !quoted.contains('\u{200B}'),
573 "nu_quote_path must drop U+200B (ZWSP): {quoted:?}"
574 );
575 }
576
577 #[test]
579 fn nu_quote_path_preserves_non_deceptive_unicode() {
580 let quoted = nu_quote_path("/home/ユーザー/.config");
581 assert!(
582 quoted.contains("ユーザー"),
583 "nu_quote_path must preserve non-deceptive Unicode: {quoted:?}"
584 );
585 }
586
587 #[test]
590 fn nu_quote_path_escapes_dollar_sign() {
591 let quoted = nu_quote_path("/home/$USER/.config");
592 let bytes = quoted.as_bytes();
593 for i in 0..bytes.len() {
594 if bytes[i] == b'$' {
595 let mut preceding = 0usize;
596 let mut j = i;
597 while j > 0 && bytes[j - 1] == b'\\' {
598 preceding += 1;
599 j -= 1;
600 }
601 assert!(
602 preceding % 2 == 1,
603 "nu_quote_path: '$' at byte {i} has {preceding} preceding backslashes \
604 (even = Nu interpolation not suppressed). Full output: {quoted:?}"
605 );
606 }
607 }
608 assert!(quoted.contains("\\$"), "expected \\$ in: {quoted:?}");
609 }
610
611 } }