pub mod cli;
pub mod patch;
pub mod sync;
use std::path::PathBuf;
use clap::Parser;
use crate::infra::b4::{self, B4Status};
use crate::infra::bootstrap;
use crate::infra::config::{self, IMAP_INBOX_MAILBOX};
use crate::infra::error::Result;
use crate::infra::imap::{ImapClient, MailboxSnapshot, RemoteImapClient};
use crate::infra::logging;
use crate::infra::sendmail::{self, GitSendEmailCheck, GitSendEmailStatus, ReplyIdentity};
const DEFAULT_SYNC_RECONNECT_ATTEMPTS: u8 = 3;
enum DoctorImapStatus {
Skipped(Vec<String>),
Connected(MailboxSnapshot),
Error(String),
}
pub fn run() -> Result<()> {
let cli = cli::Cli::parse();
let command = cli.command.unwrap_or(cli::Command::Tui);
if matches!(command, cli::Command::Version) {
println!("criew {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let mut runtime = config::load(cli.config.as_deref())?;
logging::init(&runtime.log_filter, &runtime.log_dir)?;
tracing::debug!(command = ?command, "user invoked cli command");
let startup_config_path = runtime.config_path.clone();
let mut bootstrap_state = bootstrap::prepare(&runtime)?;
match command {
cli::Command::Tui => loop {
match crate::ui::run(&runtime, &bootstrap_state)? {
crate::ui::TuiAction::Exit => break Ok(()),
crate::ui::TuiAction::Restart => {
tracing::info!(
config_path = %startup_config_path.display(),
"user requested tui restart"
);
runtime = config::load(Some(startup_config_path.as_path()))?;
bootstrap_state = bootstrap::prepare(&runtime)?;
}
}
},
cli::Command::Sync {
mailbox,
fixture_dir,
uidvalidity,
reconnect_attempts,
} => {
let request = build_sync_request(
&runtime,
mailbox,
fixture_dir,
uidvalidity,
reconnect_attempts,
);
run_sync_command(&runtime, &bootstrap_state, request, true)?;
Ok(())
}
cli::Command::Doctor => {
let b4_status = b4::check(runtime.b4_path.as_deref(), Some(&runtime.data_dir));
let send_email_status = sendmail::check();
let reply_identity = sendmail::resolve_reply_identity();
println!("criew doctor");
println!(" config_path: {}", runtime.config_path.display());
println!(" data_dir: {}", runtime.data_dir.display());
println!(" database_path: {}", runtime.database_path.display());
println!(" raw_mail_dir: {}", runtime.raw_mail_dir.display());
println!(" patch_dir: {}", runtime.patch_dir.display());
println!(" log_dir: {}", runtime.log_dir.display());
println!(" source_mailbox: {}", runtime.source_mailbox);
println!(
" default_active_mailbox: {}",
runtime.default_active_mailbox()
);
println!(" lore_base_url: {}", runtime.lore_base_url);
println!(" startup_sync: {}", runtime.startup_sync);
println!(" ui_keymap: {}", runtime.ui_keymap.as_str());
println!(
" inbox_auto_sync_interval_secs: {}",
runtime.inbox_auto_sync_interval_secs
);
if runtime.kernel_trees.is_empty() {
println!(" kernel_trees: <none>");
} else {
for tree in &runtime.kernel_trees {
println!(" kernel_tree: {}", tree.display());
}
}
println!(" schema_version: {}", bootstrap_state.db.schema_version);
println!(
" schema_version_expected: {}",
crate::infra::db::CURRENT_SCHEMA_VERSION
);
println!(
" migrations_applied: {:?}",
bootstrap_state.db.applied_migrations
);
println!(
" database_created_this_run: {}",
bootstrap_state.db.created
);
let self_email = config::resolve_self_email(&runtime);
let imap_status = probe_doctor_imap(&runtime);
println!(
"{}",
format_doctor_report(
&runtime,
&bootstrap_state,
&self_email,
&imap_status,
&send_email_status,
&reply_identity,
&b4_status,
)
);
Ok(())
}
cli::Command::Version => Ok(()),
}
}
fn run_sync_command(
runtime: &config::RuntimeConfig,
bootstrap_state: &bootstrap::BootstrapState,
request: sync::SyncRequest,
print_summary: bool,
) -> Result<sync::SyncSummary> {
let summary = sync::run(runtime, request)?;
tracing::info!(
database = %bootstrap_state.db.path.display(),
schema_version = bootstrap_state.db.schema_version,
mailbox = %summary.mailbox,
fetched = summary.fetched,
inserted = summary.inserted,
updated = summary.updated,
rebuilt_roots = summary.rebuilt_roots,
"sync command finished"
);
if print_summary {
println!("{}", format_sync_summary(&summary));
}
Ok(summary)
}
fn build_sync_request(
runtime: &config::RuntimeConfig,
mailbox: Option<String>,
fixture_dir: Option<PathBuf>,
uidvalidity: Option<u64>,
reconnect_attempts: Option<u8>,
) -> sync::SyncRequest {
sync::SyncRequest {
mailbox: mailbox.unwrap_or_else(|| runtime.source_mailbox.clone()),
fixture_dir,
uidvalidity,
reconnect_attempts: reconnect_attempts.unwrap_or(DEFAULT_SYNC_RECONNECT_ATTEMPTS),
}
}
fn probe_doctor_imap(runtime: &config::RuntimeConfig) -> DoctorImapStatus {
if !runtime.imap.is_complete() {
return DoctorImapStatus::Skipped(
runtime
.imap
.missing_required_fields()
.into_iter()
.map(ToOwned::to_owned)
.collect(),
);
}
match RemoteImapClient::new(runtime.imap.clone()).and_then(|mut client| {
client.connect()?;
client.select_mailbox(IMAP_INBOX_MAILBOX)
}) {
Ok(snapshot) => DoctorImapStatus::Connected(snapshot),
Err(error) => DoctorImapStatus::Error(error.to_string()),
}
}
fn format_doctor_report(
runtime: &config::RuntimeConfig,
bootstrap_state: &bootstrap::BootstrapState,
self_email: &config::SelfEmailResolution,
imap_status: &DoctorImapStatus,
send_email_status: &GitSendEmailCheck,
reply_identity: &std::result::Result<ReplyIdentity, String>,
b4_status: &b4::B4Check,
) -> String {
let mut lines = vec![
"criew doctor".to_string(),
format!(" config_path: {}", runtime.config_path.display()),
format!(" data_dir: {}", runtime.data_dir.display()),
format!(" database_path: {}", runtime.database_path.display()),
format!(" raw_mail_dir: {}", runtime.raw_mail_dir.display()),
format!(" patch_dir: {}", runtime.patch_dir.display()),
format!(" log_dir: {}", runtime.log_dir.display()),
format!(" source_mailbox: {}", runtime.source_mailbox),
format!(
" default_active_mailbox: {}",
runtime.default_active_mailbox()
),
format!(" lore_base_url: {}", runtime.lore_base_url),
format!(" startup_sync: {}", runtime.startup_sync),
format!(
" inbox_auto_sync_interval_secs: {}",
runtime.inbox_auto_sync_interval_secs
),
];
if runtime.kernel_trees.is_empty() {
lines.push(" kernel_trees: <none>".to_string());
} else {
lines.extend(
runtime
.kernel_trees
.iter()
.map(|tree| format!(" kernel_tree: {}", tree.display())),
);
}
lines.push(format!(
" schema_version: {}",
bootstrap_state.db.schema_version
));
lines.push(format!(
" schema_version_expected: {}",
crate::infra::db::CURRENT_SCHEMA_VERSION
));
lines.push(format!(
" migrations_applied: {:?}",
bootstrap_state.db.applied_migrations
));
lines.push(format!(
" database_created_this_run: {}",
bootstrap_state.db.created
));
lines.push(format!(
" self_email: {}",
self_email.email.as_deref().unwrap_or("<missing>")
));
lines.push(format!(
" self_email_source: {}",
self_email
.source
.map(|source| source.as_str())
.unwrap_or("<none>")
));
if let Some(error) = self_email.git_error.as_deref() {
lines.push(format!(" self_email_lookup: error ({error})"));
}
lines.push(format!(
" imap_config_status: {}",
if runtime.imap.is_complete() {
"complete"
} else {
"incomplete"
}
));
match imap_status {
DoctorImapStatus::Skipped(missing_fields) => {
lines.push(format!(
" imap_missing_fields: {}",
if missing_fields.is_empty() {
"<none>".to_string()
} else {
missing_fields.join(", ")
}
));
lines.push(" imap_connect_status: skipped".to_string());
}
DoctorImapStatus::Connected(snapshot) => {
lines.push(format!(
" imap_connection_target: {}:{} ({})",
runtime.imap.server.as_deref().unwrap_or("<missing>"),
runtime.imap.server_port.unwrap_or_default(),
runtime
.imap
.encryption
.map(|value| value.as_str())
.unwrap_or("<missing>")
));
lines.push(" imap_connect_status: ok".to_string());
lines.push(format!(" imap_select_mailbox: {}", IMAP_INBOX_MAILBOX));
lines.push(format!(" imap_uidvalidity: {}", snapshot.uidvalidity));
lines.push(format!(" imap_highest_uid: {}", snapshot.highest_uid));
lines.push(format!(
" imap_highest_modseq: {}",
snapshot
.highest_modseq
.map(|value| value.to_string())
.unwrap_or_else(|| "<none>".to_string())
));
}
DoctorImapStatus::Error(error) => {
lines.push(format!(
" imap_connection_target: {}:{} ({})",
runtime.imap.server.as_deref().unwrap_or("<missing>"),
runtime.imap.server_port.unwrap_or_default(),
runtime
.imap
.encryption
.map(|value| value.as_str())
.unwrap_or("<missing>")
));
lines.push(format!(" imap_connect_status: error ({error})"));
}
}
match &send_email_status.status {
GitSendEmailStatus::Available { path, version } => {
lines.push(format!(" git_send_email_path: {}", path.display()));
lines.push(format!(" git_send_email_version: {}", version));
lines.push(" git_send_email_status: ok".to_string());
}
GitSendEmailStatus::Broken { path, reason } => {
lines.push(format!(" git_send_email_path: {}", path.display()));
lines.push(format!(" git_send_email_status: broken ({reason})"));
}
GitSendEmailStatus::Missing => {
lines.push(" git_send_email_path: <not found>".to_string());
lines.push(" git_send_email_status: missing".to_string());
}
}
match reply_identity {
Ok(identity) => {
lines.push(format!(" reply_from: {}", identity.display));
lines.push(format!(" reply_from_email: {}", identity.email));
lines.push(format!(" reply_from_source: {}", identity.source.as_str()));
lines.push(" reply_identity_status: ok".to_string());
}
Err(error) => {
lines.push(" reply_from: <missing>".to_string());
lines.push(" reply_from_email: <missing>".to_string());
lines.push(" reply_from_source: <none>".to_string());
lines.push(format!(" reply_identity_status: error ({error})"));
}
}
match &b4_status.status {
B4Status::Available { path, version } => {
lines.push(format!(" b4_path: {}", path.display()));
lines.push(format!(" b4_version: {}", version));
lines.push(" b4_status: ok".to_string());
}
B4Status::Broken { path, reason } => {
lines.push(format!(" b4_path: {}", path.display()));
lines.push(format!(" b4_status: broken ({reason})"));
}
B4Status::Missing => {
lines.push(" b4_path: <not found>".to_string());
lines.push(" b4_status: missing".to_string());
}
}
lines.join("\n")
}
fn format_sync_summary(summary: &sync::SyncSummary) -> String {
format!(
"sync complete: mailbox={} source={} fetched={} inserted={} updated={} rebuilt_roots={} uidvalidity={} last_seen_uid={} highest_modseq={:?} synced_at={} mailbox_rebuilt={}",
summary.mailbox,
summary.source,
summary.fetched,
summary.inserted,
summary.updated,
summary.rebuilt_roots,
summary.uidvalidity,
summary.checkpoint_last_seen_uid,
summary.checkpoint_highest_modseq,
summary
.checkpoint_synced_at
.as_deref()
.unwrap_or("<unknown>"),
summary.mailbox_rebuilt
)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::infra::b4::B4Status;
use crate::infra::bootstrap::BootstrapState;
use crate::infra::config::{
DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS, ImapConfig, ImapEncryption, RuntimeConfig,
SelfEmailResolution, SelfEmailSource, UiKeymap,
};
use crate::infra::db::DatabaseState;
use crate::infra::imap::MailboxSnapshot;
use crate::infra::sendmail::{
GitSendEmailCheck, GitSendEmailStatus, ReplyIdentity, ReplyIdentitySource,
};
use super::{
DoctorImapStatus, build_sync_request, format_doctor_report, format_sync_summary,
probe_doctor_imap,
};
fn test_runtime() -> RuntimeConfig {
RuntimeConfig {
config_path: PathBuf::from("/tmp/criew-config.toml"),
data_dir: PathBuf::from("/tmp/criew"),
database_path: PathBuf::from("/tmp/criew/criew.db"),
raw_mail_dir: PathBuf::from("/tmp/criew/raw"),
patch_dir: PathBuf::from("/tmp/criew/patches"),
log_dir: PathBuf::from("/tmp/criew/logs"),
b4_path: None,
log_filter: "info".to_string(),
source_mailbox: "linux-kernel".to_string(),
imap: ImapConfig::default(),
lore_base_url: "https://lore.kernel.org".to_string(),
startup_sync: true,
ui_keymap: UiKeymap::Default,
ui_keymap_base: crate::infra::config::UiKeymapBase::Default,
ui_custom_keymap: crate::infra::config::UiCustomKeymapConfig::default(),
inbox_auto_sync_interval_secs: DEFAULT_INBOX_AUTO_SYNC_INTERVAL_SECS,
kernel_trees: Vec::new(),
}
}
fn test_bootstrap_state() -> BootstrapState {
BootstrapState {
db: DatabaseState {
path: PathBuf::from("/tmp/criew/criew.db"),
schema_version: crate::infra::db::CURRENT_SCHEMA_VERSION,
created: false,
applied_migrations: vec![1, 2, 3, crate::infra::db::CURRENT_SCHEMA_VERSION],
},
}
}
#[test]
fn build_sync_request_uses_runtime_defaults_and_overrides() {
let runtime = test_runtime();
let default_request = build_sync_request(&runtime, None, None, None, None);
assert_eq!(default_request.mailbox, "linux-kernel");
assert_eq!(
default_request.reconnect_attempts,
super::DEFAULT_SYNC_RECONNECT_ATTEMPTS
);
let overridden_request = build_sync_request(
&runtime,
Some("INBOX".to_string()),
Some(PathBuf::from("/tmp/fixtures")),
Some(42),
Some(7),
);
assert_eq!(overridden_request.mailbox, "INBOX");
assert_eq!(
overridden_request.fixture_dir,
Some(PathBuf::from("/tmp/fixtures"))
);
assert_eq!(overridden_request.uidvalidity, Some(42));
assert_eq!(overridden_request.reconnect_attempts, 7);
}
#[test]
fn format_sync_summary_renders_unknown_synced_at_fallback() {
let summary = crate::app::sync::SyncSummary {
mailbox: "linux-kernel".to_string(),
source: "fixture".to_string(),
fetched: 3,
inserted: 2,
updated: 1,
rebuilt_roots: 4,
mailbox_rebuilt: false,
uidvalidity: 11,
checkpoint_last_seen_uid: 99,
checkpoint_highest_modseq: Some(123),
checkpoint_synced_at: None,
};
let rendered = format_sync_summary(&summary);
assert!(rendered.contains("mailbox=linux-kernel"));
assert!(rendered.contains("source=fixture"));
assert!(rendered.contains("synced_at=<unknown>"));
}
#[test]
fn format_doctor_report_covers_incomplete_imap_and_missing_tools() {
let runtime = test_runtime();
let self_email = SelfEmailResolution {
email: None,
source: None,
git_error: Some("git config failed".to_string()),
};
let report = format_doctor_report(
&runtime,
&test_bootstrap_state(),
&self_email,
&DoctorImapStatus::Skipped(vec!["imap.user".to_string(), "imap.pass".to_string()]),
&GitSendEmailCheck {
status: GitSendEmailStatus::Missing,
},
&Err("missing reply identity".to_string()),
&crate::infra::b4::B4Check {
status: B4Status::Missing,
},
);
assert!(report.contains("criew doctor"));
assert!(report.contains("kernel_trees: <none>"));
assert!(report.contains("self_email_lookup: error (git config failed)"));
assert!(report.contains("imap_config_status: incomplete"));
assert!(report.contains("imap_missing_fields: imap.user, imap.pass"));
assert!(report.contains("imap_connect_status: skipped"));
assert!(report.contains("git_send_email_status: missing"));
assert!(report.contains("reply_identity_status: error (missing reply identity)"));
assert!(report.contains("b4_status: missing"));
}
#[test]
fn format_doctor_report_covers_connected_imap_and_available_tools() {
let mut runtime = test_runtime();
runtime.kernel_trees = vec![PathBuf::from("/tmp/linux")];
runtime.imap = ImapConfig {
email: Some("me@example.com".to_string()),
user: Some("imap-user".to_string()),
pass: Some("imap-pass".to_string()),
server: Some("imap.example.com".to_string()),
server_port: Some(993),
encryption: Some(ImapEncryption::Tls),
proxy: None,
};
let self_email = SelfEmailResolution {
email: Some("me@example.com".to_string()),
source: Some(SelfEmailSource::CriewImapConfig),
git_error: None,
};
let report = format_doctor_report(
&runtime,
&test_bootstrap_state(),
&self_email,
&DoctorImapStatus::Connected(MailboxSnapshot {
uidvalidity: 7,
highest_uid: 88,
highest_modseq: Some(99),
}),
&GitSendEmailCheck {
status: GitSendEmailStatus::Available {
path: PathBuf::from("/usr/bin/git"),
version: "2.45".to_string(),
},
},
&Ok(ReplyIdentity {
display: "CRIEW <me@example.com>".to_string(),
email: "me@example.com".to_string(),
source: ReplyIdentitySource::SendEmailFrom,
}),
&crate::infra::b4::B4Check {
status: B4Status::Available {
path: PathBuf::from("/usr/bin/b4"),
version: "0.14".to_string(),
},
},
);
assert!(report.contains("kernel_tree: /tmp/linux"));
assert!(report.contains("imap_config_status: complete"));
assert!(report.contains("imap_connection_target: imap.example.com:993 (tls)"));
assert!(report.contains("imap_connect_status: ok"));
assert!(report.contains("imap_uidvalidity: 7"));
assert!(report.contains("git_send_email_status: ok"));
assert!(report.contains("reply_identity_status: ok"));
assert!(report.contains("b4_status: ok"));
}
#[test]
fn probe_doctor_imap_skips_incomplete_config_without_network_access() {
let status = probe_doctor_imap(&test_runtime());
match status {
DoctorImapStatus::Skipped(missing_fields) => {
assert!(missing_fields.contains(&"imap.user".to_string()));
assert!(missing_fields.contains(&"imap.pass".to_string()));
}
_ => panic!("expected incomplete IMAP config to skip probing"),
}
}
#[test]
fn format_doctor_report_covers_imap_error_and_broken_tools() {
let mut runtime = test_runtime();
runtime.imap = ImapConfig {
email: Some("me@example.com".to_string()),
user: Some("imap-user".to_string()),
pass: Some("imap-pass".to_string()),
server: Some("imap.example.com".to_string()),
server_port: Some(993),
encryption: Some(ImapEncryption::Tls),
proxy: None,
};
let report = format_doctor_report(
&runtime,
&test_bootstrap_state(),
&SelfEmailResolution::default(),
&DoctorImapStatus::Error("connect failed".to_string()),
&GitSendEmailCheck {
status: GitSendEmailStatus::Broken {
path: PathBuf::from("/usr/bin/git"),
reason: "missing send-email".to_string(),
},
},
&Err("missing reply identity".to_string()),
&crate::infra::b4::B4Check {
status: B4Status::Broken {
path: PathBuf::from("/usr/bin/b4"),
reason: "bad runtime".to_string(),
},
},
);
assert!(report.contains("imap_connect_status: error (connect failed)"));
assert!(report.contains("git_send_email_status: broken (missing send-email)"));
assert!(report.contains("b4_status: broken (bad runtime)"));
}
}