atuin 18.24.0

atuin - magical shell history
use std::convert::Infallible;
use std::io::Read;
use std::str::FromStr;
use std::sync::Arc;

use atuin_client::record::sqlite_store::SqliteStore;
use atuin_client::settings::Settings;
use atuin_common::env::var_nonempty;
use clap::{Args, Subcommand};
use eyre::{Context, Result};
use secrecy::SecretString;
use secrecy::zeroize::Zeroizing;
use tracing::instrument;

use crate::i18n::fl;

pub mod change_password;
pub mod delete;
pub mod link;
pub mod login;
pub mod logout;
pub mod register;

const PASSWORD_ENV: &str = "ATUIN_PASSWORD";

/// A `--password` value: the password itself, or `-` to read it from stdin.
#[derive(Clone, Debug)]
pub enum PasswordArg {
    Stdin,
    Value(SecretString),
}

impl FromStr for PasswordArg {
    type Err = Infallible;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Ok(match s {
            "-" => Self::Stdin,
            password => Self::Value(password.into()),
        })
    }
}

impl PasswordArg {
    /// Resolve `arg`, falling back to `ATUIN_PASSWORD`, if set.
    ///
    /// [`Self::Stdin`] reads `stdin` to its end and drops the trailing newline.
    pub fn resolve(arg: Option<&Self>, mut stdin: impl Read) -> Result<Option<SecretString>> {
        let Some(arg) = arg else {
            return Ok(var_nonempty(PASSWORD_ENV)
                .and_then(|password| password.into_string().ok())
                .map(SecretString::from));
        };

        match arg {
            Self::Value(password) => Ok(Some(password.clone())),
            Self::Stdin => {
                let mut buf = Zeroizing::new(String::new());
                stdin.read_to_string(&mut buf).context(fl!("account-password-stdin-failed"))?;
                Ok(Some(buf.trim_end_matches(['\r', '\n']).into()))
            }
        }
    }
}

#[derive(Args, Debug)]
pub struct Cmd {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand, Debug)]
pub enum Commands {
    #[command(about = fl!("cmd-login"))]
    Login(login::Cmd),

    #[command(about = fl!("cmd-account-register"))]
    Register(register::Cmd),

    #[command(about = fl!("cmd-logout"))]
    Logout,

    #[command(about = fl!("cmd-account-delete"))]
    Delete(delete::Cmd),

    #[command(about = fl!("cmd-account-change-password"))]
    ChangePassword(change_password::Cmd),

    #[command(about = fl!("cmd-account-link"))]
    Link,
}

impl Cmd {
    #[instrument(level = "trace", skip_all, err)]
    pub async fn run(self, settings: &Arc<Settings>, store: SqliteStore) -> Result<()> {
        match self.command {
            Commands::Login(l) => l.run(settings, &store).await,
            Commands::Register(r) => r.run(settings, &store).await,
            Commands::Logout => logout::run(settings).await,
            Commands::Delete(d) => d.run(settings).await,
            Commands::ChangePassword(c) => c.run(settings).await,
            Commands::Link => link::run(settings).await,
        }
    }
}

#[cfg(test)]
mod tests {
    use rstest::rstest;
    use secrecy::ExposeSecret;

    use super::*;

    #[rstest]
    #[case::value("hunter2", "stdin", "hunter2")]
    #[case::stdin("-", "hunter2\n", "hunter2")]
    #[case::stdin_crlf("-", "hunter2\r\n", "hunter2")]
    #[case::stdin_keeps_inner_whitespace("-", " hunter 2\n", " hunter 2")]
    fn resolve_reads_value_or_stdin(
        #[case] arg: &str,
        #[case] stdin: &str,
        #[case] expected: &str,
    ) {
        let arg: PasswordArg = arg.parse().unwrap();
        let password = PasswordArg::resolve(Some(&arg), stdin.as_bytes()).unwrap();
        assert_eq!(password.as_ref().map(ExposeSecret::expose_secret), Some(expected));
    }
}