me-sh 0.2.0

Command-line tools for me.sh.
use crate::prelude::*;

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct MeshConfig {
    pub(crate) auth: Option<AuthTokens>,
    pub(crate) user: Option<MeshUser>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct AuthTokens {
    pub(crate) access_token: String,
    pub(crate) refresh_token: String,
    pub(crate) expires_in: u64,
    #[serde(default)]
    pub(crate) expires_at: u64,
    pub(crate) token_type: Option<String>,
    pub(crate) scope: Option<String>,
}

#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub(crate) struct MeshUser {
    pub(crate) id: Option<Value>,
    pub(crate) email: Option<String>,
    pub(crate) first_name: Option<String>,
    pub(crate) last_name: Option<String>,
}

pub(crate) fn read_config_file(path: &Path) -> Result<Option<MeshConfig>> {
    match fs::read_to_string(path) {
        Ok(content) => serde_json::from_str(&content)
            .into_diagnostic()
            .wrap_err_with(|| format!("parsing {}", path.display()))
            .map(Some),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
        Err(error) => Err(error)
            .into_diagnostic()
            .wrap_err_with(|| format!("reading {}", path.display())),
    }
}

pub(crate) fn default_config_path() -> PathBuf {
    let home = BaseDirs::new()
        .map(|dirs| dirs.home_dir().to_path_buf())
        .or_else(|| std::env::var_os("HOME").map(PathBuf::from))
        .unwrap_or_else(|| PathBuf::from("."));
    home.join(".config").join(CONFIG_FILE)
}

pub(crate) fn legacy_config_paths_for(config_path: &Path) -> Vec<PathBuf> {
    LEGACY_CONFIG_FILES
        .iter()
        .map(|file| {
            config_path
                .parent()
                .map(|parent| parent.join(file))
                .unwrap_or_else(|| PathBuf::from(file))
        })
        .collect()
}

pub(crate) fn token_expired(auth: &AuthTokens) -> bool {
    now_millis().saturating_add(60_000) >= auth.expires_at
}

pub(crate) fn tokens_from_value(value: Value) -> Result<AuthTokens> {
    let mut auth: AuthTokens = serde_json::from_value(value)
        .into_diagnostic()
        .wrap_err("decoding OAuth token response")?;
    if auth.expires_at == 0 {
        auth.expires_at = now_millis().saturating_add(auth.expires_in.saturating_mul(1000));
    }
    Ok(auth)
}

pub(crate) fn user_to_map(user: MeshUser) -> Map<String, Value> {
    serde_json::to_value(user)
        .ok()
        .and_then(|value| value.as_object().cloned())
        .unwrap_or_default()
}

pub(crate) fn redact_config_value(config: &MeshConfig) -> Value {
    let mut value = serde_json::to_value(config).unwrap_or(Value::Null);
    if let Some(auth) = value.get_mut("auth").and_then(Value::as_object_mut) {
        for key in ["access_token", "refresh_token"] {
            if let Some(Value::String(token)) = auth.get_mut(key) {
                *token = redact_token(token);
            }
        }
    }
    value
}

pub(crate) fn redact_token(token: &str) -> String {
    let char_count = token.chars().count();
    if char_count <= 12 {
        return "<redacted>".to_string();
    }
    let prefix = token.chars().take(6).collect::<String>();
    let suffix = token.chars().skip(char_count - 4).collect::<String>();
    format!("{prefix}...{suffix}")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn redact_token_keeps_ascii_shape() {
        assert_eq!(redact_token("abcdefghijklmnop"), "abcdef...mnop");
    }

    #[test]
    fn redact_token_handles_non_ascii_tokens() {
        let token = "😀😁😂😃😄😅😆😉😊😋😎😍😘";

        assert_eq!(redact_token(token), "😀😁😂😃😄😅...😋😎😍😘");
    }
}