use std::path::{Path, PathBuf};
use dialoguer::{Select, theme::ColorfulTheme};
use serde::{Deserialize, Serialize};
use crate::email::provider::Provider;
pub const CONFIG_DIR_ENV_VAR: &str = "PIGEON_CONFIG_DIR";
const IDENTITIES_FILE_NAME: &str = "identities.toml";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Identity {
pub alias: String,
pub email: String,
pub provider: Provider,
pub host: String,
pub port: u16,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoreFile {
#[serde(default)]
identities: Vec<Identity>,
}
#[derive(Debug, Default)]
pub struct Store {
identities: Vec<Identity>,
}
impl Store {
pub fn default_path() -> Result<PathBuf, String> {
if let Ok(dir) = std::env::var(CONFIG_DIR_ENV_VAR) {
return Ok(PathBuf::from(dir).join(IDENTITIES_FILE_NAME));
}
let project_dirs = directories::ProjectDirs::from("", "", "pigeon")
.ok_or("could not determine the config directory for this platform")?;
Ok(project_dirs.config_dir().join(IDENTITIES_FILE_NAME))
}
pub fn load(path: &Path) -> Result<Store, String> {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Store::default());
}
Err(err) => return Err(format!("failed to read {}: {err}", path.display())),
};
let file: StoreFile = toml::from_str(&contents)
.map_err(|err| format!("failed to parse {}: {err}", path.display()))?;
Ok(Store {
identities: file.identities,
})
}
pub fn save(&self, path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
let file = StoreFile {
identities: self.identities.clone(),
};
let contents = toml::to_string_pretty(&file)
.map_err(|err| format!("failed to serialize identities: {err}"))?;
std::fs::write(path, contents)
.map_err(|err| format!("failed to write {}: {err}", path.display()))
}
pub fn contains_alias(&self, alias: &str) -> bool {
self.identities
.iter()
.any(|identity| identity.alias == alias)
}
pub fn push(&mut self, identity: Identity) {
self.identities.push(identity);
}
pub fn iter(&self) -> impl Iterator<Item = &Identity> {
self.identities.iter()
}
pub fn is_empty(&self) -> bool {
self.identities.is_empty()
}
pub fn prompt_select(&self) -> Result<&Identity, String> {
match self.identities.as_slice() {
[] => {
Err("no identities configured; run 'pigeon email authenticate' first".to_string())
}
[only] => Ok(only),
identities => {
let labels: Vec<String> = identities
.iter()
.map(|identity| {
format!(
"{} ({}, {})",
identity.alias, identity.email, identity.provider
)
})
.collect();
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select an identity")
.items(&labels)
.default(0)
.interact()
.map_err(|err| format!("failed to read identity selection: {err}"))?;
Ok(&identities[selection])
}
}
}
}
const MAX_SEGMENT_LENGTH: usize = 100;
pub fn sanitize_segment(input: &str) -> String {
let mut segment = String::with_capacity(input.len());
let mut last_was_hyphen = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
segment.push(ch.to_ascii_lowercase());
last_was_hyphen = false;
} else if !last_was_hyphen && !segment.is_empty() {
segment.push('-');
last_was_hyphen = true;
}
}
segment.truncate(MAX_SEGMENT_LENGTH);
if segment.ends_with('-') {
segment.pop();
}
segment
}
pub fn sanitize_alias(email: &str) -> String {
let local_part = email.split('@').next().unwrap_or(email);
sanitize_segment(local_part)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitizes_dotted_local_part() {
assert_eq!(sanitize_alias("first.last@example.com"), "first-last");
}
#[test]
fn sanitizes_plus_addressing() {
assert_eq!(sanitize_alias("jane+work@example.com"), "jane-work");
}
#[test]
fn sanitize_segment_truncates_long_input_with_no_trailing_hyphen() {
let long_subject = "word ".repeat(50); let segment = sanitize_segment(&long_subject);
assert!(segment.len() <= MAX_SEGMENT_LENGTH);
assert!(!segment.ends_with('-'));
}
#[test]
fn round_trips_through_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("identities.toml");
let mut store = Store::default();
store.push(Identity {
alias: "first-last".to_string(),
email: "first.last@example.com".to_string(),
provider: Provider::Gmail,
host: "imap.gmail.com".to_string(),
port: 993,
});
store.save(&path).unwrap();
let loaded = Store::load(&path).unwrap();
assert!(loaded.contains_alias("first-last"));
assert_eq!(loaded.iter().count(), 1);
}
#[test]
fn missing_file_is_empty_store() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist.toml");
let loaded = Store::load(&path).unwrap();
assert!(loaded.is_empty());
}
}