use std::io::Read;
use std::path::PathBuf;
use inquire::{Confirm, Text};
use owo_colors::OwoColorize;
use crate::error::Result;
use crate::output::{ctx, print_success};
pub struct AuthArgs {
pub key: Option<String>,
pub key_stdin: bool,
pub force: bool,
}
pub fn credentials_path() -> Option<PathBuf> {
let base = dirs::config_dir().or_else(dirs::home_dir)?;
Some(base.join("partiri").join("key"))
}
pub fn read_key() -> Option<String> {
let path = credentials_path()?;
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn run(args: AuthArgs) -> Result<()> {
let creds = credentials_path()
.ok_or("Could not determine config directory. Set $HOME or $XDG_CONFIG_HOME.")?;
let non_interactive = args.key.is_some() || args.key_stdin || ctx().no_input;
if !non_interactive {
println!("\n{}\n", " partiri auth".bold().cyan());
}
if let Some(key) = args.key {
return write_validated_key(&creds, key, args.force);
}
if args.key_stdin {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("Failed to read API key from stdin: {e}"))?;
return write_validated_key(&creds, buf, args.force);
}
if ctx().no_input {
return Err(
"auth requires --key, --key-stdin, or an interactive terminal. Pass --key <KEY> or --key-stdin."
.into(),
);
}
let current = read_key();
match current {
Some(ref existing) => {
let masked = mask_key(existing);
println!(" Current key: {}", masked.bold());
let update = Confirm::new("Replace it with a new key?")
.with_default(false)
.prompt()
.map_err(|_| "Cancelled.")?;
if !update {
return Ok(());
}
}
None => {
println!(
" {}",
"No API key configured. Get one from https://partiri.cloud/settings/api-keys"
.dimmed()
);
}
}
let key = Text::new("Paste your API key:")
.prompt()
.map_err(|_| "Cancelled.")?;
write_validated_key(&creds, key, true)
}
fn write_validated_key(creds: &PathBuf, raw: String, force: bool) -> Result<()> {
let key = raw.trim().to_string();
if key.is_empty() {
return Err("API key cannot be empty.".into());
}
if key.len() < 64 {
return Err("Api Key does not look right. Check that you pasted the full key.".into());
}
if key.chars().any(|c| c.is_control()) {
return Err("API key contains control characters; check the value you provided.".into());
}
if !force && !ctx().no_input && read_key().is_some() {
return Err(
"An API key is already configured. Pass --force to overwrite, or run 'partiri auth' with no flags."
.into(),
);
}
save_credentials_file(creds, &key)
.map_err(|e| format!("Failed to write credentials to {}: {e}", creds.display()))?;
let saved = std::fs::read_to_string(creds)
.map(|s| s.trim().to_string())
.unwrap_or_default();
if saved != key {
return Err(
"Key file was written but content does not match. Check file permissions.".into(),
);
}
print_success(&format!("Key saved to {}.", creds.display()));
Ok(())
}
fn save_credentials_file(path: &PathBuf, key: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(key.as_bytes())?;
}
#[cfg(not(unix))]
{
std::fs::write(path, key)?;
}
Ok(())
}
fn mask_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
if chars.len() <= 4 {
return "****".to_string();
}
let head: String = chars[..4].iter().collect();
let tail: String = chars[chars.len() - 4..].iter().collect();
format!("{head}…{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn mask_key_empty_returns_stars() {
assert_eq!(mask_key(""), "****");
}
#[test]
fn mask_key_three_chars_returns_stars() {
assert_eq!(mask_key("abc"), "****");
}
#[test]
fn mask_key_exactly_four_returns_stars() {
assert_eq!(mask_key("abcd"), "****");
}
#[test]
fn mask_key_five_chars_shows_overlap() {
let result = mask_key("abcde");
assert!(
result.starts_with("abcd"),
"should start with first 4 chars"
);
assert!(result.ends_with("bcde"), "should end with last 4 chars");
}
#[test]
fn mask_key_long_shows_first_and_last_four() {
let result = mask_key("abcdefghij");
assert!(result.starts_with("abcd"), "should start with first 4");
assert!(result.ends_with("ghij"), "should end with last 4");
assert!(!result.contains("efgh"));
}
#[test]
fn mask_key_contains_ellipsis_separator() {
let result = mask_key("abcdefghij");
assert!(
result.contains('\u{2026}'),
"should use ellipsis separator: {}",
result
);
}
#[test]
fn credentials_path_returns_some() {
assert!(credentials_path().is_some());
}
#[test]
fn credentials_path_ends_with_partiri_key() {
let path = credentials_path().unwrap();
assert!(
path.ends_with("partiri/key"),
"expected …/partiri/key, got {:?}",
path
);
}
#[test]
fn save_credentials_creates_dirs_and_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("partiri").join("key");
save_credentials_file(&path, "my-secret-key").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "my-secret-key");
}
#[test]
fn save_credentials_overwrites_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("partiri").join("key");
save_credentials_file(&path, "old-key").unwrap();
save_credentials_file(&path, "new-key").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert_eq!(content, "new-key");
}
}