Skip to main content

lore/shell/
mod.rs

1//! Shell integration: the snippet each shell needs, and where it goes.
2//!
3//! The keybinding has to live inside the shell process, so there is no way to
4//! set it up without touching a profile file. Every tool in this category works
5//! the same way.
6
7pub 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/// Shells that lore ships a keybinding integration for.
23// Renaming `PowerShell` to satisfy `enum_variant_names` would misrepresent
24// Windows PowerShell 5.1, which is a supported target alongside PowerShell 7.
25#[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
35/// Marks the place in every snippet where the chord goes.
36const CHORD: &str = "{{chord}}";
37
38/// Wraps the generated block so setup can find and remove it later.
39const BEGIN: &str = "# >>> lore >>>";
40const END: &str = "# <<< lore <<<";
41
42/// Directories a shell already has on its PATH before any profile runs.
43/// Guarding one of these would be noise.
44const 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
73/// The integration code for a shell, with the chord written into it.
74///
75/// Compiled in and substituted once. This runs on every shell start, so it
76/// reads no files and does no work beyond printing: a slow one is the most
77/// common reason people uninstall tools of this kind.
78pub 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
89/// The single line a profile needs.
90///
91/// The snippet is fetched from the binary rather than written into the profile
92/// so that an upgraded binary cannot disagree with a stale copy on disk. The
93/// chord travels here rather than in a config file because `init` runs on every
94/// shell start and is not allowed to read one.
95pub fn init_line(shell: Shell, chord: Chord) -> String {
96    // A profile that keeps the default reads exactly as it always did.
97    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
113/// The shell that invoked lore, as far as it can be told.
114pub 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    // Git Bash and WSL both set SHELL, so reaching here on Windows means the
122    // caller is a PowerShell host.
123    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
137/// A profile file the integration can be written into.
138pub struct Profile {
139    pub path: PathBuf,
140    /// How to name this file when talking to the user.
141    pub label: String,
142}
143
144/// Every profile that needs the integration for this shell.
145///
146/// PowerShell returns more than one: Windows PowerShell 5.1 and PowerShell 7
147/// keep entirely separate profiles, and a machine commonly has both.
148pub 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
180/// Asks each installed PowerShell host where its profile lives.
181///
182/// The path cannot be assembled by hand: `Documents` is frequently redirected
183/// into OneDrive, and only the host itself knows where it ended up.
184fn 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
225/// Adds the integration to every profile for `shell`.
226pub 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
284/// Removes the integration from every profile for `shell`.
285pub 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
320/// The line that puts the binary's own directory on PATH, when it needs one.
321///
322/// Ubuntu's `~/.profile` sources `~/.bashrc` and only then adds `~/.local/bin`
323/// to PATH, so a login shell reaches the block below with lore not yet
324/// findable. The eval produces nothing, no key is bound, and nothing says why.
325/// Every WSL terminal and every ssh session is a login shell, so this is the
326/// ordinary case rather than an exotic one.
327fn 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
334/// Split from `path_guard` so the quoting can be tested without installing
335/// anything anywhere.
336fn guard_line(shell: Shell, directory: &str) -> Option<String> {
337    // Windows composes a process's PATH before it starts, so a profile always
338    // runs with the whole of it.
339    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
355/// Wraps a path so a shell reads it literally, whatever it contains.
356fn posix_quoted(text: &str) -> String {
357    format!("'{}'", text.replace('\'', r"'\''"))
358}
359
360/// Fish reads a backslash inside single quotes as an escape, which no other
361/// posix shell does.
362fn fish_quoted(text: &str) -> String {
363    format!("'{}'", text.replace('\\', r"\\").replace('\'', r"\'"))
364}
365
366/// Drops the marked block, leaving everything the user wrote untouched.
367fn 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
390/// Copies a profile aside before it is written to.
391///
392/// The suffix is appended to the whole file name rather than replacing an
393/// extension. Every profile worth backing up is a dotfile, and `with_extension`
394/// treats `.bashrc` as having none, which produced `.bashrc..lore-backup`.
395fn 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
413/// A restricted execution policy stops profile scripts from running at all, so
414/// the integration would be installed and silently do nothing.
415fn 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    /// A snippet still carrying its placeholder would be handed to the shell
464    /// verbatim and bind nothing at all.
465    #[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    /// fish binds twice, once per mode, and a substitution that only reached
482    /// the first would leave vi mode dead.
483    #[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    /// A carriage return inside these snippets breaks them at source, which is
492    /// why the repository pins line endings.
493    #[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    /// The history is positional, so a blank entry would not be ignored: it
504    /// would shift every command after it by one. Windows PowerShell drops an
505    /// empty string from a native command's argument list outright.
506    #[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    /// The panel is drawn over the prompt row and erased on the way out, so the
516    /// prompt has to be put back by hand. Redrawing it only when lore failed
517    /// left an accepted command rendered on a bare row with no prompt in front
518    /// of it.
519    #[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    /// Windows rebuilds a child's argument list out of a single string, so a
536    /// command ending in a backslash escapes the quote meant to close it. The
537    /// history travels in a file everywhere rather than only where it has to.
538    #[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    /// The bug this guards against cost a release: the picker draws a panel
554    /// under the prompt, which means asking the terminal where the cursor is,
555    /// and that question goes out on stdout. A snippet that captured stdout to
556    /// read the result swallowed it, no answer ever came back, and the panel
557    /// never opened. It only worked on Windows, where the position is read from
558    /// the console rather than asked for.
559    #[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    /// The result file is the caller's to clean up, and it is one more than the
590    /// history file, so both have to be named on the way out.
591    /// Saving offers what is on the prompt line before anything in the
592    /// history, which only works if every shell actually hands the line over.
593    #[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    /// zle runs a widget's commands with stdin on /dev/null. The picker would
627    /// fall back to /dev/tty, which macOS refuses to poll, and wait forever
628    /// for a cursor position that never arrives.
629    #[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    /// The cursor offset only reaches the prompt if every snippet asks for it
660    /// and then puts it somewhere. A snippet that asked and ignored the answer
661    /// would insert the offset as part of the command.
662    #[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    /// The chord has to survive into the profile, and a profile that kept the
694    /// default has to keep the line it was written with.
695    #[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    /// The bug this guards against made lore look broken on every WSL terminal
707    /// and every ssh session: Ubuntu's ~/.profile sources ~/.bashrc and only
708    /// then adds ~/.local/bin to PATH, so the block ran with lore not yet
709    /// findable, the eval produced nothing, and no key was bound.
710    #[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    /// Windows composes a process's PATH before it starts, and a shell already
723    /// has the system directories, so a guard there is noise.
724    #[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    /// A guard that ran twice would put the directory on PATH twice, and one
734    /// that mangled a path with a space in it would put the wrong thing there.
735    #[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    /// Fish reads a backslash inside single quotes as an escape, which no other
749    /// posix shell does.
750    #[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    /// The guard lives inside the markers, so removing the block takes it with
759    /// it and leaves the profile as it was.
760    #[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    /// Every profile worth backing up is a dotfile, and with_extension treats
770    /// one as having no extension, which produced .bashrc..lore-backup.
771    #[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    /// A backslash only separates directories on Windows, which is also the only
835    /// place a path shaped like this can reach SHELL.
836    #[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}