broot 1.56.0

File browser and launcher
Documentation
//! The goal of this mod is to ensure the launcher shell function
//! is available for PowerShell i.e. the `br` shell function can
//! be used to launch broot (and thus make it possible to execute
//! some commands, like `cd`, from the starting shell.
//!
//! In a correct installation, we have:
//! - a function declaration script in %APPDATA%/dystroy/broot/data/launcher/powershell/1
//! - a link to that script in %APPDATA%/dystroy/broot/config/launcher/powershell/br.ps1
//! - a line to source the link in the PowerShell profile (detected dynamically)
//!
//! The profile is detected by running pwsh.exe first, then
//! powershell.exe if pwsh is not found. If neither is found, it defaults to
//! %USERPROFILE%/Documents/WindowsPowerShell/Microsoft.PowerShell_profile.ps1

use {
    super::{
        ShellInstall,
        util,
    },
    crate::{
        conf,
        errors::*,
    },
    directories::UserDirs,
    std::{
        fs,
        path::PathBuf,
        process::Command,
    },
    termimad::mad_print_inline,
};

const NAME: &str = "powershell";
const VERSION: &str = "1";

const PS_FUNC: &str = r#"
# https://github.com/Canop/broot/issues/460#issuecomment-1303005689
Function br {
  $args = $args -join ' '
  $cmd_file = New-TemporaryFile

  try {
    $process = Start-Process -FilePath 'broot.exe' `
                            -ArgumentList "--outcmd $($cmd_file.FullName) $args" `
                            -NoNewWindow -PassThru -WorkingDirectory $PWD

    Wait-Process -InputObject $process #Faster than Start-Process -Wait
    If ($process.ExitCode -eq 0) {
        $cmd = Get-Content $cmd_file
        If ($cmd -ne $null) { Invoke-Expression -Command $cmd }
    } Else {
        Write-Host "`n" # Newline to tidy up broot unexpected termination
        Write-Error "broot.exe exited with error code $($process.ExitCode)"
    }
  } finally {
    Remove-Item $cmd_file
  }
}
"#;

pub fn get_script() -> &'static str {
    PS_FUNC
}

/// return the path to the link to the function script
fn get_link_path() -> PathBuf {
    conf::dir().join("launcher").join(NAME).join("br.ps1")
}

/// return the path to the script containing the function.
///
/// In XDG_DATA_HOME (typically ~/.local/share on linux)
fn get_script_path() -> PathBuf {
    conf::app_dirs()
        .data_dir()
        .join("launcher")
        .join(NAME)
        .join(VERSION)
}

/// Get PowerShell's $profile by invoking pwsh or powershell.
/// Returns None if the executable isn't present in environment
/// path or the call fails
fn get_profile(exe: &str) -> Option<PathBuf> {
    let output = Command::new(exe)
        .args(["-NoProfile", "-NoLogo", "-Command", "Write-Output", "$profile"])
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if s.is_empty() {
        None
    } else {
        Some(PathBuf::from(s))
    }
}

/// Check whether the shell function is installed, install
/// it if it wasn't refused before or if broot is launched
/// with --install.
#[allow(unreachable_code, unused_variables)]
pub fn install(si: &mut ShellInstall) -> Result<(), ShellInstallError> {
    info!("install {NAME}");
    #[cfg(unix)]
    {
        debug!("Shell install not supported for PowerShell on unix-based systems.");
        return Ok(());
    }
    let Some(user_dir) = UserDirs::new() else {
        warn!("Could not find user directory.");
        return Ok(());
    };
    let Some(document_dir) = user_dir.document_dir() else {
        warn!("Could not find user documents directory.");
        return Ok(());
    };

    let script_path = get_script_path();
    si.write_script(&script_path, PS_FUNC)?;
    let link_path = get_link_path();
    si.create_link(&link_path, &script_path)?;

    let escaped_path = link_path.to_string_lossy().replace('\'', "''");
    let source_line = format!(". '{}'", escaped_path);

    let sourcing_path = get_profile("pwsh")
        .or_else(|| get_profile("powershell"))
        .unwrap_or_else(|| document_dir.join("WindowsPowerShell").join("Microsoft.PowerShell_profile.ps1"));
    if !sourcing_path.exists() {
        debug!("Creating missing PowerShell profile file.");
        if let Some(parent) = sourcing_path.parent() {
            fs::create_dir_all(parent).context(&|| format!("creating {parent:?} directory"))?;
        }
        fs::File::create(&sourcing_path).context(&|| format!("creating {sourcing_path:?}"))?;
    }
    let sourcing_path_str = sourcing_path.to_string_lossy();
    if util::file_contains_line(&sourcing_path, &source_line)? {
        mad_print_inline!(
            &si.skin,
            "`$0` already patched, no change made.\n",
            &sourcing_path_str,
        );
    } else {
        util::append_to_file(&sourcing_path, format!("\n{source_line}\n"))?;
        mad_print_inline!(&si.skin, "`$0` successfully patched.\n", &sourcing_path_str,);
    }
    si.done = true;
    Ok(())
}