mise 2026.2.24

The front-end to your dev env
#![allow(unknown_lints)]
#![allow(clippy::literal_string_with_formatting_args)]
use std::fmt::{Display, Formatter};

use crate::config::Settings;
use crate::env::{self};
use crate::shell::{self, ActivateOptions, Shell};
use indoc::formatdoc;
use itertools::Itertools;
use shell_escape::unix::escape;

#[derive(Default)]
pub struct Fish {}

impl Fish {}

impl Shell for Fish {
    fn activate(&self, opts: ActivateOptions) -> String {
        let exe = opts.exe;
        let flags = opts.flags;

        let exe = escape(exe.to_string_lossy());
        let description = "'Update mise environment when changing directories'";
        let mut out = String::new();

        out.push_str(&shell::build_deactivation_script(self));

        out.push_str(&self.format_activate_prelude(&opts.prelude));

        // much of this is from direnv
        // https://github.com/direnv/direnv/blob/cb5222442cb9804b1574954999f6073cc636eff0/internal/cmd/shell_fish.go#L14-L36
        out.push_str(&formatdoc! {r#"
            set -gx MISE_SHELL fish
            if not set -q __MISE_ORIG_PATH
                set -gx __MISE_ORIG_PATH $PATH
            end

            function mise
              if test (count $argv) -eq 0
                command {exe}
                return
              end

              set command $argv[1]
              set -e argv[1]

              if contains -- --help $argv
                command {exe} "$command" $argv
                return $status
              end

              switch "$command"
              case deactivate shell sh
                # if help is requested, don't eval
                if contains -- -h $argv
                  command {exe} "$command" $argv
                else if contains -- --help $argv
                  command {exe} "$command" $argv
                else
                  source (command {exe} "$command" $argv |psub)
                end
              case '*'
                command {exe} "$command" $argv
              end
            end
        "#});

        if !opts.no_hook_env {
            out.push_str(&formatdoc! {r#"

            function __mise_env_eval --on-event fish_prompt --description {description};
                {exe} hook-env{flags} -s fish | source;

                if test "$mise_fish_mode" != "disable_arrow";
                    function __mise_cd_hook --on-variable PWD --description {description};
                        if test "$mise_fish_mode" = "eval_after_arrow";
                            set -g __mise_env_again 0;
                        else;
                            {exe} hook-env{flags} -s fish | source;
                        end;
                    end;
                end;
            end;

            function __mise_env_eval_2 --on-event fish_preexec --description {description};
                if set -q __mise_env_again;
                    set -e __mise_env_again;
                    {exe} hook-env{flags} -s fish | source;
                    echo;
                end;

                functions --erase __mise_cd_hook;
            end;

            __mise_env_eval
        "#});
        }
        if Settings::get().not_found_auto_install {
            out.push_str(&formatdoc! {r#"
            if functions -q fish_command_not_found; and not functions -q __mise_fish_command_not_found
                functions -e __mise_fish_command_not_found
                functions -c fish_command_not_found __mise_fish_command_not_found
            end

            function fish_command_not_found
                if string match -qrv -- '^(?:mise$|mise-)' $argv[1] &&
                    {exe} hook-not-found -s fish -- $argv[1]
                    {exe} hook-env{flags} -s fish | source
                else if functions -q __mise_fish_command_not_found
                    __mise_fish_command_not_found $argv
                else
                    __fish_default_command_not_found_handler $argv
                end
            end
            "#});
        }

        out
    }

    fn deactivate(&self) -> String {
        formatdoc! {r#"
          functions --erase __mise_env_eval
          functions --erase __mise_env_eval_2
          functions --erase __mise_cd_hook
          functions --erase mise
          set -e MISE_SHELL
          set -e __MISE_DIFF
          set -e __MISE_SESSION
        "#}
    }

    fn set_env(&self, key: &str, v: &str) -> String {
        let k = escape(key.into());
        // Fish uses space-separated list for PATH, not colon-separated string
        if key == "PATH" {
            let paths = v.split(':').map(|p| escape(p.into())).join(" ");
            format!("set -gx PATH {paths}\n")
        } else {
            let v = escape(v.into());
            format!("set -gx {k} {v}\n")
        }
    }

    fn prepend_env(&self, key: &str, value: &str) -> String {
        let k = escape(key.into());

        match key {
            env_key if env_key == *env::PATH_KEY => env::split_paths(value)
                .filter_map(|path| {
                    let path_str = path.to_str()?;
                    if path_str.is_empty() {
                        None
                    } else {
                        Some(format!(
                            "fish_add_path --global --path {}\n",
                            escape(path_str.into())
                        ))
                    }
                })
                .collect::<String>(),
            _ => {
                let v = escape(value.into());
                format!("set -gx {k} {v} ${k}\n")
            }
        }
    }

    fn unset_env(&self, k: &str) -> String {
        format!("set -e {k}\n", k = escape(k.into()))
    }

    fn set_alias(&self, name: &str, cmd: &str) -> String {
        let name = escape(name.into());
        let cmd = escape(cmd.into());
        format!("complete -e {name}\nalias {name} {cmd}\n")
    }

    fn unset_alias(&self, name: &str) -> String {
        let name = escape(name.into());
        format!("complete -e {name}\nfunctions -e {name}\n")
    }
}

impl Display for Fish {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "fish")
    }
}

#[cfg(all(test, not(windows)))]
mod tests {
    use insta::assert_snapshot;
    use std::path::Path;
    use test_log::test;

    use crate::test::replace_path;

    use super::*;

    #[test]
    fn test_activate() {
        // Unset __MISE_ORIG_PATH to avoid PATH restoration logic in output
        unsafe {
            std::env::remove_var("__MISE_ORIG_PATH");
            std::env::remove_var("__MISE_DIFF");
        }

        let fish = Fish::default();
        let exe = Path::new("/some/dir/mise");
        let opts = ActivateOptions {
            exe: exe.to_path_buf(),
            flags: " --status".into(),
            no_hook_env: false,
            prelude: vec![],
        };
        assert_snapshot!(fish.activate(opts));
    }

    #[test]
    fn test_set_env() {
        assert_snapshot!(Fish::default().set_env("FOO", "1"));
    }

    #[test]
    fn test_prepend_env() {
        let sh = Fish::default();
        assert_snapshot!(replace_path(&sh.prepend_env("PATH", "/some/dir:/2/dir")));
    }

    #[test]
    fn test_unset_env() {
        assert_snapshot!(Fish::default().unset_env("FOO"));
    }

    #[test]
    fn test_deactivate() {
        let deactivate = Fish::default().deactivate();
        assert_snapshot!(replace_path(&deactivate));
    }
}