use anyhow::{bail, Context, Result};
use std::path::PathBuf;
const GIT_COMMIT_HASH: &str = env!("GIT_COMMIT_HASH");
const GITHUB_RAW_BASE: &str =
"https://raw.githubusercontent.com/atteo/curie-build";
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Shell {
Fish,
Bash,
Zsh,
}
impl Shell {
fn source_filename(self) -> &'static str {
match self {
Shell::Fish => "curie.fish",
Shell::Bash => "curie.bash",
Shell::Zsh => "curie.zsh",
}
}
fn dest_filename(self) -> &'static str {
match self {
Shell::Fish => "curie.fish",
Shell::Bash => "curie",
Shell::Zsh => "_curie",
}
}
fn dest_dir(self) -> Result<PathBuf> {
match self {
Shell::Fish => {
let config = dirs::config_dir()
.context("cannot determine XDG config directory")?;
Ok(config.join("fish/completions"))
}
Shell::Bash => {
let data = dirs::data_local_dir()
.context("cannot determine XDG local data directory")?;
Ok(data.join("bash-completion/completions"))
}
Shell::Zsh => {
let home = dirs::home_dir()
.context("cannot determine home directory")?;
Ok(home.join(".zsh/completions"))
}
}
}
fn dest_path(self) -> Result<PathBuf> {
Ok(self.dest_dir()?.join(self.dest_filename()))
}
}
impl std::fmt::Display for Shell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Shell::Fish => write!(f, "fish"),
Shell::Bash => write!(f, "bash"),
Shell::Zsh => write!(f, "zsh"),
}
}
}
pub fn shell_from_name(name: &str) -> Result<Shell> {
match name {
"fish" => Ok(Shell::Fish),
"bash" => Ok(Shell::Bash),
"zsh" => Ok(Shell::Zsh),
other => bail!(
"unsupported shell {:?}; supported shells are: fish, bash, zsh",
other
),
}
}
pub fn detect_shell() -> Result<Shell> {
let shell_path = std::env::var("SHELL")
.context("$SHELL is not set; use --shell <fish|bash|zsh> to specify")?;
let shell_name = shell_name_from_path(&shell_path);
shell_from_name(shell_name).with_context(|| {
format!("$SHELL is {shell_path:?}; use --shell <fish|bash|zsh> to override")
})
}
fn shell_name_from_path(path: &str) -> &str {
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
}
fn completion_url(shell: Shell) -> String {
format!(
"{}/{}/completions/{}",
GITHUB_RAW_BASE,
GIT_COMMIT_HASH,
shell.source_filename(),
)
}
fn download_text(url: &str) -> Result<String> {
let client = reqwest::blocking::Client::builder()
.user_agent("curie/setup")
.timeout(std::time::Duration::from_secs(30))
.build()
.context("failed to build HTTP client")?;
let response = client
.get(url)
.send()
.with_context(|| format!("failed to connect to {url}"))?;
if !response.status().is_success() {
bail!("server returned {} for {url}", response.status());
}
response.text().context("failed to read response body")
}
fn write_completion(content: &str, dest: &std::path::Path) -> Result<()> {
let dir = dest.parent().expect("dest path has no parent");
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
std::fs::write(dest, content)
.with_context(|| format!("failed to write {}", dest.display()))
}
fn post_install_hint(shell: Shell, dest: &std::path::Path) {
match shell {
Shell::Fish | Shell::Bash => {
println!(
" Open a new terminal, or run: source {}",
dest.display()
);
}
Shell::Zsh => {
println!(" Add to ~/.zshrc if not already present:");
println!(
" fpath=({} $fpath)",
dest.parent().map(|p| p.display().to_string()).unwrap_or_default()
);
println!(" autoload -Uz compinit && compinit");
println!(" Then open a new terminal.");
}
}
}
pub fn run_setup(shell_override: Option<String>) -> Result<()> {
let shell = match shell_override {
Some(ref name) => shell_from_name(name)?,
None => detect_shell()?,
};
let url = completion_url(shell);
let dest = shell.dest_path()?;
println!(" Shell {shell}");
println!(" Downloading {url}");
let content = download_text(&url)?;
write_completion(&content, &dest)?;
println!(" Installed {}", dest.display());
post_install_hint(shell, &dest);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_name_from_absolute_path() {
assert_eq!(shell_name_from_path("/usr/bin/fish"), "fish");
assert_eq!(shell_name_from_path("/bin/bash"), "bash");
assert_eq!(shell_name_from_path("/usr/local/bin/zsh"), "zsh");
}
#[test]
fn shell_from_name_roundtrip() {
assert_eq!(shell_from_name("fish").unwrap(), Shell::Fish);
assert_eq!(shell_from_name("bash").unwrap(), Shell::Bash);
assert_eq!(shell_from_name("zsh").unwrap(), Shell::Zsh);
assert!(shell_from_name("tcsh").is_err());
assert!(shell_from_name("").is_err());
}
#[test]
fn completion_url_contains_commit_and_filename() {
let url = completion_url(Shell::Fish);
assert!(url.contains(GIT_COMMIT_HASH), "URL should contain commit hash");
assert!(url.ends_with("curie.fish"), "fish URL should end with curie.fish");
let url = completion_url(Shell::Bash);
assert!(url.ends_with("curie.bash"));
let url = completion_url(Shell::Zsh);
assert!(url.ends_with("curie.zsh"));
}
#[test]
fn dest_filenames_follow_conventions() {
assert_eq!(Shell::Fish.dest_filename(), "curie.fish");
assert_eq!(Shell::Bash.dest_filename(), "curie");
assert_eq!(Shell::Zsh.dest_filename(), "_curie");
}
#[test]
fn dest_path_includes_filename() {
let fish_dest = Shell::Fish.dest_path().unwrap();
assert_eq!(fish_dest.file_name().unwrap(), "curie.fish");
let bash_dest = Shell::Bash.dest_path().unwrap();
assert_eq!(bash_dest.file_name().unwrap(), "curie");
let zsh_dest = Shell::Zsh.dest_path().unwrap();
assert_eq!(zsh_dest.file_name().unwrap(), "_curie");
}
}