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_puts_the_prompt_line_ahead_of_its_history() {
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 line = snippet
605 .find(buffer)
606 .unwrap_or_else(|| panic!("{shell:?} never reads its prompt line"));
607 let history = ["fc -lnr", "history --max", "Get-History"]
608 .iter()
609 .find_map(|call| snippet.find(call))
610 .expect("every snippet reads its history");
611 let written = ["> \"$recent\"", "> $recent", "WriteAllLines"]
612 .iter()
613 .find_map(|call| snippet.find(call))
614 .expect("every snippet writes the history file");
615
616 assert!(line < written, "{shell:?} reads its prompt line too late");
617 if shell != Shell::PowerShell {
618 assert!(
619 line < history,
620 "{shell:?} puts its prompt line after its history"
621 );
622 }
623 }
624 }
625
626 #[test]
630 fn zsh_hands_the_picker_its_own_terminal_as_stdin() {
631 let invocation = snippet(Shell::Zsh)
632 .lines()
633 .find(|line| line.contains("lore pick"))
634 .expect("the snippet runs the picker")
635 .to_string();
636
637 assert!(
638 invocation.contains(r#"< "$TTY""#),
639 "zsh leaves stdin on /dev/null: {invocation}"
640 );
641 }
642
643 #[test]
644 fn every_snippet_removes_both_of_its_temporary_files() {
645 for shell in ALL {
646 let snippet = snippet(shell);
647 let cleanup = snippet
648 .lines()
649 .find(|line| line.contains("rm -f") || line.contains("Remove-Item"))
650 .expect("every snippet cleans up");
651
652 assert!(
653 cleanup.contains("recent") && cleanup.contains("out"),
654 "{shell:?} leaves a temporary file behind: {cleanup}"
655 );
656 }
657 }
658
659 #[test]
663 fn every_snippet_asks_for_the_cursor_and_places_it() {
664 let placements = [
665 (Shell::Bash, "READLINE_POINT"),
666 (Shell::Zsh, "CURSOR"),
667 (Shell::Fish, "commandline -C"),
668 (Shell::PowerShell, "SetCursorPosition"),
669 ];
670
671 for (shell, placement) in placements {
672 let snippet = snippet(shell);
673 assert!(
674 snippet.contains("--print-cursor"),
675 "{shell:?} never asks for the cursor"
676 );
677 assert!(
678 snippet.contains(placement),
679 "{shell:?} never places the cursor"
680 );
681 }
682 }
683
684 #[test]
685 fn every_shell_knows_how_to_load_its_snippet() {
686 for shell in ALL {
687 let line = init_line(shell, Chord::default());
688 assert!(line.contains("lore init"));
689 assert!(line.contains(shell.label()));
690 }
691 }
692
693 #[test]
696 fn only_a_changed_chord_reaches_the_init_line() {
697 for shell in ALL {
698 assert!(!init_line(shell, Chord::default()).contains("--key"));
699 assert!(
700 init_line(shell, "alt-r".parse().expect("should parse")).contains("--key alt-r"),
701 "{shell:?} loses the chord"
702 );
703 }
704 }
705
706 #[test]
711 fn a_binary_outside_the_system_directories_gets_a_path_guard() {
712 for shell in [Shell::Bash, Shell::Zsh] {
713 let guard = guard_line(shell, "/home/alp/.local/bin").expect("should guard");
714 assert!(guard.contains("'/home/alp/.local/bin'"), "{guard}");
715 assert!(guard.contains("PATH="), "{guard}");
716 }
717
718 let guard = guard_line(Shell::Fish, "/home/alp/.local/bin").expect("should guard");
719 assert!(guard.contains("set -gx PATH"), "{guard}");
720 }
721
722 #[test]
725 fn nothing_is_guarded_that_is_already_reachable() {
726 assert_eq!(guard_line(Shell::PowerShell, r"C: ools\lore"), None);
727
728 for directory in SYSTEM_BIN {
729 assert_eq!(guard_line(Shell::Bash, directory), None, "{directory}");
730 }
731 }
732
733 #[test]
736 fn the_guard_is_quoted_and_survives_being_run_twice() {
737 let guard = guard_line(Shell::Bash, "/home/o'dd dir/bin").expect("should guard");
738
739 let quoted = r"'/home/o'\''dd dir/bin'";
740 assert!(guard.contains(quoted), "{guard}");
741 assert_eq!(
742 guard.matches(quoted).count(),
743 2,
744 "the test and the assignment should both be quoted: {guard}"
745 );
746 }
747
748 #[test]
751 fn fish_escapes_what_the_other_shells_do_not() {
752 assert_eq!(posix_quoted(r"/a\b"), r"'/a\b'");
753 assert_eq!(fish_quoted(r"/a\b"), r"'/a\\b'");
754 assert_eq!(posix_quoted("/a'b"), r"'/a'\''b'");
755 assert_eq!(fish_quoted("/a'b"), r"'/a\'b'");
756 }
757
758 #[test]
761 fn uninstalling_takes_the_guard_with_it() {
762 let original = "export EDITOR=vim
763";
764 let installed = format!("{original}{}", block(Shell::Bash, Chord::default()));
765
766 assert_eq!(strip(&installed).trim_end(), original.trim_end());
767 }
768
769 #[test]
772 fn a_backup_is_named_after_the_whole_file() {
773 let scratch = std::env::temp_dir().join(format!("lore-backup-{}", std::process::id()));
774 let _ = fs::create_dir_all(&scratch);
775
776 for name in [".bashrc", "profile.ps1"] {
777 let path = scratch.join(name);
778 fs::write(
779 &path,
780 "original
781",
782 )
783 .unwrap();
784
785 let backup = back_up(&path).unwrap();
786 assert_eq!(
787 backup.file_name().unwrap(),
788 std::ffi::OsStr::new(&format!("{name}.lore-backup"))
789 );
790 assert_eq!(
791 fs::read_to_string(&backup).unwrap(),
792 "original
793"
794 );
795 }
796
797 let _ = fs::remove_dir_all(&scratch);
798 }
799
800 #[test]
801 fn stripping_removes_only_the_marked_block() {
802 let profile = format!(
803 "export EDITOR=vim\n\n{BEGIN}\n{}\n{END}\nalias ll='ls -la'\n",
804 init_line(Shell::Bash, Chord::default())
805 );
806
807 assert_eq!(strip(&profile), "export EDITOR=vim\n\nalias ll='ls -la'\n");
808 }
809
810 #[test]
811 fn installing_then_stripping_returns_the_original() {
812 let original = "export EDITOR=vim\nalias ll='ls -la'\n";
813 let installed = format!("{original}{}", block(Shell::Zsh, Chord::default()));
814
815 assert!(installed.contains(BEGIN));
816 assert_eq!(strip(&installed).trim_end(), original.trim_end());
817 }
818
819 #[test]
820 fn stripping_a_profile_without_the_block_changes_nothing() {
821 let original = "export EDITOR=vim\n";
822 assert_eq!(strip(original), original);
823 }
824
825 #[test]
826 fn shells_are_recognised_by_their_program_name() {
827 assert_eq!(from_program("/bin/bash"), Some(Shell::Bash));
828 assert_eq!(from_program("/usr/bin/zsh"), Some(Shell::Zsh));
829 assert_eq!(from_program("/usr/local/bin/fish"), Some(Shell::Fish));
830 assert_eq!(from_program("pwsh.exe"), Some(Shell::PowerShell));
831 assert_eq!(from_program("/usr/bin/nu"), None);
832 }
833
834 #[cfg(windows)]
837 #[test]
838 fn a_windows_path_is_split_the_windows_way() {
839 assert_eq!(
840 from_program(r"C:\Program Files\PowerShell\7\pwsh.exe"),
841 Some(Shell::PowerShell)
842 );
843 }
844
845 #[test]
846 fn posix_shells_share_a_dialect_and_powershell_does_not() {
847 assert_eq!(ShellFamily::from(Shell::Bash), ShellFamily::Posix);
848 assert_eq!(ShellFamily::from(Shell::Zsh), ShellFamily::Posix);
849 assert_eq!(ShellFamily::from(Shell::Fish), ShellFamily::Posix);
850 assert_eq!(
851 ShellFamily::from(Shell::PowerShell),
852 ShellFamily::PowerShell
853 );
854 }
855}