use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::Duration;
use directories::UserDirs;
use serde::Deserialize;
use crate::infra::error::{CriewError, ErrorCode, Result};
pub const DEFAULT_SOURCE_MAILBOX: &str = "linux-kernel";
pub const IMAP_INBOX_MAILBOX: &str = "INBOX";
pub const DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS: u64 = 30;
const DEFAULT_LORE_BASE_URL: &str = "https://lore.kernel.org";
const DEFAULT_RUNTIME_DIR_NAME: &str = ".criew";
const DEFAULT_CONFIG_FILE_NAME: &str = "criew-config.toml";
const LEGACY_CONFIG_FILE_NAME: &str = "config.toml";
const DEFAULT_DATABASE_FILE_NAME: &str = "criew.db";
const GIT_EMAIL_LOOKUP_ARGS: &[&str] = &["config", "user.email"];
const DEFAULT_CONFIG_TEMPLATE: &str = r#"# Auto-generated by CRIEW.
# You can keep this file minimal; omitted storage paths default under ~/.criew.
[source]
mailbox = "linux-kernel"
[ui]
startup_sync = true
# keymap = "default" # Supported: default, vim, custom
# keymap_base = "default" # Base scheme used when keymap = "custom"
# [ui.custom_keymap]
# focus_prev = ["j"]
# focus_next = ["l"]
# move_up = ["i"]
# move_down = ["k"]
# jump_top = ["g", "g"]
# jump_bottom = ["G"]
# quick_quit = ["q", "q"]
# inbox_auto_sync_interval_secs = 30
[logging]
filter = "info"
"#;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ImapEncryption {
#[serde(alias = "ssl")]
Tls,
Starttls,
None,
}
impl ImapEncryption {
pub fn as_str(self) -> &'static str {
match self {
Self::Tls => "tls",
Self::Starttls => "starttls",
Self::None => "none",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UiKeymap {
#[default]
Default,
Vim,
Custom,
}
impl UiKeymap {
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Vim => "vim",
Self::Custom => "custom",
}
}
pub fn default_base(self) -> UiKeymapBase {
match self {
Self::Vim => UiKeymapBase::Vim,
Self::Default | Self::Custom => UiKeymapBase::Default,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UiKeymapBase {
#[default]
Default,
Vim,
}
impl UiKeymapBase {
pub fn as_str(self) -> &'static str {
match self {
Self::Default => "default",
Self::Vim => "vim",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UiCustomKeymapConfig {
pub focus_prev: Option<Vec<String>>,
pub focus_next: Option<Vec<String>>,
pub move_up: Option<Vec<String>>,
pub move_down: Option<Vec<String>>,
pub jump_top: Option<Vec<String>>,
pub jump_bottom: Option<Vec<String>>,
pub quick_quit: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default)]
pub struct ImapConfig {
pub email: Option<String>,
pub user: Option<String>,
pub pass: Option<String>,
pub server: Option<String>,
pub server_port: Option<u16>,
pub encryption: Option<ImapEncryption>,
pub proxy: Option<String>,
}
impl ImapConfig {
pub fn login_user(&self) -> Option<&str> {
self.user.as_deref().or(self.email.as_deref())
}
pub fn is_complete(&self) -> bool {
self.missing_required_fields().is_empty()
}
pub fn missing_required_fields(&self) -> Vec<&'static str> {
let mut missing = Vec::new();
if self.login_user().is_none() {
missing.push("imap.user");
}
if self.pass.is_none() {
missing.push("imap.pass");
}
if self.server.is_none() {
missing.push("imap.server");
}
if self.server_port.is_none() {
missing.push("imap.serverport");
}
if self.encryption.is_none() {
missing.push("imap.encryption");
}
missing
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelfEmailSource {
CriewImapConfig,
GitConfig,
}
impl SelfEmailSource {
pub fn as_str(self) -> &'static str {
match self {
Self::CriewImapConfig => "imap.email",
Self::GitConfig => "git config user.email",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SelfEmailResolution {
pub email: Option<String>,
pub source: Option<SelfEmailSource>,
pub git_error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RuntimeConfig {
pub config_path: PathBuf,
pub data_dir: PathBuf,
pub database_path: PathBuf,
pub raw_mail_dir: PathBuf,
pub patch_dir: PathBuf,
pub log_dir: PathBuf,
pub b4_path: Option<PathBuf>,
pub log_filter: String,
pub source_mailbox: String,
pub imap: ImapConfig,
pub lore_base_url: String,
pub startup_sync: bool,
pub ui_keymap: UiKeymap,
pub ui_keymap_base: UiKeymapBase,
pub ui_custom_keymap: UiCustomKeymapConfig,
pub inbox_auto_sync_interval_secs: u64,
pub kernel_trees: Vec<PathBuf>,
}
impl RuntimeConfig {
pub fn default_active_mailbox(&self) -> &str {
if self.imap.is_complete() {
IMAP_INBOX_MAILBOX
} else {
&self.source_mailbox
}
}
pub fn inbox_auto_sync_interval(&self) -> Duration {
Duration::from_secs(self.inbox_auto_sync_interval_secs)
}
}
#[derive(Debug, Default, Deserialize)]
struct FileConfig {
#[serde(default)]
storage: StorageConfig,
#[serde(default)]
b4: B4Config,
#[serde(default)]
logging: LoggingConfig,
#[serde(default)]
source: SourceConfig,
#[serde(default)]
ui: UiConfig,
#[serde(default)]
imap: ImapFileConfig,
#[serde(default)]
kernel: KernelConfig,
}
#[derive(Debug, Default, Deserialize)]
struct StorageConfig {
data_dir: Option<PathBuf>,
database: Option<PathBuf>,
patch_dir: Option<PathBuf>,
raw_mail_dir: Option<PathBuf>,
}
#[derive(Debug, Default, Deserialize)]
struct B4Config {
path: Option<PathBuf>,
}
#[derive(Debug, Default, Deserialize)]
struct LoggingConfig {
filter: Option<String>,
dir: Option<PathBuf>,
}
#[derive(Debug, Default, Deserialize)]
struct SourceConfig {
mailbox: Option<String>,
lore_base_url: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct UiConfig {
startup_sync: Option<bool>,
keymap: Option<UiKeymap>,
keymap_base: Option<UiKeymapBase>,
#[serde(default)]
custom_keymap: UiCustomKeymapFileConfig,
inbox_auto_sync_interval_secs: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
struct UiCustomKeymapFileConfig {
focus_prev: Option<Vec<String>>,
focus_next: Option<Vec<String>>,
move_up: Option<Vec<String>>,
move_down: Option<Vec<String>>,
jump_top: Option<Vec<String>>,
jump_bottom: Option<Vec<String>>,
quick_quit: Option<Vec<String>>,
}
#[derive(Debug, Default, Deserialize)]
struct ImapFileConfig {
mailbox: Option<String>,
email: Option<String>,
#[serde(alias = "imapuser")]
user: Option<String>,
#[serde(alias = "imappass")]
pass: Option<String>,
#[serde(alias = "imapserver")]
server: Option<String>,
#[serde(alias = "imapserverport")]
serverport: Option<u16>,
#[serde(alias = "imapencryption")]
encryption: Option<ImapEncryption>,
proxy: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct KernelConfig {
tree: Option<PathBuf>,
#[serde(default)]
trees: Vec<PathBuf>,
}
pub fn load(config_override: Option<&Path>) -> Result<RuntimeConfig> {
let home_dir = UserDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
.ok_or_else(|| {
CriewError::new(
ErrorCode::ConfigRead,
"failed to determine HOME directory for criew runtime",
)
})?;
load_with_home(config_override, &home_dir)
}
pub(crate) fn load_from_document(config_path: &Path, content: &str) -> Result<RuntimeConfig> {
let home_dir = UserDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
.ok_or_else(|| {
CriewError::new(
ErrorCode::ConfigRead,
"failed to determine HOME directory for criew runtime",
)
})?;
let default_root = home_dir.join(DEFAULT_RUNTIME_DIR_NAME);
let runtime_root = runtime_root_from_config_path(config_path, &default_root);
let file_config = parse_config_document(content, config_path)?;
build_runtime_config(config_path.to_path_buf(), file_config, &runtime_root)
}
fn load_with_home(config_override: Option<&Path>, home_dir: &Path) -> Result<RuntimeConfig> {
let default_root = home_dir.join(DEFAULT_RUNTIME_DIR_NAME);
let (config_path, runtime_root) = if let Some(path) = config_override {
let config_path = path.to_path_buf();
let runtime_root = runtime_root_from_config_path(&config_path, &default_root);
(config_path, runtime_root)
} else {
select_or_initialize_default_config_path(home_dir)?
};
let file_config = if config_path.exists() {
parse_config_file(&config_path)?
} else {
FileConfig::default()
};
build_runtime_config(config_path, file_config, &runtime_root)
}
fn build_runtime_config(
config_path: PathBuf,
file_config: FileConfig,
default_root: &Path,
) -> Result<RuntimeConfig> {
let config_base_dir = config_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| default_root.to_path_buf());
let data_dir = file_config
.storage
.data_dir
.map(|path| resolve_path(&config_base_dir, path))
.unwrap_or_else(|| default_root.to_path_buf());
let database_path = file_config
.storage
.database
.map(|path| resolve_path(&config_base_dir, path))
.unwrap_or_else(|| {
data_dir
.join("db")
.join(default_database_file_name(default_root))
});
let raw_mail_dir = file_config
.storage
.raw_mail_dir
.map(|path| resolve_path(&config_base_dir, path))
.unwrap_or_else(|| data_dir.join("mail/raw"));
let patch_dir = file_config
.storage
.patch_dir
.map(|path| resolve_path(&config_base_dir, path))
.unwrap_or_else(|| data_dir.join("patches"));
let log_dir = file_config
.logging
.dir
.map(|path| resolve_path(&config_base_dir, path))
.unwrap_or_else(|| data_dir.join("logs"));
let b4_path = file_config
.b4
.path
.map(|path| resolve_path(&config_base_dir, path));
let log_filter =
normalize_optional_string(file_config.logging.filter).unwrap_or_else(|| "info".to_string());
let source_mailbox = normalize_optional_string(
file_config
.source
.mailbox
.clone()
.or(file_config.imap.mailbox.clone()),
)
.unwrap_or_else(|| DEFAULT_SOURCE_MAILBOX.to_string());
let imap = build_imap_config(&file_config.imap, &config_path)?;
let lore_base_url = normalize_optional_string(file_config.source.lore_base_url)
.unwrap_or_else(|| DEFAULT_LORE_BASE_URL.to_string());
let startup_sync = file_config.ui.startup_sync.unwrap_or(true);
let ui_keymap = file_config.ui.keymap.unwrap_or_default();
let ui_keymap_base = file_config
.ui
.keymap_base
.unwrap_or_else(|| ui_keymap.default_base());
let ui_custom_keymap = build_ui_custom_keymap_config(&file_config.ui.custom_keymap)?;
validate_ui_custom_keymap_config(ui_keymap_base, &ui_custom_keymap)?;
let inbox_auto_sync_interval_secs = file_config
.ui
.inbox_auto_sync_interval_secs
.unwrap_or(DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS);
if inbox_auto_sync_interval_secs == 0 {
return Err(CriewError::new(
ErrorCode::ConfigParse,
"ui.inbox_auto_sync_interval_secs must be greater than 0",
));
}
let mut kernel_trees = Vec::new();
if let Some(tree) = file_config.kernel.tree {
kernel_trees.push(resolve_path(&config_base_dir, tree));
}
for tree in file_config.kernel.trees {
let resolved = resolve_path(&config_base_dir, tree);
if !kernel_trees.iter().any(|existing| existing == &resolved) {
kernel_trees.push(resolved);
}
}
Ok(RuntimeConfig {
config_path,
data_dir,
database_path,
raw_mail_dir,
patch_dir,
log_dir,
b4_path,
log_filter,
source_mailbox,
imap,
lore_base_url,
startup_sync,
ui_keymap,
ui_keymap_base,
ui_custom_keymap,
inbox_auto_sync_interval_secs,
kernel_trees,
})
}
fn select_or_initialize_default_config_path(home_dir: &Path) -> Result<(PathBuf, PathBuf)> {
let default_root = home_dir.join(DEFAULT_RUNTIME_DIR_NAME);
let candidates = [
(&default_root, DEFAULT_CONFIG_FILE_NAME),
(&default_root, LEGACY_CONFIG_FILE_NAME),
];
for (runtime_root, file_name) in candidates {
let candidate = runtime_root.join(file_name);
if candidate.exists() {
return Ok((candidate, runtime_root.to_path_buf()));
}
}
let preferred_path = default_root.join(DEFAULT_CONFIG_FILE_NAME);
write_default_config_file(&preferred_path)?;
Ok((preferred_path, default_root))
}
fn write_default_config_file(path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
CriewError::with_source(
ErrorCode::Io,
format!("failed to create config directory {}", parent.display()),
error,
)
})?;
}
fs::write(path, DEFAULT_CONFIG_TEMPLATE).map_err(|error| {
CriewError::with_source(
ErrorCode::ConfigRead,
format!("failed to write default config file {}", path.display()),
error,
)
})
}
fn parse_config_file(path: &Path) -> Result<FileConfig> {
let content = fs::read_to_string(path).map_err(|error| {
CriewError::with_source(
ErrorCode::ConfigRead,
format!("failed to read config file {}", path.display()),
error,
)
})?;
parse_config_document(&content, path)
}
fn parse_config_document(content: &str, path: &Path) -> Result<FileConfig> {
if content.trim().is_empty() {
return Ok(FileConfig::default());
}
toml::from_str::<FileConfig>(content).map_err(|error| {
CriewError::with_source(
ErrorCode::ConfigParse,
format!("failed to parse TOML config {}", path.display()),
error,
)
})
}
fn build_imap_config(file_config: &ImapFileConfig, config_path: &Path) -> Result<ImapConfig> {
build_imap_config_with_env(file_config, config_path, |key| std::env::var(key).ok())
}
fn build_ui_custom_keymap_config(
file_config: &UiCustomKeymapFileConfig,
) -> Result<UiCustomKeymapConfig> {
Ok(UiCustomKeymapConfig {
focus_prev: normalize_ui_custom_keymap_binding(
"ui.custom_keymap.focus_prev",
file_config.focus_prev.clone(),
1,
1,
)?,
focus_next: normalize_ui_custom_keymap_binding(
"ui.custom_keymap.focus_next",
file_config.focus_next.clone(),
1,
1,
)?,
move_up: normalize_ui_custom_keymap_binding(
"ui.custom_keymap.move_up",
file_config.move_up.clone(),
1,
1,
)?,
move_down: normalize_ui_custom_keymap_binding(
"ui.custom_keymap.move_down",
file_config.move_down.clone(),
1,
1,
)?,
jump_top: normalize_ui_custom_keymap_binding(
"ui.custom_keymap.jump_top",
file_config.jump_top.clone(),
1,
2,
)?,
jump_bottom: normalize_ui_custom_keymap_binding(
"ui.custom_keymap.jump_bottom",
file_config.jump_bottom.clone(),
1,
2,
)?,
quick_quit: normalize_ui_custom_keymap_binding(
"ui.custom_keymap.quick_quit",
file_config.quick_quit.clone(),
1,
2,
)?,
})
}
fn normalize_ui_custom_keymap_binding(
key: &str,
tokens: Option<Vec<String>>,
min_len: usize,
max_len: usize,
) -> Result<Option<Vec<String>>> {
let Some(tokens) = tokens else {
return Ok(None);
};
if tokens.len() < min_len || tokens.len() > max_len {
return Err(CriewError::new(
ErrorCode::ConfigParse,
format!("{key} must contain between {min_len} and {max_len} keys"),
));
}
let mut normalized = Vec::with_capacity(tokens.len());
for token in tokens {
let Some(character) = normalize_ui_custom_keymap_token(&token) else {
return Err(CriewError::new(
ErrorCode::ConfigParse,
format!("{key} contains an unsupported key token: {token}"),
));
};
if is_reserved_main_page_keymap_character(character) {
return Err(CriewError::new(
ErrorCode::ConfigParse,
format!("{key} uses reserved key {character}"),
));
}
normalized.push(character.to_string());
}
Ok(Some(normalized))
}
fn normalize_ui_custom_keymap_token(token: &str) -> Option<char> {
let trimmed = token.trim();
let mut characters = trimmed.chars();
let character = characters.next()?;
if characters.next().is_some()
|| character.is_ascii_control()
|| character.is_ascii_whitespace()
|| character.is_ascii_digit()
{
return None;
}
Some(character)
}
fn is_reserved_main_page_keymap_character(character: char) -> bool {
matches!(
character,
':' | '/'
| 'e'
| 'r'
| 'a'
| 'd'
| 'u'
| 'y'
| 'n'
| '['
| ']'
| '{'
| '}'
| 'E'
| '-'
| '='
| '+'
)
}
fn validate_ui_custom_keymap_config(
base: UiKeymapBase,
custom: &UiCustomKeymapConfig,
) -> Result<()> {
let mut bindings = vec![
(
"focus_prev",
resolved_ui_keymap_binding(base, custom.focus_prev.as_ref(), "focus_prev")
.expect("focus_prev should always resolve"),
),
(
"focus_next",
resolved_ui_keymap_binding(base, custom.focus_next.as_ref(), "focus_next")
.expect("focus_next should always resolve"),
),
(
"move_up",
resolved_ui_keymap_binding(base, custom.move_up.as_ref(), "move_up")
.expect("move_up should always resolve"),
),
(
"move_down",
resolved_ui_keymap_binding(base, custom.move_down.as_ref(), "move_down")
.expect("move_down should always resolve"),
),
];
if let Some(binding) = resolved_ui_keymap_binding(base, custom.jump_top.as_ref(), "jump_top") {
bindings.push(("jump_top", binding));
}
if let Some(binding) =
resolved_ui_keymap_binding(base, custom.jump_bottom.as_ref(), "jump_bottom")
{
bindings.push(("jump_bottom", binding));
}
if let Some(binding) =
resolved_ui_keymap_binding(base, custom.quick_quit.as_ref(), "quick_quit")
{
bindings.push(("quick_quit", binding));
}
for (index, (left_name, left_binding)) in bindings.iter().enumerate() {
for (right_name, right_binding) in bindings.iter().skip(index + 1) {
if left_binding == right_binding {
return Err(CriewError::new(
ErrorCode::ConfigParse,
format!("ui.custom_keymap.{left_name} conflicts with {right_name}"),
));
}
if binding_is_prefix(left_binding, right_binding)
|| binding_is_prefix(right_binding, left_binding)
{
return Err(CriewError::new(
ErrorCode::ConfigParse,
format!("ui.custom_keymap.{left_name} has a prefix conflict with {right_name}"),
));
}
}
}
Ok(())
}
fn resolved_ui_keymap_binding<'a>(
base: UiKeymapBase,
custom: Option<&'a Vec<String>>,
action: &str,
) -> Option<Vec<&'a str>> {
if let Some(binding) = custom {
return Some(binding.iter().map(String::as_str).collect());
}
preset_ui_keymap_binding(base, action).map(|binding| binding.to_vec())
}
fn preset_ui_keymap_binding(base: UiKeymapBase, action: &str) -> Option<&'static [&'static str]> {
match (base, action) {
(UiKeymapBase::Default, "focus_prev") => Some(&["j"]),
(UiKeymapBase::Default, "focus_next") => Some(&["l"]),
(UiKeymapBase::Default, "move_up") => Some(&["i"]),
(UiKeymapBase::Default, "move_down") => Some(&["k"]),
(UiKeymapBase::Default, "jump_top") => None,
(UiKeymapBase::Default, "jump_bottom") => None,
(UiKeymapBase::Default, "quick_quit") => None,
(UiKeymapBase::Vim, "focus_prev") => Some(&["h"]),
(UiKeymapBase::Vim, "focus_next") => Some(&["l"]),
(UiKeymapBase::Vim, "move_up") => Some(&["k"]),
(UiKeymapBase::Vim, "move_down") => Some(&["j"]),
(UiKeymapBase::Vim, "jump_top") => Some(&["g", "g"]),
(UiKeymapBase::Vim, "jump_bottom") => Some(&["G"]),
(UiKeymapBase::Vim, "quick_quit") => Some(&["q", "q"]),
_ => unreachable!("unexpected keymap action"),
}
}
fn binding_is_prefix(left: &[&str], right: &[&str]) -> bool {
left.len() < right.len() && right.starts_with(left)
}
fn build_imap_config_with_env<F>(
file_config: &ImapFileConfig,
config_path: &Path,
env_lookup: F,
) -> Result<ImapConfig>
where
F: Fn(&str) -> Option<String>,
{
let email = normalize_optional_string(file_config.email.clone());
let config = ImapConfig {
email: email.clone(),
user: normalize_optional_string(file_config.user.clone()).or(email),
pass: normalize_optional_string(file_config.pass.clone()),
server: normalize_optional_string(file_config.server.clone()),
server_port: file_config.serverport,
encryption: file_config.encryption,
proxy: normalize_optional_string(file_config.proxy.clone()).or_else(|| {
[
"CRIEW_IMAP_PROXY",
"ALL_PROXY",
"all_proxy",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
]
.into_iter()
.find_map(&env_lookup)
.and_then(|value| normalize_optional_string(Some(value)))
}),
};
if config.server_port == Some(0) {
return Err(CriewError::new(
ErrorCode::ConfigParse,
format!(
"invalid IMAP config in {}: imap.serverport must be between 1 and 65535",
config_path.display()
),
));
}
Ok(config)
}
fn normalize_optional_string(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn resolve_path(base_dir: &Path, candidate: PathBuf) -> PathBuf {
if candidate.is_absolute() {
candidate
} else {
base_dir.join(candidate)
}
}
fn runtime_root_from_config_path(config_path: &Path, default_root: &Path) -> PathBuf {
if let Some(parent) = config_path.parent() {
let Some(file_name) = parent.file_name().and_then(|value| value.to_str()) else {
return default_root.to_path_buf();
};
if file_name == DEFAULT_RUNTIME_DIR_NAME {
return parent.to_path_buf();
}
}
default_root.to_path_buf()
}
fn default_database_file_name(runtime_root: &Path) -> &'static str {
let _ = runtime_root;
DEFAULT_DATABASE_FILE_NAME
}
pub fn resolve_self_email(config: &RuntimeConfig) -> SelfEmailResolution {
resolve_self_email_with(config, git_user_email)
}
fn resolve_self_email_with<F>(config: &RuntimeConfig, git_lookup: F) -> SelfEmailResolution
where
F: FnOnce() -> std::result::Result<Option<String>, String>,
{
if let Some(email) = config.imap.email.as_ref() {
return SelfEmailResolution {
email: Some(email.clone()),
source: Some(SelfEmailSource::CriewImapConfig),
git_error: None,
};
}
match git_lookup() {
Ok(Some(email)) => SelfEmailResolution {
email: Some(email),
source: Some(SelfEmailSource::GitConfig),
git_error: None,
},
Ok(None) => SelfEmailResolution::default(),
Err(error) => SelfEmailResolution {
email: None,
source: None,
git_error: Some(error),
},
}
}
fn git_user_email() -> std::result::Result<Option<String>, String> {
let output = ProcessCommand::new("git")
.args(GIT_EMAIL_LOOKUP_ARGS)
.output()
.map_err(|error| format!("failed to run git config user.email: {error}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if stderr.is_empty() {
format!("git config user.email exited with {}", output.status)
} else {
format!("git config user.email failed: {stderr}")
});
}
let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
if value.is_empty() {
Ok(None)
} else {
Ok(Some(value))
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
DEFAULT_CONFIG_FILE_NAME, DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, IMAP_INBOX_MAILBOX,
ImapFileConfig, LEGACY_CONFIG_FILE_NAME, SelfEmailSource, UiKeymap, UiKeymapBase,
build_imap_config_with_env, load, load_with_home, resolve_self_email_with,
};
fn temp_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path = std::env::temp_dir().join(format!("criew-{label}-{nonce}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
#[test]
fn resolves_relative_paths_from_config_dir() {
let base = temp_dir("config-relative");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[storage]
data_dir = "./state"
database = "./state/db/criew.sqlite"
patch_dir = "./state/patches"
raw_mail_dir = "./state/raw"
[b4]
path = "./bin/b4"
[logging]
filter = "debug"
dir = "./state/logs"
[source]
mailbox = "linux-kernel"
lore_base_url = "https://lore.kernel.org"
[ui]
startup_sync = false
keymap = "vim"
inbox_auto_sync_interval_secs = 45
[kernel]
tree = "./linux"
trees = ["./linux-next"]
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
assert_eq!(loaded.data_dir, base.join("./state"));
assert_eq!(loaded.database_path, base.join("./state/db/criew.sqlite"));
assert_eq!(loaded.patch_dir, base.join("./state/patches"));
assert_eq!(loaded.raw_mail_dir, base.join("./state/raw"));
assert_eq!(loaded.log_dir, base.join("./state/logs"));
assert_eq!(loaded.b4_path, Some(base.join("./bin/b4")));
assert_eq!(loaded.log_filter, "debug");
assert_eq!(loaded.source_mailbox, "linux-kernel");
assert_eq!(loaded.lore_base_url, "https://lore.kernel.org");
assert!(!loaded.startup_sync);
assert_eq!(loaded.ui_keymap, UiKeymap::Vim);
assert_eq!(loaded.inbox_auto_sync_interval_secs, 45);
assert_eq!(
loaded.kernel_trees,
vec![base.join("./linux"), base.join("./linux-next")]
);
assert!(!loaded.imap.is_complete());
let _ = fs::remove_dir_all(base);
}
#[test]
fn creates_default_config_file_under_criew_root() {
let home = temp_dir("config-default-home");
let loaded = load_with_home(None, &home).expect("load config");
let expected_config_path = home.join(".criew").join(DEFAULT_CONFIG_FILE_NAME);
assert_eq!(loaded.config_path, expected_config_path);
assert!(loaded.config_path.exists());
assert_eq!(loaded.data_dir, home.join(".criew"));
assert_eq!(loaded.log_dir, home.join(".criew/logs"));
assert_eq!(loaded.raw_mail_dir, home.join(".criew/mail/raw"));
assert_eq!(loaded.patch_dir, home.join(".criew/patches"));
assert_eq!(loaded.database_path, home.join(".criew/db/criew.db"));
assert!(loaded.startup_sync);
assert_eq!(loaded.ui_keymap, UiKeymap::Default);
assert_eq!(
loaded.inbox_auto_sync_interval_secs,
DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS
);
assert_eq!(loaded.default_active_mailbox(), "linux-kernel");
let content = fs::read_to_string(&loaded.config_path).expect("read generated config");
assert!(content.contains("[source]"));
assert!(content.contains("mailbox = \"linux-kernel\""));
let _ = fs::remove_dir_all(home);
}
#[test]
fn loads_custom_ui_keymap_from_config() {
let base = temp_dir("config-custom-keymap");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "custom"
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
assert_eq!(loaded.ui_keymap, UiKeymap::Custom);
let _ = fs::remove_dir_all(base);
}
#[test]
fn loads_custom_ui_keymap_overrides_and_base_from_config() {
let base = temp_dir("config-custom-keymap-overrides");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "custom"
keymap_base = "vim"
[ui.custom_keymap]
focus_prev = ["x"]
quick_quit = ["z", "z"]
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
assert_eq!(loaded.ui_keymap, UiKeymap::Custom);
assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim);
assert_eq!(
loaded.ui_custom_keymap.focus_prev,
Some(vec!["x".to_string()])
);
assert_eq!(
loaded.ui_custom_keymap.quick_quit,
Some(vec!["z".to_string(), "z".to_string()])
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn infers_custom_keymap_base_from_vim_scheme_when_omitted() {
let base = temp_dir("config-vim-infers-custom-base");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "vim"
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
assert_eq!(loaded.ui_keymap, UiKeymap::Vim);
assert_eq!(loaded.ui_keymap_base, UiKeymapBase::Vim);
let _ = fs::remove_dir_all(base);
}
#[test]
fn rejects_custom_keymap_prefix_conflicts_against_base() {
let base = temp_dir("config-custom-keymap-conflict");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[ui]
keymap = "custom"
keymap_base = "vim"
[ui.custom_keymap]
focus_prev = ["g"]
"#,
)
.expect("write config");
let error = match load(Some(&config_path)) {
Ok(_) => panic!("conflicting config should fail"),
Err(error) => error,
};
assert!(
error.to_string().contains("prefix conflict"),
"unexpected error: {error}"
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn falls_back_to_config_alias_filename_when_present() {
let home = temp_dir("config-legacy-home");
let legacy_config_path = home.join(".criew").join(LEGACY_CONFIG_FILE_NAME);
fs::create_dir_all(legacy_config_path.parent().expect("legacy config parent"))
.expect("create legacy config dir");
fs::write(
&legacy_config_path,
r#"
[source]
mailbox = "legacy-mailbox"
"#,
)
.expect("write legacy config");
let loaded = load_with_home(None, &home).expect("load config");
assert_eq!(loaded.config_path, legacy_config_path);
assert_eq!(loaded.source_mailbox, "legacy-mailbox");
assert!(!home.join(".criew").join(DEFAULT_CONFIG_FILE_NAME).exists());
let _ = fs::remove_dir_all(home);
}
#[test]
fn loads_imap_config_with_modern_and_legacy_keys() {
let base = temp_dir("imap-config");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[source]
mailbox = "linux-kernel"
[imap]
email = "me@example.com"
imapuser = "imap-user"
imappass = "imap-pass"
imapserver = "imap.example.com"
imapserverport = 993
imapencryption = "tls"
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
assert_eq!(loaded.imap.email.as_deref(), Some("me@example.com"));
assert_eq!(loaded.imap.user.as_deref(), Some("imap-user"));
assert_eq!(loaded.imap.pass.as_deref(), Some("imap-pass"));
assert_eq!(loaded.imap.server.as_deref(), Some("imap.example.com"));
assert_eq!(loaded.imap.server_port, Some(993));
assert_eq!(
loaded.imap.encryption.map(|value| value.as_str()),
Some("tls")
);
assert!(loaded.imap.is_complete());
assert_eq!(loaded.default_active_mailbox(), IMAP_INBOX_MAILBOX);
let _ = fs::remove_dir_all(base);
}
#[test]
fn rejects_zero_inbox_auto_sync_interval() {
let base = temp_dir("config-zero-auto-sync-interval");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[ui]
inbox_auto_sync_interval_secs = 0
"#,
)
.expect("write config");
let error = load(Some(&config_path)).expect_err("zero interval should be rejected");
assert!(
error
.to_string()
.contains("ui.inbox_auto_sync_interval_secs must be greater than 0")
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn falls_back_to_imap_email_for_login_user_when_user_is_omitted() {
let base = temp_dir("imap-email-fallback");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[imap]
email = "me@example.com"
pass = "imap-pass"
server = "imap.gmail.com"
serverport = 993
encryption = "ssl"
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
assert_eq!(loaded.imap.email.as_deref(), Some("me@example.com"));
assert_eq!(loaded.imap.user.as_deref(), Some("me@example.com"));
assert_eq!(loaded.imap.login_user(), Some("me@example.com"));
assert!(loaded.imap.is_complete());
assert_eq!(loaded.default_active_mailbox(), IMAP_INBOX_MAILBOX);
let _ = fs::remove_dir_all(base);
}
#[test]
fn accepts_ssl_alias_for_tls_imap_encryption() {
let base = temp_dir("imap-ssl-alias");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[imap]
user = "imap-user"
pass = "imap-pass"
server = "imap.gmail.com"
serverport = 993
encryption = "ssl"
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
assert_eq!(
loaded.imap.encryption.map(|value| value.as_str()),
Some("tls")
);
assert!(loaded.imap.is_complete());
let _ = fs::remove_dir_all(base);
}
#[test]
fn loads_explicit_imap_proxy_from_config() {
let config = build_imap_config_with_env(
&ImapFileConfig {
proxy: Some("socks5://10.0.2.2:7890".to_string()),
..ImapFileConfig::default()
},
Path::new("/tmp/criew-config.toml"),
|_| None,
)
.expect("build imap config");
assert_eq!(config.proxy.as_deref(), Some("socks5://10.0.2.2:7890"));
}
#[test]
fn falls_back_to_imap_proxy_environment_variable() {
let config = build_imap_config_with_env(
&ImapFileConfig::default(),
Path::new("/tmp/criew-config.toml"),
|key| match key {
"CRIEW_IMAP_PROXY" => Some("http://10.0.2.2:7890".to_string()),
_ => None,
},
)
.expect("build imap config");
assert_eq!(config.proxy.as_deref(), Some("http://10.0.2.2:7890"));
}
#[test]
fn rejects_zero_imap_port() {
let base = temp_dir("imap-port");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[imap]
user = "imap-user"
pass = "imap-pass"
server = "imap.example.com"
serverport = 0
encryption = "tls"
"#,
)
.expect("write config");
let error = load(Some(&config_path)).expect_err("invalid config should fail");
assert!(error.to_string().contains("imap.serverport"));
let _ = fs::remove_dir_all(base);
}
#[test]
fn self_email_prefers_imap_config_over_git() {
let base = temp_dir("self-email-imap");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[imap]
email = "criew@example.com"
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
let resolution =
resolve_self_email_with(&loaded, || Ok(Some("git@example.com".to_string())));
assert_eq!(resolution.email.as_deref(), Some("criew@example.com"));
assert_eq!(resolution.source, Some(SelfEmailSource::CriewImapConfig));
assert!(resolution.git_error.is_none());
let _ = fs::remove_dir_all(base);
}
#[test]
fn self_email_falls_back_to_git_when_imap_email_missing() {
let base = temp_dir("self-email-git");
let config_path = base.join("config.toml");
fs::write(
&config_path,
r#"
[source]
mailbox = "linux-kernel"
"#,
)
.expect("write config");
let loaded = load(Some(&config_path)).expect("load config");
let resolution =
resolve_self_email_with(&loaded, || Ok(Some("git@example.com".to_string())));
assert_eq!(resolution.email.as_deref(), Some("git@example.com"));
assert_eq!(resolution.source, Some(SelfEmailSource::GitConfig));
assert!(resolution.git_error.is_none());
let _ = fs::remove_dir_all(base);
}
}