1pub mod chord;
8
9use std::env;
10use std::fs;
11use std::io::{self, BufRead, Write};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15use anyhow::{Context, Result, bail};
16use clap::ValueEnum;
17use directories::BaseDirs;
18
19use crate::model::ShellFamily;
20use crate::shell::chord::Chord;
21
22#[allow(clippy::enum_variant_names)]
26#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
27pub enum Shell {
28 Bash,
29 Zsh,
30 Fish,
31 #[value(name = "powershell")]
32 PowerShell,
33}
34
35const CHORD: &str = "{{chord}}";
37
38const BEGIN: &str = "# >>> lore >>>";
40const END: &str = "# <<< lore <<<";
41
42const SYSTEM_BIN: &[&str] = &[
45 "/bin",
46 "/sbin",
47 "/usr/bin",
48 "/usr/sbin",
49 "/usr/local/bin",
50 "/usr/local/sbin",
51];
52
53impl From<Shell> for ShellFamily {
54 fn from(shell: Shell) -> Self {
55 match shell {
56 Shell::Bash | Shell::Zsh | Shell::Fish => ShellFamily::Posix,
57 Shell::PowerShell => ShellFamily::PowerShell,
58 }
59 }
60}
61
62impl Shell {
63 fn label(self) -> &'static str {
64 match self {
65 Shell::Bash => "bash",
66 Shell::Zsh => "zsh",
67 Shell::Fish => "fish",
68 Shell::PowerShell => "powershell",
69 }
70 }
71}
72
73pub fn snippet(shell: Shell, chord: Chord) -> String {
79 let template = match shell {
80 Shell::Bash => include_str!("../../assets/shell/bash.sh"),
81 Shell::Zsh => include_str!("../../assets/shell/zsh.zsh"),
82 Shell::Fish => include_str!("../../assets/shell/fish.fish"),
83 Shell::PowerShell => include_str!("../../assets/shell/powershell.ps1"),
84 };
85
86 template.replace(CHORD, &chord.render(shell))
87}
88
89pub fn init_line(shell: Shell, chord: Chord) -> String {
96 let key = if chord.is_default() {
98 String::new()
99 } else {
100 format!(" --key {chord}")
101 };
102
103 match shell {
104 Shell::Bash => format!(r#"eval "$(lore init bash{key})""#),
105 Shell::Zsh => format!(r#"eval "$(lore init zsh{key})""#),
106 Shell::Fish => format!("lore init fish{key} | source"),
107 Shell::PowerShell => {
108 format!("Invoke-Expression (& lore init powershell{key} | Out-String)")
109 }
110 }
111}
112
113pub fn detect() -> Option<Shell> {
115 if let Ok(path) = env::var("SHELL")
116 && let Some(shell) = from_program(&path)
117 {
118 return Some(shell);
119 }
120
121 cfg!(windows).then_some(Shell::PowerShell)
124}
125
126fn from_program(path: &str) -> Option<Shell> {
127 let name = Path::new(path).file_stem()?.to_str()?.to_lowercase();
128 match name.as_str() {
129 "bash" | "sh" => Some(Shell::Bash),
130 "zsh" => Some(Shell::Zsh),
131 "fish" => Some(Shell::Fish),
132 "pwsh" | "powershell" => Some(Shell::PowerShell),
133 _ => None,
134 }
135}
136
137pub struct Profile {
139 pub path: PathBuf,
140 pub label: String,
142}
143
144pub fn profiles(shell: Shell) -> Result<Vec<Profile>> {
149 let dirs = BaseDirs::new().context("could not determine the home directory")?;
150 let home = dirs.home_dir();
151
152 let profile = |path: PathBuf, label: &str| Profile {
153 path,
154 label: label.to_string(),
155 };
156
157 match shell {
158 Shell::Bash => Ok(vec![profile(home.join(".bashrc"), shell.label())]),
159 Shell::Zsh => {
160 let base = env::var_os("ZDOTDIR")
161 .filter(|value| !value.is_empty())
162 .map(PathBuf::from)
163 .unwrap_or_else(|| home.to_path_buf());
164 Ok(vec![profile(base.join(".zshrc"), shell.label())])
165 }
166 Shell::Fish => {
167 let base = env::var_os("XDG_CONFIG_HOME")
168 .filter(|value| !value.is_empty())
169 .map(PathBuf::from)
170 .unwrap_or_else(|| home.join(".config"));
171 Ok(vec![profile(
172 base.join("fish").join("config.fish"),
173 shell.label(),
174 )])
175 }
176 Shell::PowerShell => powershell_profiles(),
177 }
178}
179
180fn powershell_profiles() -> Result<Vec<Profile>> {
185 let hosts = [
186 ("powershell", "Windows PowerShell 5.1"),
187 ("pwsh", "PowerShell 7"),
188 ];
189
190 let found: Vec<Profile> = hosts
191 .into_iter()
192 .filter_map(|(program, label)| {
193 ask_profile_path(program).map(|path| Profile {
194 path,
195 label: label.to_string(),
196 })
197 })
198 .collect();
199
200 if found.is_empty() {
201 bail!("no PowerShell host was found on PATH");
202 }
203 Ok(found)
204}
205
206fn ask_profile_path(program: &str) -> Option<PathBuf> {
207 let output = Command::new(program)
208 .args([
209 "-NoProfile",
210 "-NonInteractive",
211 "-Command",
212 "$PROFILE.CurrentUserCurrentHost",
213 ])
214 .output()
215 .ok()?;
216
217 if !output.status.success() {
218 return None;
219 }
220
221 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
222 (!path.is_empty()).then(|| PathBuf::from(path))
223}
224
225pub fn install(shell: Shell, chord: Chord, assume_yes: bool) -> Result<()> {
227 for profile in profiles(shell)? {
228 install_one(shell, chord, &profile, assume_yes)?;
229 }
230
231 if let Some(warning) = execution_policy_warning(shell) {
232 println!();
233 println!("{warning}");
234 }
235
236 Ok(())
237}
238
239fn install_one(shell: Shell, chord: Chord, profile: &Profile, assume_yes: bool) -> Result<()> {
240 let existing = fs::read_to_string(&profile.path).unwrap_or_default();
241 if existing.contains(BEGIN) {
242 println!(
243 "{}: already set up ({})",
244 profile.label,
245 profile.path.display()
246 );
247 return Ok(());
248 }
249
250 let block = block(shell, chord);
251 println!("{}: {}", profile.label, profile.path.display());
252 println!("The following will be appended:");
253 println!("{block}");
254
255 if !assume_yes && !confirm("Append it?")? {
256 println!("{}: skipped", profile.label);
257 return Ok(());
258 }
259
260 if profile.path.exists() {
261 let backup = back_up(&profile.path)?;
262 println!("{}: backed up to {}", profile.label, backup.display());
263 } else if let Some(parent) = profile.path.parent() {
264 fs::create_dir_all(parent)
265 .with_context(|| format!("failed to create {}", parent.display()))?;
266 }
267
268 let mut file = fs::OpenOptions::new()
269 .create(true)
270 .append(true)
271 .open(&profile.path)
272 .with_context(|| format!("failed to open {}", profile.path.display()))?;
273 file.write_all(block.as_bytes())
274 .with_context(|| format!("failed to write {}", profile.path.display()))?;
275
276 println!(
277 "{}: done, open a new shell and press {}",
278 profile.label,
279 chord.spoken()
280 );
281 Ok(())
282}
283
284pub fn uninstall(shell: Shell) -> Result<()> {
286 for profile in profiles(shell)? {
287 let Ok(existing) = fs::read_to_string(&profile.path) else {
288 continue;
289 };
290 if !existing.contains(BEGIN) {
291 println!("{}: nothing to remove", profile.label);
292 continue;
293 }
294
295 let backup = back_up(&profile.path)?;
296 fs::write(&profile.path, strip(&existing))
297 .with_context(|| format!("failed to write {}", profile.path.display()))?;
298 println!(
299 "{}: removed ({} kept as {})",
300 profile.label,
301 profile.path.display(),
302 backup.display()
303 );
304 }
305
306 Ok(())
307}
308
309fn block(shell: Shell, chord: Chord) -> String {
310 let mut body = String::new();
311 if let Some(guard) = path_guard(shell) {
312 body.push_str(&guard);
313 body.push('\n');
314 }
315 body.push_str(&init_line(shell, chord));
316
317 format!("\n{BEGIN}\n{body}\n{END}\n")
318}
319
320fn path_guard(shell: Shell) -> Option<String> {
328 let exe = env::current_exe().ok()?;
329 let directory = exe.parent()?.to_str()?;
330
331 guard_line(shell, directory)
332}
333
334fn guard_line(shell: Shell, directory: &str) -> Option<String> {
337 if shell == Shell::PowerShell || SYSTEM_BIN.contains(&directory) {
340 return None;
341 }
342
343 Some(match shell {
344 Shell::Fish => {
345 let quoted = fish_quoted(directory);
346 format!("contains {quoted} $PATH; or set -gx PATH {quoted} $PATH")
347 }
348 _ => {
349 let quoted = posix_quoted(directory);
350 format!(r#"case ":$PATH:" in *:{quoted}:*) ;; *) PATH={quoted}:"$PATH" ;; esac"#)
351 }
352 })
353}
354
355fn posix_quoted(text: &str) -> String {
357 format!("'{}'", text.replace('\'', r"'\''"))
358}
359
360fn fish_quoted(text: &str) -> String {
363 format!("'{}'", text.replace('\\', r"\\").replace('\'', r"\'"))
364}
365
366fn strip(existing: &str) -> String {
368 let mut out = String::with_capacity(existing.len());
369 let mut inside = false;
370
371 for line in existing.lines() {
372 let trimmed = line.trim();
373 if trimmed == BEGIN {
374 inside = true;
375 continue;
376 }
377 if trimmed == END {
378 inside = false;
379 continue;
380 }
381 if !inside {
382 out.push_str(line);
383 out.push('\n');
384 }
385 }
386
387 out
388}
389
390fn back_up(path: &Path) -> Result<PathBuf> {
396 let mut name = path.file_name().unwrap_or_default().to_os_string();
397 name.push(".lore-backup");
398 let backup = path.with_file_name(name);
399
400 fs::copy(path, &backup).with_context(|| format!("failed to back up {}", path.display()))?;
401 Ok(backup)
402}
403
404fn confirm(question: &str) -> Result<bool> {
405 print!("{question} [y/N] ");
406 io::stdout().flush()?;
407
408 let mut answer = String::new();
409 io::stdin().lock().read_line(&mut answer)?;
410 Ok(matches!(answer.trim().to_lowercase().as_str(), "y" | "yes"))
411}
412
413fn execution_policy_warning(shell: Shell) -> Option<String> {
416 if shell != Shell::PowerShell {
417 return None;
418 }
419
420 let output = Command::new("powershell")
421 .args([
422 "-NoProfile",
423 "-NonInteractive",
424 "-Command",
425 "Get-ExecutionPolicy",
426 ])
427 .output()
428 .ok()?;
429 let policy = String::from_utf8_lossy(&output.stdout).trim().to_string();
430
431 ["restricted", "allsigned"]
432 .contains(&policy.to_lowercase().as_str())
433 .then(|| {
434 format!(
435 "Warning: the PowerShell execution policy is {policy}, so profile scripts never \
436 run.\nAllow them with: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned"
437 )
438 })
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 const ALL: [Shell; 4] = [Shell::Bash, Shell::Zsh, Shell::Fish, Shell::PowerShell];
446
447 fn snippet(shell: Shell) -> String {
448 super::snippet(shell, Chord::default())
449 }
450
451 #[test]
452 fn every_shell_binds_the_chord() {
453 for shell in ALL {
454 let snippet = snippet(shell);
455 assert!(!snippet.is_empty(), "{shell:?} has no snippet");
456 assert!(
457 snippet.contains("lore pick"),
458 "{shell:?} never calls the picker"
459 );
460 }
461 }
462
463 #[test]
466 fn no_snippet_reaches_the_shell_with_its_placeholder_intact() {
467 for shell in ALL {
468 for key in ["ctrl-g", "alt-r"] {
469 let chord: Chord = key.parse().expect("should parse");
470 let snippet = super::snippet(shell, chord);
471
472 assert!(!snippet.contains(CHORD), "{shell:?} kept the placeholder");
473 assert!(
474 snippet.contains(&chord.render(shell)),
475 "{shell:?} never binds {key}"
476 );
477 }
478 }
479 }
480
481 #[test]
484 fn fish_binds_the_chord_in_both_of_its_modes() {
485 let chord: Chord = "alt-r".parse().expect("should parse");
486 let snippet = super::snippet(Shell::Fish, chord);
487
488 assert_eq!(snippet.matches(&chord.render(Shell::Fish)).count(), 2);
489 }
490
491 #[test]
494 fn snippets_never_carry_carriage_returns() {
495 for shell in ALL {
496 assert!(
497 !snippet(shell).contains('\r'),
498 "{shell:?} snippet contains a carriage return"
499 );
500 }
501 }
502
503 #[test]
507 fn powershell_drops_blank_history_entries() {
508 let snippet = snippet(Shell::PowerShell);
509 assert!(
510 snippet.contains("Where-Object { $_ }"),
511 "the history reaches lore unfiltered"
512 );
513 }
514
515 #[test]
520 fn powershell_redraws_the_prompt_on_every_path() {
521 let snippet = snippet(Shell::PowerShell);
522 let redraw = snippet
523 .find("InvokePrompt")
524 .expect("the prompt is never redrawn");
525 let insert = snippet.find("Insert(").expect("nothing is ever inserted");
526
527 assert_eq!(
528 snippet.matches("InvokePrompt").count(),
529 1,
530 "the prompt is redrawn on one path only"
531 );
532 assert!(redraw < insert, "the prompt lands on top of the insertion");
533 }
534
535 #[test]
539 fn every_snippet_hands_its_history_over_in_a_file() {
540 for shell in ALL {
541 let snippet = snippet(shell);
542 assert!(
543 snippet.contains("--history"),
544 "{shell:?} does not pass a history file"
545 );
546 assert!(
547 snippet.contains("rm -f") || snippet.contains("Remove-Item"),
548 "{shell:?} leaves its history file behind"
549 );
550 }
551 }
552
553 #[test]
560 fn no_snippet_captures_the_pickers_stdout() {
561 for shell in ALL {
562 let snippet = snippet(shell);
563 assert!(
564 snippet.contains("--output"),
565 "{shell:?} does not ask for the result in a file"
566 );
567
568 let invocation = snippet
569 .lines()
570 .find(|line| line.contains("lore pick"))
571 .expect("every snippet runs the picker")
572 .to_string();
573
574 assert!(
575 !invocation.contains('='),
576 "{shell:?} assigns the picker's output: {invocation}"
577 );
578 assert!(
579 !invocation.contains("$("),
580 "{shell:?} captures the picker's output: {invocation}"
581 );
582 assert!(
583 !invocation.contains("(lore"),
584 "{shell:?} captures the picker's output: {invocation}"
585 );
586 }
587 }
588
589 #[test]
594 fn every_snippet_hands_over_the_prompt_line() {
595 let buffers = [
596 (Shell::Bash, "READLINE_LINE"),
597 (Shell::Zsh, "BUFFER"),
598 (Shell::Fish, "(commandline)"),
599 (Shell::PowerShell, "GetBufferState"),
600 ];
601
602 for (shell, buffer) in buffers {
603 let snippet = snippet(shell);
604 let read = snippet
605 .find(buffer)
606 .unwrap_or_else(|| panic!("{shell:?} never reads its prompt line"));
607
608 let invocation = snippet
609 .lines()
610 .find(|line| line.contains("lore pick"))
611 .unwrap_or_else(|| panic!("{shell:?} never runs the picker"));
612 assert!(
613 invocation.contains("--line"),
614 "{shell:?} does not pass the prompt line: {invocation}"
615 );
616
617 let passed = snippet.find("--line").expect("just checked");
618 assert!(read < passed, "{shell:?} reads its prompt line too late");
619 }
620 }
621
622 #[test]
626 fn zsh_hands_the_picker_its_own_terminal_as_stdin() {
627 let invocation = snippet(Shell::Zsh)
628 .lines()
629 .find(|line| line.contains("lore pick"))
630 .expect("the snippet runs the picker")
631 .to_string();
632
633 assert!(
634 invocation.contains(r#"< "$TTY""#),
635 "zsh leaves stdin on /dev/null: {invocation}"
636 );
637 }
638
639 #[test]
640 fn every_snippet_removes_all_of_its_temporary_files() {
641 for shell in ALL {
642 let snippet = snippet(shell);
643 let cleanup = snippet
644 .lines()
645 .find(|line| line.contains("rm -f") || line.contains("Remove-Item"))
646 .expect("every snippet cleans up");
647
648 let typed = if shell == Shell::PowerShell {
651 "typed"
652 } else {
653 "line"
654 };
655 for file in ["recent", typed, "out"] {
656 assert!(
657 cleanup.contains(file),
658 "{shell:?} leaves its {file} file behind: {cleanup}"
659 );
660 }
661 }
662 }
663
664 #[test]
668 fn every_snippet_asks_for_the_cursor_and_places_it() {
669 let placements = [
670 (Shell::Bash, "READLINE_POINT"),
671 (Shell::Zsh, "CURSOR"),
672 (Shell::Fish, "commandline -C"),
673 (Shell::PowerShell, "SetCursorPosition"),
674 ];
675
676 for (shell, placement) in placements {
677 let snippet = snippet(shell);
678 assert!(
679 snippet.contains("--print-cursor"),
680 "{shell:?} never asks for the cursor"
681 );
682 assert!(
683 snippet.contains(placement),
684 "{shell:?} never places the cursor"
685 );
686 }
687 }
688
689 #[test]
690 fn every_shell_knows_how_to_load_its_snippet() {
691 for shell in ALL {
692 let line = init_line(shell, Chord::default());
693 assert!(line.contains("lore init"));
694 assert!(line.contains(shell.label()));
695 }
696 }
697
698 #[test]
701 fn only_a_changed_chord_reaches_the_init_line() {
702 for shell in ALL {
703 assert!(!init_line(shell, Chord::default()).contains("--key"));
704 assert!(
705 init_line(shell, "alt-r".parse().expect("should parse")).contains("--key alt-r"),
706 "{shell:?} loses the chord"
707 );
708 }
709 }
710
711 #[test]
716 fn a_binary_outside_the_system_directories_gets_a_path_guard() {
717 for shell in [Shell::Bash, Shell::Zsh] {
718 let guard = guard_line(shell, "/home/alp/.local/bin").expect("should guard");
719 assert!(guard.contains("'/home/alp/.local/bin'"), "{guard}");
720 assert!(guard.contains("PATH="), "{guard}");
721 }
722
723 let guard = guard_line(Shell::Fish, "/home/alp/.local/bin").expect("should guard");
724 assert!(guard.contains("set -gx PATH"), "{guard}");
725 }
726
727 #[test]
730 fn nothing_is_guarded_that_is_already_reachable() {
731 assert_eq!(guard_line(Shell::PowerShell, r"C: ools\lore"), None);
732
733 for directory in SYSTEM_BIN {
734 assert_eq!(guard_line(Shell::Bash, directory), None, "{directory}");
735 }
736 }
737
738 #[test]
741 fn the_guard_is_quoted_and_survives_being_run_twice() {
742 let guard = guard_line(Shell::Bash, "/home/o'dd dir/bin").expect("should guard");
743
744 let quoted = r"'/home/o'\''dd dir/bin'";
745 assert!(guard.contains(quoted), "{guard}");
746 assert_eq!(
747 guard.matches(quoted).count(),
748 2,
749 "the test and the assignment should both be quoted: {guard}"
750 );
751 }
752
753 #[test]
756 fn fish_escapes_what_the_other_shells_do_not() {
757 assert_eq!(posix_quoted(r"/a\b"), r"'/a\b'");
758 assert_eq!(fish_quoted(r"/a\b"), r"'/a\\b'");
759 assert_eq!(posix_quoted("/a'b"), r"'/a'\''b'");
760 assert_eq!(fish_quoted("/a'b"), r"'/a\'b'");
761 }
762
763 #[test]
766 fn uninstalling_takes_the_guard_with_it() {
767 let original = "export EDITOR=vim
768";
769 let installed = format!("{original}{}", block(Shell::Bash, Chord::default()));
770
771 assert_eq!(strip(&installed).trim_end(), original.trim_end());
772 }
773
774 #[test]
777 fn a_backup_is_named_after_the_whole_file() {
778 let scratch = std::env::temp_dir().join(format!("lore-backup-{}", std::process::id()));
779 let _ = fs::create_dir_all(&scratch);
780
781 for name in [".bashrc", "profile.ps1"] {
782 let path = scratch.join(name);
783 fs::write(
784 &path,
785 "original
786",
787 )
788 .unwrap();
789
790 let backup = back_up(&path).unwrap();
791 assert_eq!(
792 backup.file_name().unwrap(),
793 std::ffi::OsStr::new(&format!("{name}.lore-backup"))
794 );
795 assert_eq!(
796 fs::read_to_string(&backup).unwrap(),
797 "original
798"
799 );
800 }
801
802 let _ = fs::remove_dir_all(&scratch);
803 }
804
805 #[test]
806 fn stripping_removes_only_the_marked_block() {
807 let profile = format!(
808 "export EDITOR=vim\n\n{BEGIN}\n{}\n{END}\nalias ll='ls -la'\n",
809 init_line(Shell::Bash, Chord::default())
810 );
811
812 assert_eq!(strip(&profile), "export EDITOR=vim\n\nalias ll='ls -la'\n");
813 }
814
815 #[test]
816 fn installing_then_stripping_returns_the_original() {
817 let original = "export EDITOR=vim\nalias ll='ls -la'\n";
818 let installed = format!("{original}{}", block(Shell::Zsh, Chord::default()));
819
820 assert!(installed.contains(BEGIN));
821 assert_eq!(strip(&installed).trim_end(), original.trim_end());
822 }
823
824 #[test]
825 fn stripping_a_profile_without_the_block_changes_nothing() {
826 let original = "export EDITOR=vim\n";
827 assert_eq!(strip(original), original);
828 }
829
830 #[test]
831 fn shells_are_recognised_by_their_program_name() {
832 assert_eq!(from_program("/bin/bash"), Some(Shell::Bash));
833 assert_eq!(from_program("/usr/bin/zsh"), Some(Shell::Zsh));
834 assert_eq!(from_program("/usr/local/bin/fish"), Some(Shell::Fish));
835 assert_eq!(from_program("pwsh.exe"), Some(Shell::PowerShell));
836 assert_eq!(from_program("/usr/bin/nu"), None);
837 }
838
839 #[cfg(windows)]
842 #[test]
843 fn a_windows_path_is_split_the_windows_way() {
844 assert_eq!(
845 from_program(r"C:\Program Files\PowerShell\7\pwsh.exe"),
846 Some(Shell::PowerShell)
847 );
848 }
849
850 #[test]
851 fn posix_shells_share_a_dialect_and_powershell_does_not() {
852 assert_eq!(ShellFamily::from(Shell::Bash), ShellFamily::Posix);
853 assert_eq!(ShellFamily::from(Shell::Zsh), ShellFamily::Posix);
854 assert_eq!(ShellFamily::from(Shell::Fish), ShellFamily::Posix);
855 assert_eq!(
856 ShellFamily::from(Shell::PowerShell),
857 ShellFamily::PowerShell
858 );
859 }
860}