diff --git a/src/auth/flow.rs b/src/auth/flow.rs index b89612e..e7e16f0 100644 --- a/src/auth/flow.rs +++ b/src/auth/flow.rs @@ -1,9 +1,25 @@ -use super::AuthError; -use crate::config::GmailConfig; -use anyhow::Result; -use oauth2::{AuthorizationCode, CsrfToken, RedirectUrl}; +use super::file_store::{CredentialStore, FileCredentialStore, StoredCredentials}; +use super::oauth_client::{ + ImportedOAuthClient, OAuthClientSource, PreparedSetup, resolve as resolve_oauth_client, +}; +use crate::config::{ConfigReport, GmailConfig}; +use crate::gmail::GmailClient; +use crate::store; +use crate::store::accounts::{self, AccountRecord, UpsertAccountInput}; +use crate::time::current_epoch_seconds; +use crate::workspace::{self, WorkspacePaths}; +use anyhow::{Context, Result}; +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, PkceCodeChallenge, RedirectUrl, + Scope, TokenResponse, TokenUrl, basic::BasicClient, +}; +use reqwest::redirect::Policy; +use secrecy::{ExposeSecret, SecretString}; +use serde::Serialize; +use std::io::Write; use std::net::SocketAddr; use std::time::Duration; +use thiserror::Error; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::time::{Instant, timeout}; @@ -12,6 +28,556 @@ use url::Url; const CALLBACK_TIMEOUT_SECS: u64 = 180; const CALLBACK_PATH: &str = "/oauth2/callback"; +#[derive(Debug, Clone, Serialize)] +pub struct AuthStatusReport { + pub configured: bool, + pub oauth_client_source: String, + pub oauth_client_path: std::path::PathBuf, + pub oauth_client_exists: bool, + pub credential_path: std::path::PathBuf, + pub credential_exists: bool, + pub access_token_expires_at_epoch_s: Option, + pub scopes: Vec, + pub active_account: Option, +} + +impl AuthStatusReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("configured={}", self.configured); + println!("oauth_client_source={}", self.oauth_client_source); + println!("oauth_client_path={}", self.oauth_client_path.display()); + println!("oauth_client_exists={}", self.oauth_client_exists); + println!("credential_path={}", self.credential_path.display()); + println!("credential_exists={}", self.credential_exists); + match self.access_token_expires_at_epoch_s { + Some(expires_at) => println!("access_token_expires_at_epoch_s={expires_at}"), + None => println!("access_token_expires_at_epoch_s="), + } + println!("scopes={}", self.scopes.join(",")); + match &self.active_account { + Some(account) => { + println!("active_account_id={}", account.account_id); + println!("active_account_email={}", account.email_address); + println!("active_account_history_id={}", account.history_id); + } + None => println!("active_account="), + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct SetupReport { + #[serde(skip_serializing_if = "Option::is_none")] + pub imported_client: Option, + pub login: LoginReport, +} + +impl SetupReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("oauth_client_imported={}", self.imported_client.is_some()); + if let Some(imported_client) = &self.imported_client { + println!( + "oauth_client_source_kind={}", + imported_client.source_kind.as_str() + ); + match &imported_client.source_path { + Some(source_path) => { + println!("oauth_client_source_path={}", source_path.display()) + } + None => println!("oauth_client_source_path="), + } + println!( + "oauth_client_path={}", + imported_client.oauth_client_path.display() + ); + println!( + "oauth_client_auto_discovered={}", + imported_client.auto_discovered + ); + println!("oauth_client_id={}", imported_client.client_id); + println!( + "oauth_client_secret_present={}", + imported_client.client_secret_present + ); + } + self.login.print(false)?; + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LoginReport { + pub opened_browser: bool, + pub credential_path: std::path::PathBuf, + pub access_token_expires_at_epoch_s: Option, + pub scopes: Vec, + pub account: AccountRecord, +} + +impl LoginReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("opened_browser={}", self.opened_browser); + println!("credential_path={}", self.credential_path.display()); + match self.access_token_expires_at_epoch_s { + Some(expires_at) => println!("access_token_expires_at_epoch_s={expires_at}"), + None => println!("access_token_expires_at_epoch_s="), + } + println!("scopes={}", self.scopes.join(",")); + println!("account_id={}", self.account.account_id); + println!("email_address={}", self.account.email_address); + println!("history_id={}", self.account.history_id); + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LogoutReport { + pub credential_path: std::path::PathBuf, + pub credential_removed: bool, + pub deactivated_accounts: usize, +} + +impl LogoutReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("credential_path={}", self.credential_path.display()); + println!("credential_removed={}", self.credential_removed); + println!("deactivated_accounts={}", self.deactivated_accounts); + } + + Ok(()) + } +} + +#[derive(Debug, Error)] +pub enum AuthError { + #[error("oauth callback returned a malformed request")] + MalformedCallbackRequest, + #[error("oauth callback did not include an authorization code")] + MissingAuthorizationCode, + #[error("oauth callback returned an error: {0}")] + OAuthCallback(String), + #[error("oauth callback state did not match the original request")] + StateMismatch, + #[error("failed to bind or parse the loopback redirect URL")] + InvalidRedirectUrl, + #[error("opening the browser failed: {0}")] + BrowserOpen(String), + #[error("timed out waiting for the Gmail OAuth callback")] + CallbackTimedOut, + #[error("loopback callback I/O failed")] + CallbackIo(#[source] std::io::Error), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum AuthorizationPrompt { + StdoutText(String), + StderrJson(String), +} + +pub async fn setup( + config_report: &ConfigReport, + credentials_file: Option, + no_browser: bool, + json: bool, +) -> Result { + let setup_action = super::oauth_client::prepare_setup( + &config_report.config.gmail, + &config_report.config.workspace, + credentials_file, + json, + )?; + let (imported_client, completed_login) = match &setup_action { + PreparedSetup::UseExisting => ( + None, + authenticate_with_client_override(config_report, None, no_browser, json).await?, + ), + PreparedSetup::ImportClient(prepared_import) => { + let completed_login = authenticate_with_client_override( + config_report, + Some(prepared_import.resolved_client().clone()), + no_browser, + json, + ) + .await?; + super::oauth_client::persist_prepared_google_desktop_client(prepared_import)?; + ( + Some(prepared_import.imported_client().clone()), + completed_login, + ) + } + PreparedSetup::ImportAdc(prepared_import) => { + let completed_login = authenticate_with_refresh_token_override( + config_report, + prepared_import.resolved_client().clone(), + prepared_import.refresh_token().clone(), + ) + .await?; + super::oauth_client::persist_prepared_google_desktop_client( + prepared_import.client_import(), + )?; + ( + Some(prepared_import.imported_client().clone()), + completed_login, + ) + } + }; + let login = finalize_login(config_report, completed_login).await?; + + Ok(SetupReport { + imported_client, + login, + }) +} + +pub async fn login( + config_report: &ConfigReport, + no_browser: bool, + json: bool, +) -> Result { + let completed_login = + authenticate_with_client_override(config_report, None, no_browser, json).await?; + finalize_login(config_report, completed_login).await +} + +pub fn status(config_report: &ConfigReport) -> Result { + let credential_store = credential_store(config_report); + let credentials = credential_store.load()?; + let oauth_client_path = config_report + .config + .gmail + .oauth_client_path(&config_report.config.workspace); + let source = super::oauth_client::oauth_client_source( + &config_report.config.gmail, + &config_report.config.workspace, + )?; + let oauth_client_exists = matches!(source, OAuthClientSource::WorkspaceFile); + let configured = !matches!(source, OAuthClientSource::Unconfigured); + let active_account = if config_report.config.store.database_path.exists() { + accounts::get_active( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + )? + } else { + None + }; + + Ok(AuthStatusReport { + configured, + oauth_client_source: source.as_str().to_owned(), + oauth_client_path, + oauth_client_exists, + credential_path: credential_store.path().to_path_buf(), + credential_exists: credentials.is_some(), + access_token_expires_at_epoch_s: credentials + .as_ref() + .and_then(|credentials| credentials.expires_at_epoch_s), + scopes: credentials + .map(|credentials| credentials.scopes) + .unwrap_or_else(|| config_report.config.gmail.scopes.clone()), + active_account, + }) +} + +pub fn logout(config_report: &ConfigReport) -> Result { + let credential_store = credential_store(config_report); + let credential_removed = credential_store.clear()?; + let deactivated_accounts = if config_report.config.store.database_path.exists() { + accounts::deactivate_all( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + current_epoch_seconds()?, + )? + } else { + 0 + }; + + Ok(LogoutReport { + credential_path: credential_store.path().to_path_buf(), + credential_removed, + deactivated_accounts, + }) +} + +async fn authenticate_with_client_override( + config_report: &ConfigReport, + resolved_client_override: Option, + no_browser: bool, + json: bool, +) -> Result { + let resolved_client = match resolved_client_override { + Some(resolved_client) => resolved_client, + None => resolve_oauth_client(&config_report.config.gmail, &config_report.config.workspace)?, + }; + let mut oauth_client = BasicClient::new(ClientId::new(resolved_client.client_id)) + .set_auth_uri(AuthUrl::new(config_report.config.gmail.auth_url.clone())?) + .set_token_uri(TokenUrl::new(config_report.config.gmail.token_url.clone())?); + if let Some(secret) = resolved_client.client_secret + && !secret.is_empty() + { + oauth_client = oauth_client.set_client_secret(ClientSecret::new(secret)); + } + let listener = CallbackListener::bind(&config_report.config.gmail).await?; + oauth_client = oauth_client.set_redirect_uri(listener.redirect_url.clone()); + let http_client = oauth_http_client(&config_report.config.gmail)?; + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let should_open_browser = config_report.config.gmail.open_browser && !no_browser; + let mut authorization_request = oauth_client + .authorize_url(CsrfToken::new_random) + .set_pkce_challenge(pkce_challenge) + .add_extra_param("access_type", "offline") + .add_extra_param("prompt", "consent"); + for scope in &config_report.config.gmail.scopes { + authorization_request = authorization_request.add_scope(Scope::new(scope.clone())); + } + let (authorize_url, csrf_state) = authorization_request.url(); + + emit_authorization_prompt(authorization_prompt( + &authorize_url, + json, + should_open_browser, + ))?; + let opened_browser = open_browser_if_requested(&authorize_url, should_open_browser)?; + let code = listener.wait_for_code(&csrf_state).await?; + let token = oauth_client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request_async(&http_client) + .await + .context("failed to exchange Gmail OAuth authorization code")?; + completed_login_from_token_response(config_report, &token, None, opened_browser).await +} + +async fn authenticate_with_refresh_token_override( + config_report: &ConfigReport, + resolved_client: super::oauth_client::ResolvedOAuthClient, + refresh_token: SecretString, +) -> Result { + let mut oauth_client = BasicClient::new(ClientId::new(resolved_client.client_id)) + .set_auth_uri(AuthUrl::new(config_report.config.gmail.auth_url.clone())?) + .set_token_uri(TokenUrl::new(config_report.config.gmail.token_url.clone())?); + if let Some(secret) = resolved_client.client_secret + && !secret.is_empty() + { + oauth_client = oauth_client.set_client_secret(ClientSecret::new(secret)); + } + let http_client = oauth_http_client(&config_report.config.gmail)?; + let token = oauth_client + .exchange_refresh_token(&oauth2::RefreshToken::new( + refresh_token.expose_secret().to_owned(), + )) + .request_async(&http_client) + .await + .context("failed to exchange gcloud ADC refresh token for a Gmail access token")?; + completed_login_from_token_response(config_report, &token, Some(refresh_token), false).await +} + +async fn completed_login_from_token_response( + config_report: &ConfigReport, + token: &T, + refresh_token_fallback: Option, + opened_browser: bool, +) -> Result +where + T: TokenResponse, +{ + let profile = GmailClient::fetch_profile_with_access_token( + &config_report.config.gmail, + token.access_token().secret(), + ) + .await?; + let now_epoch_s = current_epoch_seconds()?; + let mut account_input = UpsertAccountInput { + email_address: profile.email_address, + history_id: profile.history_id, + messages_total: profile.messages_total, + threads_total: profile.threads_total, + access_scope: String::new(), + refreshed_at_epoch_s: now_epoch_s, + }; + let mut credentials = StoredCredentials::from_token_response( + account_input.gmail_account_id(), + token, + &config_report.config.gmail.scopes, + ); + if credentials.refresh_token.is_none() { + credentials.refresh_token = refresh_token_fallback; + } + account_input.access_scope = credentials.scopes.join(" "); + Ok(CompletedOAuthLogin { + opened_browser, + credentials, + account_input, + }) +} + +async fn finalize_login( + config_report: &ConfigReport, + completed_login: CompletedOAuthLogin, +) -> Result { + let workspace_paths = configured_workspace_paths(config_report)?; + let credential_store = credential_store(config_report); + let account = persist_login_state( + config_report, + &workspace_paths, + &credential_store, + &completed_login.credentials, + &completed_login.account_input, + ) + .await?; + + Ok(LoginReport { + opened_browser: completed_login.opened_browser, + credential_path: credential_store.path().to_path_buf(), + access_token_expires_at_epoch_s: completed_login.credentials.expires_at_epoch_s, + scopes: completed_login.credentials.scopes, + account, + }) +} + +struct CompletedOAuthLogin { + opened_browser: bool, + credentials: StoredCredentials, + account_input: UpsertAccountInput, +} + +fn credential_store(config_report: &ConfigReport) -> FileCredentialStore { + FileCredentialStore::new( + config_report + .config + .gmail + .credential_path(&config_report.config.workspace), + ) +} + +pub(super) fn configured_workspace_paths(config_report: &ConfigReport) -> Result { + let repo_root = + workspace::configured_repo_root_from_locations(&config_report.locations.repo_config_path)?; + Ok(WorkspacePaths::from_config( + repo_root, + &config_report.config.workspace, + )) +} + +fn oauth_http_client(config: &GmailConfig) -> Result { + reqwest::Client::builder() + .redirect(Policy::none()) + .timeout(Duration::from_secs(config.request_timeout_secs)) + .build() + .context("failed to build OAuth reqwest client") +} + +pub(super) async fn persist_login_state( + config_report: &ConfigReport, + workspace_paths: &WorkspacePaths, + credential_store: &FileCredentialStore, + credentials: &StoredCredentials, + account_input: &UpsertAccountInput, +) -> Result { + let config_report = config_report.clone(); + let workspace_paths = workspace_paths.clone(); + let credential_store = credential_store.clone(); + let credentials = credentials.clone(); + let account_input = account_input.clone(); + + let account = tokio::task::spawn_blocking(move || { + workspace_paths.ensure_runtime_dirs()?; + let previous_credentials = credential_store.load()?; + credential_store.save(&credentials)?; + match persist_active_account(&config_report, &account_input) { + Ok(account) => Ok(account), + Err(error) => { + rollback_credentials(&credential_store, previous_credentials).with_context(|| { + format!( + "failed to roll back credential state after login persistence error: {error}" + ) + })?; + Err(error) + } + } + }) + .await??; + + Ok(account) +} + +fn persist_active_account( + config_report: &ConfigReport, + account_input: &UpsertAccountInput, +) -> Result { + store::init(config_report)?; + accounts::upsert_active( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + account_input, + ) +} + +fn rollback_credentials( + credential_store: &FileCredentialStore, + previous_credentials: Option, +) -> Result<()> { + match previous_credentials { + Some(previous_credentials) => credential_store.save(&previous_credentials), + None => { + credential_store.clear()?; + Ok(()) + } + } +} + +pub(super) fn authorization_prompt( + authorize_url: &url::Url, + json: bool, + should_open_browser: bool, +) -> Option { + match (json, should_open_browser) { + (false, _) => Some(AuthorizationPrompt::StdoutText(format!( + "Complete Gmail authorization by visiting:\n{authorize_url}\n" + ))), + (true, false) => Some(AuthorizationPrompt::StderrJson( + serde_json::json!({ "authorization_url": authorize_url }).to_string(), + )), + (true, true) => None, + } +} + +fn emit_authorization_prompt(prompt: Option) -> Result<()> { + match prompt { + Some(AuthorizationPrompt::StdoutText(message)) => { + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "{message}")?; + stdout.flush()?; + } + Some(AuthorizationPrompt::StderrJson(message)) => { + let mut stderr = std::io::stderr().lock(); + writeln!(stderr, "{message}")?; + stderr.flush()?; + } + None => {} + } + + Ok(()) +} + #[derive(Debug)] pub struct CallbackListener { listener: TcpListener, @@ -142,7 +708,7 @@ async fn write_callback_response( Ok(()) } -fn redirect_url_for(local_addr: SocketAddr) -> Result { +pub(super) fn redirect_url_for(local_addr: SocketAddr) -> Result { RedirectUrl::new(format!("http://{local_addr}{CALLBACK_PATH}")) .map_err(|_| AuthError::InvalidRedirectUrl.into()) } @@ -153,7 +719,7 @@ fn is_malformed_callback_error(error: &anyhow::Error) -> bool { .is_some_and(|error| matches!(error, AuthError::MalformedCallbackRequest)) } -fn parse_callback_request(request: &str) -> Result> { +pub(super) fn parse_callback_request(request: &str) -> Result> { let request_line = request .lines() .next() @@ -198,210 +764,7 @@ fn parse_callback_request(request: &str) -> Result, - pub scopes: Vec, - pub active_account: Option, -} - -impl AuthStatusReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("configured={}", self.configured); - println!("oauth_client_source={}", self.oauth_client_source); - println!("oauth_client_path={}", self.oauth_client_path.display()); - println!("oauth_client_exists={}", self.oauth_client_exists); - println!("credential_path={}", self.credential_path.display()); - println!("credential_exists={}", self.credential_exists); - match self.access_token_expires_at_epoch_s { - Some(expires_at) => println!("access_token_expires_at_epoch_s={expires_at}"), - None => println!("access_token_expires_at_epoch_s="), - } - println!("scopes={}", self.scopes.join(",")); - match &self.active_account { - Some(account) => { - println!("active_account_id={}", account.account_id); - println!("active_account_email={}", account.email_address); - println!("active_account_history_id={}", account.history_id); - } - None => println!("active_account="), - } - } - - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SetupReport { - #[serde(skip_serializing_if = "Option::is_none")] - pub imported_client: Option, - pub login: LoginReport, -} - -impl SetupReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("oauth_client_imported={}", self.imported_client.is_some()); - if let Some(imported_client) = &self.imported_client { - println!( - "oauth_client_source_kind={}", - imported_client.source_kind.as_str() - ); - match &imported_client.source_path { - Some(source_path) => { - println!("oauth_client_source_path={}", source_path.display()) - } - None => println!("oauth_client_source_path="), - } - println!( - "oauth_client_path={}", - imported_client.oauth_client_path.display() - ); - println!( - "oauth_client_auto_discovered={}", - imported_client.auto_discovered - ); - println!("oauth_client_id={}", imported_client.client_id); - println!( - "oauth_client_secret_present={}", - imported_client.client_secret_present - ); - } - self.login.print(false)?; - } - - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct LoginReport { - pub opened_browser: bool, - pub credential_path: std::path::PathBuf, - pub access_token_expires_at_epoch_s: Option, - pub scopes: Vec, - pub account: AccountRecord, -} - -impl LoginReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("opened_browser={}", self.opened_browser); - println!("credential_path={}", self.credential_path.display()); - match self.access_token_expires_at_epoch_s { - Some(expires_at) => println!("access_token_expires_at_epoch_s={expires_at}"), - None => println!("access_token_expires_at_epoch_s="), - } - println!("scopes={}", self.scopes.join(",")); - println!("account_id={}", self.account.account_id); - println!("email_address={}", self.account.email_address); - println!("history_id={}", self.account.history_id); - } - - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct LogoutReport { - pub credential_path: std::path::PathBuf, - pub credential_removed: bool, - pub deactivated_accounts: usize, -} - -impl LogoutReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("credential_path={}", self.credential_path.display()); - println!("credential_removed={}", self.credential_removed); - println!("deactivated_accounts={}", self.deactivated_accounts); - } - - Ok(()) - } -} - -#[derive(Debug, Error)] -pub(crate) enum AuthError { - #[error("oauth callback returned a malformed request")] - MalformedCallbackRequest, - #[error("oauth callback did not include an authorization code")] - MissingAuthorizationCode, - #[error("oauth callback returned an error: {0}")] - OAuthCallback(String), - #[error("oauth callback state did not match the original request")] - StateMismatch, - #[error("failed to bind or parse the loopback redirect URL")] - InvalidRedirectUrl, - #[error("opening the browser failed: {0}")] - BrowserOpen(String), - #[error("timed out waiting for the Gmail OAuth callback")] - CallbackTimedOut, - #[error("loopback callback I/O failed")] - CallbackIo(#[source] std::io::Error), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum AuthorizationPrompt { - StdoutText(String), - StderrJson(String), -} - -pub async fn setup( - config_report: &ConfigReport, - credentials_file: Option, - no_browser: bool, - json: bool, -) -> Result { - let setup_action = oauth_client::prepare_setup( - &config_report.config.gmail, - &config_report.config.workspace, - credentials_file, - json, - )?; - let (imported_client, completed_login) = match &setup_action { - PreparedSetup::UseExisting => ( - None, - authenticate_with_client_override(config_report, None, no_browser, json).await?, - ), - PreparedSetup::ImportClient(prepared_import) => { - let completed_login = authenticate_with_client_override( - config_report, - Some(prepared_import.resolved_client().clone()), - no_browser, - json, - ) - .await?; - oauth_client::persist_prepared_google_desktop_client(prepared_import)?; - ( - Some(prepared_import.imported_client().clone()), - completed_login, - ) - } - PreparedSetup::ImportAdc(prepared_import) => { - let completed_login = authenticate_with_refresh_token_override( - config_report, - prepared_import.resolved_client().clone(), - prepared_import.refresh_token().clone(), - ) - .await?; - oauth_client::persist_prepared_google_desktop_client(prepared_import.client_import())?; - ( - Some(prepared_import.imported_client().clone()), - completed_login, - ) - } - }; - let login = finalize_login(config_report, completed_login)?; - - Ok(SetupReport { - imported_client, - login, - }) -} - -pub async fn login( - config_report: &ConfigReport, - no_browser: bool, - json: bool, -) -> Result { - let completed_login = - authenticate_with_client_override(config_report, None, no_browser, json).await?; - finalize_login(config_report, completed_login) -} - -async fn authenticate_with_client_override( - config_report: &ConfigReport, - resolved_client_override: Option, - no_browser: bool, - json: bool, -) -> Result { - let resolved_client = match resolved_client_override { - Some(resolved_client) => resolved_client, - None => resolve_oauth_client(&config_report.config.gmail, &config_report.config.workspace)?, - }; - let mut oauth_client = BasicClient::new(ClientId::new(resolved_client.client_id)) - .set_auth_uri(AuthUrl::new(config_report.config.gmail.auth_url.clone())?) - .set_token_uri(TokenUrl::new(config_report.config.gmail.token_url.clone())?); - if let Some(secret) = resolved_client.client_secret - && !secret.is_empty() - { - oauth_client = oauth_client.set_client_secret(ClientSecret::new(secret)); - } - let listener = flow::CallbackListener::bind(&config_report.config.gmail).await?; - oauth_client = oauth_client.set_redirect_uri(listener.redirect_url.clone()); - let http_client = oauth_http_client(&config_report.config.gmail)?; - let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - let should_open_browser = config_report.config.gmail.open_browser && !no_browser; - let mut authorization_request = oauth_client - .authorize_url(CsrfToken::new_random) - .set_pkce_challenge(pkce_challenge) - .add_extra_param("access_type", "offline") - .add_extra_param("prompt", "consent"); - for scope in &config_report.config.gmail.scopes { - authorization_request = authorization_request.add_scope(Scope::new(scope.clone())); - } - let (authorize_url, csrf_state) = authorization_request.url(); - - emit_authorization_prompt(authorization_prompt( - &authorize_url, - json, - should_open_browser, - ))?; - let opened_browser = flow::open_browser_if_requested(&authorize_url, should_open_browser)?; - let code = listener.wait_for_code(&csrf_state).await?; - let token = oauth_client - .exchange_code(code) - .set_pkce_verifier(pkce_verifier) - .request_async(&http_client) - .await - .context("failed to exchange Gmail OAuth authorization code")?; - completed_login_from_token_response(config_report, &token, None, opened_browser).await -} - -async fn authenticate_with_refresh_token_override( - config_report: &ConfigReport, - resolved_client: oauth_client::ResolvedOAuthClient, - refresh_token: SecretString, -) -> Result { - let mut oauth_client = BasicClient::new(ClientId::new(resolved_client.client_id)) - .set_auth_uri(AuthUrl::new(config_report.config.gmail.auth_url.clone())?) - .set_token_uri(TokenUrl::new(config_report.config.gmail.token_url.clone())?); - if let Some(secret) = resolved_client.client_secret - && !secret.is_empty() - { - oauth_client = oauth_client.set_client_secret(ClientSecret::new(secret)); - } - let http_client = oauth_http_client(&config_report.config.gmail)?; - let token = oauth_client - .exchange_refresh_token(&oauth2::RefreshToken::new( - refresh_token.clone().expose_secret().to_owned(), - )) - .request_async(&http_client) - .await - .context("failed to exchange gcloud ADC refresh token for a Gmail access token")?; - completed_login_from_token_response(config_report, &token, Some(refresh_token), false).await -} - -async fn completed_login_from_token_response( - config_report: &ConfigReport, - token: &T, - refresh_token_fallback: Option, - opened_browser: bool, -) -> Result -where - T: TokenResponse, -{ - let profile = GmailClient::fetch_profile_with_access_token( - &config_report.config.gmail, - token.access_token().secret(), - ) - .await?; - let now_epoch_s = current_epoch_seconds()?; - let mut account_input = UpsertAccountInput { - email_address: profile.email_address, - history_id: profile.history_id, - messages_total: profile.messages_total, - threads_total: profile.threads_total, - access_scope: String::new(), - refreshed_at_epoch_s: now_epoch_s, - }; - let mut credentials = StoredCredentials::from_token_response( - account_input.gmail_account_id(), - token, - &config_report.config.gmail.scopes, - ); - if credentials.refresh_token.is_none() { - credentials.refresh_token = refresh_token_fallback; - } - account_input.access_scope = credentials.scopes.join(" "); - Ok(CompletedOAuthLogin { - opened_browser, - credentials, - account_input, - }) -} - -fn finalize_login( - config_report: &ConfigReport, - completed_login: CompletedOAuthLogin, -) -> Result { - let workspace_paths = configured_workspace_paths(config_report)?; - let credential_store = credential_store(config_report); - let account = persist_login_state( - config_report, - &workspace_paths, - &credential_store, - &completed_login.credentials, - &completed_login.account_input, - )?; - - Ok(LoginReport { - opened_browser: completed_login.opened_browser, - credential_path: credential_store.path().to_path_buf(), - access_token_expires_at_epoch_s: completed_login.credentials.expires_at_epoch_s, - scopes: completed_login.credentials.scopes, - account, - }) -} - -struct CompletedOAuthLogin { - opened_browser: bool, - credentials: StoredCredentials, - account_input: UpsertAccountInput, -} - -pub fn status(config_report: &ConfigReport) -> Result { - let credential_store = credential_store(config_report); - let credentials = credential_store.load()?; - let oauth_client_path = config_report - .config - .gmail - .oauth_client_path(&config_report.config.workspace); - let source = oauth_client::oauth_client_source( - &config_report.config.gmail, - &config_report.config.workspace, - )?; - let oauth_client_exists = matches!(source, OAuthClientSource::WorkspaceFile); - let configured = !matches!(source, OAuthClientSource::Unconfigured); - let active_account = if config_report.config.store.database_path.exists() { - accounts::get_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - )? - } else { - None - }; - - Ok(AuthStatusReport { - configured, - oauth_client_source: source.as_str().to_owned(), - oauth_client_path, - oauth_client_exists, - credential_path: credential_store.path().to_path_buf(), - credential_exists: credentials.is_some(), - access_token_expires_at_epoch_s: credentials - .as_ref() - .and_then(|credentials| credentials.expires_at_epoch_s), - scopes: credentials - .map(|credentials| credentials.scopes) - .unwrap_or_else(|| config_report.config.gmail.scopes.clone()), - active_account, - }) -} - -pub fn logout(config_report: &ConfigReport) -> Result { - let credential_store = credential_store(config_report); - let credential_removed = credential_store.clear()?; - let deactivated_accounts = if config_report.config.store.database_path.exists() { - accounts::deactivate_all( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - current_epoch_seconds()?, - )? - } else { - 0 - }; - - Ok(LogoutReport { - credential_path: credential_store.path().to_path_buf(), - credential_removed, - deactivated_accounts, - }) -} - -fn credential_store(config_report: &ConfigReport) -> FileCredentialStore { - FileCredentialStore::new( - config_report - .config - .gmail - .credential_path(&config_report.config.workspace), - ) -} - -fn configured_workspace_paths(config_report: &ConfigReport) -> Result { - let repo_root = - workspace::configured_repo_root_from_locations(&config_report.locations.repo_config_path)?; - Ok(WorkspacePaths::from_config( - repo_root, - &config_report.config.workspace, - )) -} - -fn oauth_http_client(config: &GmailConfig) -> Result { - reqwest::Client::builder() - .redirect(Policy::none()) - .timeout(Duration::from_secs(config.request_timeout_secs)) - .build() - .context("failed to build OAuth reqwest client") -} - -fn persist_login_state( - config_report: &ConfigReport, - workspace_paths: &WorkspacePaths, - credential_store: &FileCredentialStore, - credentials: &StoredCredentials, - account_input: &UpsertAccountInput, -) -> Result { - workspace_paths.ensure_runtime_dirs()?; - let previous_credentials = credential_store.load()?; - credential_store.save(credentials)?; - match persist_active_account(config_report, account_input) { - Ok(account) => Ok(account), - Err(error) => { - rollback_credentials(credential_store, previous_credentials).with_context(|| { - format!( - "failed to roll back credential state after login persistence error: {error}" - ) - })?; - Err(error) - } - } -} - -fn persist_active_account( - config_report: &ConfigReport, - account_input: &UpsertAccountInput, -) -> Result { - store::init(config_report)?; - accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - account_input, - ) -} - -fn rollback_credentials( - credential_store: &FileCredentialStore, - previous_credentials: Option, -) -> Result<()> { - match previous_credentials { - Some(previous_credentials) => credential_store.save(&previous_credentials), - None => { - credential_store.clear()?; - Ok(()) - } - } -} - -fn authorization_prompt( - authorize_url: &url::Url, - json: bool, - should_open_browser: bool, -) -> Option { - match (json, should_open_browser) { - (false, _) => Some(AuthorizationPrompt::StdoutText(format!( - "Complete Gmail authorization by visiting:\n{authorize_url}\n" - ))), - (true, false) => Some(AuthorizationPrompt::StderrJson( - serde_json::json!({ "authorization_url": authorize_url }).to_string(), - )), - (true, true) => None, - } -} - -fn emit_authorization_prompt(prompt: Option) -> Result<()> { - match prompt { - Some(AuthorizationPrompt::StdoutText(message)) => { - let mut stdout = std::io::stdout().lock(); - writeln!(stdout, "{message}")?; - stdout.flush()?; - } - Some(AuthorizationPrompt::StderrJson(message)) => { - let mut stderr = std::io::stderr().lock(); - writeln!(stderr, "{message}")?; - stderr.flush()?; - } - None => {} - } - - Ok(()) -} +pub use flow::{AuthError, AuthStatusReport, login, logout, setup, status}; #[cfg(test)] -mod tests { - use super::{ - AuthorizationPrompt, authorization_prompt, configured_workspace_paths, login, logout, - persist_login_state, setup, status, - }; - use crate::auth::file_store::{CredentialStore, FileCredentialStore, StoredCredentials}; - use crate::auth::oauth_client::{self, PreparedSetup, setup_guidance}; - use crate::config::resolve; - use crate::store::accounts::UpsertAccountInput; - use crate::workspace::WorkspacePaths; - use rusqlite::Connection; - use secrecy::SecretString; - use std::fs; - use tempfile::TempDir; - use url::Url; - - #[test] - fn omits_authorization_prompt_for_json_output() { - let authorize_url = Url::parse("https://example.com/oauth").unwrap(); - - assert_eq!(authorization_prompt(&authorize_url, true, true), None); - } - - #[test] - fn routes_headless_json_authorization_url_to_stderr() { - let authorize_url = Url::parse("https://example.com/oauth").unwrap(); - - assert_eq!( - authorization_prompt(&authorize_url, true, false), - Some(AuthorizationPrompt::StderrJson(String::from( - r#"{"authorization_url":"https://example.com/oauth"}"# - ))) - ); - } - - #[test] - fn renders_authorization_prompt_for_human_output() { - let authorize_url = Url::parse("https://example.com/oauth").unwrap(); - - assert_eq!( - authorization_prompt(&authorize_url, false, false), - Some(AuthorizationPrompt::StdoutText(String::from( - "Complete Gmail authorization by visiting:\nhttps://example.com/oauth\n" - ))) - ); - } - - #[test] - fn logout_clears_credentials_when_accounts_table_is_absent() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - let credential_store = FileCredentialStore::new( - config_report - .config - .gmail - .credential_path(&config_report.config.workspace), - ); - credential_store - .save(&StoredCredentials { - account_id: String::from("gmail:operator@example.com"), - access_token: SecretString::from(String::from("access-token")), - refresh_token: Some(SecretString::from(String::from("refresh-token"))), - expires_at_epoch_s: Some(123), - scopes: vec![String::from("scope:a")], - }) - .unwrap(); - - let connection = Connection::open(&config_report.config.store.database_path).unwrap(); - connection - .execute_batch( - "PRAGMA user_version = 1; - CREATE TABLE app_metadata ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) STRICT;", - ) - .unwrap(); - - let report = logout(&config_report).unwrap(); - - assert!(report.credential_removed); - assert_eq!(report.deactivated_accounts, 0); - assert!(credential_store.load().unwrap().is_none()); - assert!(config_report.config.store.database_path.exists()); - } - - #[tokio::test] - async fn login_without_oauth_client_does_not_create_runtime_state() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - let config_report = resolve(&paths).unwrap(); - - let error = login(&config_report, true, true).await.unwrap_err(); - - assert_eq!( - error.to_string(), - "gmail OAuth client is not configured; run `mailroom auth setup` or set gmail.client_id" - ); - assert!(!config_report.config.store.database_path.exists()); - assert!(!config_report.config.workspace.runtime_root.exists()); - } - - #[test] - fn status_reports_imported_oauth_client_as_configured() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - let config_report = resolve(&paths).unwrap(); - let oauth_client_path = config_report - .config - .gmail - .oauth_client_path(&config_report.config.workspace); - fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); - fs::write( - &oauth_client_path, - r#"{ - "client_id": "desktop-client.apps.googleusercontent.com", - "client_secret": "desktop-secret" -}"#, - ) - .unwrap(); - - let report = status(&config_report).unwrap(); - - assert!(report.configured); - assert_eq!(report.oauth_client_source, "workspace_file"); - assert!(report.oauth_client_exists); - } - - #[test] - fn status_distinguishes_configured_auth_from_imported_client_file_presence() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - let repo_config_path = repo_root.join(".mailroom/config.toml"); - fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); - fs::write( - &repo_config_path, - r#" -[gmail] -client_id = "inline-client.apps.googleusercontent.com" -client_secret = "inline-secret" -"#, - ) - .unwrap(); - - let config_report = resolve(&paths).unwrap(); - let report = status(&config_report).unwrap(); - - assert!(report.configured); - assert_eq!(report.oauth_client_source, "config"); - assert!(!report.oauth_client_exists); - } - - #[test] - fn status_errors_when_malformed_imported_oauth_client_exists() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - let repo_config_path = repo_root.join(".mailroom/config.toml"); - let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); - fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); - fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); - fs::write( - &repo_config_path, - r#" -[gmail] -client_id = "inline-client.apps.googleusercontent.com" -client_secret = "inline-secret" -"#, - ) - .unwrap(); - fs::write(&oauth_client_path, "{not-json").unwrap(); - - let config_report = resolve(&paths).unwrap(); - let error = status(&config_report).unwrap_err(); - - assert!( - error - .to_string() - .contains("failed to parse OAuth client from") - ); - } - - #[test] - fn status_reports_valid_imported_oauth_client_as_authoritative_over_inline_config() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - let repo_config_path = repo_root.join(".mailroom/config.toml"); - let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); - fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); - fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); - fs::write( - &repo_config_path, - r#" -[gmail] -client_id = "inline-client.apps.googleusercontent.com" -client_secret = "inline-secret" -"#, - ) - .unwrap(); - fs::write( - &oauth_client_path, - r#"{ - "client_id": "imported-client.apps.googleusercontent.com", - "client_secret": "imported-secret" -}"#, - ) - .unwrap(); - - let config_report = resolve(&paths).unwrap(); - let report = status(&config_report).unwrap(); - - assert!(report.configured); - assert_eq!(report.oauth_client_source, "workspace_file"); - assert!(report.oauth_client_exists); - } - - #[tokio::test] - async fn setup_missing_credentials_file_reports_guidance() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - let config_report = resolve(&paths).unwrap(); - - let error = setup( - &config_report, - Some(repo_root.join("missing-client-secret.json")), - true, - true, - ) - .await - .unwrap_err(); - - assert!( - error - .to_string() - .contains("Google desktop-app credentials JSON was not found") - ); - assert!(setup_guidance().contains("console.cloud.google.com")); - assert!(!config_report.config.workspace.runtime_root.exists()); - } - - #[tokio::test] - async fn setup_preserves_existing_oauth_client_when_login_fails_after_staging_replacement() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - let repo_config_path = repo_root.join(".mailroom/config.toml"); - let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); - let credentials_path = repo_root.join("client_secret_replacement.json"); - fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); - fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); - fs::write( - &repo_config_path, - r#" -[gmail] -auth_url = "not-a-valid-url" -"#, - ) - .unwrap(); - fs::write( - &oauth_client_path, - r#"{ - "client_id": "existing-client.apps.googleusercontent.com", - "client_secret": "existing-secret" -}"#, - ) - .unwrap(); - fs::write( - &credentials_path, - r#"{ - "installed": { - "client_id": "replacement-client.apps.googleusercontent.com", - "client_secret": "replacement-secret" - } -}"#, - ) - .unwrap(); - - let config_report = resolve(&paths).unwrap(); - let original_oauth_client = fs::read_to_string(&oauth_client_path).unwrap(); - let error = setup(&config_report, Some(credentials_path), true, true) - .await - .unwrap_err(); - - assert!(error.to_string().contains("relative URL without a base")); - assert_eq!( - fs::read_to_string(&oauth_client_path).unwrap(), - original_oauth_client - ); - } - - #[test] - fn setup_reuses_existing_imported_oauth_client_when_no_new_credentials_file_is_given() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); - fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); - fs::write( - &oauth_client_path, - r#"{ - "client_id": "imported-client.apps.googleusercontent.com", - "client_secret": "imported-secret" -}"#, - ) - .unwrap(); - - let config_report = resolve(&paths).unwrap(); - let setup_action = oauth_client::prepare_setup( - &config_report.config.gmail, - &config_report.config.workspace, - None, - false, - ) - .unwrap(); - - assert!(matches!(setup_action, PreparedSetup::UseExisting)); - } - - #[test] - fn persist_login_state_does_not_upsert_account_when_credential_save_fails() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - let config_report = resolve(&paths).unwrap(); - let workspace_paths = configured_workspace_paths(&config_report).unwrap(); - let credential_store = FileCredentialStore::new( - config_report - .config - .gmail - .credential_path(&config_report.config.workspace), - ); - - workspace_paths.ensure_runtime_dirs().unwrap(); - fs::create_dir(credential_store.path()).unwrap(); - - let error = persist_login_state( - &config_report, - &workspace_paths, - &credential_store, - &StoredCredentials { - account_id: String::from("gmail:operator@example.com"), - access_token: SecretString::from(String::from("access-token")), - refresh_token: Some(SecretString::from(String::from("refresh-token"))), - expires_at_epoch_s: Some(123), - scopes: vec![String::from("scope:a")], - }, - &UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("12345"), - messages_total: 10, - threads_total: 7, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap_err(); - - assert!(!error.to_string().is_empty()); - assert!(!config_report.config.store.database_path.exists()); - } - - #[test] - fn persist_login_state_rolls_back_new_credentials_when_store_init_fails() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - let config_report = resolve(&paths).unwrap(); - let workspace_paths = configured_workspace_paths(&config_report).unwrap(); - let credential_store = FileCredentialStore::new( - config_report - .config - .gmail - .credential_path(&config_report.config.workspace), - ); - - workspace_paths.ensure_runtime_dirs().unwrap(); - fs::create_dir(&config_report.config.store.database_path).unwrap(); - - let error = persist_login_state( - &config_report, - &workspace_paths, - &credential_store, - &StoredCredentials { - account_id: String::from("gmail:operator@example.com"), - access_token: SecretString::from(String::from("access-token")), - refresh_token: Some(SecretString::from(String::from("refresh-token"))), - expires_at_epoch_s: Some(123), - scopes: vec![String::from("scope:a")], - }, - &UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("12345"), - messages_total: 10, - threads_total: 7, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap_err(); - - assert!(!error.to_string().is_empty()); - assert!(credential_store.load().unwrap().is_none()); - assert!(config_report.config.store.database_path.is_dir()); - } - - #[test] - fn persist_login_state_restores_previous_credentials_when_store_init_fails() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - let config_report = resolve(&paths).unwrap(); - let workspace_paths = configured_workspace_paths(&config_report).unwrap(); - let credential_store = FileCredentialStore::new( - config_report - .config - .gmail - .credential_path(&config_report.config.workspace), - ); - - workspace_paths.ensure_runtime_dirs().unwrap(); - credential_store - .save(&StoredCredentials { - account_id: String::from("gmail:previous@example.com"), - access_token: SecretString::from(String::from("previous-access-token")), - refresh_token: Some(SecretString::from(String::from("previous-refresh-token"))), - expires_at_epoch_s: Some(321), - scopes: vec![String::from("scope:previous")], - }) - .unwrap(); - fs::create_dir(&config_report.config.store.database_path).unwrap(); - - let error = persist_login_state( - &config_report, - &workspace_paths, - &credential_store, - &StoredCredentials { - account_id: String::from("gmail:operator@example.com"), - access_token: SecretString::from(String::from("access-token")), - refresh_token: Some(SecretString::from(String::from("refresh-token"))), - expires_at_epoch_s: Some(123), - scopes: vec![String::from("scope:a")], - }, - &UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("12345"), - messages_total: 10, - threads_total: 7, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap_err(); - - let restored = credential_store.load().unwrap().unwrap(); - - assert!(!error.to_string().is_empty()); - assert_eq!(restored.account_id, "gmail:previous@example.com"); - assert_eq!(restored.expires_at_epoch_s, Some(321)); - assert_eq!(restored.scopes, vec![String::from("scope:previous")]); - } -} +mod tests; diff --git a/src/auth/tests.rs b/src/auth/tests.rs new file mode 100644 index 0000000..f133f08 --- /dev/null +++ b/src/auth/tests.rs @@ -0,0 +1,667 @@ +use super::flow::{ + AuthorizationPrompt, CallbackListener, authorization_prompt, configured_workspace_paths, + parse_callback_request, persist_login_state, redirect_url_for, +}; +use super::{login, logout, setup, status}; +use crate::auth::file_store::{CredentialStore, FileCredentialStore, StoredCredentials}; +use crate::auth::oauth_client::{self, PreparedSetup, setup_guidance}; +use crate::config::{GmailConfig, resolve}; +use crate::store::accounts::UpsertAccountInput; +use crate::workspace::WorkspacePaths; +use oauth2::CsrfToken; +use rusqlite::Connection; +use secrecy::SecretString; +use std::fs; +use std::net::SocketAddr; +use tempfile::TempDir; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, +}; +use url::Url; + +#[test] +fn omits_authorization_prompt_for_json_output() { + let authorize_url = Url::parse("https://example.com/oauth").unwrap(); + + assert_eq!(authorization_prompt(&authorize_url, true, true), None); +} + +#[test] +fn routes_headless_json_authorization_url_to_stderr() { + let authorize_url = Url::parse("https://example.com/oauth").unwrap(); + + assert_eq!( + authorization_prompt(&authorize_url, true, false), + Some(AuthorizationPrompt::StderrJson(String::from( + r#"{"authorization_url":"https://example.com/oauth"}"# + ))) + ); +} + +#[test] +fn renders_authorization_prompt_for_human_output() { + let authorize_url = Url::parse("https://example.com/oauth").unwrap(); + + assert_eq!( + authorization_prompt(&authorize_url, false, false), + Some(AuthorizationPrompt::StdoutText(String::from( + "Complete Gmail authorization by visiting:\nhttps://example.com/oauth\n" + ))) + ); +} + +#[test] +fn logout_clears_credentials_when_accounts_table_is_absent() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + paths.ensure_runtime_dirs().unwrap(); + let config_report = resolve(&paths).unwrap(); + let credential_store = FileCredentialStore::new( + config_report + .config + .gmail + .credential_path(&config_report.config.workspace), + ); + credential_store + .save(&StoredCredentials { + account_id: String::from("gmail:operator@example.com"), + access_token: SecretString::from(String::from("access-token")), + refresh_token: Some(SecretString::from(String::from("refresh-token"))), + expires_at_epoch_s: Some(123), + scopes: vec![String::from("scope:a")], + }) + .unwrap(); + + let connection = Connection::open(&config_report.config.store.database_path).unwrap(); + connection + .execute_batch( + "PRAGMA user_version = 1; + CREATE TABLE app_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT;", + ) + .unwrap(); + + let report = logout(&config_report).unwrap(); + + assert!(report.credential_removed); + assert_eq!(report.deactivated_accounts, 0); + assert!(credential_store.load().unwrap().is_none()); + assert!(config_report.config.store.database_path.exists()); +} + +#[tokio::test] +async fn login_without_oauth_client_does_not_create_runtime_state() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + let config_report = resolve(&paths).unwrap(); + + let error = login(&config_report, true, true).await.unwrap_err(); + + assert_eq!( + error.to_string(), + "gmail OAuth client is not configured; run `mailroom auth setup` or set gmail.client_id" + ); + assert!(!config_report.config.store.database_path.exists()); + assert!(!config_report.config.workspace.runtime_root.exists()); +} + +#[test] +fn status_reports_imported_oauth_client_as_configured() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + let config_report = resolve(&paths).unwrap(); + let oauth_client_path = config_report + .config + .gmail + .oauth_client_path(&config_report.config.workspace); + fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); + fs::write( + &oauth_client_path, + r#"{ + "client_id": "desktop-client.apps.googleusercontent.com", + "client_secret": "desktop-secret" +}"#, + ) + .unwrap(); + + let report = status(&config_report).unwrap(); + + assert!(report.configured); + assert_eq!(report.oauth_client_source, "workspace_file"); + assert!(report.oauth_client_exists); +} + +#[test] +fn status_distinguishes_configured_auth_from_imported_client_file_presence() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let repo_config_path = repo_root.join(".mailroom/config.toml"); + fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); + fs::write( + &repo_config_path, + r#" +[gmail] +client_id = "inline-client.apps.googleusercontent.com" +client_secret = "inline-secret" +"#, + ) + .unwrap(); + + let config_report = resolve(&paths).unwrap(); + let report = status(&config_report).unwrap(); + + assert!(report.configured); + assert_eq!(report.oauth_client_source, "config"); + assert!(!report.oauth_client_exists); +} + +#[test] +fn status_errors_when_malformed_imported_oauth_client_exists() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let repo_config_path = repo_root.join(".mailroom/config.toml"); + let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); + fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); + fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); + fs::write( + &repo_config_path, + r#" +[gmail] +client_id = "inline-client.apps.googleusercontent.com" +client_secret = "inline-secret" +"#, + ) + .unwrap(); + fs::write(&oauth_client_path, "{not-json").unwrap(); + + let config_report = resolve(&paths).unwrap(); + let error = status(&config_report).unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to parse OAuth client from") + ); +} + +#[test] +fn status_reports_valid_imported_oauth_client_as_authoritative_over_inline_config() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let repo_config_path = repo_root.join(".mailroom/config.toml"); + let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); + fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); + fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); + fs::write( + &repo_config_path, + r#" +[gmail] +client_id = "inline-client.apps.googleusercontent.com" +client_secret = "inline-secret" +"#, + ) + .unwrap(); + fs::write( + &oauth_client_path, + r#"{ + "client_id": "imported-client.apps.googleusercontent.com", + "client_secret": "imported-secret" +}"#, + ) + .unwrap(); + + let config_report = resolve(&paths).unwrap(); + let report = status(&config_report).unwrap(); + + assert!(report.configured); + assert_eq!(report.oauth_client_source, "workspace_file"); + assert!(report.oauth_client_exists); +} + +#[tokio::test] +async fn setup_missing_credentials_file_reports_guidance() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let config_report = resolve(&paths).unwrap(); + + let error = setup( + &config_report, + Some(repo_root.join("missing-client-secret.json")), + true, + true, + ) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .contains("Google desktop-app credentials JSON was not found") + ); + assert!(setup_guidance().contains("console.cloud.google.com")); + assert!(!config_report.config.workspace.runtime_root.exists()); +} + +#[tokio::test] +async fn setup_preserves_existing_oauth_client_when_login_fails_after_staging_replacement() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let repo_config_path = repo_root.join(".mailroom/config.toml"); + let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); + let credentials_path = repo_root.join("client_secret_replacement.json"); + fs::create_dir_all(repo_config_path.parent().unwrap()).unwrap(); + fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); + fs::write( + &repo_config_path, + r#" +[gmail] +auth_url = "not-a-valid-url" +"#, + ) + .unwrap(); + fs::write( + &oauth_client_path, + r#"{ + "client_id": "existing-client.apps.googleusercontent.com", + "client_secret": "existing-secret" +}"#, + ) + .unwrap(); + fs::write( + &credentials_path, + r#"{ + "installed": { + "client_id": "replacement-client.apps.googleusercontent.com", + "client_secret": "replacement-secret" + } +}"#, + ) + .unwrap(); + + let config_report = resolve(&paths).unwrap(); + let original_oauth_client = fs::read_to_string(&oauth_client_path).unwrap(); + let error = setup(&config_report, Some(credentials_path), true, true) + .await + .unwrap_err(); + + assert!(error.to_string().contains("relative URL without a base")); + assert_eq!( + fs::read_to_string(&oauth_client_path).unwrap(), + original_oauth_client + ); +} + +#[test] +fn setup_reuses_existing_imported_oauth_client_when_no_new_credentials_file_is_given() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let oauth_client_path = repo_root.join(".mailroom/auth/gmail-oauth-client.json"); + fs::create_dir_all(oauth_client_path.parent().unwrap()).unwrap(); + fs::write( + &oauth_client_path, + r#"{ + "client_id": "imported-client.apps.googleusercontent.com", + "client_secret": "imported-secret" +}"#, + ) + .unwrap(); + + let config_report = resolve(&paths).unwrap(); + let setup_action = oauth_client::prepare_setup( + &config_report.config.gmail, + &config_report.config.workspace, + None, + false, + ) + .unwrap(); + + assert!(matches!(setup_action, PreparedSetup::UseExisting)); +} + +#[tokio::test] +async fn persist_login_state_does_not_upsert_account_when_credential_save_fails() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + let config_report = resolve(&paths).unwrap(); + let workspace_paths = configured_workspace_paths(&config_report).unwrap(); + let credential_store = FileCredentialStore::new( + config_report + .config + .gmail + .credential_path(&config_report.config.workspace), + ); + + workspace_paths.ensure_runtime_dirs().unwrap(); + fs::create_dir(credential_store.path()).unwrap(); + + let error = persist_login_state( + &config_report, + &workspace_paths, + &credential_store, + &StoredCredentials { + account_id: String::from("gmail:operator@example.com"), + access_token: SecretString::from(String::from("access-token")), + refresh_token: Some(SecretString::from(String::from("refresh-token"))), + expires_at_epoch_s: Some(123), + scopes: vec![String::from("scope:a")], + }, + &UpsertAccountInput { + email_address: String::from("operator@example.com"), + history_id: String::from("12345"), + messages_total: 10, + threads_total: 7, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 100, + }, + ) + .await + .unwrap_err(); + + assert!(!error.to_string().is_empty()); + assert!(!config_report.config.store.database_path.exists()); +} + +#[tokio::test] +async fn persist_login_state_rolls_back_new_credentials_when_store_init_fails() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + let config_report = resolve(&paths).unwrap(); + let workspace_paths = configured_workspace_paths(&config_report).unwrap(); + let credential_store = FileCredentialStore::new( + config_report + .config + .gmail + .credential_path(&config_report.config.workspace), + ); + + workspace_paths.ensure_runtime_dirs().unwrap(); + fs::create_dir(&config_report.config.store.database_path).unwrap(); + + let error = persist_login_state( + &config_report, + &workspace_paths, + &credential_store, + &StoredCredentials { + account_id: String::from("gmail:operator@example.com"), + access_token: SecretString::from(String::from("access-token")), + refresh_token: Some(SecretString::from(String::from("refresh-token"))), + expires_at_epoch_s: Some(123), + scopes: vec![String::from("scope:a")], + }, + &UpsertAccountInput { + email_address: String::from("operator@example.com"), + history_id: String::from("12345"), + messages_total: 10, + threads_total: 7, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 100, + }, + ) + .await + .unwrap_err(); + + assert!(!error.to_string().is_empty()); + assert!(credential_store.load().unwrap().is_none()); + assert!(config_report.config.store.database_path.is_dir()); +} + +#[tokio::test] +async fn persist_login_state_restores_previous_credentials_when_store_init_fails() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + let config_report = resolve(&paths).unwrap(); + let workspace_paths = configured_workspace_paths(&config_report).unwrap(); + let credential_store = FileCredentialStore::new( + config_report + .config + .gmail + .credential_path(&config_report.config.workspace), + ); + + workspace_paths.ensure_runtime_dirs().unwrap(); + credential_store + .save(&StoredCredentials { + account_id: String::from("gmail:previous@example.com"), + access_token: SecretString::from(String::from("previous-access-token")), + refresh_token: Some(SecretString::from(String::from("previous-refresh-token"))), + expires_at_epoch_s: Some(321), + scopes: vec![String::from("scope:previous")], + }) + .unwrap(); + fs::create_dir(&config_report.config.store.database_path).unwrap(); + + let error = persist_login_state( + &config_report, + &workspace_paths, + &credential_store, + &StoredCredentials { + account_id: String::from("gmail:operator@example.com"), + access_token: SecretString::from(String::from("access-token")), + refresh_token: Some(SecretString::from(String::from("refresh-token"))), + expires_at_epoch_s: Some(123), + scopes: vec![String::from("scope:a")], + }, + &UpsertAccountInput { + email_address: String::from("operator@example.com"), + history_id: String::from("12345"), + messages_total: 10, + threads_total: 7, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 100, + }, + ) + .await + .unwrap_err(); + + let restored = credential_store.load().unwrap().unwrap(); + + assert!(!error.to_string().is_empty()); + assert_eq!(restored.account_id, "gmail:previous@example.com"); + assert_eq!(restored.expires_at_epoch_s, Some(321)); + assert_eq!(restored.scopes, vec![String::from("scope:previous")]); +} + +#[test] +fn parses_successful_callback_request() { + let callback = parse_callback_request( + "GET /oauth2/callback?code=abc&state=def HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .unwrap() + .unwrap(); + + assert_eq!(callback.code, "abc"); + assert_eq!(callback.state, "def"); +} + +#[test] +fn parses_oauth_error_response() { + let response = parse_callback_request( + "GET /oauth2/callback?error=access_denied&error_description=nope HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .unwrap(); + + assert_eq!(response.unwrap_err(), "access_denied: nope"); +} + +#[test] +fn redirect_url_brackets_ipv6_hosts() { + let local_addr: SocketAddr = "[::1]:8181".parse().unwrap(); + + assert_eq!( + redirect_url_for(local_addr).unwrap().as_str(), + "http://[::1]:8181/oauth2/callback" + ); +} + +#[tokio::test] +async fn wait_for_code_returns_oauth_callback_error() { + let listener = CallbackListener::bind(&GmailConfig { + client_id: Some(String::from("client-id")), + client_secret: None, + auth_url: String::from("https://accounts.google.com/o/oauth2/v2/auth"), + token_url: String::from("https://oauth2.googleapis.com/token"), + api_base_url: String::from("https://gmail.googleapis.com/gmail/v1"), + listen_host: String::from("127.0.0.1"), + listen_port: 0, + open_browser: false, + request_timeout_secs: 30, + scopes: vec![String::from("https://www.googleapis.com/auth/gmail.modify")], + }) + .await + .unwrap(); + let callback_url = Url::parse(&listener.redirect_url.to_string()).unwrap(); + let callback_host = callback_url.host_str().unwrap(); + let callback_port = callback_url.port().unwrap(); + let wait_for_code = tokio::spawn(async move { + listener + .wait_for_code(&CsrfToken::new(String::from("expected-state"))) + .await + .unwrap_err() + .to_string() + }); + + let mut stream = TcpStream::connect((callback_host, callback_port)) + .await + .unwrap(); + stream + .write_all( + b"GET /oauth2/callback?error=access_denied&error_description=nope HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).await.unwrap(); + + assert!(response.contains("400 Bad Request")); + assert!(response.contains("access_denied: nope")); + assert_eq!( + wait_for_code.await.unwrap(), + String::from("oauth callback returned an error: access_denied: nope") + ); +} + +#[tokio::test] +async fn wait_for_code_ignores_unrelated_requests_until_callback_arrives() { + let listener = CallbackListener::bind(&GmailConfig { + client_id: Some(String::from("client-id")), + client_secret: None, + auth_url: String::from("https://accounts.google.com/o/oauth2/v2/auth"), + token_url: String::from("https://oauth2.googleapis.com/token"), + api_base_url: String::from("https://gmail.googleapis.com/gmail/v1"), + listen_host: String::from("127.0.0.1"), + listen_port: 0, + open_browser: false, + request_timeout_secs: 30, + scopes: vec![String::from("https://www.googleapis.com/auth/gmail.modify")], + }) + .await + .unwrap(); + let callback_url = Url::parse(&listener.redirect_url.to_string()).unwrap(); + let callback_host = callback_url.host_str().unwrap(); + let callback_port = callback_url.port().unwrap(); + let wait_for_code = tokio::spawn(async move { + listener + .wait_for_code(&CsrfToken::new(String::from("expected-state"))) + .await + .unwrap() + .secret() + .to_owned() + }); + + let mut unrelated_stream = TcpStream::connect((callback_host, callback_port)) + .await + .unwrap(); + unrelated_stream + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut unrelated_response = String::new(); + unrelated_stream + .read_to_string(&mut unrelated_response) + .await + .unwrap(); + + let mut callback_stream = TcpStream::connect((callback_host, callback_port)) + .await + .unwrap(); + callback_stream + .write_all( + b"GET /oauth2/callback?code=real-code&state=expected-state HTTP/1.1\r\nHost: localhost\r\n\r\n", + ) + .await + .unwrap(); + let mut callback_response = String::new(); + callback_stream + .read_to_string(&mut callback_response) + .await + .unwrap(); + + assert!(unrelated_response.contains("400 Bad Request")); + assert!(unrelated_response.contains("/oauth2/callback")); + assert!(callback_response.contains("200 OK")); + assert_eq!(wait_for_code.await.unwrap(), String::from("real-code")); +} + +#[tokio::test] +async fn wait_for_code_reads_callback_requests_across_multiple_tcp_reads() { + let listener = CallbackListener::bind(&GmailConfig { + client_id: Some(String::from("client-id")), + client_secret: None, + auth_url: String::from("https://accounts.google.com/o/oauth2/v2/auth"), + token_url: String::from("https://oauth2.googleapis.com/token"), + api_base_url: String::from("https://gmail.googleapis.com/gmail/v1"), + listen_host: String::from("127.0.0.1"), + listen_port: 0, + open_browser: false, + request_timeout_secs: 30, + scopes: vec![String::from("https://www.googleapis.com/auth/gmail.modify")], + }) + .await + .unwrap(); + let callback_url = Url::parse(&listener.redirect_url.to_string()).unwrap(); + let callback_host = callback_url.host_str().unwrap(); + let callback_port = callback_url.port().unwrap(); + let wait_for_code = tokio::spawn(async move { + listener + .wait_for_code(&CsrfToken::new(String::from("expected-state"))) + .await + .unwrap() + .secret() + .to_owned() + }); + + let mut callback_stream = TcpStream::connect((callback_host, callback_port)) + .await + .unwrap(); + callback_stream + .write_all(b"GET /oauth2/callback?code=split") + .await + .unwrap(); + callback_stream + .write_all(b"-code&state=expected-state HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut callback_response = String::new(); + callback_stream + .read_to_string(&mut callback_response) + .await + .unwrap(); + + assert!(callback_response.contains("200 OK")); + assert_eq!(wait_for_code.await.unwrap(), String::from("split-code")); +} diff --git a/src/cli_output.rs b/src/cli_output.rs deleted file mode 100644 index 0a3dd85..0000000 --- a/src/cli_output.rs +++ /dev/null @@ -1,1320 +0,0 @@ -use crate::CliInputError; -use crate::attachments::AttachmentServiceError; -use crate::auth::{self, oauth_client::OAuthClientError}; -use crate::automation::AutomationServiceError; -use crate::gmail::GmailClientError; -use crate::store::{ - automation::{AutomationStoreReadError, AutomationStoreWriteError}, - mailbox::{MailboxReadError, MailboxWriteError}, - workflows::{WorkflowStoreReadError, WorkflowStoreWriteError}, -}; -use crate::workflows::WorkflowServiceError; -use anyhow::Error as AnyhowError; -use serde::Serialize; -use std::io::Write; -use std::process::ExitCode; - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "snake_case")] -enum ErrorCode { - ValidationFailed, - AuthRequired, - NotFound, - Conflict, - Timeout, - RateLimited, - RemoteFailure, - StorageFailure, - InternalFailure, -} - -impl ErrorCode { - fn exit_code(self) -> u8 { - match self { - Self::ValidationFailed => 2, - Self::AuthRequired => 3, - Self::NotFound => 4, - Self::Conflict => 5, - Self::Timeout | Self::RateLimited | Self::RemoteFailure => 6, - Self::StorageFailure => 7, - Self::InternalFailure => 10, - } - } -} - -#[derive(Debug, Serialize)] -struct JsonSuccessEnvelope<'a, T> { - success: bool, - data: &'a T, -} - -#[derive(Debug, Clone, Serialize)] -pub(crate) struct JsonErrorBody { - code: ErrorCode, - message: String, - kind: String, - operation: String, - causes: Vec, -} - -#[derive(Debug, Serialize)] -struct JsonFailureEnvelope<'a> { - success: bool, - error: &'a JsonErrorBody, -} - -pub(crate) fn print_json_success(data: &T) -> anyhow::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - write_json_success(&mut stdout, data) -} - -pub(crate) fn print_json_failure(error: &JsonErrorBody) -> anyhow::Result<()> { - let stdout = std::io::stdout(); - let mut stdout = stdout.lock(); - write_json_failure(&mut stdout, error) -} - -pub(crate) fn write_json_success( - writer: &mut W, - data: &T, -) -> anyhow::Result<()> { - serde_json::to_writer_pretty(&mut *writer, &json_success_value(data))?; - writeln!(writer)?; - Ok(()) -} - -pub(crate) fn write_json_failure( - writer: &mut W, - error: &JsonErrorBody, -) -> anyhow::Result<()> { - serde_json::to_writer_pretty(&mut *writer, &json_failure_value(error))?; - writeln!(writer)?; - Ok(()) -} - -pub(crate) fn describe_error(error: &AnyhowError, operation: &str) -> JsonErrorBody { - let (code, kind) = classify_error(error); - let message = error.to_string(); - let causes = error - .chain() - .skip(1) - .map(|cause| cause.to_string()) - .collect::>(); - - JsonErrorBody { - code, - message, - kind: kind.to_owned(), - operation: operation.to_owned(), - causes, - } -} - -pub(crate) fn exit_code(error: &JsonErrorBody) -> ExitCode { - ExitCode::from(error.code.exit_code()) -} - -fn json_success_value(data: &T) -> JsonSuccessEnvelope<'_, T> { - JsonSuccessEnvelope { - success: true, - data, - } -} - -fn json_failure_value(error: &JsonErrorBody) -> JsonFailureEnvelope<'_> { - JsonFailureEnvelope { - success: false, - error, - } -} - -fn classify_error(error: &AnyhowError) -> (ErrorCode, &'static str) { - if let Some(attachment_error) = find_cause::(error) { - return match attachment_error { - AttachmentServiceError::NoActiveAccount => { - (ErrorCode::AuthRequired, "attachment.account.required") - } - AttachmentServiceError::AttachmentNotFound { .. } => { - (ErrorCode::NotFound, "attachment.not_found") - } - AttachmentServiceError::InvalidLimit - | AttachmentServiceError::InvalidVaultPath { .. } => { - (ErrorCode::ValidationFailed, "attachment.validation") - } - AttachmentServiceError::DestinationConflict { .. } => { - (ErrorCode::Conflict, "attachment.destination_conflict") - } - AttachmentServiceError::BlockingTask { .. } => { - (ErrorCode::InternalFailure, "attachment.blocking_join") - } - AttachmentServiceError::CreateDirectory { .. } - | AttachmentServiceError::WriteFile { .. } - | AttachmentServiceError::ReadFile { .. } - | AttachmentServiceError::CopyFile { .. } - | AttachmentServiceError::StoreWrite { .. } - | AttachmentServiceError::StoreRead { .. } => { - (ErrorCode::StorageFailure, "attachment.storage") - } - }; - } - - if let Some(automation_error) = find_cause::(error) - && !matches!(automation_error, AutomationServiceError::Gmail { .. }) - { - return match automation_error { - AutomationServiceError::NoActiveAccount => { - (ErrorCode::AuthRequired, "automation.account.required") - } - AutomationServiceError::RunAccountMismatch { .. } => { - (ErrorCode::AuthRequired, "automation.account.mismatch") - } - AutomationServiceError::ApplyAlreadyInProgress { .. } => { - (ErrorCode::Conflict, "automation.apply.in_progress") - } - AutomationServiceError::InvalidLimit - | AutomationServiceError::ExecuteRequired - | AutomationServiceError::RuleFileMissing { .. } - | AutomationServiceError::RuleFileRead { .. } - | AutomationServiceError::RuleFileParse { .. } - | AutomationServiceError::RuleValidation { .. } => { - (ErrorCode::ValidationFailed, "automation.validation") - } - AutomationServiceError::RunNotFound { .. } => { - (ErrorCode::NotFound, "automation.not_found") - } - AutomationServiceError::ApplyLock { .. } => { - (ErrorCode::StorageFailure, "automation.apply_lock") - } - AutomationServiceError::TaskPanic { .. } => { - (ErrorCode::InternalFailure, "automation.task_panic") - } - AutomationServiceError::StoreInit { .. } - | AutomationServiceError::MailboxRead { .. } - | AutomationServiceError::AutomationRead { .. } - | AutomationServiceError::AutomationWrite { .. } => { - (ErrorCode::StorageFailure, "automation.storage") - } - AutomationServiceError::Gmail { .. } => unreachable!(), - }; - } - - if find_cause::(error).is_some() { - return (ErrorCode::StorageFailure, "store.automation.write"); - } - - if find_cause::(error).is_some() { - return (ErrorCode::StorageFailure, "store.automation.read"); - } - - if let Some(workflow_error) = find_cause::(error) { - match workflow_error { - WorkflowServiceError::WorkflowNotFound { .. } - | WorkflowServiceError::CurrentDraftNotFound { .. } - | WorkflowServiceError::RemoteDraftNotFound { .. } - | WorkflowServiceError::LocalSnapshotMissing { .. } - | WorkflowServiceError::ThreadHasNoMessages { .. } - | WorkflowServiceError::SourceMessageMissing { .. } - | WorkflowServiceError::DraftAttachmentNotFound { .. } => { - return (ErrorCode::NotFound, "workflow.not_found"); - } - WorkflowServiceError::NoActiveAccount => { - return (ErrorCode::AuthRequired, "workflow.account.required"); - } - WorkflowServiceError::AuthenticatedAccountMismatch { .. } => { - return (ErrorCode::AuthRequired, "workflow.account.mismatch"); - } - WorkflowServiceError::ReplyRecipientUndetermined - | WorkflowServiceError::ReplyDraftWithoutRecipients - | WorkflowServiceError::DraftWithoutToRecipients - | WorkflowServiceError::CleanupLabelsRequired - | WorkflowServiceError::AddLabelsNotFoundLocally - | WorkflowServiceError::RemoveLabelsNotFoundLocally - | WorkflowServiceError::DraftAttachmentNameAmbiguous { .. } - | WorkflowServiceError::AttachmentNotFile { .. } - | WorkflowServiceError::AttachmentFileName { .. } - | WorkflowServiceError::InvalidDateFormat { .. } - | WorkflowServiceError::InvalidDateMonth { .. } - | WorkflowServiceError::InvalidDateDay { .. } => { - return (ErrorCode::ValidationFailed, "workflow.validation"); - } - WorkflowServiceError::RemoteDraftMissingBeforeSend { .. } => { - return (ErrorCode::Conflict, "workflow.remote_draft.send_guard"); - } - WorkflowServiceError::BlockingTask { .. } => { - return (ErrorCode::InternalFailure, "workflow.blocking_join"); - } - WorkflowServiceError::Time { .. } => { - return (ErrorCode::InternalFailure, "workflow.time"); - } - WorkflowServiceError::LabelCleanupInvariant => { - return (ErrorCode::InternalFailure, "workflow.invariant"); - } - WorkflowServiceError::RemoteDraftRollback { .. } => { - return (ErrorCode::InternalFailure, "workflow.remote_draft.rollback"); - } - WorkflowServiceError::RemoteSendStateReconcile { .. } => { - return (ErrorCode::StorageFailure, "workflow.send.reconcile"); - } - WorkflowServiceError::RemoteDraftStateReconcile { .. } => { - return (ErrorCode::StorageFailure, "workflow.draft.reconcile"); - } - WorkflowServiceError::AttachmentMetadata { .. } - | WorkflowServiceError::AttachmentNormalize { .. } - | WorkflowServiceError::AttachmentRead { .. } => { - return (ErrorCode::ValidationFailed, "workflow.validation"); - } - WorkflowServiceError::StoreInit { .. } => { - return (ErrorCode::StorageFailure, "workflow.store_init"); - } - WorkflowServiceError::AccountState { .. } => { - return (ErrorCode::StorageFailure, "workflow.account_state"); - } - WorkflowServiceError::GmailClientInit { .. } => { - return (ErrorCode::InternalFailure, "workflow.gmail_client"); - } - WorkflowServiceError::RepoRoot { .. } => { - return (ErrorCode::InternalFailure, "workflow.repo_root"); - } - WorkflowServiceError::MessageBuild { .. } => { - return (ErrorCode::InternalFailure, "workflow.message_build"); - } - WorkflowServiceError::Gmail(_) - | WorkflowServiceError::WorkflowStoreRead(_) - | WorkflowServiceError::WorkflowStoreWrite(_) - | WorkflowServiceError::MailboxRead(_) - | WorkflowServiceError::ActiveAccountRefresh { .. } - | WorkflowServiceError::Json(_) - | WorkflowServiceError::IntConversion(_) => {} - } - } - - if let Some(workflow_write_error) = find_cause::(error) { - return match workflow_write_error { - WorkflowStoreWriteError::MissingWorkflow { .. } => { - (ErrorCode::NotFound, "store.workflow.write.missing_workflow") - } - WorkflowStoreWriteError::Conflict { .. } => { - (ErrorCode::Conflict, "store.workflow.write.conflict") - } - WorkflowStoreWriteError::ReadyToSendRequiresSendableDraft => { - (ErrorCode::Conflict, "store.workflow.write.ready_to_send") - } - WorkflowStoreWriteError::Read(_) => (ErrorCode::StorageFailure, "store.workflow.read"), - WorkflowStoreWriteError::OpenDatabase { .. } - | WorkflowStoreWriteError::ReloadWorkflow { .. } - | WorkflowStoreWriteError::ReloadDraftRevision { .. } - | WorkflowStoreWriteError::Query(_) - | WorkflowStoreWriteError::Serialization(_) => { - (ErrorCode::StorageFailure, "store.workflow.write") - } - }; - } - - if find_cause::(error).is_some() { - return (ErrorCode::StorageFailure, "store.workflow.read"); - } - - if find_cause::(error).is_some() { - return (ErrorCode::StorageFailure, "store.mailbox.read"); - } - - if let Some(mailbox_write_error) = find_cause::(error) { - return match mailbox_write_error { - MailboxWriteError::OpenDatabase { .. } | MailboxWriteError::Query(_) => { - (ErrorCode::StorageFailure, "store.mailbox.write") - } - MailboxWriteError::AccountMismatch { .. } => ( - ErrorCode::AuthRequired, - "store.mailbox.write.account_mismatch", - ), - MailboxWriteError::AttachmentNotFound { .. } => ( - ErrorCode::NotFound, - "store.mailbox.write.attachment_not_found", - ), - MailboxWriteError::InvariantViolation { .. } - | MailboxWriteError::RowCountMismatch { .. } => { - (ErrorCode::InternalFailure, "store.mailbox.write") - } - }; - } - - if let Some(gmail_error) = find_cause::(error) { - return match gmail_error { - GmailClientError::InvalidQuotaBudget { .. } => { - (ErrorCode::ValidationFailed, "gmail.quota_budget") - } - GmailClientError::QuotaExhausted { .. } => { - (ErrorCode::ValidationFailed, "gmail.quota_exhausted") - } - GmailClientError::MissingCredentials | GmailClientError::MissingRefreshToken => { - (ErrorCode::AuthRequired, "gmail.credentials") - } - GmailClientError::CredentialLoad { .. } | GmailClientError::CredentialSave { .. } => { - (ErrorCode::StorageFailure, "gmail.credentials.store") - } - GmailClientError::OAuthClient { .. } => { - (ErrorCode::ValidationFailed, "gmail.oauth_client") - } - GmailClientError::TokenRefresh { .. } => { - (ErrorCode::AuthRequired, "gmail.token_refresh") - } - GmailClientError::Clock { .. } | GmailClientError::HttpClientBuild { .. } => { - (ErrorCode::InternalFailure, "gmail.client") - } - GmailClientError::Transport { source, .. } if source.is_timeout() => { - (ErrorCode::Timeout, "gmail.transport") - } - GmailClientError::Transport { .. } => (ErrorCode::RemoteFailure, "gmail.transport"), - GmailClientError::ResponseDecode { .. } => { - (ErrorCode::RemoteFailure, "gmail.response_decode") - } - GmailClientError::AttachmentPartMissing { .. } - | GmailClientError::AttachmentBodyMissing { .. } => { - (ErrorCode::RemoteFailure, "gmail.attachment") - } - GmailClientError::Api { status, .. } - if *status == reqwest::StatusCode::UNAUTHORIZED => - { - (ErrorCode::AuthRequired, "gmail.api_status") - } - GmailClientError::Api { status, .. } if *status == reqwest::StatusCode::NOT_FOUND => { - (ErrorCode::NotFound, "gmail.api_status") - } - GmailClientError::Api { status, .. } - if *status == reqwest::StatusCode::TOO_MANY_REQUESTS => - { - (ErrorCode::RateLimited, "gmail.api_status") - } - GmailClientError::Api { status, .. } - if *status == reqwest::StatusCode::REQUEST_TIMEOUT => - { - (ErrorCode::Timeout, "gmail.api_status") - } - GmailClientError::Api { status, .. } if status.is_server_error() => { - (ErrorCode::RemoteFailure, "gmail.api_status") - } - GmailClientError::Api { .. } => (ErrorCode::RemoteFailure, "gmail.api_status"), - }; - } - - if let Some(auth_error) = find_cause::(error) { - return match auth_error { - auth::AuthError::CallbackTimedOut => (ErrorCode::Timeout, "auth.callback"), - auth::AuthError::MalformedCallbackRequest - | auth::AuthError::MissingAuthorizationCode - | auth::AuthError::OAuthCallback(_) - | auth::AuthError::StateMismatch - | auth::AuthError::InvalidRedirectUrl - | auth::AuthError::BrowserOpen(_) => (ErrorCode::ValidationFailed, "auth.callback"), - auth::AuthError::CallbackIo(_) => (ErrorCode::InternalFailure, "auth.callback"), - }; - } - - if find_cause::(error).is_some() { - return (ErrorCode::ValidationFailed, "auth.oauth_client"); - } - - if find_cause::(error).is_some() { - return (ErrorCode::ValidationFailed, "cli.validation"); - } - - if find_cause::(error).is_some() { - return (ErrorCode::StorageFailure, "store.sqlite"); - } - - if let Some(reqwest_error) = find_cause::(error) { - if reqwest_error.is_timeout() { - return (ErrorCode::Timeout, "http.transport"); - } - return (ErrorCode::RemoteFailure, "http.transport"); - } - - (ErrorCode::InternalFailure, "internal.unclassified") -} - -fn find_cause(error: &AnyhowError) -> Option<&T> -where - T: std::error::Error + Send + Sync + 'static, -{ - error.chain().find_map(|cause| cause.downcast_ref::()) -} - -#[cfg(test)] -mod tests { - use super::{describe_error, exit_code, json_failure_value, json_success_value}; - use crate::CliInputError; - use crate::auth::file_store::{CredentialStore, FileCredentialStore, StoredCredentials}; - use crate::automation::AutomationServiceError; - use crate::config::resolve; - use crate::gmail::GmailClientError; - use crate::store; - use crate::store::accounts; - use crate::store::automation::{ - AutomationActionKind, AutomationActionSnapshot, AutomationMatchReason, - CreateAutomationRunInput, NewAutomationRunCandidate, create_automation_run, - }; - use crate::store::mailbox::{ - GmailAttachmentUpsertInput, GmailMessageUpsertInput, MailboxWriteError, - }; - use crate::store::workflows::{WorkflowStoreReadError, WorkflowStoreWriteError}; - use crate::workflows::WorkflowServiceError; - use crate::workspace::WorkspacePaths; - use anyhow::anyhow; - use reqwest::StatusCode; - use secrecy::SecretString; - use serde_json::{json, to_value}; - use std::fs; - use std::io::ErrorKind; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - #[test] - fn json_success_envelope_wraps_payload_in_success_and_data() { - let value = to_value(json_success_value(&json!({ "thread_id": "thread-1" }))).unwrap(); - - assert_eq!( - value, - json!({ - "success": true, - "data": { - "thread_id": "thread-1" - } - }) - ); - } - - #[test] - fn workflow_not_found_uses_not_found_code_and_exit_bucket() { - let error = anyhow!(WorkflowServiceError::WorkflowNotFound { - thread_id: String::from("thread-1"), - }); - - let report = describe_error(&error, "workflow.show"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["success"], json!(false)); - assert_eq!(value["error"]["code"], json!("not_found")); - assert_eq!(value["error"]["kind"], json!("workflow.not_found")); - assert_eq!(value["error"]["operation"], json!("workflow.show")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); - } - - #[test] - fn gmail_rate_limit_maps_to_rate_limited_code() { - let error = anyhow!(GmailClientError::Api { - path: String::from("users/me/labels"), - status: StatusCode::TOO_MANY_REQUESTS, - body: String::from("slow down"), - }); - - let report = describe_error(&error, "gmail.labels.list"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("rate_limited")); - assert_eq!(value["error"]["kind"], json!("gmail.api_status")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(6)); - } - - #[test] - fn remote_draft_send_guard_maps_to_conflict_code() { - let error = anyhow!(WorkflowServiceError::RemoteDraftMissingBeforeSend { - thread_id: String::from("thread-1"), - draft_id: String::from("draft-1"), - }); - - let report = describe_error(&error, "workflow.draft.send"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("conflict")); - assert_eq!( - value["error"]["kind"], - json!("workflow.remote_draft.send_guard") - ); - assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); - } - - #[test] - fn remote_draft_rollback_maps_to_internal_failure_code() { - let error = anyhow!(WorkflowServiceError::RemoteDraftRollback { - thread_id: String::from("thread-1"), - draft_id: String::from("draft-1"), - source: anyhow!("rollback failed"), - }); - - let report = describe_error(&error, "workflow.draft.start"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("internal_failure")); - assert_eq!( - value["error"]["kind"], - json!("workflow.remote_draft.rollback") - ); - assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); - } - - #[test] - fn local_cli_input_errors_map_to_validation_failed_code() { - let error = anyhow!(CliInputError::DraftBodyInputSourceConflict); - - let report = describe_error(&error, "draft.body.set"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("validation_failed")); - assert_eq!(value["error"]["kind"], json!("cli.validation")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); - } - - #[test] - fn sync_cli_zero_value_errors_map_to_validation_failed_code() { - let error = anyhow!(CliInputError::RecentDaysZero); - - let report = describe_error(&error, "sync.run"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("validation_failed")); - assert_eq!(value["error"]["kind"], json!("cli.validation")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); - } - - #[test] - fn automation_run_account_mismatch_maps_to_auth_required_code() { - let error = anyhow!(AutomationServiceError::RunAccountMismatch { - run_id: 42, - expected_account_id: String::from("gmail:operator@example.com"), - actual_account_id: String::from("gmail:other@example.com"), - }); - - let report = describe_error(&error, "automation.apply"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("auth_required")); - assert_eq!(value["error"]["kind"], json!("automation.account.mismatch")); - assert_eq!(value["error"]["operation"], json!("automation.apply")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); - } - - #[test] - fn automation_apply_in_progress_maps_to_conflict_code() { - let error = anyhow!(AutomationServiceError::ApplyAlreadyInProgress { run_id: 42 }); - - let report = describe_error(&error, "automation.apply"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("conflict")); - assert_eq!( - value["error"]["kind"], - json!("automation.apply.in_progress") - ); - assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); - } - - #[test] - fn attachment_file_errors_map_to_validation_failed_code() { - let error = anyhow!(WorkflowServiceError::AttachmentRead { - path: String::from("/tmp/report.pdf"), - source: std::io::Error::new(ErrorKind::NotFound, "missing attachment"), - }); - - let report = describe_error(&error, "draft.send"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("validation_failed")); - assert_eq!(value["error"]["kind"], json!("workflow.validation")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); - } - - #[test] - fn invalid_quota_budget_maps_to_gmail_quota_budget_validation_error() { - let error = anyhow!(GmailClientError::InvalidQuotaBudget { - units_per_minute: 0, - minimum_units_per_minute: 5, - }); - - let report = describe_error(&error, "sync.run"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("validation_failed")); - assert_eq!(value["error"]["kind"], json!("gmail.quota_budget")); - assert_eq!(value["error"]["operation"], json!("sync.run")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); - } - - #[test] - fn quota_exhausted_maps_to_gmail_quota_exhausted_validation_error() { - let error = anyhow!(GmailClientError::QuotaExhausted { - requested_units: 10, - available_units_per_minute: 5, - }); - - let report = describe_error(&error, "thread.show"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("validation_failed")); - assert_eq!(value["error"]["kind"], json!("gmail.quota_exhausted")); - assert_eq!(value["error"]["operation"], json!("thread.show")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); - } - - #[test] - fn attachment_store_write_errors_map_to_storage_failure_code() { - let error = anyhow!(crate::attachments::AttachmentServiceError::StoreWrite { - source: anyhow!("database is locked"), - }); - - let report = describe_error(&error, "attachment.fetch"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("attachment.storage")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn attachment_store_read_errors_map_to_storage_failure_code() { - let error = anyhow!(crate::attachments::AttachmentServiceError::StoreRead { - source: crate::store::mailbox::MailboxReadError::Query(rusqlite::Error::InvalidQuery), - }); - - let report = describe_error(&error, "attachment.list"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("attachment.storage")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn mailbox_write_query_errors_map_to_storage_failure_code() { - let error = anyhow!(MailboxWriteError::Query(rusqlite::Error::InvalidQuery)); - - let report = describe_error(&error, "sync.run"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("store.mailbox.write")); - assert_eq!(value["error"]["operation"], json!("sync.run")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn mailbox_write_account_mismatch_maps_to_auth_required_code() { - let error = anyhow!(MailboxWriteError::AccountMismatch { - expected_account_id: String::from("gmail:expected@example.com"), - outcome_account_id: String::from("gmail:actual@example.com"), - }); - - let report = describe_error(&error, "sync.run"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("auth_required")); - assert_eq!( - value["error"]["kind"], - json!("store.mailbox.write.account_mismatch") - ); - assert_eq!(value["error"]["operation"], json!("sync.run")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); - } - - #[test] - fn mailbox_write_attachment_not_found_maps_to_not_found_code() { - let error = anyhow!(MailboxWriteError::AttachmentNotFound { - account_id: String::from("gmail:operator@example.com"), - attachment_key: String::from("m-1:1.2"), - }); - - let report = describe_error(&error, "attachment.fetch"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("not_found")); - assert_eq!( - value["error"]["kind"], - json!("store.mailbox.write.attachment_not_found") - ); - assert_eq!(value["error"]["operation"], json!("attachment.fetch")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); - } - - #[test] - fn mailbox_write_invariant_violation_maps_to_internal_failure_code() { - let error = anyhow!(MailboxWriteError::InvariantViolation { - operation: "persist_successful_sync_outcome", - detail: String::from("summary disappeared"), - }); - - let report = describe_error(&error, "sync.run"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("internal_failure")); - assert_eq!(value["error"]["kind"], json!("store.mailbox.write")); - assert_eq!(value["error"]["operation"], json!("sync.run")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); - } - - #[test] - fn mailbox_write_row_count_mismatch_maps_to_internal_failure_code() { - let error = anyhow!(MailboxWriteError::RowCountMismatch { - operation: "delete_messages", - expected: 1, - actual: 0, - }); - - let report = describe_error(&error, "sync.run"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("internal_failure")); - assert_eq!(value["error"]["kind"], json!("store.mailbox.write")); - assert_eq!(value["error"]["operation"], json!("sync.run")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); - } - - #[test] - fn attachment_show_not_found_maps_to_not_found_exit_code() { - let error = anyhow!( - crate::attachments::AttachmentServiceError::AttachmentNotFound { - attachment_key: String::from("m-1:1.2"), - } - ); - - let report = describe_error(&error, "attachment.show"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("not_found")); - assert_eq!(value["error"]["kind"], json!("attachment.not_found")); - assert_eq!(value["error"]["operation"], json!("attachment.show")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); - } - - #[test] - fn attachment_not_found_maps_to_not_found_exit_code() { - let error = anyhow!( - crate::attachments::AttachmentServiceError::AttachmentNotFound { - attachment_key: String::from("m-1:1.2"), - } - ); - - let report = describe_error(&error, "attachment.fetch"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("not_found")); - assert_eq!(value["error"]["kind"], json!("attachment.not_found")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); - } - - #[test] - fn attachment_export_store_write_errors_map_to_storage_failure_code() { - let error = anyhow!(crate::attachments::AttachmentServiceError::StoreWrite { - source: anyhow!("database is locked"), - }); - - let report = describe_error(&error, "attachment.export"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("attachment.storage")); - assert_eq!(value["error"]["operation"], json!("attachment.export")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn attachment_export_conflicts_map_to_conflict_exit_code() { - let error = anyhow!( - crate::attachments::AttachmentServiceError::DestinationConflict { - path: std::path::PathBuf::from("/tmp/export.bin"), - } - ); - - let report = describe_error(&error, "attachment.export"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("conflict")); - assert_eq!( - value["error"]["kind"], - json!("attachment.destination_conflict") - ); - assert_eq!(value["error"]["operation"], json!("attachment.export")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); - } - - #[test] - fn remote_send_state_reconcile_maps_to_storage_failure_code() { - let error = anyhow!(WorkflowServiceError::RemoteSendStateReconcile { - thread_id: String::from("thread-1"), - sent_message_id: String::from("sent-message-1"), - source: anyhow!("database is locked"), - }); - - let report = describe_error(&error, "draft.send"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("workflow.send.reconcile")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn remote_draft_state_reconcile_maps_to_storage_failure_code() { - let error = anyhow!(WorkflowServiceError::RemoteDraftStateReconcile { - thread_id: String::from("thread-1"), - draft_id: String::from("draft-1"), - source: anyhow!("database is locked"), - }); - - let report = describe_error(&error, "workflow.draft.body"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("workflow.draft.reconcile")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn workflow_account_mismatch_maps_to_auth_required_code() { - let error = anyhow!(WorkflowServiceError::AuthenticatedAccountMismatch { - thread_id: String::from("thread-1"), - expected_account_id: String::from("gmail:other@example.com"), - actual_account_id: String::from("gmail:operator@example.com"), - }); - - let report = describe_error(&error, "workflow.cleanup"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("auth_required")); - assert_eq!(value["error"]["kind"], json!("workflow.account.mismatch")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); - } - - #[test] - fn workflow_time_error_maps_to_internal_failure_code() { - let error = anyhow!(WorkflowServiceError::Time { - source: anyhow!("system time before unix epoch"), - }); - - let report = describe_error(&error, "workflow.snooze"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("internal_failure")); - assert_eq!(value["error"]["kind"], json!("workflow.time")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); - } - - #[test] - fn workflow_gmail_client_error_maps_to_internal_failure_code() { - let error = anyhow!(WorkflowServiceError::GmailClientInit { - source: anyhow!("gmail client init failed"), - }); - - let report = describe_error(&error, "workflow.cleanup"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("internal_failure")); - assert_eq!(value["error"]["kind"], json!("workflow.gmail_client")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); - } - - #[test] - fn workflow_repo_root_error_maps_to_internal_failure_code() { - let error = anyhow!(WorkflowServiceError::RepoRoot { - source: anyhow!("repo root lookup failed"), - }); - - let report = describe_error(&error, "workflow.draft.attach_remove"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("internal_failure")); - assert_eq!(value["error"]["kind"], json!("workflow.repo_root")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); - } - - #[test] - fn workflow_store_write_conflict_maps_to_conflict_code() { - let error = anyhow!(WorkflowStoreWriteError::Conflict { - thread_id: String::from("thread-1"), - }); - - let report = describe_error(&error, "workflow.promote"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("conflict")); - assert_eq!( - value["error"]["kind"], - json!("store.workflow.write.conflict") - ); - assert_eq!(value["error"]["operation"], json!("workflow.promote")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); - } - - #[test] - fn workflow_store_write_read_passthrough_maps_to_read_kind() { - let error = anyhow!(WorkflowStoreWriteError::Read(WorkflowStoreReadError::Io( - std::io::Error::new(ErrorKind::NotFound, "missing db"), - ))); - - let report = describe_error(&error, "workflow.promote"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("store.workflow.read")); - assert_eq!(value["error"]["operation"], json!("workflow.promote")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn store_init_maps_to_workflow_storage_kind() { - let error = anyhow!(WorkflowServiceError::StoreInit { - source: anyhow!("disk offline"), - }); - - let report = describe_error(&error, "workflow.show"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["code"], json!("storage_failure")); - assert_eq!(value["error"]["kind"], json!("workflow.store_init")); - assert_eq!(value["error"]["operation"], json!("workflow.show")); - assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); - } - - #[test] - fn describe_error_preserves_ordered_cause_chain_with_duplicates() { - let nested = anyhow!("leaf"); - let wrapped = nested.context("leaf").context("top"); - - let report = describe_error(&wrapped, "workflow.show"); - let value = to_value(json_failure_value(&report)).unwrap(); - - assert_eq!(value["error"]["message"], json!("top")); - assert_eq!(value["error"]["causes"], json!(["leaf", "leaf"])); - } - - #[test] - fn cli_entrypoint_contract_round_trips_json_and_human_failures() { - use std::process::Command; - use tempfile::TempDir; - - let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); - let manifest_path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR")); - let repo_root = TempDir::new().unwrap(); - std::fs::create_dir(repo_root.path().join(".git")).unwrap(); - let home_dir = TempDir::new().unwrap(); - let xdg_config_home = home_dir.path().join(".config"); - - let report = describe_error( - &anyhow!(WorkflowServiceError::NoActiveAccount), - "workflow.show", - ); - let expected_json = to_value(json_failure_value(&report)).unwrap(); - - let json_output = Command::new(&cargo) - .args([ - "run", - "--quiet", - "--manifest-path", - &manifest_path, - "--", - "workflow", - "show", - "thread-1", - "--json", - ]) - .env("XDG_CONFIG_HOME", &xdg_config_home) - .current_dir(repo_root.path()) - .output() - .unwrap(); - assert_eq!(json_output.status.code(), Some(3)); - assert!(json_output.stderr.is_empty()); - - let json_stdout = String::from_utf8(json_output.stdout).unwrap(); - let json_value: serde_json::Value = serde_json::from_str(&json_stdout).unwrap(); - assert_eq!(json_value, expected_json); - assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); - - let human_output = Command::new(&cargo) - .args([ - "run", - "--quiet", - "--manifest-path", - &manifest_path, - "--", - "workflow", - "show", - "thread-1", - ]) - .env("XDG_CONFIG_HOME", &xdg_config_home) - .current_dir(repo_root.path()) - .output() - .unwrap(); - assert_eq!(human_output.status.code(), Some(3)); - assert!(human_output.stdout.is_empty()); - let human_stderr = String::from_utf8(human_output.stderr).unwrap(); - let human_stderr_lower = human_stderr.to_lowercase(); - assert!(human_stderr_lower.contains("no active gmail account found")); - assert!(human_stderr_lower.contains("mailroom auth login")); - } - - #[test] - fn automation_apply_auth_failure_uses_auth_exit_code_in_json_and_human_modes() { - use std::process::Command; - use tempfile::TempDir; - - let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); - let manifest_path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR")); - let repo_root = TempDir::new().unwrap(); - std::fs::create_dir(repo_root.path().join(".git")).unwrap(); - - let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - store::init(&config_report).unwrap(); - let account = accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &accounts::UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("100"), - messages_total: 1, - threads_total: 1, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap(); - let detail = create_automation_run( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &CreateAutomationRunInput { - account_id: account.account_id, - rule_file_path: String::from(".mailroom/automation.toml"), - rule_file_hash: String::from("hash"), - selected_rule_ids: vec![String::from("archive-digest")], - created_at_epoch_s: 100, - candidates: vec![NewAutomationRunCandidate { - rule_id: String::from("archive-digest"), - thread_id: String::from("thread-1"), - message_id: String::from("message-1"), - internal_date_epoch_ms: 1_700_000_000_000, - subject: String::from("Daily digest"), - from_header: String::from("Digest "), - from_address: Some(String::from("digest@example.com")), - snippet: String::from("Digest snippet"), - label_names: vec![String::from("INBOX")], - attachment_count: 0, - has_list_unsubscribe: true, - list_id_header: Some(String::from("")), - list_unsubscribe_header: Some(String::from("")), - list_unsubscribe_post_header: None, - precedence_header: Some(String::from("bulk")), - auto_submitted_header: None, - action: AutomationActionSnapshot { - kind: AutomationActionKind::Archive, - add_label_ids: Vec::new(), - add_label_names: Vec::new(), - remove_label_ids: vec![String::from("INBOX")], - remove_label_names: vec![String::from("INBOX")], - }, - reason: AutomationMatchReason { - from_address: Some(String::from("digest@example.com")), - subject_terms: vec![String::from("digest")], - label_names: vec![String::from("INBOX")], - older_than_days: Some(7), - has_attachments: Some(false), - has_list_unsubscribe: Some(true), - list_id_terms: vec![String::from("digest")], - precedence_values: vec![String::from("bulk")], - }, - }], - }, - ) - .unwrap(); - - let report = describe_error( - &anyhow!(GmailClientError::MissingCredentials), - "automation.apply", - ); - let expected_json = to_value(json_failure_value(&report)).unwrap(); - - let json_output = Command::new(&cargo) - .args([ - "run", - "--quiet", - "--manifest-path", - &manifest_path, - "--", - "automation", - "apply", - &detail.run.run_id.to_string(), - "--execute", - "--json", - ]) - .current_dir(repo_root.path()) - .output() - .unwrap(); - assert_eq!(json_output.status.code(), Some(3)); - assert!(json_output.stderr.is_empty()); - - let json_stdout = String::from_utf8(json_output.stdout).unwrap(); - let json_value: serde_json::Value = serde_json::from_str(&json_stdout).unwrap(); - assert_eq!(json_value, expected_json); - - let human_output = Command::new(&cargo) - .args([ - "run", - "--quiet", - "--manifest-path", - &manifest_path, - "--", - "automation", - "apply", - &detail.run.run_id.to_string(), - "--execute", - ]) - .current_dir(repo_root.path()) - .output() - .unwrap(); - assert_eq!(human_output.status.code(), Some(3)); - assert!(human_output.stdout.is_empty()); - let human_stderr = String::from_utf8(human_output.stderr).unwrap(); - let human_stderr_lower = human_stderr.to_lowercase(); - assert!(human_stderr_lower.contains("mailroom is not authenticated")); - assert!(human_stderr_lower.contains("mailroom auth login")); - } - - #[tokio::test] - async fn attachment_fetch_cli_contract_maps_zero_row_vault_update_to_not_found() { - use std::process::Command; - use tempfile::TempDir; - - let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); - let manifest_path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR")); - let repo_root = TempDir::new().unwrap(); - fs::create_dir(repo_root.path().join(".git")).unwrap(); - let home_dir = TempDir::new().unwrap(); - let xdg_config_home = home_dir.path().join(".config"); - - let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - store::init(&config_report).unwrap(); - accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &accounts::UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("100"), - messages_total: 1, - threads_total: 1, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap(); - store::mailbox::upsert_messages( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &[GmailMessageUpsertInput { - account_id: String::from("gmail:operator@example.com"), - message_id: String::from("m-1"), - thread_id: String::from("t-1"), - history_id: String::from("101"), - internal_date_epoch_ms: 1_700_000_000_000, - snippet: String::from("Attachment fixture"), - subject: String::from("Fixture"), - from_header: String::from("Fixture "), - from_address: Some(String::from("fixture@example.com")), - recipient_headers: String::from("operator@example.com"), - to_header: String::from("operator@example.com"), - cc_header: String::new(), - bcc_header: String::new(), - reply_to_header: String::new(), - size_estimate: 256, - automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), - label_ids: vec![String::from("INBOX")], - label_names_text: String::from("INBOX"), - attachments: vec![GmailAttachmentUpsertInput { - attachment_key: String::from("m-1:1.2"), - part_id: String::from("1.2"), - gmail_attachment_id: Some(String::from("att-1")), - filename: String::from("fixture.bin"), - mime_type: String::from("application/octet-stream"), - size_bytes: 5, - content_disposition: Some(String::from("attachment")), - content_id: None, - is_inline: false, - }], - }], - 100, - ) - .unwrap(); - - let credentials_path = config_report - .config - .gmail - .credential_path(&config_report.config.workspace); - FileCredentialStore::new(credentials_path) - .save(&StoredCredentials { - account_id: String::from("gmail:operator@example.com"), - access_token: SecretString::from(String::from("fixture-access-token")), - refresh_token: None, - expires_at_epoch_s: Some(u64::MAX), - scopes: vec![String::from("https://www.googleapis.com/auth/gmail.modify")], - }) - .unwrap(); - - let connection = - rusqlite::Connection::open(&config_report.config.store.database_path).unwrap(); - connection - .execute_batch( - "CREATE TRIGGER test_ignore_attachment_vault_update - BEFORE UPDATE OF - vault_content_hash, - vault_relative_path, - vault_size_bytes, - vault_fetched_at_epoch_s - ON gmail_message_attachments - FOR EACH ROW - BEGIN - SELECT RAISE(IGNORE); - END;", - ) - .unwrap(); - - let gmail_api = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/gmail/v1/users/me/messages/m-1/attachments/att-1")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": "aGVsbG8", - "size": 5 - }))) - .mount(&gmail_api) - .await; - - let output = Command::new(&cargo) - .args([ - "run", - "--quiet", - "--manifest-path", - &manifest_path, - "--", - "attachment", - "fetch", - "m-1:1.2", - "--json", - ]) - .env("XDG_CONFIG_HOME", &xdg_config_home) - .env( - "MAILROOM_GMAIL__API_BASE_URL", - format!("{}/gmail/v1", gmail_api.uri()), - ) - .current_dir(repo_root.path()) - .output() - .unwrap(); - - assert_eq!(output.status.code(), Some(4)); - assert!(output.stderr.is_empty()); - - let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - assert_eq!(value["success"], json!(false)); - assert_eq!(value["error"]["code"], json!("not_found")); - assert_eq!(value["error"]["kind"], json!("attachment.not_found")); - assert_eq!(value["error"]["operation"], json!("attachment.fetch")); - } -} diff --git a/src/cli_output/errors.rs b/src/cli_output/errors.rs new file mode 100644 index 0000000..7dcd268 --- /dev/null +++ b/src/cli_output/errors.rs @@ -0,0 +1,384 @@ +use crate::CliInputError; +use crate::attachments::AttachmentServiceError; +use crate::auth::{self, oauth_client::OAuthClientError}; +use crate::automation::AutomationServiceError; +use crate::gmail::GmailClientError; +use crate::store::{ + automation::{AutomationStoreReadError, AutomationStoreWriteError}, + mailbox::{MailboxReadError, MailboxWriteError}, + workflows::{WorkflowStoreReadError, WorkflowStoreWriteError}, +}; +use crate::workflows::WorkflowServiceError; +use anyhow::Error as AnyhowError; +use serde::Serialize; +use std::process::ExitCode; + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +enum ErrorCode { + ValidationFailed, + AuthRequired, + NotFound, + Conflict, + Timeout, + RateLimited, + RemoteFailure, + StorageFailure, + InternalFailure, +} + +impl ErrorCode { + fn exit_code(self) -> u8 { + match self { + Self::ValidationFailed => 2, + Self::AuthRequired => 3, + Self::NotFound => 4, + Self::Conflict => 5, + Self::Timeout | Self::RateLimited | Self::RemoteFailure => 6, + Self::StorageFailure => 7, + Self::InternalFailure => 10, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct JsonErrorBody { + code: ErrorCode, + message: String, + kind: String, + operation: String, + causes: Vec, +} + +pub(crate) fn describe_error(error: &AnyhowError, operation: &str) -> JsonErrorBody { + let (code, kind) = classify_error(error); + let message = error.to_string(); + let causes = error + .chain() + .skip(1) + .map(|cause| cause.to_string()) + .collect::>(); + + JsonErrorBody { + code, + message, + kind: kind.to_owned(), + operation: operation.to_owned(), + causes, + } +} + +pub(crate) fn exit_code(error: &JsonErrorBody) -> ExitCode { + ExitCode::from(error.code.exit_code()) +} + +fn classify_error(error: &AnyhowError) -> (ErrorCode, &'static str) { + if let Some(attachment_error) = find_cause::(error) { + return match attachment_error { + AttachmentServiceError::NoActiveAccount => { + (ErrorCode::AuthRequired, "attachment.account.required") + } + AttachmentServiceError::AttachmentNotFound { .. } => { + (ErrorCode::NotFound, "attachment.not_found") + } + AttachmentServiceError::InvalidLimit + | AttachmentServiceError::InvalidVaultPath { .. } => { + (ErrorCode::ValidationFailed, "attachment.validation") + } + AttachmentServiceError::DestinationConflict { .. } => { + (ErrorCode::Conflict, "attachment.destination_conflict") + } + AttachmentServiceError::BlockingTask { .. } => { + (ErrorCode::InternalFailure, "attachment.blocking_join") + } + AttachmentServiceError::CreateDirectory { .. } + | AttachmentServiceError::WriteFile { .. } + | AttachmentServiceError::ReadFile { .. } + | AttachmentServiceError::CopyFile { .. } + | AttachmentServiceError::StoreWrite { .. } + | AttachmentServiceError::StoreRead { .. } => { + (ErrorCode::StorageFailure, "attachment.storage") + } + }; + } + + if let Some(automation_error) = find_cause::(error) + && !matches!(automation_error, AutomationServiceError::Gmail { .. }) + { + return match automation_error { + AutomationServiceError::NoActiveAccount => { + (ErrorCode::AuthRequired, "automation.account.required") + } + AutomationServiceError::RunAccountMismatch { .. } => { + (ErrorCode::AuthRequired, "automation.account.mismatch") + } + AutomationServiceError::ApplyAlreadyInProgress { .. } => { + (ErrorCode::Conflict, "automation.apply.in_progress") + } + AutomationServiceError::InvalidLimit + | AutomationServiceError::ExecuteRequired + | AutomationServiceError::RuleFileMissing { .. } + | AutomationServiceError::RuleFileRead { .. } + | AutomationServiceError::RuleFileParse { .. } + | AutomationServiceError::RuleValidation { .. } => { + (ErrorCode::ValidationFailed, "automation.validation") + } + AutomationServiceError::RunNotFound { .. } => { + (ErrorCode::NotFound, "automation.not_found") + } + AutomationServiceError::ApplyLock { .. } => { + (ErrorCode::StorageFailure, "automation.apply_lock") + } + AutomationServiceError::TaskPanic { .. } => { + (ErrorCode::InternalFailure, "automation.task_panic") + } + AutomationServiceError::StoreInit { .. } + | AutomationServiceError::MailboxRead { .. } + | AutomationServiceError::AutomationRead { .. } + | AutomationServiceError::AutomationWrite { .. } => { + (ErrorCode::StorageFailure, "automation.storage") + } + AutomationServiceError::Gmail { .. } => unreachable!(), + }; + } + + if find_cause::(error).is_some() { + return (ErrorCode::StorageFailure, "store.automation.write"); + } + + if find_cause::(error).is_some() { + return (ErrorCode::StorageFailure, "store.automation.read"); + } + + if let Some(workflow_error) = find_cause::(error) { + match workflow_error { + WorkflowServiceError::WorkflowNotFound { .. } + | WorkflowServiceError::CurrentDraftNotFound { .. } + | WorkflowServiceError::RemoteDraftNotFound { .. } + | WorkflowServiceError::LocalSnapshotMissing { .. } + | WorkflowServiceError::ThreadHasNoMessages { .. } + | WorkflowServiceError::SourceMessageMissing { .. } + | WorkflowServiceError::DraftAttachmentNotFound { .. } => { + return (ErrorCode::NotFound, "workflow.not_found"); + } + WorkflowServiceError::NoActiveAccount => { + return (ErrorCode::AuthRequired, "workflow.account.required"); + } + WorkflowServiceError::AuthenticatedAccountMismatch { .. } => { + return (ErrorCode::AuthRequired, "workflow.account.mismatch"); + } + WorkflowServiceError::ReplyRecipientUndetermined + | WorkflowServiceError::ReplyDraftWithoutRecipients + | WorkflowServiceError::DraftWithoutToRecipients + | WorkflowServiceError::CleanupLabelsRequired + | WorkflowServiceError::AddLabelsNotFoundLocally + | WorkflowServiceError::RemoveLabelsNotFoundLocally + | WorkflowServiceError::DraftAttachmentNameAmbiguous { .. } + | WorkflowServiceError::AttachmentNotFile { .. } + | WorkflowServiceError::AttachmentFileName { .. } + | WorkflowServiceError::InvalidDateFormat { .. } + | WorkflowServiceError::InvalidDateMonth { .. } + | WorkflowServiceError::InvalidDateDay { .. } => { + return (ErrorCode::ValidationFailed, "workflow.validation"); + } + WorkflowServiceError::RemoteDraftMissingBeforeSend { .. } => { + return (ErrorCode::Conflict, "workflow.remote_draft.send_guard"); + } + WorkflowServiceError::BlockingTask { .. } => { + return (ErrorCode::InternalFailure, "workflow.blocking_join"); + } + WorkflowServiceError::Time { .. } => { + return (ErrorCode::InternalFailure, "workflow.time"); + } + WorkflowServiceError::LabelCleanupInvariant => { + return (ErrorCode::InternalFailure, "workflow.invariant"); + } + WorkflowServiceError::RemoteDraftRollback { .. } => { + return (ErrorCode::InternalFailure, "workflow.remote_draft.rollback"); + } + WorkflowServiceError::RemoteSendStateReconcile { .. } => { + return (ErrorCode::StorageFailure, "workflow.send.reconcile"); + } + WorkflowServiceError::RemoteDraftStateReconcile { .. } => { + return (ErrorCode::StorageFailure, "workflow.draft.reconcile"); + } + WorkflowServiceError::AttachmentMetadata { .. } + | WorkflowServiceError::AttachmentNormalize { .. } + | WorkflowServiceError::AttachmentRead { .. } => { + return (ErrorCode::ValidationFailed, "workflow.validation"); + } + WorkflowServiceError::StoreInit { .. } => { + return (ErrorCode::StorageFailure, "workflow.store_init"); + } + WorkflowServiceError::AccountState { .. } => { + return (ErrorCode::StorageFailure, "workflow.account_state"); + } + WorkflowServiceError::GmailClientInit { .. } => { + return (ErrorCode::InternalFailure, "workflow.gmail_client"); + } + WorkflowServiceError::RepoRoot { .. } => { + return (ErrorCode::InternalFailure, "workflow.repo_root"); + } + WorkflowServiceError::MessageBuild { .. } => { + return (ErrorCode::InternalFailure, "workflow.message_build"); + } + WorkflowServiceError::Gmail(_) + | WorkflowServiceError::WorkflowStoreRead(_) + | WorkflowServiceError::WorkflowStoreWrite(_) + | WorkflowServiceError::MailboxRead(_) + | WorkflowServiceError::ActiveAccountRefresh { .. } + | WorkflowServiceError::Json(_) + | WorkflowServiceError::IntConversion(_) => {} + } + } + + if let Some(workflow_write_error) = find_cause::(error) { + return match workflow_write_error { + WorkflowStoreWriteError::MissingWorkflow { .. } => { + (ErrorCode::NotFound, "store.workflow.write.missing_workflow") + } + WorkflowStoreWriteError::Conflict { .. } => { + (ErrorCode::Conflict, "store.workflow.write.conflict") + } + WorkflowStoreWriteError::ReadyToSendRequiresSendableDraft => { + (ErrorCode::Conflict, "store.workflow.write.ready_to_send") + } + WorkflowStoreWriteError::Read(_) => (ErrorCode::StorageFailure, "store.workflow.read"), + WorkflowStoreWriteError::OpenDatabase { .. } + | WorkflowStoreWriteError::ReloadWorkflow { .. } + | WorkflowStoreWriteError::ReloadDraftRevision { .. } + | WorkflowStoreWriteError::Query(_) + | WorkflowStoreWriteError::Serialization(_) => { + (ErrorCode::StorageFailure, "store.workflow.write") + } + }; + } + + if find_cause::(error).is_some() { + return (ErrorCode::StorageFailure, "store.workflow.read"); + } + + if find_cause::(error).is_some() { + return (ErrorCode::StorageFailure, "store.mailbox.read"); + } + + if let Some(mailbox_write_error) = find_cause::(error) { + return match mailbox_write_error { + MailboxWriteError::OpenDatabase { .. } | MailboxWriteError::Query(_) => { + (ErrorCode::StorageFailure, "store.mailbox.write") + } + MailboxWriteError::AccountMismatch { .. } => ( + ErrorCode::AuthRequired, + "store.mailbox.write.account_mismatch", + ), + MailboxWriteError::AttachmentNotFound { .. } => ( + ErrorCode::NotFound, + "store.mailbox.write.attachment_not_found", + ), + MailboxWriteError::InvariantViolation { .. } + | MailboxWriteError::RowCountMismatch { .. } => { + (ErrorCode::InternalFailure, "store.mailbox.write") + } + }; + } + + if let Some(gmail_error) = find_cause::(error) { + return match gmail_error { + GmailClientError::InvalidQuotaBudget { .. } => { + (ErrorCode::ValidationFailed, "gmail.quota_budget") + } + GmailClientError::QuotaExhausted { .. } => { + (ErrorCode::ValidationFailed, "gmail.quota_exhausted") + } + GmailClientError::MissingCredentials | GmailClientError::MissingRefreshToken => { + (ErrorCode::AuthRequired, "gmail.credentials") + } + GmailClientError::CredentialLoad { .. } | GmailClientError::CredentialSave { .. } => { + (ErrorCode::StorageFailure, "gmail.credentials.store") + } + GmailClientError::OAuthClient { .. } => { + (ErrorCode::ValidationFailed, "gmail.oauth_client") + } + GmailClientError::TokenRefresh { .. } => { + (ErrorCode::AuthRequired, "gmail.token_refresh") + } + GmailClientError::Clock { .. } | GmailClientError::HttpClientBuild { .. } => { + (ErrorCode::InternalFailure, "gmail.client") + } + GmailClientError::Transport { source, .. } if source.is_timeout() => { + (ErrorCode::Timeout, "gmail.transport") + } + GmailClientError::Transport { .. } => (ErrorCode::RemoteFailure, "gmail.transport"), + GmailClientError::ResponseDecode { .. } => { + (ErrorCode::RemoteFailure, "gmail.response_decode") + } + GmailClientError::AttachmentPartMissing { .. } + | GmailClientError::AttachmentBodyMissing { .. } => { + (ErrorCode::RemoteFailure, "gmail.attachment") + } + GmailClientError::Api { status, .. } + if *status == reqwest::StatusCode::UNAUTHORIZED => + { + (ErrorCode::AuthRequired, "gmail.api_status") + } + GmailClientError::Api { status, .. } if *status == reqwest::StatusCode::NOT_FOUND => { + (ErrorCode::NotFound, "gmail.api_status") + } + GmailClientError::Api { status, .. } + if *status == reqwest::StatusCode::TOO_MANY_REQUESTS => + { + (ErrorCode::RateLimited, "gmail.api_status") + } + GmailClientError::Api { status, .. } + if *status == reqwest::StatusCode::REQUEST_TIMEOUT => + { + (ErrorCode::Timeout, "gmail.api_status") + } + GmailClientError::Api { status, .. } if status.is_server_error() => { + (ErrorCode::RemoteFailure, "gmail.api_status") + } + GmailClientError::Api { .. } => (ErrorCode::RemoteFailure, "gmail.api_status"), + }; + } + + if let Some(auth_error) = find_cause::(error) { + return match auth_error { + auth::AuthError::CallbackTimedOut => (ErrorCode::Timeout, "auth.callback"), + auth::AuthError::MalformedCallbackRequest + | auth::AuthError::MissingAuthorizationCode + | auth::AuthError::OAuthCallback(_) + | auth::AuthError::StateMismatch + | auth::AuthError::InvalidRedirectUrl + | auth::AuthError::BrowserOpen(_) => (ErrorCode::ValidationFailed, "auth.callback"), + auth::AuthError::CallbackIo(_) => (ErrorCode::InternalFailure, "auth.callback"), + }; + } + + if find_cause::(error).is_some() { + return (ErrorCode::ValidationFailed, "auth.oauth_client"); + } + + if find_cause::(error).is_some() { + return (ErrorCode::ValidationFailed, "cli.validation"); + } + + if find_cause::(error).is_some() { + return (ErrorCode::StorageFailure, "store.sqlite"); + } + + if let Some(reqwest_error) = find_cause::(error) { + if reqwest_error.is_timeout() { + return (ErrorCode::Timeout, "http.transport"); + } + return (ErrorCode::RemoteFailure, "http.transport"); + } + + (ErrorCode::InternalFailure, "internal.unclassified") +} + +fn find_cause(error: &AnyhowError) -> Option<&T> +where + T: std::error::Error + Send + Sync + 'static, +{ + error.chain().find_map(|cause| cause.downcast_ref::()) +} diff --git a/src/cli_output/human.rs b/src/cli_output/human.rs new file mode 100644 index 0000000..b24c8a0 --- /dev/null +++ b/src/cli_output/human.rs @@ -0,0 +1,5 @@ +use anyhow::Error as AnyhowError; + +pub(crate) fn print_human_failure(error: &AnyhowError) { + eprintln!("{error:#}"); +} diff --git a/src/cli_output/json.rs b/src/cli_output/json.rs new file mode 100644 index 0000000..81cc043 --- /dev/null +++ b/src/cli_output/json.rs @@ -0,0 +1,54 @@ +use super::errors::JsonErrorBody; +use anyhow::Result; +use serde::Serialize; +use std::io::Write; + +#[derive(Debug, Serialize)] +pub(crate) struct JsonSuccessEnvelope<'a, T> { + success: bool, + data: &'a T, +} + +#[derive(Debug, Serialize)] +pub(crate) struct JsonFailureEnvelope<'a> { + success: bool, + error: &'a JsonErrorBody, +} + +pub(crate) fn print_json_success(data: &T) -> Result<()> { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + write_json_success(&mut stdout, data) +} + +pub(crate) fn print_json_failure(error: &JsonErrorBody) -> Result<()> { + let stdout = std::io::stdout(); + let mut stdout = stdout.lock(); + write_json_failure(&mut stdout, error) +} + +pub(crate) fn write_json_success(writer: &mut W, data: &T) -> Result<()> { + serde_json::to_writer_pretty(&mut *writer, &json_success_value(data))?; + writeln!(writer)?; + Ok(()) +} + +fn write_json_failure(writer: &mut W, error: &JsonErrorBody) -> Result<()> { + serde_json::to_writer_pretty(&mut *writer, &json_failure_value(error))?; + writeln!(writer)?; + Ok(()) +} + +pub(crate) fn json_success_value(data: &T) -> JsonSuccessEnvelope<'_, T> { + JsonSuccessEnvelope { + success: true, + data, + } +} + +pub(crate) fn json_failure_value(error: &JsonErrorBody) -> JsonFailureEnvelope<'_> { + JsonFailureEnvelope { + success: false, + error, + } +} diff --git a/src/cli_output/mod.rs b/src/cli_output/mod.rs new file mode 100644 index 0000000..78605e6 --- /dev/null +++ b/src/cli_output/mod.rs @@ -0,0 +1,10 @@ +mod errors; +mod human; +mod json; + +pub(crate) use errors::{describe_error, exit_code}; +pub(crate) use human::print_human_failure; +pub(crate) use json::{print_json_failure, print_json_success, write_json_success}; + +#[cfg(test)] +mod tests; diff --git a/src/cli_output/tests.rs b/src/cli_output/tests.rs new file mode 100644 index 0000000..a0cda3c --- /dev/null +++ b/src/cli_output/tests.rs @@ -0,0 +1,881 @@ +use super::json::{json_failure_value, json_success_value}; +use super::{describe_error, exit_code}; +use crate::CliInputError; +use crate::auth::file_store::{CredentialStore, FileCredentialStore, StoredCredentials}; +use crate::automation::AutomationServiceError; +use crate::config::resolve; +use crate::gmail::GmailClientError; +use crate::store; +use crate::store::accounts; +use crate::store::automation::{ + AutomationActionKind, AutomationActionSnapshot, AutomationMatchReason, + CreateAutomationRunInput, NewAutomationRunCandidate, create_automation_run, +}; +use crate::store::mailbox::{ + GmailAttachmentUpsertInput, GmailMessageUpsertInput, MailboxWriteError, +}; +use crate::store::workflows::{WorkflowStoreReadError, WorkflowStoreWriteError}; +use crate::workflows::WorkflowServiceError; +use crate::workspace::WorkspacePaths; +use anyhow::anyhow; +use reqwest::StatusCode; +use secrecy::SecretString; +use serde_json::{json, to_value}; +use std::fs; +use std::io::ErrorKind; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +#[test] +fn json_success_envelope_wraps_payload_in_success_and_data() { + let value = to_value(json_success_value(&json!({ "thread_id": "thread-1" }))).unwrap(); + + assert_eq!( + value, + json!({ + "success": true, + "data": { + "thread_id": "thread-1" + } + }) + ); +} + +#[test] +fn workflow_not_found_uses_not_found_code_and_exit_bucket() { + let error = anyhow!(WorkflowServiceError::WorkflowNotFound { + thread_id: String::from("thread-1"), + }); + + let report = describe_error(&error, "workflow.show"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["success"], json!(false)); + assert_eq!(value["error"]["code"], json!("not_found")); + assert_eq!(value["error"]["kind"], json!("workflow.not_found")); + assert_eq!(value["error"]["operation"], json!("workflow.show")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); +} + +#[test] +fn gmail_rate_limit_maps_to_rate_limited_code() { + let error = anyhow!(GmailClientError::Api { + path: String::from("users/me/labels"), + status: StatusCode::TOO_MANY_REQUESTS, + body: String::from("slow down"), + }); + + let report = describe_error(&error, "gmail.labels.list"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("rate_limited")); + assert_eq!(value["error"]["kind"], json!("gmail.api_status")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(6)); +} + +#[test] +fn remote_draft_send_guard_maps_to_conflict_code() { + let error = anyhow!(WorkflowServiceError::RemoteDraftMissingBeforeSend { + thread_id: String::from("thread-1"), + draft_id: String::from("draft-1"), + }); + + let report = describe_error(&error, "workflow.draft.send"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("conflict")); + assert_eq!( + value["error"]["kind"], + json!("workflow.remote_draft.send_guard") + ); + assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); +} + +#[test] +fn remote_draft_rollback_maps_to_internal_failure_code() { + let error = anyhow!(WorkflowServiceError::RemoteDraftRollback { + thread_id: String::from("thread-1"), + draft_id: String::from("draft-1"), + source: anyhow!("rollback failed"), + }); + + let report = describe_error(&error, "workflow.draft.start"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("internal_failure")); + assert_eq!( + value["error"]["kind"], + json!("workflow.remote_draft.rollback") + ); + assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); +} + +#[test] +fn local_cli_input_errors_map_to_validation_failed_code() { + let error = anyhow!(CliInputError::DraftBodyInputSourceConflict); + + let report = describe_error(&error, "draft.body.set"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("validation_failed")); + assert_eq!(value["error"]["kind"], json!("cli.validation")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); +} + +#[test] +fn sync_cli_zero_value_errors_map_to_validation_failed_code() { + let error = anyhow!(CliInputError::RecentDaysZero); + + let report = describe_error(&error, "sync.run"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("validation_failed")); + assert_eq!(value["error"]["kind"], json!("cli.validation")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); +} + +#[test] +fn automation_run_account_mismatch_maps_to_auth_required_code() { + let error = anyhow!(AutomationServiceError::RunAccountMismatch { + run_id: 42, + expected_account_id: String::from("gmail:operator@example.com"), + actual_account_id: String::from("gmail:other@example.com"), + }); + + let report = describe_error(&error, "automation.apply"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("auth_required")); + assert_eq!(value["error"]["kind"], json!("automation.account.mismatch")); + assert_eq!(value["error"]["operation"], json!("automation.apply")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); +} + +#[test] +fn automation_apply_in_progress_maps_to_conflict_code() { + let error = anyhow!(AutomationServiceError::ApplyAlreadyInProgress { run_id: 42 }); + + let report = describe_error(&error, "automation.apply"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("conflict")); + assert_eq!( + value["error"]["kind"], + json!("automation.apply.in_progress") + ); + assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); +} + +#[test] +fn attachment_file_errors_map_to_validation_failed_code() { + let error = anyhow!(WorkflowServiceError::AttachmentRead { + path: String::from("/tmp/report.pdf"), + source: std::io::Error::new(ErrorKind::NotFound, "missing attachment"), + }); + + let report = describe_error(&error, "draft.send"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("validation_failed")); + assert_eq!(value["error"]["kind"], json!("workflow.validation")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); +} + +#[test] +fn invalid_quota_budget_maps_to_gmail_quota_budget_validation_error() { + let error = anyhow!(GmailClientError::InvalidQuotaBudget { + units_per_minute: 0, + minimum_units_per_minute: 5, + }); + + let report = describe_error(&error, "sync.run"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("validation_failed")); + assert_eq!(value["error"]["kind"], json!("gmail.quota_budget")); + assert_eq!(value["error"]["operation"], json!("sync.run")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); +} + +#[test] +fn quota_exhausted_maps_to_gmail_quota_exhausted_validation_error() { + let error = anyhow!(GmailClientError::QuotaExhausted { + requested_units: 10, + available_units_per_minute: 5, + }); + + let report = describe_error(&error, "thread.show"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("validation_failed")); + assert_eq!(value["error"]["kind"], json!("gmail.quota_exhausted")); + assert_eq!(value["error"]["operation"], json!("thread.show")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(2)); +} + +#[test] +fn attachment_store_write_errors_map_to_storage_failure_code() { + let error = anyhow!(crate::attachments::AttachmentServiceError::StoreWrite { + source: anyhow!("database is locked"), + }); + + let report = describe_error(&error, "attachment.fetch"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("attachment.storage")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn attachment_store_read_errors_map_to_storage_failure_code() { + let error = anyhow!(crate::attachments::AttachmentServiceError::StoreRead { + source: crate::store::mailbox::MailboxReadError::Query(rusqlite::Error::InvalidQuery), + }); + + let report = describe_error(&error, "attachment.list"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("attachment.storage")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn mailbox_write_query_errors_map_to_storage_failure_code() { + let error = anyhow!(MailboxWriteError::Query(rusqlite::Error::InvalidQuery)); + + let report = describe_error(&error, "sync.run"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("store.mailbox.write")); + assert_eq!(value["error"]["operation"], json!("sync.run")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn mailbox_write_account_mismatch_maps_to_auth_required_code() { + let error = anyhow!(MailboxWriteError::AccountMismatch { + expected_account_id: String::from("gmail:expected@example.com"), + outcome_account_id: String::from("gmail:actual@example.com"), + }); + + let report = describe_error(&error, "sync.run"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("auth_required")); + assert_eq!( + value["error"]["kind"], + json!("store.mailbox.write.account_mismatch") + ); + assert_eq!(value["error"]["operation"], json!("sync.run")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); +} + +#[test] +fn mailbox_write_attachment_not_found_maps_to_not_found_code() { + let error = anyhow!(MailboxWriteError::AttachmentNotFound { + account_id: String::from("gmail:operator@example.com"), + attachment_key: String::from("m-1:1.2"), + }); + + let report = describe_error(&error, "attachment.fetch"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("not_found")); + assert_eq!( + value["error"]["kind"], + json!("store.mailbox.write.attachment_not_found") + ); + assert_eq!(value["error"]["operation"], json!("attachment.fetch")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); +} + +#[test] +fn mailbox_write_invariant_violation_maps_to_internal_failure_code() { + let error = anyhow!(MailboxWriteError::InvariantViolation { + operation: "persist_successful_sync_outcome", + detail: String::from("summary disappeared"), + }); + + let report = describe_error(&error, "sync.run"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("internal_failure")); + assert_eq!(value["error"]["kind"], json!("store.mailbox.write")); + assert_eq!(value["error"]["operation"], json!("sync.run")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); +} + +#[test] +fn mailbox_write_row_count_mismatch_maps_to_internal_failure_code() { + let error = anyhow!(MailboxWriteError::RowCountMismatch { + operation: "delete_messages", + expected: 1, + actual: 0, + }); + + let report = describe_error(&error, "sync.run"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("internal_failure")); + assert_eq!(value["error"]["kind"], json!("store.mailbox.write")); + assert_eq!(value["error"]["operation"], json!("sync.run")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); +} + +#[test] +fn attachment_show_not_found_maps_to_not_found_exit_code() { + let error = anyhow!( + crate::attachments::AttachmentServiceError::AttachmentNotFound { + attachment_key: String::from("m-1:1.2"), + } + ); + + let report = describe_error(&error, "attachment.show"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("not_found")); + assert_eq!(value["error"]["kind"], json!("attachment.not_found")); + assert_eq!(value["error"]["operation"], json!("attachment.show")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); +} + +#[test] +fn attachment_not_found_maps_to_not_found_exit_code() { + let error = anyhow!( + crate::attachments::AttachmentServiceError::AttachmentNotFound { + attachment_key: String::from("m-1:1.2"), + } + ); + + let report = describe_error(&error, "attachment.fetch"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("not_found")); + assert_eq!(value["error"]["kind"], json!("attachment.not_found")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(4)); +} + +#[test] +fn attachment_export_store_write_errors_map_to_storage_failure_code() { + let error = anyhow!(crate::attachments::AttachmentServiceError::StoreWrite { + source: anyhow!("database is locked"), + }); + + let report = describe_error(&error, "attachment.export"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("attachment.storage")); + assert_eq!(value["error"]["operation"], json!("attachment.export")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn attachment_export_conflicts_map_to_conflict_exit_code() { + let error = anyhow!( + crate::attachments::AttachmentServiceError::DestinationConflict { + path: std::path::PathBuf::from("/tmp/export.bin"), + } + ); + + let report = describe_error(&error, "attachment.export"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("conflict")); + assert_eq!( + value["error"]["kind"], + json!("attachment.destination_conflict") + ); + assert_eq!(value["error"]["operation"], json!("attachment.export")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); +} + +#[test] +fn remote_send_state_reconcile_maps_to_storage_failure_code() { + let error = anyhow!(WorkflowServiceError::RemoteSendStateReconcile { + thread_id: String::from("thread-1"), + sent_message_id: String::from("sent-message-1"), + source: anyhow!("database is locked"), + }); + + let report = describe_error(&error, "draft.send"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("workflow.send.reconcile")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn remote_draft_state_reconcile_maps_to_storage_failure_code() { + let error = anyhow!(WorkflowServiceError::RemoteDraftStateReconcile { + thread_id: String::from("thread-1"), + draft_id: String::from("draft-1"), + source: anyhow!("database is locked"), + }); + + let report = describe_error(&error, "workflow.draft.body"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("workflow.draft.reconcile")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn workflow_account_mismatch_maps_to_auth_required_code() { + let error = anyhow!(WorkflowServiceError::AuthenticatedAccountMismatch { + thread_id: String::from("thread-1"), + expected_account_id: String::from("gmail:other@example.com"), + actual_account_id: String::from("gmail:operator@example.com"), + }); + + let report = describe_error(&error, "workflow.cleanup"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("auth_required")); + assert_eq!(value["error"]["kind"], json!("workflow.account.mismatch")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); +} + +#[test] +fn workflow_time_error_maps_to_internal_failure_code() { + let error = anyhow!(WorkflowServiceError::Time { + source: anyhow!("system time before unix epoch"), + }); + + let report = describe_error(&error, "workflow.snooze"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("internal_failure")); + assert_eq!(value["error"]["kind"], json!("workflow.time")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); +} + +#[test] +fn workflow_gmail_client_error_maps_to_internal_failure_code() { + let error = anyhow!(WorkflowServiceError::GmailClientInit { + source: anyhow!("gmail client init failed"), + }); + + let report = describe_error(&error, "workflow.cleanup"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("internal_failure")); + assert_eq!(value["error"]["kind"], json!("workflow.gmail_client")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); +} + +#[test] +fn workflow_repo_root_error_maps_to_internal_failure_code() { + let error = anyhow!(WorkflowServiceError::RepoRoot { + source: anyhow!("repo root lookup failed"), + }); + + let report = describe_error(&error, "workflow.draft.attach_remove"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("internal_failure")); + assert_eq!(value["error"]["kind"], json!("workflow.repo_root")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(10)); +} + +#[test] +fn workflow_store_write_conflict_maps_to_conflict_code() { + let error = anyhow!(WorkflowStoreWriteError::Conflict { + thread_id: String::from("thread-1"), + }); + + let report = describe_error(&error, "workflow.promote"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("conflict")); + assert_eq!( + value["error"]["kind"], + json!("store.workflow.write.conflict") + ); + assert_eq!(value["error"]["operation"], json!("workflow.promote")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(5)); +} + +#[test] +fn workflow_store_write_read_passthrough_maps_to_read_kind() { + let error = anyhow!(WorkflowStoreWriteError::Read(WorkflowStoreReadError::Io( + std::io::Error::new(ErrorKind::NotFound, "missing db"), + ))); + + let report = describe_error(&error, "workflow.promote"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("store.workflow.read")); + assert_eq!(value["error"]["operation"], json!("workflow.promote")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn store_init_maps_to_workflow_storage_kind() { + let error = anyhow!(WorkflowServiceError::StoreInit { + source: anyhow!("disk offline"), + }); + + let report = describe_error(&error, "workflow.show"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["code"], json!("storage_failure")); + assert_eq!(value["error"]["kind"], json!("workflow.store_init")); + assert_eq!(value["error"]["operation"], json!("workflow.show")); + assert_eq!(exit_code(&report), std::process::ExitCode::from(7)); +} + +#[test] +fn describe_error_preserves_ordered_cause_chain_with_duplicates() { + let nested = anyhow!("leaf"); + let wrapped = nested.context("leaf").context("top"); + + let report = describe_error(&wrapped, "workflow.show"); + let value = to_value(json_failure_value(&report)).unwrap(); + + assert_eq!(value["error"]["message"], json!("top")); + assert_eq!(value["error"]["causes"], json!(["leaf", "leaf"])); +} + +#[test] +fn cli_entrypoint_contract_round_trips_json_and_human_failures() { + use std::process::Command; + use tempfile::TempDir; + + let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); + let manifest_path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR")); + let repo_root = TempDir::new().unwrap(); + std::fs::create_dir(repo_root.path().join(".git")).unwrap(); + let home_dir = TempDir::new().unwrap(); + let xdg_config_home = home_dir.path().join(".config"); + + let report = describe_error( + &anyhow!(WorkflowServiceError::NoActiveAccount), + "workflow.show", + ); + let expected_json = to_value(json_failure_value(&report)).unwrap(); + + let json_output = Command::new(&cargo) + .args([ + "run", + "--quiet", + "--manifest-path", + &manifest_path, + "--", + "workflow", + "show", + "thread-1", + "--json", + ]) + .env("XDG_CONFIG_HOME", &xdg_config_home) + .current_dir(repo_root.path()) + .output() + .unwrap(); + assert_eq!(json_output.status.code(), Some(3)); + assert!(json_output.stderr.is_empty()); + + let json_stdout = String::from_utf8(json_output.stdout).unwrap(); + let json_value: serde_json::Value = serde_json::from_str(&json_stdout).unwrap(); + assert_eq!(json_value, expected_json); + assert_eq!(exit_code(&report), std::process::ExitCode::from(3)); + + let human_output = Command::new(&cargo) + .args([ + "run", + "--quiet", + "--manifest-path", + &manifest_path, + "--", + "workflow", + "show", + "thread-1", + ]) + .env("XDG_CONFIG_HOME", &xdg_config_home) + .current_dir(repo_root.path()) + .output() + .unwrap(); + assert_eq!(human_output.status.code(), Some(3)); + assert!(human_output.stdout.is_empty()); + let human_stderr = String::from_utf8(human_output.stderr).unwrap(); + let human_stderr_lower = human_stderr.to_lowercase(); + assert!(human_stderr_lower.contains("no active gmail account found")); + assert!(human_stderr_lower.contains("mailroom auth login")); +} + +#[test] +fn automation_apply_auth_failure_uses_auth_exit_code_in_json_and_human_modes() { + use std::process::Command; + use tempfile::TempDir; + + let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); + let manifest_path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR")); + let repo_root = TempDir::new().unwrap(); + std::fs::create_dir(repo_root.path().join(".git")).unwrap(); + let config_dir = repo_root.path().join("config"); + std::fs::create_dir_all(&config_dir).unwrap(); + + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + let config_report = resolve(&paths).unwrap(); + store::init(&config_report).unwrap(); + let account = accounts::upsert_active( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &accounts::UpsertAccountInput { + email_address: String::from("operator@example.com"), + history_id: String::from("100"), + messages_total: 1, + threads_total: 1, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 100, + }, + ) + .unwrap(); + let detail = create_automation_run( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &CreateAutomationRunInput { + account_id: account.account_id, + rule_file_path: String::from(".mailroom/automation.toml"), + rule_file_hash: String::from("hash"), + selected_rule_ids: vec![String::from("archive-digest")], + created_at_epoch_s: 100, + candidates: vec![NewAutomationRunCandidate { + rule_id: String::from("archive-digest"), + thread_id: String::from("thread-1"), + message_id: String::from("message-1"), + internal_date_epoch_ms: 1_700_000_000_000, + subject: String::from("Daily digest"), + from_header: String::from("Digest "), + from_address: Some(String::from("digest@example.com")), + snippet: String::from("Digest snippet"), + label_names: vec![String::from("INBOX")], + attachment_count: 0, + has_list_unsubscribe: true, + list_id_header: Some(String::from("")), + list_unsubscribe_header: Some(String::from("")), + list_unsubscribe_post_header: None, + precedence_header: Some(String::from("bulk")), + auto_submitted_header: None, + action: AutomationActionSnapshot { + kind: AutomationActionKind::Archive, + add_label_ids: Vec::new(), + add_label_names: Vec::new(), + remove_label_ids: vec![String::from("INBOX")], + remove_label_names: vec![String::from("INBOX")], + }, + reason: AutomationMatchReason { + from_address: Some(String::from("digest@example.com")), + subject_terms: vec![String::from("digest")], + label_names: vec![String::from("INBOX")], + older_than_days: Some(7), + has_attachments: Some(false), + has_list_unsubscribe: Some(true), + list_id_terms: vec![String::from("digest")], + precedence_values: vec![String::from("bulk")], + }, + }], + }, + ) + .unwrap(); + + let report = describe_error( + &anyhow!(GmailClientError::MissingCredentials), + "automation.apply", + ); + let expected_json = to_value(json_failure_value(&report)).unwrap(); + + let json_output = Command::new(&cargo) + .args([ + "run", + "--quiet", + "--manifest-path", + &manifest_path, + "--", + "automation", + "apply", + &detail.run.run_id.to_string(), + "--execute", + "--json", + ]) + .env("XDG_CONFIG_HOME", &config_dir) + .env_remove("HOME") + .current_dir(repo_root.path()) + .output() + .unwrap(); + assert_eq!(json_output.status.code(), Some(3)); + assert!(json_output.stderr.is_empty()); + + let json_stdout = String::from_utf8(json_output.stdout).unwrap(); + let json_value: serde_json::Value = serde_json::from_str(&json_stdout).unwrap(); + assert_eq!(json_value, expected_json); + + let human_output = Command::new(&cargo) + .args([ + "run", + "--quiet", + "--manifest-path", + &manifest_path, + "--", + "automation", + "apply", + &detail.run.run_id.to_string(), + "--execute", + ]) + .env("XDG_CONFIG_HOME", &config_dir) + .env_remove("HOME") + .current_dir(repo_root.path()) + .output() + .unwrap(); + assert_eq!(human_output.status.code(), Some(3)); + assert!(human_output.stdout.is_empty()); + let human_stderr = String::from_utf8(human_output.stderr).unwrap(); + let human_stderr_lower = human_stderr.to_lowercase(); + assert!(human_stderr_lower.contains("mailroom is not authenticated")); + assert!(human_stderr_lower.contains("mailroom auth login")); +} + +#[tokio::test] +async fn attachment_fetch_cli_contract_maps_zero_row_vault_update_to_not_found() { + use std::process::Command; + use tempfile::TempDir; + + let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); + let manifest_path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR")); + let repo_root = TempDir::new().unwrap(); + fs::create_dir(repo_root.path().join(".git")).unwrap(); + let home_dir = TempDir::new().unwrap(); + let xdg_config_home = home_dir.path().join(".config"); + + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + let config_report = resolve(&paths).unwrap(); + store::init(&config_report).unwrap(); + accounts::upsert_active( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &accounts::UpsertAccountInput { + email_address: String::from("operator@example.com"), + history_id: String::from("100"), + messages_total: 1, + threads_total: 1, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 100, + }, + ) + .unwrap(); + store::mailbox::upsert_messages( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &[GmailMessageUpsertInput { + account_id: String::from("gmail:operator@example.com"), + message_id: String::from("m-1"), + thread_id: String::from("t-1"), + history_id: String::from("101"), + internal_date_epoch_ms: 1_700_000_000_000, + snippet: String::from("Attachment fixture"), + subject: String::from("Fixture"), + from_header: String::from("Fixture "), + from_address: Some(String::from("fixture@example.com")), + recipient_headers: String::from("operator@example.com"), + to_header: String::from("operator@example.com"), + cc_header: String::new(), + bcc_header: String::new(), + reply_to_header: String::new(), + size_estimate: 256, + automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), + label_ids: vec![String::from("INBOX")], + label_names_text: String::from("INBOX"), + attachments: vec![GmailAttachmentUpsertInput { + attachment_key: String::from("m-1:1.2"), + part_id: String::from("1.2"), + gmail_attachment_id: Some(String::from("att-1")), + filename: String::from("fixture.bin"), + mime_type: String::from("application/octet-stream"), + size_bytes: 5, + content_disposition: Some(String::from("attachment")), + content_id: None, + is_inline: false, + }], + }], + 100, + ) + .unwrap(); + + let credentials_path = config_report + .config + .gmail + .credential_path(&config_report.config.workspace); + FileCredentialStore::new(credentials_path) + .save(&StoredCredentials { + account_id: String::from("gmail:operator@example.com"), + access_token: SecretString::from(String::from("fixture-access-token")), + refresh_token: None, + expires_at_epoch_s: Some(u64::MAX), + scopes: vec![String::from("https://www.googleapis.com/auth/gmail.modify")], + }) + .unwrap(); + + let connection = rusqlite::Connection::open(&config_report.config.store.database_path).unwrap(); + connection + .execute_batch( + "CREATE TRIGGER test_ignore_attachment_vault_update + BEFORE UPDATE OF + vault_content_hash, + vault_relative_path, + vault_size_bytes, + vault_fetched_at_epoch_s + ON gmail_message_attachments + FOR EACH ROW + BEGIN + SELECT RAISE(IGNORE); + END;", + ) + .unwrap(); + + let gmail_api = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/messages/m-1/attachments/att-1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": "aGVsbG8", + "size": 5 + }))) + .mount(&gmail_api) + .await; + + let output = Command::new(&cargo) + .args([ + "run", + "--quiet", + "--manifest-path", + &manifest_path, + "--", + "attachment", + "fetch", + "m-1:1.2", + "--json", + ]) + .env("XDG_CONFIG_HOME", &xdg_config_home) + .env( + "MAILROOM_GMAIL__API_BASE_URL", + format!("{}/gmail/v1", gmail_api.uri()), + ) + .current_dir(repo_root.path()) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(4)); + assert!(output.stderr.is_empty()); + + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["success"], json!(false)); + assert_eq!(value["error"]["code"], json!("not_found")); + assert_eq!(value["error"]["kind"], json!("attachment.not_found")); + assert_eq!(value["error"]["operation"], json!("attachment.fetch")); +} diff --git a/src/handlers/account.rs b/src/handlers/account.rs new file mode 100644 index 0000000..be3bc90 --- /dev/null +++ b/src/handlers/account.rs @@ -0,0 +1,122 @@ +use crate::cli::AccountCommand; +use crate::config; +use crate::store; +use crate::workspace; +use anyhow::Result; +use serde::Serialize; + +pub(crate) async fn handle_account_command( + paths: &workspace::WorkspacePaths, + command: AccountCommand, +) -> Result<()> { + match command { + AccountCommand::Show { json } => { + refresh_active_account(&config::resolve(paths)?) + .await? + .print(json)?; + } + } + + Ok(()) +} + +async fn refresh_active_account(config_report: &config::ConfigReport) -> Result { + let account = crate::refresh_active_account_record(config_report).await?; + + Ok(AccountShowReport { account }) +} + +#[derive(Debug, Clone, Serialize)] +struct AccountShowReport { + account: store::accounts::AccountRecord, +} + +impl AccountShowReport { + fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("account_id={}", self.account.account_id); + println!("email_address={}", self.account.email_address); + println!("history_id={}", self.account.history_id); + println!("messages_total={}", self.account.messages_total); + println!("threads_total={}", self.account.threads_total); + println!( + "last_profile_refresh_epoch_s={}", + self.account.last_profile_refresh_epoch_s + ); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::refresh_active_account; + use crate::auth::file_store::{CredentialStore, FileCredentialStore, StoredCredentials}; + use crate::config::resolve; + use crate::workspace::WorkspacePaths; + use secrecy::SecretString; + use tempfile::TempDir; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn refresh_active_account_persists_stored_granted_scopes() { + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/gmail/v1/users/me/profile")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "emailAddress": "operator@example.com", + "messagesTotal": 10, + "threadsTotal": 7, + "historyId": "12345" + }))) + .mount(&mock_server) + .await; + + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + paths.ensure_runtime_dirs().unwrap(); + let mut config_report = resolve(&paths).unwrap(); + config_report.config.gmail.api_base_url = format!("{}/gmail/v1", mock_server.uri()); + config_report.config.gmail.scopes = vec![String::from("requested:scope")]; + let credential_store = FileCredentialStore::new( + config_report + .config + .gmail + .credential_path(&config_report.config.workspace), + ); + credential_store + .save(&StoredCredentials { + account_id: String::from("gmail:operator@example.com"), + access_token: SecretString::from(String::from("access-token")), + refresh_token: Some(SecretString::from(String::from("refresh-token"))), + expires_at_epoch_s: Some(u64::MAX), + scopes: vec![String::from("granted:scope")], + }) + .unwrap(); + + let report = refresh_active_account(&config_report).await.unwrap(); + + assert_eq!(report.account.access_scope, "granted:scope"); + } + + #[tokio::test] + async fn refresh_active_account_without_credentials_does_not_create_database() { + let temp_dir = TempDir::new().unwrap(); + let repo_root = temp_dir.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root); + let config_report = resolve(&paths).unwrap(); + + let error = refresh_active_account(&config_report).await.unwrap_err(); + + assert_eq!( + error.to_string(), + "mailroom is not authenticated; run `mailroom auth login` first" + ); + assert!(!config_report.config.store.database_path.exists()); + assert!(!config_report.config.workspace.runtime_root.exists()); + } +} diff --git a/src/handlers/attachment.rs b/src/handlers/attachment.rs new file mode 100644 index 0000000..311b652 --- /dev/null +++ b/src/handlers/attachment.rs @@ -0,0 +1,56 @@ +use crate::attachments; +use crate::cli::AttachmentCommand; +use crate::{config, workspace}; +use anyhow::Result; + +pub(crate) async fn handle_attachment_command( + paths: &workspace::WorkspacePaths, + command: AttachmentCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + AttachmentCommand::List { + thread_id, + message_id, + filename, + mime_type, + fetched_only, + limit, + json, + } => attachments::list( + &config_report, + attachments::AttachmentListRequest { + thread_id, + message_id, + filename, + mime_type, + fetched_only, + limit, + }, + ) + .await? + .print(json)?, + AttachmentCommand::Show { + attachment_key, + json, + } => attachments::show(&config_report, attachment_key) + .await? + .print(json)?, + AttachmentCommand::Fetch { + attachment_key, + json, + } => attachments::fetch(&config_report, attachment_key) + .await? + .print(json)?, + AttachmentCommand::Export { + attachment_key, + to, + json, + } => attachments::export(&config_report, attachment_key, to) + .await? + .print(json)?, + } + + Ok(()) +} diff --git a/src/handlers/audit.rs b/src/handlers/audit.rs new file mode 100644 index 0000000..3219b17 --- /dev/null +++ b/src/handlers/audit.rs @@ -0,0 +1,18 @@ +use crate::audit; +use crate::cli::AuditCommand; +use crate::{config, workspace}; +use anyhow::Result; + +pub(crate) fn handle_audit_command( + paths: &workspace::WorkspacePaths, + command: AuditCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + AuditCommand::Labels { json } => audit::labels(&config_report)?.print(json)?, + AuditCommand::Verification { json } => audit::verification(&config_report)?.print(json)?, + } + + Ok(()) +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs new file mode 100644 index 0000000..c8eadab --- /dev/null +++ b/src/handlers/auth.rs @@ -0,0 +1,39 @@ +use crate::auth; +use crate::cli::AuthCommand; +use crate::{config, workspace}; +use anyhow::Result; + +pub(crate) async fn handle_auth_command( + paths: &workspace::WorkspacePaths, + command: AuthCommand, +) -> Result<()> { + let paths = paths.clone(); + let config_report = tokio::task::spawn_blocking(move || config::resolve(&paths)).await??; + + match command { + AuthCommand::Setup { + credentials_file, + json, + no_browser, + } => auth::setup(&config_report, credentials_file, no_browser, json) + .await? + .print(json)?, + AuthCommand::Login { json, no_browser } => auth::login(&config_report, no_browser, json) + .await? + .print(json)?, + AuthCommand::Status { json } => { + let config_report = config_report.clone(); + tokio::task::spawn_blocking(move || auth::status(&config_report)) + .await?? + .print(json)?; + } + AuthCommand::Logout { json } => { + let config_report = config_report.clone(); + tokio::task::spawn_blocking(move || auth::logout(&config_report)) + .await?? + .print(json)?; + } + } + + Ok(()) +} diff --git a/src/handlers/automation.rs b/src/handlers/automation.rs new file mode 100644 index 0000000..00205b8 --- /dev/null +++ b/src/handlers/automation.rs @@ -0,0 +1,41 @@ +use crate::automation; +use crate::cli::{AutomationCommand, AutomationRulesCommand}; +use crate::{config, workspace}; +use anyhow::Result; + +pub(crate) async fn handle_automation_command( + paths: &workspace::WorkspacePaths, + command: AutomationCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + AutomationCommand::Rules { + command: AutomationRulesCommand::Validate { json }, + } => automation::validate_rules(&config_report) + .await? + .print(json)?, + AutomationCommand::Run { + rule_ids, + limit, + json, + } => automation::run_preview( + &config_report, + automation::AutomationRunRequest { rule_ids, limit }, + ) + .await? + .print(json)?, + AutomationCommand::Show { run_id, json } => automation::show_run(&config_report, run_id) + .await? + .print(json)?, + AutomationCommand::Apply { + run_id, + execute, + json, + } => automation::apply_run(&config_report, run_id, execute) + .await? + .print(json)?, + } + + Ok(()) +} diff --git a/src/handlers/config.rs b/src/handlers/config.rs new file mode 100644 index 0000000..168dd09 --- /dev/null +++ b/src/handlers/config.rs @@ -0,0 +1,91 @@ +use crate::cli::ConfigCommand; +use crate::{config, configured_paths, workspace}; +use anyhow::Result; + +pub(crate) fn handle_config_command( + paths: &workspace::WorkspacePaths, + command: ConfigCommand, +) -> Result<()> { + match command { + ConfigCommand::Show { json } => config::resolve(paths)?.print(json)?, + } + + Ok(()) +} + +pub(crate) fn handle_paths_command(paths: &workspace::WorkspacePaths, json: bool) -> Result<()> { + match config::resolve(paths) { + Ok(config_report) => configured_paths(&config_report)?.print(json)?, + Err(error) => { + eprintln!( + "warning: config::resolve failed for `mailroom paths`; falling back to repo-local paths: {error}" + ); + paths.print(json)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::config::resolve; + use crate::workspace::WorkspacePaths; + use serde_json::Value; + use std::fs; + use std::process::Command; + use tempfile::TempDir; + + #[test] + fn paths_command_still_prints_repo_local_paths_when_config_is_malformed() { + let cargo = std::env::var("CARGO").unwrap_or_else(|_| String::from("cargo")); + let manifest_path = format!("{}/Cargo.toml", env!("CARGO_MANIFEST_DIR")); + let repo_root = TempDir::with_prefix("mailroom-paths-malformed-config").unwrap(); + std::fs::create_dir(repo_root.path().join(".git")).unwrap(); + let config_dir = repo_root.path().join("config"); + fs::create_dir_all(&config_dir).unwrap(); + fs::create_dir_all(repo_root.path().join(".mailroom")).unwrap(); + fs::write( + repo_root.path().join(".mailroom/config.toml"), + "[workspace\n", + ) + .unwrap(); + + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + assert!(resolve(&paths).is_err()); + + let output = Command::new(&cargo) + .args([ + "run", + "--quiet", + "--manifest-path", + &manifest_path, + "--", + "paths", + "--json", + ]) + .env("XDG_CONFIG_HOME", &config_dir) + .env_remove("HOME") + .current_dir(repo_root.path()) + .output() + .unwrap(); + assert!(output.status.success()); + + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!(stderr.contains( + "warning: config::resolve failed for `mailroom paths`; falling back to repo-local paths:" + )); + assert!(!stderr.contains("{error:#?}")); + + let stdout: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["success"], Value::Bool(true)); + assert_eq!( + stdout["data"]["repo_root"], + Value::String(repo_root.path().display().to_string()) + ); + assert_eq!( + stdout["data"]["runtime_root"], + Value::String(repo_root.path().join(".mailroom").display().to_string()) + ); + } +} diff --git a/src/handlers/doctor.rs b/src/handlers/doctor.rs new file mode 100644 index 0000000..541c17e --- /dev/null +++ b/src/handlers/doctor.rs @@ -0,0 +1,10 @@ +use crate::doctor; +use crate::{config, configured_paths, workspace}; +use anyhow::Result; + +pub(crate) fn handle_doctor_command(paths: &workspace::WorkspacePaths, json: bool) -> Result<()> { + let config_report = config::resolve(paths)?; + let configured_paths = configured_paths(&config_report)?; + doctor::DoctorReport::inspect(&configured_paths, config_report)?.print(json)?; + Ok(()) +} diff --git a/src/handlers/gmail.rs b/src/handlers/gmail.rs new file mode 100644 index 0000000..8e7df45 --- /dev/null +++ b/src/handlers/gmail.rs @@ -0,0 +1,43 @@ +use crate::cli::{GmailCommand, GmailLabelsCommand}; +use crate::{config, gmail, gmail_client_for_config, workspace}; +use anyhow::Result; +use serde::Serialize; + +pub(crate) async fn handle_gmail_command( + paths: &workspace::WorkspacePaths, + command: GmailCommand, +) -> Result<()> { + let paths = paths.clone(); + let config_report = tokio::task::spawn_blocking(move || config::resolve(&paths)).await??; + + match command { + GmailCommand::Labels { + command: GmailLabelsCommand::List { json }, + } => GmailLabelsReport { + labels: gmail_client_for_config(&config_report)? + .list_labels() + .await?, + } + .print(json)?, + } + + Ok(()) +} + +#[derive(Debug, Clone, Serialize)] +struct GmailLabelsReport { + labels: Vec, +} + +impl GmailLabelsReport { + fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + for label in &self.labels { + println!("{}\t{}\t{}", label.id, label.name, label.label_type); + } + } + Ok(()) + } +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..a6cc61d --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,29 @@ +mod account; +mod attachment; +mod audit; +mod auth; +mod automation; +mod config; +mod doctor; +mod gmail; +mod search; +mod store; +mod sync; +mod workflow; +mod workspace; + +pub(crate) use account::handle_account_command; +pub(crate) use attachment::handle_attachment_command; +pub(crate) use audit::handle_audit_command; +pub(crate) use auth::handle_auth_command; +pub(crate) use automation::handle_automation_command; +pub(crate) use config::{handle_config_command, handle_paths_command}; +pub(crate) use doctor::handle_doctor_command; +pub(crate) use gmail::handle_gmail_command; +pub(crate) use search::handle_search_command; +pub(crate) use store::handle_store_command; +pub(crate) use sync::handle_sync_command; +pub(crate) use workflow::{ + handle_cleanup_command, handle_draft_command, handle_triage_command, handle_workflow_command, +}; +pub(crate) use workspace::handle_workspace_command; diff --git a/src/handlers/search.rs b/src/handlers/search.rs new file mode 100644 index 0000000..1619b50 --- /dev/null +++ b/src/handlers/search.rs @@ -0,0 +1,25 @@ +use crate::cli::SearchArgs; +use crate::{config, mailbox, workspace}; +use anyhow::Result; + +pub(crate) async fn handle_search_command( + paths: &workspace::WorkspacePaths, + args: SearchArgs, +) -> Result<()> { + let config_report = config::resolve(paths)?; + mailbox::search( + &config_report, + mailbox::SearchRequest { + terms: args.terms, + label: args.label, + from_address: args.from_address, + after: args.after, + before: args.before, + limit: args.limit, + }, + ) + .await? + .print(args.json)?; + + Ok(()) +} diff --git a/src/handlers/store.rs b/src/handlers/store.rs new file mode 100644 index 0000000..67cdc13 --- /dev/null +++ b/src/handlers/store.rs @@ -0,0 +1,21 @@ +use crate::cli::StoreCommand; +use crate::{config, configured_paths, store, workspace}; +use anyhow::Result; + +pub(crate) fn handle_store_command( + paths: &workspace::WorkspacePaths, + command: StoreCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + StoreCommand::Init { json } => { + let configured_paths = configured_paths(&config_report)?; + configured_paths.ensure_runtime_dirs()?; + store::init(&config_report)?.print(json)?; + } + StoreCommand::Doctor { json } => store::inspect(config_report)?.print(json)?, + } + + Ok(()) +} diff --git a/src/handlers/sync.rs b/src/handlers/sync.rs new file mode 100644 index 0000000..f933442 --- /dev/null +++ b/src/handlers/sync.rs @@ -0,0 +1,307 @@ +use crate::CliInputError; +use crate::cli::{SyncCommand, SyncPerfCommand, SyncProfileArg, SyncRunArgs}; +use crate::{config, mailbox, workspace}; +use anyhow::Result; + +pub(crate) async fn handle_sync_command( + paths: &workspace::WorkspacePaths, + command: SyncCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + SyncCommand::Run(args) | SyncCommand::Benchmark(args) => { + mailbox::sync_run_with_options(&config_report, resolve_sync_run_options(&args)?) + .await? + .print(args.json)? + } + SyncCommand::History { limit, json } => mailbox::sync_history(&config_report, limit) + .await? + .print(json)?, + SyncCommand::Perf { + command: SyncPerfCommand::Explain { limit, json }, + } + | SyncCommand::PerfExplain { limit, json } => { + mailbox::sync_perf_explain(&config_report, limit) + .await? + .print(json)? + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SyncProfileDefaults { + force_full: bool, + recent_days: u32, + quota_units_per_minute: u32, + message_fetch_concurrency: usize, +} + +fn default_sync_profile_defaults() -> SyncProfileDefaults { + SyncProfileDefaults { + force_full: false, + recent_days: mailbox::DEFAULT_BOOTSTRAP_RECENT_DAYS, + quota_units_per_minute: mailbox::DEFAULT_SYNC_QUOTA_UNITS_PER_MINUTE, + message_fetch_concurrency: mailbox::DEFAULT_MESSAGE_FETCH_CONCURRENCY, + } +} + +fn sync_profile_defaults(profile: SyncProfileArg) -> SyncProfileDefaults { + match profile { + SyncProfileArg::DeepAudit => SyncProfileDefaults { + force_full: true, + recent_days: 365, + quota_units_per_minute: 9_000, + message_fetch_concurrency: 3, + }, + } +} + +fn resolve_sync_run_options(args: &SyncRunArgs) -> Result { + let defaults = args + .profile + .map(sync_profile_defaults) + .unwrap_or_else(default_sync_profile_defaults); + let recent_days = args.recent_days.unwrap_or(defaults.recent_days); + if recent_days == 0 { + return Err(CliInputError::RecentDaysZero.into()); + } + let quota_units_per_minute = args + .quota_units_per_minute + .unwrap_or(defaults.quota_units_per_minute); + if quota_units_per_minute == 0 { + return Err(CliInputError::QuotaUnitsPerMinuteZero.into()); + } + let message_fetch_concurrency = args + .message_fetch_concurrency + .unwrap_or(defaults.message_fetch_concurrency); + if message_fetch_concurrency == 0 { + return Err(CliInputError::MessageFetchConcurrencyZero.into()); + } + + Ok(mailbox::SyncRunOptions { + force_full: args.full || defaults.force_full, + recent_days, + quota_units_per_minute, + message_fetch_concurrency, + }) +} + +#[cfg(test)] +mod tests { + use super::resolve_sync_run_options; + use crate::CliInputError; + use crate::cli::{Cli, Commands, SyncCommand, SyncProfileArg, SyncRunArgs}; + use clap::{CommandFactory, Parser, error::ErrorKind}; + + #[test] + fn resolve_sync_run_options_uses_legacy_defaults_without_profile() { + let args = SyncRunArgs { + full: false, + profile: None, + recent_days: None, + quota_units_per_minute: None, + message_fetch_concurrency: None, + json: false, + }; + + let options = resolve_sync_run_options(&args).unwrap(); + + assert_eq!( + options.recent_days, + crate::mailbox::DEFAULT_BOOTSTRAP_RECENT_DAYS + ); + assert_eq!( + options.quota_units_per_minute, + crate::mailbox::DEFAULT_SYNC_QUOTA_UNITS_PER_MINUTE + ); + assert_eq!( + options.message_fetch_concurrency, + crate::mailbox::DEFAULT_MESSAGE_FETCH_CONCURRENCY + ); + assert!(!options.force_full); + } + + #[test] + fn resolve_sync_run_options_applies_deep_audit_profile_defaults() { + let args = SyncRunArgs { + full: false, + profile: Some(SyncProfileArg::DeepAudit), + recent_days: None, + quota_units_per_minute: None, + message_fetch_concurrency: None, + json: false, + }; + + let options = resolve_sync_run_options(&args).unwrap(); + + assert_eq!(options.recent_days, 365); + assert_eq!(options.quota_units_per_minute, 9_000); + assert_eq!(options.message_fetch_concurrency, 3); + assert!(options.force_full); + } + + #[test] + fn resolve_sync_run_options_keeps_explicit_overrides_authoritative() { + let args = SyncRunArgs { + full: false, + profile: Some(SyncProfileArg::DeepAudit), + recent_days: Some(180), + quota_units_per_minute: Some(8_000), + message_fetch_concurrency: Some(2), + json: false, + }; + + let options = resolve_sync_run_options(&args).unwrap(); + + assert_eq!(options.recent_days, 180); + assert_eq!(options.quota_units_per_minute, 8_000); + assert_eq!(options.message_fetch_concurrency, 2); + assert!(options.force_full); + } + + #[test] + fn resolve_sync_run_options_rejects_zero_overrides() { + let args = SyncRunArgs { + full: false, + profile: None, + recent_days: Some(0), + quota_units_per_minute: Some(0), + message_fetch_concurrency: Some(0), + json: false, + }; + + let error = resolve_sync_run_options(&args).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(CliInputError::RecentDaysZero) + )); + assert_eq!(error.to_string(), "--recent-days must be greater than zero"); + } + + #[test] + fn resolve_sync_run_options_rejects_zero_quota_override() { + let args = SyncRunArgs { + full: false, + profile: None, + recent_days: None, + quota_units_per_minute: Some(0), + message_fetch_concurrency: None, + json: false, + }; + + let error = resolve_sync_run_options(&args).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(CliInputError::QuotaUnitsPerMinuteZero) + )); + assert_eq!( + error.to_string(), + "--quota-units-per-minute must be greater than zero" + ); + } + + #[test] + fn resolve_sync_run_options_rejects_zero_message_fetch_concurrency_override() { + let args = SyncRunArgs { + full: false, + profile: None, + recent_days: None, + quota_units_per_minute: None, + message_fetch_concurrency: Some(0), + json: false, + }; + + let error = resolve_sync_run_options(&args).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(CliInputError::MessageFetchConcurrencyZero) + )); + assert_eq!( + error.to_string(), + "--message-fetch-concurrency must be greater than zero" + ); + } + + #[test] + fn sync_run_and_benchmark_parse_the_same_profile_surface() { + let run_args = extract_sync_run_args([ + "mailroom", + "sync", + "run", + "--profile", + "deep-audit", + "--quota-units-per-minute", + "8000", + ]); + let benchmark_args = extract_sync_run_args([ + "mailroom", + "sync", + "benchmark", + "--profile", + "deep-audit", + "--quota-units-per-minute", + "8000", + ]); + + assert_eq!( + run_args, + SyncRunArgs { + full: false, + profile: Some(SyncProfileArg::DeepAudit), + recent_days: None, + quota_units_per_minute: Some(8_000), + message_fetch_concurrency: None, + json: false, + } + ); + assert_eq!(run_args, benchmark_args); + assert_eq!( + resolve_sync_run_options(&run_args).unwrap(), + resolve_sync_run_options(&benchmark_args).unwrap() + ); + } + + #[test] + fn sync_profile_help_lists_deep_audit_profile() { + let mut command = Cli::command(); + let error = command + .try_get_matches_from_mut(["mailroom", "sync", "run", "--help"]) + .unwrap_err(); + + assert_eq!(error.kind(), ErrorKind::DisplayHelp); + let rendered = error.to_string(); + assert!(rendered.contains("--profile ")); + assert!(rendered.contains("deep-audit")); + } + + #[test] + fn sync_profile_rejects_unknown_value() { + let error = + Cli::try_parse_from(["mailroom", "sync", "run", "--profile", "unknown"]).unwrap_err(); + + assert_eq!(error.kind(), ErrorKind::InvalidValue); + let rendered = error.to_string(); + assert!(rendered.contains("deep-audit")); + assert!(rendered.contains("--profile ")); + } + + fn extract_sync_run_args(args: I) -> SyncRunArgs + where + I: IntoIterator, + T: Into + Clone, + { + let cli = Cli::try_parse_from(args).unwrap(); + match cli.command { + Commands::Sync { + command: SyncCommand::Run(args), + } + | Commands::Sync { + command: SyncCommand::Benchmark(args), + } => args, + other => panic!("expected sync run or benchmark args, got {other:?}"), + } + } +} diff --git a/src/handlers/workflow.rs b/src/handlers/workflow.rs new file mode 100644 index 0000000..da6c194 --- /dev/null +++ b/src/handlers/workflow.rs @@ -0,0 +1,312 @@ +use crate::CliInputError; +use crate::cli::{ + CleanupCommand, DraftAttachmentCommand, DraftCommand, TriageBucketArg, TriageCommand, + WorkflowCommand, WorkflowPromoteTargetArg, WorkflowStageArg, +}; +use crate::store; +use crate::{config, workflows, workspace}; +use anyhow::Result; +use std::io::Read; +use std::path::PathBuf; + +pub(crate) async fn handle_workflow_command( + paths: &workspace::WorkspacePaths, + command: WorkflowCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + WorkflowCommand::List { + stage, + triage_bucket, + json, + } => workflows::list_workflows( + &config_report, + stage.map(workflow_stage_from_arg), + triage_bucket.map(triage_bucket_from_arg), + ) + .await? + .print(json)?, + WorkflowCommand::Show { thread_id, json } => { + workflows::show_workflow(&config_report, thread_id) + .await? + .print(json)? + } + WorkflowCommand::Promote { + thread_id, + to, + json, + } => workflows::promote_workflow( + &config_report, + thread_id, + workflow_promote_target_from_arg(to), + ) + .await? + .print(json)?, + WorkflowCommand::Snooze { + thread_id, + until, + clear, + json, + } => { + let until = resolve_snooze_until(until, clear)?; + workflows::snooze_workflow(&config_report, thread_id, until) + .await? + .print(json)?; + } + } + + Ok(()) +} + +pub(crate) async fn handle_triage_command( + paths: &workspace::WorkspacePaths, + command: TriageCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + TriageCommand::Set { + thread_id, + bucket, + note, + json, + } => workflows::set_triage( + &config_report, + thread_id, + triage_bucket_from_arg(bucket), + note, + ) + .await? + .print(json)?, + } + + Ok(()) +} + +pub(crate) async fn handle_draft_command( + paths: &workspace::WorkspacePaths, + command: DraftCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + DraftCommand::Start { + thread_id, + reply_all, + json, + } => workflows::draft_start( + &config_report, + thread_id, + if reply_all { + store::workflows::ReplyMode::ReplyAll + } else { + store::workflows::ReplyMode::Reply + }, + ) + .await? + .print(json)?, + DraftCommand::Body { + thread_id, + text, + file, + stdin, + json, + } => { + let body_text = resolve_draft_body_input(text, file, stdin).await?; + workflows::draft_body_set(&config_report, thread_id, body_text) + .await? + .print(json)?; + } + DraftCommand::Attach { command } => match command { + DraftAttachmentCommand::Add { + thread_id, + path, + json, + } => workflows::draft_attach_add(&config_report, thread_id, path) + .await? + .print(json)?, + DraftAttachmentCommand::Remove { + thread_id, + path, + json, + } => workflows::draft_attach_remove(&config_report, thread_id, path) + .await? + .print(json)?, + }, + DraftCommand::Send { thread_id, json } => workflows::draft_send(&config_report, thread_id) + .await? + .print(json)?, + } + + Ok(()) +} + +pub(crate) async fn handle_cleanup_command( + paths: &workspace::WorkspacePaths, + command: CleanupCommand, +) -> Result<()> { + let config_report = config::resolve(paths)?; + + match command { + CleanupCommand::Archive { + thread_id, + execute, + json, + } => workflows::cleanup_archive(&config_report, thread_id, execute) + .await? + .print(json)?, + CleanupCommand::Label { + thread_id, + add_labels, + remove_labels, + execute, + json, + } => workflows::cleanup_label( + &config_report, + thread_id, + execute, + add_labels, + remove_labels, + ) + .await? + .print(json)?, + CleanupCommand::Trash { + thread_id, + execute, + json, + } => workflows::cleanup_trash(&config_report, thread_id, execute) + .await? + .print(json)?, + } + + Ok(()) +} + +fn workflow_stage_from_arg(value: WorkflowStageArg) -> store::workflows::WorkflowStage { + match value { + WorkflowStageArg::Triage => store::workflows::WorkflowStage::Triage, + WorkflowStageArg::FollowUp => store::workflows::WorkflowStage::FollowUp, + WorkflowStageArg::Drafting => store::workflows::WorkflowStage::Drafting, + WorkflowStageArg::ReadyToSend => store::workflows::WorkflowStage::ReadyToSend, + WorkflowStageArg::Sent => store::workflows::WorkflowStage::Sent, + WorkflowStageArg::Closed => store::workflows::WorkflowStage::Closed, + } +} + +fn workflow_promote_target_from_arg( + value: WorkflowPromoteTargetArg, +) -> store::workflows::WorkflowStage { + match value { + WorkflowPromoteTargetArg::FollowUp => store::workflows::WorkflowStage::FollowUp, + WorkflowPromoteTargetArg::ReadyToSend => store::workflows::WorkflowStage::ReadyToSend, + WorkflowPromoteTargetArg::Closed => store::workflows::WorkflowStage::Closed, + } +} + +fn triage_bucket_from_arg(value: TriageBucketArg) -> store::workflows::TriageBucket { + match value { + TriageBucketArg::Urgent => store::workflows::TriageBucket::Urgent, + TriageBucketArg::NeedsReplySoon => store::workflows::TriageBucket::NeedsReplySoon, + TriageBucketArg::Waiting => store::workflows::TriageBucket::Waiting, + TriageBucketArg::Fyi => store::workflows::TriageBucket::Fyi, + } +} + +fn resolve_snooze_until(until: Option, clear: bool) -> Result> { + if !clear && until.is_none() { + return Err(CliInputError::SnoozeRequiresUntilOrClear.into()); + } + if clear && until.is_some() { + return Err(CliInputError::SnoozeUntilConflict.into()); + } + + if clear { Ok(None) } else { Ok(until) } +} + +async fn resolve_draft_body_input( + text: Option, + file: Option, + stdin: bool, +) -> Result { + let selected = usize::from(text.is_some()) + usize::from(file.is_some()) + usize::from(stdin); + if selected != 1 { + return Err(CliInputError::DraftBodyInputSourceConflict.into()); + } + + if let Some(text) = text { + return Ok(text); + } + + if let Some(file) = file { + return Ok(tokio::task::spawn_blocking(move || { + std::fs::read_to_string(&file) + .map_err(|source| CliInputError::DraftBodyFileRead { path: file, source }) + }) + .await??); + } + + Ok(tokio::task::spawn_blocking(move || { + let mut buffer = String::new(); + std::io::stdin() + .read_to_string(&mut buffer) + .map_err(|source| CliInputError::DraftBodyStdinRead { source })?; + Ok::<_, CliInputError>(buffer) + }) + .await??) +} + +#[cfg(test)] +mod tests { + use super::{resolve_draft_body_input, resolve_snooze_until}; + use crate::CliInputError; + use std::path::PathBuf; + + #[test] + fn resolve_snooze_until_requires_explicit_until_or_clear() { + let error = resolve_snooze_until(None, false).unwrap_err(); + + assert_eq!(error.to_string(), "use --until YYYY-MM-DD or --clear"); + } + + #[test] + fn resolve_snooze_until_rejects_conflicting_flags() { + let error = resolve_snooze_until(Some(String::from("2026-05-01")), true).unwrap_err(); + + assert_eq!(error.to_string(), "use either --until or --clear, not both"); + } + + #[tokio::test] + async fn resolve_draft_body_input_requires_exactly_one_source() { + let error = resolve_draft_body_input(None, None, false) + .await + .unwrap_err(); + + assert_eq!( + error.to_string(), + "use exactly one of --text, --file, or --stdin" + ); + assert!(matches!( + error.downcast_ref::(), + Some(CliInputError::DraftBodyInputSourceConflict) + )); + } + + #[tokio::test] + async fn resolve_draft_body_input_reports_file_read_as_typed_validation_error() { + let missing_path = PathBuf::from("/definitely/missing/mailroom-draft-body.txt"); + let error = resolve_draft_body_input(None, Some(missing_path.clone()), false) + .await + .unwrap_err(); + + assert!( + error + .to_string() + .starts_with("failed to read /definitely/missing/mailroom-draft-body.txt:") + ); + assert!(matches!( + error.downcast_ref::(), + Some(CliInputError::DraftBodyFileRead { path, .. }) if path == &missing_path + )); + } +} diff --git a/src/handlers/workspace.rs b/src/handlers/workspace.rs new file mode 100644 index 0000000..65e69e4 --- /dev/null +++ b/src/handlers/workspace.rs @@ -0,0 +1,25 @@ +use crate::{config, configured_paths, workspace}; +use anyhow::Result; + +pub(crate) fn handle_workspace_command( + paths: &workspace::WorkspacePaths, + command: crate::cli::WorkspaceCommand, +) -> Result<()> { + match command { + crate::cli::WorkspaceCommand::Init => { + let config_report = config::resolve(paths)?; + let configured_paths = configured_paths(&config_report)?; + let created = configured_paths.ensure_runtime_dirs()?; + println!( + "initialized {} new runtime paths under {}", + created.len(), + configured_paths.runtime_root.display() + ); + for path in created { + println!("{}", path.display()); + } + } + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 4b080dc..f1eb60d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ mod cli_output; mod config; mod doctor; mod gmail; +mod handlers; mod mailbox; mod store; mod time; @@ -18,12 +19,16 @@ use clap::Parser; use cli::{ AccountCommand, AttachmentCommand, AuditCommand, AuthCommand, AutomationCommand, AutomationRulesCommand, CleanupCommand, Cli, Commands, ConfigCommand, DraftAttachmentCommand, - DraftCommand, GmailCommand, GmailLabelsCommand, SearchArgs, StoreCommand, SyncCommand, - SyncPerfCommand, SyncProfileArg, SyncRunArgs, TriageBucketArg, TriageCommand, WorkflowCommand, - WorkflowPromoteTargetArg, WorkflowStageArg, WorkspaceCommand, + DraftCommand, GmailCommand, GmailLabelsCommand, StoreCommand, SyncCommand, SyncPerfCommand, + TriageCommand, WorkflowCommand, +}; +use handlers::{ + handle_account_command, handle_attachment_command, handle_audit_command, handle_auth_command, + handle_automation_command, handle_cleanup_command, handle_config_command, + handle_doctor_command, handle_draft_command, handle_gmail_command, handle_paths_command, + handle_search_command, handle_store_command, handle_sync_command, handle_triage_command, + handle_workflow_command, handle_workspace_command, }; -use serde::Serialize; -use std::io::Read; use std::path::{Path, PathBuf}; use std::process::ExitCode; use thiserror::Error; @@ -70,7 +75,7 @@ pub async fn run() -> ExitCode { eprintln!("{error:#}"); } } else { - eprintln!("{error:#}"); + cli_output::print_human_failure(&error); } cli_output::exit_code(&report) } @@ -83,12 +88,24 @@ async fn run_cli(cli: Cli) -> Result<()> { let paths = workspace::WorkspacePaths::from_repo_root(repo_root); match cli.command { - Commands::Audit { command } => handle_audit_command(&paths, command)?, + Commands::Audit { command } => { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || handle_audit_command(&paths, command)).await??; + } Commands::Auth { command } => handle_auth_command(&paths, command).await?, Commands::Account { command } => handle_account_command(&paths, command).await?, - Commands::Config { command } => handle_config_command(&paths, command)?, - Commands::Paths { json } => handle_paths_command(&paths, json)?, - Commands::Doctor { json } => handle_doctor_command(&paths, json)?, + Commands::Config { command } => { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || handle_config_command(&paths, command)).await??; + } + Commands::Paths { json } => { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || handle_paths_command(&paths, json)).await??; + } + Commands::Doctor { json } => { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || handle_doctor_command(&paths, json)).await??; + } Commands::Gmail { command } => handle_gmail_command(&paths, command).await?, Commands::Roadmap => print_roadmap(), Commands::Search(args) => handle_search_command(&paths, args).await?, @@ -99,33 +116,20 @@ async fn run_cli(cli: Cli) -> Result<()> { Commands::Triage { command } => handle_triage_command(&paths, command).await?, Commands::Draft { command } => handle_draft_command(&paths, command).await?, Commands::Cleanup { command } => handle_cleanup_command(&paths, command).await?, - Commands::Workspace { command } => handle_workspace_command(&paths, command)?, - Commands::Store { command } => handle_store_command(&paths, command)?, - } - - Ok(()) -} - -fn handle_paths_command(paths: &workspace::WorkspacePaths, json: bool) -> Result<()> { - match config::resolve(paths) { - Ok(config_report) => configured_paths(&config_report)?.print(json)?, - Err(_) => paths.print(json)?, + Commands::Workspace { command } => { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || handle_workspace_command(&paths, command)) + .await??; + } + Commands::Store { command } => { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || handle_store_command(&paths, command)).await??; + } } Ok(()) } -fn resolve_snooze_until(until: Option, clear: bool) -> Result> { - if !clear && until.is_none() { - return Err(CliInputError::SnoozeRequiresUntilOrClear.into()); - } - if clear && until.is_some() { - return Err(CliInputError::SnoozeUntilConflict.into()); - } - - if clear { Ok(None) } else { Ok(until) } -} - #[derive(Clone, Copy)] struct CommandMetadata { json: bool, @@ -339,89 +343,6 @@ fn command_metadata(command: &Commands) -> CommandMetadata { } } -fn handle_audit_command(paths: &workspace::WorkspacePaths, command: AuditCommand) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - AuditCommand::Labels { json } => audit::labels(&config_report)?.print(json)?, - AuditCommand::Verification { json } => audit::verification(&config_report)?.print(json)?, - } - - Ok(()) -} - -async fn handle_auth_command( - paths: &workspace::WorkspacePaths, - command: AuthCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - AuthCommand::Setup { - credentials_file, - json, - no_browser, - } => auth::setup(&config_report, credentials_file, no_browser, json) - .await? - .print(json)?, - AuthCommand::Login { json, no_browser } => auth::login(&config_report, no_browser, json) - .await? - .print(json)?, - AuthCommand::Status { json } => auth::status(&config_report)?.print(json)?, - AuthCommand::Logout { json } => auth::logout(&config_report)?.print(json)?, - } - - Ok(()) -} - -async fn handle_account_command( - paths: &workspace::WorkspacePaths, - command: AccountCommand, -) -> Result<()> { - match command { - AccountCommand::Show { json } => { - refresh_active_account(&config::resolve(paths)?) - .await? - .print(json)?; - } - } - - Ok(()) -} - -fn handle_config_command(paths: &workspace::WorkspacePaths, command: ConfigCommand) -> Result<()> { - match command { - ConfigCommand::Show { json } => config::resolve(paths)?.print(json)?, - } - - Ok(()) -} - -fn handle_doctor_command(paths: &workspace::WorkspacePaths, json: bool) -> Result<()> { - let config_report = config::resolve(paths)?; - let configured_paths = configured_paths(&config_report)?; - doctor::DoctorReport::inspect(&configured_paths, config_report)?.print(json)?; - Ok(()) -} - -async fn handle_gmail_command( - paths: &workspace::WorkspacePaths, - command: GmailCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - GmailCommand::Labels { - command: GmailLabelsCommand::List { json }, - } => GmailLabelsReport { - labels: gmail_client(&config_report)?.list_labels().await?, - } - .print(json)?, - } - - Ok(()) -} - fn print_roadmap() { println!( "v1 milestone: search + thread workflow + draft/send + reviewed cleanup + controlled attachment export\n\ @@ -432,472 +353,6 @@ fn print_roadmap() { ); } -async fn handle_search_command(paths: &workspace::WorkspacePaths, args: SearchArgs) -> Result<()> { - let config_report = config::resolve(paths)?; - mailbox::search( - &config_report, - mailbox::SearchRequest { - terms: args.terms, - label: args.label, - from_address: args.from_address, - after: args.after, - before: args.before, - limit: args.limit, - }, - ) - .await? - .print(args.json)?; - - Ok(()) -} - -async fn handle_attachment_command( - paths: &workspace::WorkspacePaths, - command: AttachmentCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - AttachmentCommand::List { - thread_id, - message_id, - filename, - mime_type, - fetched_only, - limit, - json, - } => attachments::list( - &config_report, - attachments::AttachmentListRequest { - thread_id, - message_id, - filename, - mime_type, - fetched_only, - limit, - }, - ) - .await? - .print(json)?, - AttachmentCommand::Show { - attachment_key, - json, - } => attachments::show(&config_report, attachment_key) - .await? - .print(json)?, - AttachmentCommand::Fetch { - attachment_key, - json, - } => attachments::fetch(&config_report, attachment_key) - .await? - .print(json)?, - AttachmentCommand::Export { - attachment_key, - to, - json, - } => attachments::export(&config_report, attachment_key, to) - .await? - .print(json)?, - } - - Ok(()) -} - -async fn handle_automation_command( - paths: &workspace::WorkspacePaths, - command: AutomationCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - AutomationCommand::Rules { - command: AutomationRulesCommand::Validate { json }, - } => automation::validate_rules(&config_report) - .await? - .print(json)?, - AutomationCommand::Run { - rule_ids, - limit, - json, - } => automation::run_preview( - &config_report, - automation::AutomationRunRequest { rule_ids, limit }, - ) - .await? - .print(json)?, - AutomationCommand::Show { run_id, json } => automation::show_run(&config_report, run_id) - .await? - .print(json)?, - AutomationCommand::Apply { - run_id, - execute, - json, - } => automation::apply_run(&config_report, run_id, execute) - .await? - .print(json)?, - } - - Ok(()) -} - -async fn handle_sync_command( - paths: &workspace::WorkspacePaths, - command: SyncCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - SyncCommand::Run(args) | SyncCommand::Benchmark(args) => { - mailbox::sync_run_with_options(&config_report, resolve_sync_run_options(&args)?) - .await? - .print(args.json)? - } - SyncCommand::History { limit, json } => mailbox::sync_history(&config_report, limit) - .await? - .print(json)?, - SyncCommand::Perf { - command: SyncPerfCommand::Explain { limit, json }, - } - | SyncCommand::PerfExplain { limit, json } => { - mailbox::sync_perf_explain(&config_report, limit) - .await? - .print(json)? - } - } - - Ok(()) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct SyncProfileDefaults { - force_full: bool, - recent_days: u32, - quota_units_per_minute: u32, - message_fetch_concurrency: usize, -} - -fn default_sync_profile_defaults() -> SyncProfileDefaults { - SyncProfileDefaults { - force_full: false, - recent_days: mailbox::DEFAULT_BOOTSTRAP_RECENT_DAYS, - quota_units_per_minute: mailbox::DEFAULT_SYNC_QUOTA_UNITS_PER_MINUTE, - message_fetch_concurrency: mailbox::DEFAULT_MESSAGE_FETCH_CONCURRENCY, - } -} - -fn sync_profile_defaults(profile: SyncProfileArg) -> SyncProfileDefaults { - match profile { - SyncProfileArg::DeepAudit => SyncProfileDefaults { - force_full: true, - recent_days: 365, - quota_units_per_minute: 9_000, - message_fetch_concurrency: 3, - }, - } -} - -fn resolve_sync_run_options(args: &SyncRunArgs) -> Result { - let defaults = args - .profile - .map(sync_profile_defaults) - .unwrap_or_else(default_sync_profile_defaults); - let recent_days = args.recent_days.unwrap_or(defaults.recent_days); - if recent_days == 0 { - return Err(CliInputError::RecentDaysZero.into()); - } - let quota_units_per_minute = args - .quota_units_per_minute - .unwrap_or(defaults.quota_units_per_minute); - if quota_units_per_minute == 0 { - return Err(CliInputError::QuotaUnitsPerMinuteZero.into()); - } - let message_fetch_concurrency = args - .message_fetch_concurrency - .unwrap_or(defaults.message_fetch_concurrency); - if message_fetch_concurrency == 0 { - return Err(CliInputError::MessageFetchConcurrencyZero.into()); - } - - Ok(mailbox::SyncRunOptions { - force_full: args.full || defaults.force_full, - recent_days, - quota_units_per_minute, - message_fetch_concurrency, - }) -} - -async fn handle_workflow_command( - paths: &workspace::WorkspacePaths, - command: WorkflowCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - WorkflowCommand::List { - stage, - triage_bucket, - json, - } => workflows::list_workflows( - &config_report, - stage.map(workflow_stage_from_arg), - triage_bucket.map(triage_bucket_from_arg), - ) - .await? - .print(json)?, - WorkflowCommand::Show { thread_id, json } => { - workflows::show_workflow(&config_report, thread_id) - .await? - .print(json)? - } - WorkflowCommand::Promote { - thread_id, - to, - json, - } => workflows::promote_workflow( - &config_report, - thread_id, - workflow_promote_target_from_arg(to), - ) - .await? - .print(json)?, - WorkflowCommand::Snooze { - thread_id, - until, - clear, - json, - } => { - let until = resolve_snooze_until(until, clear)?; - workflows::snooze_workflow(&config_report, thread_id, until) - .await? - .print(json)?; - } - } - - Ok(()) -} - -async fn handle_triage_command( - paths: &workspace::WorkspacePaths, - command: TriageCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - TriageCommand::Set { - thread_id, - bucket, - note, - json, - } => workflows::set_triage( - &config_report, - thread_id, - triage_bucket_from_arg(bucket), - note, - ) - .await? - .print(json)?, - } - - Ok(()) -} - -async fn handle_draft_command( - paths: &workspace::WorkspacePaths, - command: DraftCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - DraftCommand::Start { - thread_id, - reply_all, - json, - } => workflows::draft_start( - &config_report, - thread_id, - if reply_all { - store::workflows::ReplyMode::ReplyAll - } else { - store::workflows::ReplyMode::Reply - }, - ) - .await? - .print(json)?, - DraftCommand::Body { - thread_id, - text, - file, - stdin, - json, - } => { - let body_text = resolve_draft_body_input(text, file, stdin)?; - workflows::draft_body_set(&config_report, thread_id, body_text) - .await? - .print(json)?; - } - DraftCommand::Attach { command } => match command { - DraftAttachmentCommand::Add { - thread_id, - path, - json, - } => workflows::draft_attach_add(&config_report, thread_id, path) - .await? - .print(json)?, - DraftAttachmentCommand::Remove { - thread_id, - path, - json, - } => workflows::draft_attach_remove(&config_report, thread_id, path) - .await? - .print(json)?, - }, - DraftCommand::Send { thread_id, json } => workflows::draft_send(&config_report, thread_id) - .await? - .print(json)?, - } - - Ok(()) -} - -async fn handle_cleanup_command( - paths: &workspace::WorkspacePaths, - command: CleanupCommand, -) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - CleanupCommand::Archive { - thread_id, - execute, - json, - } => workflows::cleanup_archive(&config_report, thread_id, execute) - .await? - .print(json)?, - CleanupCommand::Label { - thread_id, - add_labels, - remove_labels, - execute, - json, - } => workflows::cleanup_label( - &config_report, - thread_id, - execute, - add_labels, - remove_labels, - ) - .await? - .print(json)?, - CleanupCommand::Trash { - thread_id, - execute, - json, - } => workflows::cleanup_trash(&config_report, thread_id, execute) - .await? - .print(json)?, - } - - Ok(()) -} - -fn handle_workspace_command( - paths: &workspace::WorkspacePaths, - command: WorkspaceCommand, -) -> Result<()> { - match command { - WorkspaceCommand::Init => { - let config_report = config::resolve(paths)?; - let configured_paths = configured_paths(&config_report)?; - let created = configured_paths.ensure_runtime_dirs()?; - println!( - "initialized {} new runtime paths under {}", - created.len(), - configured_paths.runtime_root.display() - ); - for path in created { - println!("{}", path.display()); - } - } - } - - Ok(()) -} - -fn handle_store_command(paths: &workspace::WorkspacePaths, command: StoreCommand) -> Result<()> { - let config_report = config::resolve(paths)?; - - match command { - StoreCommand::Init { json } => { - let configured_paths = configured_paths(&config_report)?; - configured_paths.ensure_runtime_dirs()?; - store::init(&config_report)?.print(json)?; - } - StoreCommand::Doctor { json } => store::inspect(config_report)?.print(json)?, - } - - Ok(()) -} - -fn workflow_stage_from_arg(value: WorkflowStageArg) -> store::workflows::WorkflowStage { - match value { - WorkflowStageArg::Triage => store::workflows::WorkflowStage::Triage, - WorkflowStageArg::FollowUp => store::workflows::WorkflowStage::FollowUp, - WorkflowStageArg::Drafting => store::workflows::WorkflowStage::Drafting, - WorkflowStageArg::ReadyToSend => store::workflows::WorkflowStage::ReadyToSend, - WorkflowStageArg::Sent => store::workflows::WorkflowStage::Sent, - WorkflowStageArg::Closed => store::workflows::WorkflowStage::Closed, - } -} - -fn workflow_promote_target_from_arg( - value: WorkflowPromoteTargetArg, -) -> store::workflows::WorkflowStage { - match value { - WorkflowPromoteTargetArg::FollowUp => store::workflows::WorkflowStage::FollowUp, - WorkflowPromoteTargetArg::ReadyToSend => store::workflows::WorkflowStage::ReadyToSend, - WorkflowPromoteTargetArg::Closed => store::workflows::WorkflowStage::Closed, - } -} - -fn triage_bucket_from_arg(value: TriageBucketArg) -> store::workflows::TriageBucket { - match value { - TriageBucketArg::Urgent => store::workflows::TriageBucket::Urgent, - TriageBucketArg::NeedsReplySoon => store::workflows::TriageBucket::NeedsReplySoon, - TriageBucketArg::Waiting => store::workflows::TriageBucket::Waiting, - TriageBucketArg::Fyi => store::workflows::TriageBucket::Fyi, - } -} - -fn resolve_draft_body_input( - text: Option, - file: Option, - stdin: bool, -) -> Result { - let selected = usize::from(text.is_some()) + usize::from(file.is_some()) + usize::from(stdin); - if selected != 1 { - return Err(CliInputError::DraftBodyInputSourceConflict.into()); - } - - if let Some(text) = text { - return Ok(text); - } - - if let Some(file) = file { - return std::fs::read_to_string(&file) - .map_err(|source| CliInputError::DraftBodyFileRead { path: file, source }.into()); - } - - let mut buffer = String::new(); - std::io::stdin() - .read_to_string(&mut buffer) - .map_err(|source| CliInputError::DraftBodyStdinRead { source })?; - Ok(buffer) -} - -fn gmail_client(config_report: &config::ConfigReport) -> Result { - gmail_client_for_config(config_report) -} - pub(crate) fn gmail_client_for_config( config_report: &config::ConfigReport, ) -> Result { @@ -957,12 +412,6 @@ pub(crate) async fn refresh_active_account_record_with_client( .await? } -async fn refresh_active_account(config_report: &config::ConfigReport) -> Result { - let account = refresh_active_account_record(config_report).await?; - - Ok(AccountShowReport { account }) -} - fn discover_repo_root(start: PathBuf) -> Result { start .ancestors() @@ -977,22 +426,13 @@ fn is_repo_root(path: &Path) -> bool { #[cfg(test)] mod tests { - use super::{ - CliInputError, discover_repo_root, handle_paths_command, refresh_active_account, - resolve_draft_body_input, resolve_snooze_until, resolve_sync_run_options, - }; - use crate::auth::file_store::{CredentialStore, FileCredentialStore, StoredCredentials}; - use crate::cli::{Cli, Commands, SyncCommand, SyncProfileArg, SyncRunArgs}; + use super::discover_repo_root; use crate::config::resolve; use crate::workspace::WorkspacePaths; - use clap::{CommandFactory, Parser, error::ErrorKind}; - use secrecy::SecretString; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use tempfile::TempDir; - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; #[test] fn repo_local_runtime_paths_are_stable() { @@ -1131,335 +571,6 @@ runtime_root = "{}" fs::remove_dir_all(repo_root).unwrap(); } - #[test] - fn paths_command_still_prints_repo_local_paths_when_config_is_malformed() { - let repo_root = unique_temp_dir("mailroom-paths-malformed-config"); - if repo_root.exists() { - fs::remove_dir_all(&repo_root).unwrap(); - } - - fs::create_dir_all(repo_root.join(".mailroom")).unwrap(); - fs::write(repo_root.join(".mailroom/config.toml"), "[workspace\n").unwrap(); - - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - assert!(resolve(&paths).is_err()); - handle_paths_command(&paths, true).unwrap(); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[tokio::test] - async fn refresh_active_account_persists_stored_granted_scopes() { - let mock_server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/gmail/v1/users/me/profile")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "emailAddress": "operator@example.com", - "messagesTotal": 10, - "threadsTotal": 7, - "historyId": "12345" - }))) - .mount(&mock_server) - .await; - - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - paths.ensure_runtime_dirs().unwrap(); - let mut config_report = resolve(&paths).unwrap(); - config_report.config.gmail.api_base_url = format!("{}/gmail/v1", mock_server.uri()); - config_report.config.gmail.scopes = vec![String::from("requested:scope")]; - let credential_store = FileCredentialStore::new( - config_report - .config - .gmail - .credential_path(&config_report.config.workspace), - ); - credential_store - .save(&StoredCredentials { - account_id: String::from("gmail:operator@example.com"), - access_token: SecretString::from(String::from("access-token")), - refresh_token: Some(SecretString::from(String::from("refresh-token"))), - expires_at_epoch_s: Some(u64::MAX), - scopes: vec![String::from("granted:scope")], - }) - .unwrap(); - - let report = refresh_active_account(&config_report).await.unwrap(); - - assert_eq!(report.account.access_scope, "granted:scope"); - } - - #[tokio::test] - async fn refresh_active_account_without_credentials_does_not_create_database() { - let temp_dir = TempDir::new().unwrap(); - let repo_root = temp_dir.path().to_path_buf(); - let paths = WorkspacePaths::from_repo_root(repo_root); - let config_report = resolve(&paths).unwrap(); - - let error = refresh_active_account(&config_report).await.unwrap_err(); - - assert_eq!( - error.to_string(), - "mailroom is not authenticated; run `mailroom auth login` first" - ); - assert!(!config_report.config.store.database_path.exists()); - assert!(!config_report.config.workspace.runtime_root.exists()); - } - - #[test] - fn resolve_snooze_until_requires_explicit_until_or_clear() { - let error = resolve_snooze_until(None, false).unwrap_err(); - - assert_eq!(error.to_string(), "use --until YYYY-MM-DD or --clear"); - } - - #[test] - fn resolve_snooze_until_rejects_conflicting_flags() { - let error = resolve_snooze_until(Some(String::from("2026-05-01")), true).unwrap_err(); - - assert_eq!(error.to_string(), "use either --until or --clear, not both"); - } - - #[test] - fn resolve_draft_body_input_requires_exactly_one_source() { - let error = resolve_draft_body_input(None, None, false).unwrap_err(); - - assert_eq!( - error.to_string(), - "use exactly one of --text, --file, or --stdin" - ); - assert!(matches!( - error.downcast_ref::(), - Some(CliInputError::DraftBodyInputSourceConflict) - )); - } - - #[test] - fn resolve_draft_body_input_reports_file_read_as_typed_validation_error() { - let missing_path = PathBuf::from("/definitely/missing/mailroom-draft-body.txt"); - let error = resolve_draft_body_input(None, Some(missing_path.clone()), false).unwrap_err(); - - assert!( - error - .to_string() - .starts_with("failed to read /definitely/missing/mailroom-draft-body.txt:") - ); - assert!(matches!( - error.downcast_ref::(), - Some(CliInputError::DraftBodyFileRead { path, .. }) if path == &missing_path - )); - } - - #[test] - fn resolve_sync_run_options_uses_legacy_defaults_without_profile() { - let args = SyncRunArgs { - full: false, - profile: None, - recent_days: None, - quota_units_per_minute: None, - message_fetch_concurrency: None, - json: false, - }; - - let options = resolve_sync_run_options(&args).unwrap(); - - assert_eq!( - options.recent_days, - crate::mailbox::DEFAULT_BOOTSTRAP_RECENT_DAYS - ); - assert_eq!( - options.quota_units_per_minute, - crate::mailbox::DEFAULT_SYNC_QUOTA_UNITS_PER_MINUTE - ); - assert_eq!( - options.message_fetch_concurrency, - crate::mailbox::DEFAULT_MESSAGE_FETCH_CONCURRENCY - ); - assert!(!options.force_full); - } - - #[test] - fn resolve_sync_run_options_applies_deep_audit_profile_defaults() { - let args = SyncRunArgs { - full: false, - profile: Some(SyncProfileArg::DeepAudit), - recent_days: None, - quota_units_per_minute: None, - message_fetch_concurrency: None, - json: false, - }; - - let options = resolve_sync_run_options(&args).unwrap(); - - assert_eq!(options.recent_days, 365); - assert_eq!(options.quota_units_per_minute, 9_000); - assert_eq!(options.message_fetch_concurrency, 3); - assert!(options.force_full); - } - - #[test] - fn resolve_sync_run_options_keeps_explicit_overrides_authoritative() { - let args = SyncRunArgs { - full: false, - profile: Some(SyncProfileArg::DeepAudit), - recent_days: Some(180), - quota_units_per_minute: Some(8_000), - message_fetch_concurrency: Some(2), - json: false, - }; - - let options = resolve_sync_run_options(&args).unwrap(); - - assert_eq!(options.recent_days, 180); - assert_eq!(options.quota_units_per_minute, 8_000); - assert_eq!(options.message_fetch_concurrency, 2); - assert!(options.force_full); - } - - #[test] - fn resolve_sync_run_options_rejects_zero_overrides() { - let args = SyncRunArgs { - full: false, - profile: None, - recent_days: Some(0), - quota_units_per_minute: Some(0), - message_fetch_concurrency: Some(0), - json: false, - }; - - let error = resolve_sync_run_options(&args).unwrap_err(); - assert!(matches!( - error.downcast_ref::(), - Some(CliInputError::RecentDaysZero) - )); - assert_eq!(error.to_string(), "--recent-days must be greater than zero"); - } - - #[test] - fn resolve_sync_run_options_rejects_zero_quota_override() { - let args = SyncRunArgs { - full: false, - profile: None, - recent_days: None, - quota_units_per_minute: Some(0), - message_fetch_concurrency: None, - json: false, - }; - - let error = resolve_sync_run_options(&args).unwrap_err(); - assert!(matches!( - error.downcast_ref::(), - Some(CliInputError::QuotaUnitsPerMinuteZero) - )); - assert_eq!( - error.to_string(), - "--quota-units-per-minute must be greater than zero" - ); - } - - #[test] - fn resolve_sync_run_options_rejects_zero_message_fetch_concurrency_override() { - let args = SyncRunArgs { - full: false, - profile: None, - recent_days: None, - quota_units_per_minute: None, - message_fetch_concurrency: Some(0), - json: false, - }; - - let error = resolve_sync_run_options(&args).unwrap_err(); - assert!(matches!( - error.downcast_ref::(), - Some(CliInputError::MessageFetchConcurrencyZero) - )); - assert_eq!( - error.to_string(), - "--message-fetch-concurrency must be greater than zero" - ); - } - - #[test] - fn sync_run_and_benchmark_parse_the_same_profile_surface() { - let run_args = extract_sync_run_args([ - "mailroom", - "sync", - "run", - "--profile", - "deep-audit", - "--quota-units-per-minute", - "8000", - ]); - let benchmark_args = extract_sync_run_args([ - "mailroom", - "sync", - "benchmark", - "--profile", - "deep-audit", - "--quota-units-per-minute", - "8000", - ]); - - assert_eq!( - run_args, - SyncRunArgs { - full: false, - profile: Some(SyncProfileArg::DeepAudit), - recent_days: None, - quota_units_per_minute: Some(8_000), - message_fetch_concurrency: None, - json: false, - } - ); - assert_eq!(run_args, benchmark_args); - assert_eq!( - resolve_sync_run_options(&run_args).unwrap(), - resolve_sync_run_options(&benchmark_args).unwrap() - ); - } - - #[test] - fn sync_profile_help_lists_deep_audit_profile() { - let mut command = Cli::command(); - let error = command - .try_get_matches_from_mut(["mailroom", "sync", "run", "--help"]) - .unwrap_err(); - - assert_eq!(error.kind(), ErrorKind::DisplayHelp); - let rendered = error.to_string(); - assert!(rendered.contains("--profile ")); - assert!(rendered.contains("deep-audit")); - } - - #[test] - fn sync_profile_rejects_unknown_value() { - let error = - Cli::try_parse_from(["mailroom", "sync", "run", "--profile", "unknown"]).unwrap_err(); - - assert_eq!(error.kind(), ErrorKind::InvalidValue); - let rendered = error.to_string(); - assert!(rendered.contains("deep-audit")); - assert!(rendered.contains("--profile ")); - } - - fn extract_sync_run_args(args: I) -> SyncRunArgs - where - I: IntoIterator, - T: Into + Clone, - { - let cli = Cli::try_parse_from(args).unwrap(); - match cli.command { - Commands::Sync { - command: SyncCommand::Run(args), - } - | Commands::Sync { - command: SyncCommand::Benchmark(args), - } => args, - other => panic!("expected sync run or benchmark args, got {other:?}"), - } - } - fn unique_temp_dir(prefix: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1468,45 +579,3 @@ runtime_root = "{}" std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) } } - -#[derive(Debug, Clone, Serialize)] -struct AccountShowReport { - account: store::accounts::AccountRecord, -} - -impl AccountShowReport { - fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("account_id={}", self.account.account_id); - println!("email_address={}", self.account.email_address); - println!("history_id={}", self.account.history_id); - println!("messages_total={}", self.account.messages_total); - println!("threads_total={}", self.account.threads_total); - println!( - "last_profile_refresh_epoch_s={}", - self.account.last_profile_refresh_epoch_s - ); - } - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize)] -struct GmailLabelsReport { - labels: Vec, -} - -impl GmailLabelsReport { - fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - for label in &self.labels { - println!("{} {} {}", label.id, label.name, label.label_type); - } - } - Ok(()) - } -} diff --git a/src/store/connection.rs b/src/store/connection.rs index 213e147..1b7bc77 100644 --- a/src/store/connection.rs +++ b/src/store/connection.rs @@ -1,10 +1,54 @@ use super::SQLITE_APPLICATION_ID; use anyhow::{Result, bail}; use rusqlite::{Connection, OpenFlags}; +use serde::Serialize; +use std::io::{self, Write}; use std::path::Path; +use std::path::PathBuf; use std::time::Duration; use thiserror::Error; +#[derive(Debug, Clone, Serialize)] +pub struct StoreInitReport { + pub database_path: PathBuf, + pub database_previously_existed: bool, + pub schema_version: i64, + pub known_migrations: usize, + pub pending_migrations: usize, + pub pragmas: StorePragmas, +} + +#[derive(Debug, Clone, Serialize)] +pub struct StorePragmas { + pub application_id: i64, + pub user_version: i64, + pub foreign_keys: bool, + pub trusted_schema: bool, + pub journal_mode: String, + pub synchronous: i64, + pub busy_timeout_ms: i64, +} + +impl StoreInitReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("database_path={}", self.database_path.display()); + println!( + "database_previously_existed={}", + self.database_previously_existed + ); + println!("schema_version={}", self.schema_version); + println!("known_migrations={}", self.known_migrations); + println!("pending_migrations={}", self.pending_migrations); + print_pragmas(&self.pragmas)?; + } + + Ok(()) + } +} + #[derive(Debug, Error)] pub(crate) enum DatabaseOpenError { #[error( @@ -45,7 +89,7 @@ pub fn open_existing( Ok(connection) } -fn configure_busy_timeout( +pub(super) fn configure_busy_timeout( connection: &Connection, busy_timeout_ms: u64, ) -> std::result::Result<(), DatabaseOpenError> { @@ -84,6 +128,45 @@ pub fn configure_hardening_pragmas(connection: &Connection) -> Result<()> { Ok(()) } +pub(super) fn read_pragmas(connection: &Connection) -> Result { + let application_id = connection.pragma_query_value(None, "application_id", |row| row.get(0))?; + let user_version = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + let foreign_keys = + connection.pragma_query_value::(None, "foreign_keys", |row| row.get(0))? != 0; + let trusted_schema = + connection.pragma_query_value::(None, "trusted_schema", |row| row.get(0))? != 0; + let journal_mode = connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?; + let synchronous = connection.pragma_query_value(None, "synchronous", |row| row.get(0))?; + let busy_timeout_ms = connection.pragma_query_value(None, "busy_timeout", |row| row.get(0))?; + + Ok(StorePragmas { + application_id, + user_version, + foreign_keys, + trusted_schema, + journal_mode, + synchronous, + busy_timeout_ms, + }) +} + +pub(crate) fn print_pragmas(pragmas: &StorePragmas) -> Result<()> { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + write_pragmas(&mut stdout, pragmas) +} + +pub(crate) fn write_pragmas(writer: &mut W, pragmas: &StorePragmas) -> Result<()> { + writeln!(writer, "application_id={}", pragmas.application_id)?; + writeln!(writer, "user_version={}", pragmas.user_version)?; + writeln!(writer, "foreign_keys={}", pragmas.foreign_keys)?; + writeln!(writer, "trusted_schema={}", pragmas.trusted_schema)?; + writeln!(writer, "journal_mode={}", pragmas.journal_mode)?; + writeln!(writer, "synchronous={}", pragmas.synchronous)?; + writeln!(writer, "busy_timeout_ms={}", pragmas.busy_timeout_ms)?; + Ok(()) +} + fn create_flags() -> OpenFlags { OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE @@ -97,15 +180,3 @@ fn read_only_flags() -> OpenFlags { fn existing_flags() -> OpenFlags { OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX } - -#[cfg(test)] -mod tests { - use super::configure_busy_timeout; - use rusqlite::Connection; - - #[test] - fn configure_busy_timeout_rejects_zero() { - let connection = Connection::open_in_memory().unwrap(); - assert!(configure_busy_timeout(&connection, 0).is_err()); - } -} diff --git a/src/store/doctor.rs b/src/store/doctor.rs new file mode 100644 index 0000000..33d37e1 --- /dev/null +++ b/src/store/doctor.rs @@ -0,0 +1,583 @@ +use super::connection::{StorePragmas, read_pragmas, write_pragmas}; +use super::{automation, mailbox, pending_migrations, workflows}; +use crate::config::ConfigReport; +use anyhow::Result; +use serde::Serialize; +use std::fmt::Display; +use std::io::{self, Write}; + +fn write_kv(writer: &mut W, key: &str, value: V) -> Result<()> { + writeln!(writer, "{key}={value}")?; + Ok(()) +} + +fn write_kv_opt( + writer: &mut W, + key: &str, + value: Option, + none_text: &str, +) -> Result<()> { + match value { + Some(v) => writeln!(writer, "{key}={v}")?, + None => writeln!(writer, "{key}={none_text}")?, + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize)] +pub struct StoreDoctorReport { + pub config: ConfigReport, + pub database_exists: bool, + pub database_path: std::path::PathBuf, + pub known_migrations: usize, + pub schema_version: Option, + pub pending_migrations: Option, + pub pragmas: Option, + pub mailbox: Option, + pub workflows: Option, + pub automation: Option, +} + +impl StoreDoctorReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + self.write_human(&mut stdout)?; + } + + Ok(()) + } + + fn write_human(&self, writer: &mut W) -> Result<()> { + write_kv(writer, "database_path", self.database_path.display())?; + write_kv(writer, "database_exists", self.database_exists)?; + write_kv(writer, "known_migrations", self.known_migrations)?; + write_kv( + writer, + "user_config", + self.config + .locations + .user_config_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| String::from("")), + )?; + write_kv( + writer, + "user_config_exists", + self.config.locations.user_config_exists, + )?; + write_kv( + writer, + "repo_config", + self.config.locations.repo_config_path.display(), + )?; + write_kv( + writer, + "repo_config_exists", + self.config.locations.repo_config_exists, + )?; + write_kv_opt( + writer, + "schema_version", + self.schema_version, + "", + )?; + write_kv_opt( + writer, + "pending_migrations", + self.pending_migrations, + "", + )?; + if let Some(pragmas) = &self.pragmas { + write_pragmas(writer, pragmas)?; + } + if let Some(mailbox) = &self.mailbox { + write_kv(writer, "mailbox_message_count", mailbox.message_count)?; + write_kv(writer, "mailbox_label_count", mailbox.label_count)?; + write_kv( + writer, + "mailbox_indexed_message_count", + mailbox.indexed_message_count, + )?; + write_kv(writer, "mailbox_attachment_count", mailbox.attachment_count)?; + write_kv( + writer, + "mailbox_vaulted_attachment_count", + mailbox.vaulted_attachment_count, + )?; + write_kv( + writer, + "mailbox_attachment_export_count", + mailbox.attachment_export_count, + )?; + match &mailbox.sync_state { + Some(sync_state) => { + write_kv(writer, "mailbox_sync_status", sync_state.last_sync_status)?; + write_kv(writer, "mailbox_sync_mode", sync_state.last_sync_mode)?; + write_kv(writer, "mailbox_sync_epoch_s", sync_state.last_sync_epoch_s)?; + write_kv_opt( + writer, + "mailbox_last_full_sync_success_epoch_s", + sync_state.last_full_sync_success_epoch_s, + "", + )?; + write_kv_opt( + writer, + "mailbox_last_incremental_sync_success_epoch_s", + sync_state.last_incremental_sync_success_epoch_s, + "", + )?; + write_kv_opt( + writer, + "mailbox_cursor_history_id", + sync_state.cursor_history_id.as_deref(), + "", + )?; + write_kv( + writer, + "mailbox_sync_pipeline_enabled", + sync_state.pipeline_enabled, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_list_queue_high_water", + sync_state.pipeline_list_queue_high_water, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_write_queue_high_water", + sync_state.pipeline_write_queue_high_water, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_write_batch_count", + sync_state.pipeline_write_batch_count, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_writer_wait_ms", + sync_state.pipeline_writer_wait_ms, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_fetch_batch_count", + sync_state.pipeline_fetch_batch_count, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_fetch_batch_avg_ms", + sync_state.pipeline_fetch_batch_avg_ms, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_fetch_batch_max_ms", + sync_state.pipeline_fetch_batch_max_ms, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_writer_tx_count", + sync_state.pipeline_writer_tx_count, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_writer_tx_avg_ms", + sync_state.pipeline_writer_tx_avg_ms, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_writer_tx_max_ms", + sync_state.pipeline_writer_tx_max_ms, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_reorder_buffer_high_water", + sync_state.pipeline_reorder_buffer_high_water, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_staged_message_count", + sync_state.pipeline_staged_message_count, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_staged_delete_count", + sync_state.pipeline_staged_delete_count, + )?; + write_kv( + writer, + "mailbox_sync_pipeline_staged_attachment_count", + sync_state.pipeline_staged_attachment_count, + )?; + } + None => write_kv(writer, "mailbox_sync_status", "")?, + } + match &mailbox.full_sync_checkpoint { + Some(checkpoint) => { + write_kv( + writer, + "mailbox_full_sync_checkpoint_status", + checkpoint.status, + )?; + write_kv( + writer, + "mailbox_full_sync_checkpoint_bootstrap_query", + &checkpoint.bootstrap_query, + )?; + write_kv( + writer, + "mailbox_full_sync_checkpoint_pages_fetched", + checkpoint.pages_fetched, + )?; + write_kv( + writer, + "mailbox_full_sync_checkpoint_messages_upserted", + checkpoint.messages_upserted, + )?; + write_kv( + writer, + "mailbox_full_sync_checkpoint_staged_message_count", + checkpoint.staged_message_count, + )?; + write_kv( + writer, + "mailbox_full_sync_checkpoint_next_page_token_present", + checkpoint.next_page_token.is_some(), + )?; + write_kv( + writer, + "mailbox_full_sync_checkpoint_updated_at_epoch_s", + checkpoint.updated_at_epoch_s, + )?; + } + None => write_kv(writer, "mailbox_full_sync_checkpoint_status", "")?, + } + match &mailbox.sync_pacing_state { + Some(pacing_state) => { + write_kv( + writer, + "mailbox_sync_pacing_learned_quota_units_per_minute", + pacing_state.learned_quota_units_per_minute, + )?; + write_kv( + writer, + "mailbox_sync_pacing_learned_message_fetch_concurrency", + pacing_state.learned_message_fetch_concurrency, + )?; + write_kv( + writer, + "mailbox_sync_pacing_clean_run_streak", + pacing_state.clean_run_streak, + )?; + write_kv_opt( + writer, + "mailbox_sync_pacing_last_pressure_kind", + pacing_state.last_pressure_kind, + "", + )?; + write_kv( + writer, + "mailbox_sync_pacing_updated_at_epoch_s", + pacing_state.updated_at_epoch_s, + )?; + } + None => write_kv( + writer, + "mailbox_sync_pacing_learned_quota_units_per_minute", + "", + )?, + } + match &mailbox.sync_run_summary { + Some(summary) => { + write_kv(writer, "mailbox_sync_run_summary_mode", summary.sync_mode)?; + write_kv( + writer, + "mailbox_sync_run_summary_comparability_kind", + summary.comparability_kind, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_comparability_key", + &summary.comparability_key, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_comparability_label", + &summary.comparability_label, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_latest_run_id", + summary.latest_run_id, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_latest_status", + summary.latest_status, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_latest_finished_at_epoch_s", + summary.latest_finished_at_epoch_s, + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_best_clean_run_id", + summary.best_clean_run_id, + "", + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_best_clean_quota_units_per_minute", + summary.best_clean_quota_units_per_minute, + "", + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_best_clean_message_fetch_concurrency", + summary.best_clean_message_fetch_concurrency, + "", + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_best_clean_messages_per_second", + summary.best_clean_messages_per_second, + "", + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_best_clean_duration_ms", + summary.best_clean_duration_ms, + "", + )?; + write_kv( + writer, + "mailbox_sync_run_summary_recent_success_count", + summary.recent_success_count, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_recent_failure_count", + summary.recent_failure_count, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_recent_failure_streak", + summary.recent_failure_streak, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_recent_clean_success_streak", + summary.recent_clean_success_streak, + )?; + write_kv( + writer, + "mailbox_sync_run_summary_regression_detected", + summary.regression_detected, + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_regression_kind", + summary.regression_kind, + "", + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_regression_run_id", + summary.regression_run_id, + "", + )?; + write_kv_opt( + writer, + "mailbox_sync_run_summary_regression_message", + summary.regression_message.as_deref(), + "", + )?; + write_kv( + writer, + "mailbox_sync_run_summary_updated_at_epoch_s", + summary.updated_at_epoch_s, + )?; + } + None => write_kv(writer, "mailbox_sync_run_summary_latest_run_id", "")?, + } + } + if let Some(workflows) = &self.workflows { + write_kv(writer, "workflow_count", workflows.workflow_count)?; + write_kv(writer, "workflow_open_count", workflows.open_workflow_count)?; + write_kv( + writer, + "workflow_draft_count", + workflows.draft_workflow_count, + )?; + write_kv(writer, "workflow_event_count", workflows.event_count)?; + write_kv( + writer, + "workflow_draft_revision_count", + workflows.draft_revision_count, + )?; + } + if let Some(automation) = &self.automation { + write_kv(writer, "automation_run_count", automation.run_count)?; + write_kv( + writer, + "automation_previewed_run_count", + automation.previewed_run_count, + )?; + write_kv( + writer, + "automation_applied_run_count", + automation.applied_run_count, + )?; + write_kv( + writer, + "automation_apply_failed_run_count", + automation.apply_failed_run_count, + )?; + write_kv( + writer, + "automation_candidate_count", + automation.candidate_count, + )?; + } + + Ok(()) + } +} + +pub fn inspect(config_report: ConfigReport) -> Result { + let database_path = config_report.config.store.database_path.clone(); + let known_migrations = super::migrations::known_migration_count(); + + if !database_path.exists() { + return Ok(StoreDoctorReport { + config: config_report, + database_exists: false, + database_path, + known_migrations, + schema_version: None, + pending_migrations: None, + pragmas: None, + mailbox: None, + workflows: None, + automation: None, + }); + } + + let connection = super::connection::open_read_only_for_diagnostics( + &database_path, + config_report.config.store.busy_timeout_ms, + )?; + let pragmas = read_pragmas(&connection)?; + let pending_migrations = pending_migrations(known_migrations, pragmas.user_version)?; + let mailbox = + mailbox::inspect_mailbox(&database_path, config_report.config.store.busy_timeout_ms)?; + let workflows = + workflows::inspect_workflows(&database_path, config_report.config.store.busy_timeout_ms)?; + let automation = + automation::inspect_automation(&database_path, config_report.config.store.busy_timeout_ms)?; + + Ok(StoreDoctorReport { + config: config_report, + database_exists: true, + database_path, + known_migrations, + schema_version: Some(pragmas.user_version), + pending_migrations: Some(pending_migrations), + pragmas: Some(pragmas), + mailbox, + workflows, + automation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::resolve; + use crate::store::mailbox::{ + MailboxDoctorReport, SyncMode, SyncPacingPressureKind, SyncPacingStateRecord, + SyncRunComparabilityKind, SyncRunRegressionKind, SyncRunSummaryRecord, SyncStatus, + }; + use crate::workspace::WorkspacePaths; + use tempfile::TempDir; + + #[test] + fn write_human_restores_sync_run_summary_fields() { + let repo_root = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + let config = resolve(&paths).unwrap(); + let report = StoreDoctorReport { + config, + database_exists: true, + database_path: repo_root.path().join(".mailroom/store.sqlite3"), + known_migrations: 16, + schema_version: Some(16), + pending_migrations: Some(0), + pragmas: None, + mailbox: Some(MailboxDoctorReport { + sync_state: None, + full_sync_checkpoint: None, + sync_pacing_state: Some(SyncPacingStateRecord { + account_id: String::from("gmail:operator@example.com"), + learned_quota_units_per_minute: 12_000, + learned_message_fetch_concurrency: 4, + clean_run_streak: 3, + last_pressure_kind: Some(SyncPacingPressureKind::Quota), + updated_at_epoch_s: 530, + }), + sync_run_summary: Some(SyncRunSummaryRecord { + account_id: String::from("gmail:operator@example.com"), + sync_mode: SyncMode::Incremental, + comparability_kind: SyncRunComparabilityKind::IncrementalWorkloadTier, + comparability_key: String::from("large"), + comparability_label: String::from("large"), + latest_run_id: 42, + latest_status: SyncStatus::Ok, + latest_finished_at_epoch_s: 530, + best_clean_run_id: Some(41), + best_clean_quota_units_per_minute: Some(12_000), + best_clean_message_fetch_concurrency: Some(4), + best_clean_messages_per_second: Some(600.0), + best_clean_duration_ms: Some(1_000), + recent_success_count: 5, + recent_failure_count: 1, + recent_failure_streak: 0, + recent_clean_success_streak: 3, + regression_detected: true, + regression_kind: Some(SyncRunRegressionKind::ThroughputDrop), + regression_run_id: Some(42), + regression_message: Some(String::from("throughput dropped")), + updated_at_epoch_s: 531, + }), + message_count: 0, + label_count: 0, + indexed_message_count: 0, + attachment_count: 0, + vaulted_attachment_count: 0, + attachment_export_count: 0, + }), + workflows: None, + automation: None, + }; + + let mut output = Vec::new(); + report.write_human(&mut output).unwrap(); + let output = String::from_utf8(output).unwrap(); + + assert!(output.contains("mailbox_sync_pacing_updated_at_epoch_s=530")); + assert!(output.contains("mailbox_sync_run_summary_mode=incremental")); + assert!( + output + .contains("mailbox_sync_run_summary_comparability_kind=incremental_workload_tier") + ); + assert!(output.contains("mailbox_sync_run_summary_latest_run_id=42")); + assert!(output.contains("mailbox_sync_run_summary_best_clean_run_id=41")); + assert!(output.contains("mailbox_sync_run_summary_regression_kind=throughput_drop")); + assert!(output.contains("mailbox_sync_run_summary_regression_message=throughput dropped")); + assert!(output.contains("mailbox_sync_run_summary_updated_at_epoch_s=531")); + } +} diff --git a/src/store/mod.rs b/src/store/mod.rs index 3af29e0..ad57f47 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -1,415 +1,20 @@ pub mod accounts; pub mod automation; mod connection; +pub mod doctor; pub mod mailbox; mod migrations; pub mod workflows; use crate::config::ConfigReport; use anyhow::{Result, anyhow}; -use rusqlite::Connection; -use serde::Serialize; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -pub const SQLITE_APPLICATION_ID: i64 = 0x4D41_494C; - -#[derive(Debug, Clone, Serialize)] -pub struct StoreInitReport { - pub database_path: PathBuf, - pub database_previously_existed: bool, - pub schema_version: i64, - pub known_migrations: usize, - pub pending_migrations: usize, - pub pragmas: StorePragmas, -} - -#[derive(Debug, Clone, Serialize)] -pub struct StoreDoctorReport { - pub config: ConfigReport, - pub database_exists: bool, - pub database_path: PathBuf, - pub known_migrations: usize, - pub schema_version: Option, - pub pending_migrations: Option, - pub pragmas: Option, - pub mailbox: Option, - pub workflows: Option, - pub automation: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct StorePragmas { - pub application_id: i64, - pub user_version: i64, - pub foreign_keys: bool, - pub trusted_schema: bool, - pub journal_mode: String, - pub synchronous: i64, - pub busy_timeout_ms: i64, -} +pub use connection::StoreInitReport; +pub use doctor::{StoreDoctorReport, inspect}; -impl StoreInitReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("database_path={}", self.database_path.display()); - println!( - "database_previously_existed={}", - self.database_previously_existed - ); - println!("schema_version={}", self.schema_version); - println!("known_migrations={}", self.known_migrations); - println!("pending_migrations={}", self.pending_migrations); - print_pragmas(&self.pragmas); - } - - Ok(()) - } -} - -impl StoreDoctorReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("database_path={}", self.database_path.display()); - println!("database_exists={}", self.database_exists); - println!("known_migrations={}", self.known_migrations); - println!( - "user_config={}", - self.config - .locations - .user_config_path - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| String::from("")) - ); - println!( - "user_config_exists={}", - self.config.locations.user_config_exists - ); - println!( - "repo_config={}", - self.config.locations.repo_config_path.display() - ); - println!( - "repo_config_exists={}", - self.config.locations.repo_config_exists - ); - match self.schema_version { - Some(version) => println!("schema_version={version}"), - None => println!("schema_version="), - } - match self.pending_migrations { - Some(pending) => println!("pending_migrations={pending}"), - None => println!("pending_migrations="), - } - if let Some(pragmas) = &self.pragmas { - print_pragmas(pragmas); - } - if let Some(mailbox) = &self.mailbox { - println!("mailbox_message_count={}", mailbox.message_count); - println!("mailbox_label_count={}", mailbox.label_count); - println!( - "mailbox_indexed_message_count={}", - mailbox.indexed_message_count - ); - println!("mailbox_attachment_count={}", mailbox.attachment_count); - println!( - "mailbox_vaulted_attachment_count={}", - mailbox.vaulted_attachment_count - ); - println!( - "mailbox_attachment_export_count={}", - mailbox.attachment_export_count - ); - match &mailbox.sync_state { - Some(sync_state) => { - println!("mailbox_sync_status={}", sync_state.last_sync_status); - println!("mailbox_sync_mode={}", sync_state.last_sync_mode); - println!("mailbox_sync_epoch_s={}", sync_state.last_sync_epoch_s); - match sync_state.last_full_sync_success_epoch_s { - Some(epoch) => { - println!("mailbox_last_full_sync_success_epoch_s={epoch}") - } - None => println!("mailbox_last_full_sync_success_epoch_s="), - } - match sync_state.last_incremental_sync_success_epoch_s { - Some(epoch) => { - println!("mailbox_last_incremental_sync_success_epoch_s={epoch}") - } - None => { - println!("mailbox_last_incremental_sync_success_epoch_s=") - } - } - match &sync_state.cursor_history_id { - Some(history_id) => println!("mailbox_cursor_history_id={history_id}"), - None => println!("mailbox_cursor_history_id="), - } - println!( - "mailbox_sync_pipeline_enabled={}", - sync_state.pipeline_enabled - ); - println!( - "mailbox_sync_pipeline_list_queue_high_water={}", - sync_state.pipeline_list_queue_high_water - ); - println!( - "mailbox_sync_pipeline_write_queue_high_water={}", - sync_state.pipeline_write_queue_high_water - ); - println!( - "mailbox_sync_pipeline_write_batch_count={}", - sync_state.pipeline_write_batch_count - ); - println!( - "mailbox_sync_pipeline_writer_wait_ms={}", - sync_state.pipeline_writer_wait_ms - ); - println!( - "mailbox_sync_pipeline_fetch_batch_count={}", - sync_state.pipeline_fetch_batch_count - ); - println!( - "mailbox_sync_pipeline_fetch_batch_avg_ms={}", - sync_state.pipeline_fetch_batch_avg_ms - ); - println!( - "mailbox_sync_pipeline_fetch_batch_max_ms={}", - sync_state.pipeline_fetch_batch_max_ms - ); - println!( - "mailbox_sync_pipeline_writer_tx_count={}", - sync_state.pipeline_writer_tx_count - ); - println!( - "mailbox_sync_pipeline_writer_tx_avg_ms={}", - sync_state.pipeline_writer_tx_avg_ms - ); - println!( - "mailbox_sync_pipeline_writer_tx_max_ms={}", - sync_state.pipeline_writer_tx_max_ms - ); - println!( - "mailbox_sync_pipeline_reorder_buffer_high_water={}", - sync_state.pipeline_reorder_buffer_high_water - ); - println!( - "mailbox_sync_pipeline_staged_message_count={}", - sync_state.pipeline_staged_message_count - ); - println!( - "mailbox_sync_pipeline_staged_delete_count={}", - sync_state.pipeline_staged_delete_count - ); - println!( - "mailbox_sync_pipeline_staged_attachment_count={}", - sync_state.pipeline_staged_attachment_count - ); - } - None => println!("mailbox_sync_status="), - } - match &mailbox.full_sync_checkpoint { - Some(checkpoint) => { - println!("mailbox_full_sync_checkpoint_status={}", checkpoint.status); - println!( - "mailbox_full_sync_checkpoint_bootstrap_query={}", - checkpoint.bootstrap_query - ); - println!( - "mailbox_full_sync_checkpoint_pages_fetched={}", - checkpoint.pages_fetched - ); - println!( - "mailbox_full_sync_checkpoint_messages_upserted={}", - checkpoint.messages_upserted - ); - println!( - "mailbox_full_sync_checkpoint_staged_message_count={}", - checkpoint.staged_message_count - ); - println!( - "mailbox_full_sync_checkpoint_next_page_token_present={}", - checkpoint.next_page_token.is_some() - ); - println!( - "mailbox_full_sync_checkpoint_updated_at_epoch_s={}", - checkpoint.updated_at_epoch_s - ); - } - None => println!("mailbox_full_sync_checkpoint_status="), - } - match &mailbox.sync_pacing_state { - Some(pacing_state) => { - println!( - "mailbox_sync_pacing_learned_quota_units_per_minute={}", - pacing_state.learned_quota_units_per_minute - ); - println!( - "mailbox_sync_pacing_learned_message_fetch_concurrency={}", - pacing_state.learned_message_fetch_concurrency - ); - println!( - "mailbox_sync_pacing_clean_run_streak={}", - pacing_state.clean_run_streak - ); - match pacing_state.last_pressure_kind { - Some(kind) => { - println!("mailbox_sync_pacing_last_pressure_kind={kind}") - } - None => println!("mailbox_sync_pacing_last_pressure_kind="), - } - println!( - "mailbox_sync_pacing_updated_at_epoch_s={}", - pacing_state.updated_at_epoch_s - ); - } - None => println!("mailbox_sync_pacing_learned_quota_units_per_minute="), - } - match &mailbox.sync_run_summary { - Some(summary) => { - println!("mailbox_sync_run_summary_mode={}", summary.sync_mode); - println!( - "mailbox_sync_run_summary_comparability_kind={}", - summary.comparability_kind - ); - println!( - "mailbox_sync_run_summary_comparability_key={}", - summary.comparability_key - ); - println!( - "mailbox_sync_run_summary_comparability_label={}", - summary.comparability_label - ); - println!( - "mailbox_sync_run_summary_latest_run_id={}", - summary.latest_run_id - ); - println!( - "mailbox_sync_run_summary_latest_status={}", - summary.latest_status - ); - println!( - "mailbox_sync_run_summary_latest_finished_at_epoch_s={}", - summary.latest_finished_at_epoch_s - ); - match summary.best_clean_run_id { - Some(run_id) => { - println!("mailbox_sync_run_summary_best_clean_run_id={run_id}") - } - None => println!("mailbox_sync_run_summary_best_clean_run_id="), - } - match summary.best_clean_quota_units_per_minute { - Some(value) => println!( - "mailbox_sync_run_summary_best_clean_quota_units_per_minute={value}" - ), - None => println!( - "mailbox_sync_run_summary_best_clean_quota_units_per_minute=" - ), - } - match summary.best_clean_message_fetch_concurrency { - Some(value) => println!( - "mailbox_sync_run_summary_best_clean_message_fetch_concurrency={value}" - ), - None => println!( - "mailbox_sync_run_summary_best_clean_message_fetch_concurrency=" - ), - } - match summary.best_clean_messages_per_second { - Some(value) => println!( - "mailbox_sync_run_summary_best_clean_messages_per_second={value}" - ), - None => println!( - "mailbox_sync_run_summary_best_clean_messages_per_second=" - ), - } - match summary.best_clean_duration_ms { - Some(value) => { - println!("mailbox_sync_run_summary_best_clean_duration_ms={value}") - } - None => { - println!("mailbox_sync_run_summary_best_clean_duration_ms=") - } - } - println!( - "mailbox_sync_run_summary_recent_success_count={}", - summary.recent_success_count - ); - println!( - "mailbox_sync_run_summary_recent_failure_count={}", - summary.recent_failure_count - ); - println!( - "mailbox_sync_run_summary_recent_failure_streak={}", - summary.recent_failure_streak - ); - println!( - "mailbox_sync_run_summary_recent_clean_success_streak={}", - summary.recent_clean_success_streak - ); - println!( - "mailbox_sync_run_summary_regression_detected={}", - summary.regression_detected - ); - match summary.regression_kind { - Some(kind) => { - println!("mailbox_sync_run_summary_regression_kind={kind}") - } - None => println!("mailbox_sync_run_summary_regression_kind="), - } - match summary.regression_run_id { - Some(run_id) => { - println!("mailbox_sync_run_summary_regression_run_id={run_id}") - } - None => println!("mailbox_sync_run_summary_regression_run_id="), - } - match &summary.regression_message { - Some(message) => { - println!("mailbox_sync_run_summary_regression_message={message}") - } - None => println!("mailbox_sync_run_summary_regression_message="), - } - println!( - "mailbox_sync_run_summary_updated_at_epoch_s={}", - summary.updated_at_epoch_s - ); - } - None => println!("mailbox_sync_run_summary_latest_run_id="), - } - } - if let Some(workflows) = &self.workflows { - println!("workflow_count={}", workflows.workflow_count); - println!("workflow_open_count={}", workflows.open_workflow_count); - println!("workflow_draft_count={}", workflows.draft_workflow_count); - println!("workflow_event_count={}", workflows.event_count); - println!( - "workflow_draft_revision_count={}", - workflows.draft_revision_count - ); - } - if let Some(automation) = &self.automation { - println!("automation_run_count={}", automation.run_count); - println!( - "automation_previewed_run_count={}", - automation.previewed_run_count - ); - println!( - "automation_applied_run_count={}", - automation.applied_run_count - ); - println!( - "automation_apply_failed_run_count={}", - automation.apply_failed_run_count - ); - println!("automation_candidate_count={}", automation.candidate_count); - } - } - - Ok(()) - } -} +pub const SQLITE_APPLICATION_ID: i64 = 0x4D41_494C; pub fn init(config_report: &ConfigReport) -> Result { let database_path = config_report.config.store.database_path.clone(); @@ -419,7 +24,7 @@ pub fn init(config_report: &ConfigReport) -> Result { let mut connection = connection::open_or_create(&database_path, config_report.config.store.busy_timeout_ms)?; - let initial_pragmas = read_pragmas(&connection)?; + let initial_pragmas = connection::read_pragmas(&connection)?; validate_application_id(&database_path, initial_pragmas.application_id)?; connection::configure_hardening_pragmas(&connection)?; harden_database_permissions(&database_path)?; @@ -427,7 +32,7 @@ pub fn init(config_report: &ConfigReport) -> Result { migrations::apply(&mut connection)?; harden_database_permissions(&database_path)?; - let pragmas = read_pragmas(&connection)?; + let pragmas = connection::read_pragmas(&connection)?; let known_migrations = migrations::known_migration_count(); let pending_migrations = pending_migrations(known_migrations, pragmas.user_version)?; @@ -441,52 +46,6 @@ pub fn init(config_report: &ConfigReport) -> Result { }) } -pub fn inspect(config_report: ConfigReport) -> Result { - let database_path = config_report.config.store.database_path.clone(); - let known_migrations = migrations::known_migration_count(); - - if !database_path.exists() { - return Ok(StoreDoctorReport { - config: config_report, - database_exists: false, - database_path, - known_migrations, - schema_version: None, - pending_migrations: None, - pragmas: None, - mailbox: None, - workflows: None, - automation: None, - }); - } - - let connection = connection::open_read_only_for_diagnostics( - &database_path, - config_report.config.store.busy_timeout_ms, - )?; - let pragmas = read_pragmas(&connection)?; - let pending_migrations = pending_migrations(known_migrations, pragmas.user_version)?; - let mailbox = - mailbox::inspect_mailbox(&database_path, config_report.config.store.busy_timeout_ms)?; - let workflows = - workflows::inspect_workflows(&database_path, config_report.config.store.busy_timeout_ms)?; - let automation = - automation::inspect_automation(&database_path, config_report.config.store.busy_timeout_ms)?; - - Ok(StoreDoctorReport { - config: config_report, - database_exists: true, - database_path, - known_migrations, - schema_version: Some(pragmas.user_version), - pending_migrations: Some(pending_migrations), - pragmas: Some(pragmas), - mailbox, - workflows, - automation, - }) -} - fn ensure_database_parent_exists(path: &Path) -> Result<()> { let parent = path .parent() @@ -523,46 +82,18 @@ fn validate_application_id(database_path: &Path, application_id: i64) -> Result< Ok(()) } -fn read_pragmas(connection: &Connection) -> Result { - let application_id = connection.pragma_query_value(None, "application_id", |row| row.get(0))?; - let user_version = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; - let foreign_keys = - connection.pragma_query_value::(None, "foreign_keys", |row| row.get(0))? != 0; - let trusted_schema = - connection.pragma_query_value::(None, "trusted_schema", |row| row.get(0))? != 0; - let journal_mode = connection.pragma_query_value(None, "journal_mode", |row| row.get(0))?; - let synchronous = connection.pragma_query_value(None, "synchronous", |row| row.get(0))?; - let busy_timeout_ms = connection.pragma_query_value(None, "busy_timeout", |row| row.get(0))?; - - Ok(StorePragmas { - application_id, - user_version, - foreign_keys, - trusted_schema, - journal_mode, - synchronous, - busy_timeout_ms, - }) -} - -fn print_pragmas(pragmas: &StorePragmas) { - println!("application_id={}", pragmas.application_id); - println!("user_version={}", pragmas.user_version); - println!("foreign_keys={}", pragmas.foreign_keys); - println!("trusted_schema={}", pragmas.trusted_schema); - println!("journal_mode={}", pragmas.journal_mode); - println!("synchronous={}", pragmas.synchronous); - println!("busy_timeout_ms={}", pragmas.busy_timeout_ms); -} - #[cfg(unix)] fn harden_database_permissions(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; let mut candidate_paths = Vec::with_capacity(3); candidate_paths.push(path.to_path_buf()); - candidate_paths.push(PathBuf::from(format!("{}-wal", path.display()))); - candidate_paths.push(PathBuf::from(format!("{}-shm", path.display()))); + let mut wal = path.as_os_str().to_os_string(); + wal.push("-wal"); + candidate_paths.push(std::path::PathBuf::from(wal)); + let mut shm = path.as_os_str().to_os_string(); + shm.push("-shm"); + candidate_paths.push(std::path::PathBuf::from(shm)); for candidate in candidate_paths { if candidate.exists() { @@ -579,761 +110,4 @@ fn harden_database_permissions(_path: &Path) -> Result<()> { } #[cfg(test)] -mod tests { - use super::{SQLITE_APPLICATION_ID, harden_database_permissions, init, inspect, migrations}; - use crate::config::resolve; - use crate::store::{accounts, mailbox}; - use crate::workspace::WorkspacePaths; - use rusqlite::Connection; - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - fn migrations_validate_successfully() { - migrations::validate_migrations().unwrap(); - } - - #[test] - fn store_init_creates_and_migrates_database() { - let repo_root = unique_temp_dir("mailroom-store-init"); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - - let report = init(&config_report).unwrap(); - - assert!(report.database_path.exists()); - assert_eq!(report.schema_version, 16); - assert_eq!(report.pragmas.application_id, SQLITE_APPLICATION_ID); - - let connection = Connection::open(&report.database_path).unwrap(); - let substrate_tables: i64 = connection - .query_row( - "SELECT COUNT(*) FROM sqlite_master - WHERE type = 'table' - AND name IN ( - 'app_metadata', - 'accounts', - 'gmail_labels', - 'gmail_messages', - 'gmail_message_labels', - 'gmail_sync_state', - 'gmail_full_sync_stage_labels', - 'gmail_full_sync_stage_messages', - 'gmail_full_sync_stage_message_labels', - 'gmail_full_sync_stage_attachments', - 'gmail_full_sync_checkpoint', - 'gmail_sync_pacing_state', - 'gmail_sync_run_history', - 'gmail_sync_run_summary', - 'gmail_incremental_sync_stage_delete_ids', - 'gmail_incremental_sync_stage_messages', - 'gmail_incremental_sync_stage_message_labels', - 'gmail_incremental_sync_stage_attachments', - 'gmail_full_sync_stage_pages', - 'gmail_full_sync_stage_page_messages' - )", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(substrate_tables, 20); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[test] - fn store_doctor_reports_absent_database_without_creating_it() { - let repo_root = unique_temp_dir("mailroom-store-doctor"); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - paths.ensure_runtime_dirs().unwrap(); - - let report = inspect(resolve(&paths).unwrap()).unwrap(); - - assert!(!report.database_exists); - assert!(report.pragmas.is_none()); - assert!(report.schema_version.is_none()); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[test] - fn store_doctor_reports_persisted_drift_without_rewriting_it() { - let repo_root = unique_temp_dir("mailroom-store-doctor-drift"); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - paths.ensure_runtime_dirs().unwrap(); - - let mut config_report = resolve(&paths).unwrap(); - let init_report = init(&config_report).unwrap(); - - { - let connection = Connection::open(&init_report.database_path).unwrap(); - connection - .pragma_update(None, "application_id", 7_i64) - .unwrap(); - connection - .pragma_update_and_check(None, "journal_mode", "DELETE", |row| { - row.get::<_, String>(0) - }) - .unwrap(); - connection - .pragma_update(None, "synchronous", "FULL") - .unwrap(); - } - - config_report.config.store.database_path = init_report.database_path.clone(); - let report = inspect(config_report).unwrap(); - - let pragmas = report.pragmas.unwrap(); - assert_eq!(pragmas.application_id, 7); - assert_eq!(pragmas.journal_mode, "delete"); - assert!(pragmas.foreign_keys); - assert!(!pragmas.trusted_schema); - assert_eq!(pragmas.synchronous, 1); - - let connection = Connection::open(&init_report.database_path).unwrap(); - let application_id: i64 = connection - .pragma_query_value(None, "application_id", |row| row.get(0)) - .unwrap(); - let journal_mode: String = connection - .pragma_query_value(None, "journal_mode", |row| row.get(0)) - .unwrap(); - let synchronous: i64 = connection - .pragma_query_value(None, "synchronous", |row| row.get(0)) - .unwrap(); - - assert_eq!(application_id, 7); - assert_eq!(journal_mode, "delete"); - assert_eq!(synchronous, 2); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[test] - fn store_init_rejects_foreign_database_before_mutating_it() { - let repo_root = unique_temp_dir("mailroom-store-init-foreign"); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - paths.ensure_runtime_dirs().unwrap(); - - let config_report = resolve(&paths).unwrap(); - { - let connection = Connection::open(&config_report.config.store.database_path).unwrap(); - connection - .pragma_update(None, "application_id", 7_i64) - .unwrap(); - connection - .pragma_update(None, "user_version", 0_i64) - .unwrap(); - } - - let error = init(&config_report).unwrap_err(); - let error_message = error.to_string(); - assert!(error_message.contains("application_id 7")); - assert!(error_message.contains("expected 0 or")); - - let connection = Connection::open(&config_report.config.store.database_path).unwrap(); - let application_id: i64 = connection - .pragma_query_value(None, "application_id", |row| row.get(0)) - .unwrap(); - let user_version: i64 = connection - .pragma_query_value(None, "user_version", |row| row.get(0)) - .unwrap(); - - assert_eq!(application_id, 7); - assert_eq!(user_version, 0); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[test] - fn pending_migrations_errors_when_database_is_ahead() { - let error = super::pending_migrations(12, 13).unwrap_err(); - assert!( - error - .to_string() - .contains("database schema version 13 is newer than embedded migrations (12)") - ); - } - - #[cfg(unix)] - #[test] - fn store_doctor_can_inspect_read_only_database_copy() { - let repo_root = unique_temp_dir("mailroom-store-doctor-readonly"); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - paths.ensure_runtime_dirs().unwrap(); - - let mut config_report = resolve(&paths).unwrap(); - let init_report = init(&config_report).unwrap(); - let read_only_db = repo_root.join("readonly.sqlite3"); - - fs::copy(&init_report.database_path, &read_only_db).unwrap(); - fs::set_permissions(&read_only_db, fs::Permissions::from_mode(0o400)).unwrap(); - - config_report.config.store.database_path = read_only_db.clone(); - let report = inspect(config_report).unwrap(); - - assert!(report.database_exists); - assert_eq!(report.database_path, read_only_db); - let pragmas = report.pragmas.unwrap(); - assert_eq!(pragmas.application_id, SQLITE_APPLICATION_ID); - assert!(pragmas.foreign_keys); - assert!(!pragmas.trusted_schema); - assert_eq!(pragmas.synchronous, 1); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[cfg(unix)] - #[test] - fn harden_database_permissions_updates_sqlite_sidecars() { - let repo_root = unique_temp_dir("mailroom-store-permissions"); - fs::create_dir_all(&repo_root).unwrap(); - - let database_path = repo_root.join("store.sqlite3"); - let wal_path = repo_root.join("store.sqlite3-wal"); - let shm_path = repo_root.join("store.sqlite3-shm"); - - fs::write(&database_path, b"").unwrap(); - fs::write(&wal_path, b"").unwrap(); - fs::write(&shm_path, b"").unwrap(); - - fs::set_permissions(&database_path, fs::Permissions::from_mode(0o644)).unwrap(); - fs::set_permissions(&wal_path, fs::Permissions::from_mode(0o644)).unwrap(); - fs::set_permissions(&shm_path, fs::Permissions::from_mode(0o644)).unwrap(); - - harden_database_permissions(&database_path).unwrap(); - - let database_mode = fs::metadata(&database_path).unwrap().permissions().mode() & 0o777; - let wal_mode = fs::metadata(&wal_path).unwrap().permissions().mode() & 0o777; - let shm_mode = fs::metadata(&shm_path).unwrap().permissions().mode() & 0o777; - - assert_eq!(database_mode, 0o600); - assert_eq!(wal_mode, 0o600); - assert_eq!(shm_mode, 0o600); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[test] - fn migration_from_v6_backfills_attachment_account_scope_for_realistic_fixture() { - const MESSAGE_COUNT_PER_ACCOUNT: usize = 160; - const ATTACHMENTS_PER_MESSAGE: usize = 2; - let repo_root = unique_temp_dir("mailroom-store-migration-v6-backfill"); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - init(&config_report).unwrap(); - - let account_specs = [ - ("operator@example.com", "gmail:operator@example.com", "op"), - ("other@example.com", "gmail:other@example.com", "other"), - ]; - for (email, account_id, prefix) in account_specs { - accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &accounts::UpsertAccountInput { - email_address: email.to_owned(), - history_id: String::from("100"), - messages_total: MESSAGE_COUNT_PER_ACCOUNT as i64, - threads_total: MESSAGE_COUNT_PER_ACCOUNT as i64, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap(); - mailbox::replace_labels( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - account_id, - &[crate::gmail::GmailLabel { - id: String::from("INBOX"), - name: String::from("INBOX"), - label_type: String::from("system"), - message_list_visibility: None, - label_list_visibility: None, - messages_total: None, - messages_unread: None, - threads_total: None, - threads_unread: None, - }], - 100, - ) - .unwrap(); - - let messages = (0..MESSAGE_COUNT_PER_ACCOUNT) - .map(|index| mailbox::GmailMessageUpsertInput { - account_id: account_id.to_owned(), - message_id: format!("{prefix}-m-{index}"), - thread_id: format!("{prefix}-t-{index}"), - history_id: format!("{}", 200 + index), - internal_date_epoch_ms: 1_700_000_000_000 + i64::try_from(index).unwrap(), - snippet: format!("Mailbox fixture message {index}"), - subject: format!("Fixture {index}"), - from_header: format!("Fixture <{prefix}@example.com>"), - from_address: Some(format!("{prefix}@example.com")), - recipient_headers: email.to_owned(), - to_header: email.to_owned(), - cc_header: String::new(), - bcc_header: String::new(), - reply_to_header: String::new(), - size_estimate: 2048, - automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), - label_ids: vec![String::from("INBOX")], - label_names_text: String::from("INBOX"), - attachments: (0..ATTACHMENTS_PER_MESSAGE) - .map(|part_index| mailbox::GmailAttachmentUpsertInput { - attachment_key: format!("{prefix}-m-{index}:1.{}", part_index + 1), - part_id: format!("1.{}", part_index + 1), - gmail_attachment_id: Some(format!("att-{prefix}-{index}-{part_index}")), - filename: format!("fixture-{index}-{part_index}.bin"), - mime_type: String::from("application/octet-stream"), - size_bytes: 256, - content_disposition: Some(String::from("attachment")), - content_id: None, - is_inline: false, - }) - .collect(), - }) - .collect::>(); - mailbox::upsert_messages( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &messages, - 200, - ) - .unwrap(); - } - - let expected_attachment_count = i64::try_from( - account_specs.len() * MESSAGE_COUNT_PER_ACCOUNT * ATTACHMENTS_PER_MESSAGE, - ) - .unwrap(); - let connection = Connection::open(&config_report.config.store.database_path).unwrap(); - let seeded_count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM gmail_message_attachments", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(seeded_count, expected_attachment_count); - - connection - .execute_batch(include_str!( - "../../migrations/15-sync-run-history/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/14-sync-pipeline-telemetry-and-page-manifests/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/13-bounded-sync-pipeline/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/12-sync-pacing-state-hardening/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/11-sync-pacing-state/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/10-full-sync-checkpoints/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/09-mailbox-full-sync-staging/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/08-automation-rules-and-bulk-actions/down.sql" - )) - .unwrap(); - connection - .execute_batch(include_str!( - "../../migrations/07-account-scoped-attachment-keys/down.sql" - )) - .unwrap(); - connection - .pragma_update(None, "user_version", 6_i64) - .unwrap(); - let account_column_count: i64 = connection - .query_row( - "SELECT COUNT(*) - FROM pragma_table_info('gmail_message_attachments') - WHERE name = 'account_id'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(account_column_count, 0); - drop(connection); - - let migration_report = init(&config_report).unwrap(); - assert_eq!(migration_report.schema_version, 16); - assert_eq!(migration_report.pending_migrations, 0); - - let connection = Connection::open(&config_report.config.store.database_path).unwrap(); - let migrated_count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM gmail_message_attachments", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(migrated_count, expected_attachment_count); - - let null_account_count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM gmail_message_attachments WHERE account_id IS NULL", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(null_account_count, 0); - - let mismatched_account_count: i64 = connection - .query_row( - "SELECT COUNT(*) - FROM gmail_message_attachments gma - INNER JOIN gmail_messages gm - ON gm.message_rowid = gma.message_rowid - WHERE gma.account_id != gm.account_id", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(mismatched_account_count, 0); - - let shared_key = String::from("post-migration-shared:1.1"); - let operator_message = mailbox::GmailMessageUpsertInput { - account_id: String::from("gmail:operator@example.com"), - message_id: String::from("post-op-m-1"), - thread_id: String::from("post-op-t-1"), - history_id: String::from("9991"), - internal_date_epoch_ms: 1_800_000_000_001, - snippet: String::from("Post migration"), - subject: String::from("Post migration"), - from_header: String::from("Fixture "), - from_address: Some(String::from("operator@example.com")), - recipient_headers: String::from("operator@example.com"), - to_header: String::from("operator@example.com"), - cc_header: String::new(), - bcc_header: String::new(), - reply_to_header: String::new(), - size_estimate: 123, - automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), - label_ids: vec![String::from("INBOX")], - label_names_text: String::from("INBOX"), - attachments: vec![mailbox::GmailAttachmentUpsertInput { - attachment_key: shared_key.clone(), - part_id: String::from("1.1"), - gmail_attachment_id: Some(String::from("att-post-op")), - filename: String::from("post.bin"), - mime_type: String::from("application/octet-stream"), - size_bytes: 1, - content_disposition: Some(String::from("attachment")), - content_id: None, - is_inline: false, - }], - }; - let other_message = mailbox::GmailMessageUpsertInput { - account_id: String::from("gmail:other@example.com"), - message_id: String::from("post-other-m-1"), - thread_id: String::from("post-other-t-1"), - history_id: String::from("9992"), - internal_date_epoch_ms: 1_800_000_000_002, - snippet: String::from("Post migration"), - subject: String::from("Post migration"), - from_header: String::from("Fixture "), - from_address: Some(String::from("other@example.com")), - recipient_headers: String::from("other@example.com"), - to_header: String::from("other@example.com"), - cc_header: String::new(), - bcc_header: String::new(), - reply_to_header: String::new(), - size_estimate: 123, - automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), - label_ids: vec![String::from("INBOX")], - label_names_text: String::from("INBOX"), - attachments: vec![mailbox::GmailAttachmentUpsertInput { - attachment_key: shared_key.clone(), - part_id: String::from("1.1"), - gmail_attachment_id: Some(String::from("att-post-other")), - filename: String::from("post.bin"), - mime_type: String::from("application/octet-stream"), - size_bytes: 1, - content_disposition: Some(String::from("attachment")), - content_id: None, - is_inline: false, - }], - }; - mailbox::upsert_messages( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &[operator_message], - 300, - ) - .unwrap(); - mailbox::upsert_messages( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &[other_message], - 300, - ) - .unwrap(); - - let shared_key_count: i64 = Connection::open(&config_report.config.store.database_path) - .unwrap() - .query_row( - "SELECT COUNT(*) - FROM gmail_message_attachments - WHERE attachment_key = ?1", - [&shared_key], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(shared_key_count, 2); - - fs::remove_dir_all(repo_root).unwrap(); - } - - #[test] - fn migration_v16_round_trip_rebuilds_and_preserves_sync_run_summaries() { - let repo_root = unique_temp_dir("mailroom-store-migration-v16-sync-history"); - let paths = WorkspacePaths::from_repo_root(repo_root.clone()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - init(&config_report).unwrap(); - - let account = accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &accounts::UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("500"), - messages_total: 0, - threads_total: 0, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 500, - }, - ) - .unwrap(); - - let sync_state = mailbox::upsert_sync_state( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &mailbox::SyncStateUpdate { - account_id: account.account_id.clone(), - cursor_history_id: Some(String::from("500")), - bootstrap_query: String::from("in:anywhere -in:spam -in:trash newer_than:30d"), - last_sync_mode: mailbox::SyncMode::Incremental, - last_sync_status: mailbox::SyncStatus::Ok, - last_error: None, - last_sync_epoch_s: 500, - last_full_sync_success_epoch_s: Some(490), - last_incremental_sync_success_epoch_s: Some(500), - pipeline_enabled: false, - pipeline_list_queue_high_water: 0, - pipeline_write_queue_high_water: 0, - pipeline_write_batch_count: 0, - pipeline_writer_wait_ms: 0, - pipeline_fetch_batch_count: 0, - pipeline_fetch_batch_avg_ms: 0, - pipeline_fetch_batch_max_ms: 0, - pipeline_writer_tx_count: 0, - pipeline_writer_tx_avg_ms: 0, - pipeline_writer_tx_max_ms: 0, - pipeline_reorder_buffer_high_water: 0, - pipeline_staged_message_count: 0, - pipeline_staged_delete_count: 0, - pipeline_staged_attachment_count: 0, - }, - ) - .unwrap(); - - let make_outcome = |started_at_epoch_s: i64, - finished_at_epoch_s: i64, - messages_listed: i64| { - let comparability = mailbox::comparability_for_incremental_workload(messages_listed, 0); - mailbox::SyncRunOutcomeInput { - account_id: account.account_id.clone(), - sync_mode: mailbox::SyncMode::Incremental, - status: mailbox::SyncStatus::Ok, - comparability_kind: comparability.kind, - comparability_key: comparability.key, - startup_seed_run_id: None, - started_at_epoch_s, - finished_at_epoch_s, - bootstrap_query: String::from("in:anywhere -in:spam -in:trash newer_than:30d"), - cursor_history_id: Some(String::from("500")), - fallback_from_history: false, - resumed_from_checkpoint: false, - pages_fetched: 1, - messages_listed, - messages_upserted: messages_listed, - messages_deleted: 0, - labels_synced: 10, - checkpoint_reused_pages: 0, - checkpoint_reused_messages_upserted: 0, - pipeline_enabled: true, - pipeline_list_queue_high_water: 1, - pipeline_write_queue_high_water: 1, - pipeline_write_batch_count: 1, - pipeline_writer_wait_ms: 10, - pipeline_fetch_batch_count: 1, - pipeline_fetch_batch_avg_ms: 10, - pipeline_fetch_batch_max_ms: 10, - pipeline_writer_tx_count: 1, - pipeline_writer_tx_avg_ms: 5, - pipeline_writer_tx_max_ms: 5, - pipeline_reorder_buffer_high_water: 1, - pipeline_staged_message_count: messages_listed, - pipeline_staged_delete_count: 0, - pipeline_staged_attachment_count: 0, - adaptive_pacing_enabled: true, - quota_units_budget_per_minute: 12_000, - message_fetch_concurrency: 4, - quota_units_cap_per_minute: 12_000, - message_fetch_concurrency_cap: 4, - starting_quota_units_per_minute: 12_000, - starting_message_fetch_concurrency: 4, - effective_quota_units_per_minute: 12_000, - effective_message_fetch_concurrency: 4, - adaptive_downshift_count: 0, - estimated_quota_units_reserved: messages_listed * 5, - http_attempt_count: messages_listed, - retry_count: 0, - quota_pressure_retry_count: 0, - concurrency_pressure_retry_count: 0, - backend_retry_count: 0, - throttle_wait_count: 0, - throttle_wait_ms: 0, - retry_after_wait_ms: 0, - duration_ms: messages_listed * 10, - pages_per_second: 1.0, - messages_per_second: messages_listed as f64, - error_message: None, - } - }; - - let (_, tiny_history, _) = mailbox::persist_successful_sync_outcome( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &sync_state, - &make_outcome(500, 510, 10), - ) - .unwrap(); - let (_, large_history, _) = mailbox::persist_successful_sync_outcome( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &sync_state, - &make_outcome(520, 530, 600), - ) - .unwrap(); - - let connection = Connection::open(&config_report.config.store.database_path).unwrap(); - let pre_down_summary_count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", - [&account.account_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(pre_down_summary_count, 2); - - connection - .execute_batch(include_str!( - "../../migrations/16-sync-history-comparability/down.sql" - )) - .unwrap(); - connection - .pragma_update(None, "user_version", 15_i64) - .unwrap(); - - let post_down_summary_count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", - [&account.account_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(post_down_summary_count, 1); - let post_down_latest_run_id: i64 = connection - .query_row( - "SELECT latest_run_id FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", - [&account.account_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(post_down_latest_run_id, large_history.run_id); - - connection - .execute_batch(include_str!( - "../../migrations/16-sync-history-comparability/up.sql" - )) - .unwrap(); - connection - .pragma_update(None, "user_version", 16_i64) - .unwrap(); - - let post_up_summary_count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", - [&account.account_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(post_up_summary_count, 2); - - let tiny_best_clean_run_id: i64 = connection - .query_row( - "SELECT best_clean_run_id - FROM gmail_sync_run_summary - WHERE account_id = ?1 - AND sync_mode = 'incremental' - AND comparability_key = 'tiny'", - [&account.account_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(tiny_best_clean_run_id, tiny_history.run_id); - - let large_best_clean_run_id: i64 = connection - .query_row( - "SELECT best_clean_run_id - FROM gmail_sync_run_summary - WHERE account_id = ?1 - AND sync_mode = 'incremental' - AND comparability_key = 'large'", - [&account.account_id], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(large_best_clean_run_id, large_history.run_id); - - drop(connection); - fs::remove_dir_all(repo_root).unwrap(); - } - - fn unique_temp_dir(prefix: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) - } -} +mod tests; diff --git a/src/store/tests.rs b/src/store/tests.rs new file mode 100644 index 0000000..51162be --- /dev/null +++ b/src/store/tests.rs @@ -0,0 +1,740 @@ +use super::{SQLITE_APPLICATION_ID, harden_database_permissions, init, inspect, migrations}; +use crate::config::resolve; +use crate::store::{accounts, mailbox}; +use crate::workspace::WorkspacePaths; +use rusqlite::Connection; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use tempfile::TempDir; + +#[test] +fn configure_busy_timeout_rejects_zero() { + let connection = Connection::open_in_memory().unwrap(); + assert!(super::connection::configure_busy_timeout(&connection, 0).is_err()); +} + +#[test] +fn migrations_validate_successfully() { + migrations::validate_migrations().unwrap(); +} + +#[test] +fn store_init_creates_and_migrates_database() { + let repo_root = TempDir::with_prefix("mailroom-store-init").unwrap(); + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + let config_report = resolve(&paths).unwrap(); + + let report = init(&config_report).unwrap(); + + assert!(report.database_path.exists()); + assert_eq!(report.schema_version, 16); + assert_eq!(report.pragmas.application_id, SQLITE_APPLICATION_ID); + + let connection = Connection::open(&report.database_path).unwrap(); + let substrate_tables: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' + AND name IN ( + 'app_metadata', + 'accounts', + 'gmail_labels', + 'gmail_messages', + 'gmail_message_labels', + 'gmail_sync_state', + 'gmail_full_sync_stage_labels', + 'gmail_full_sync_stage_messages', + 'gmail_full_sync_stage_message_labels', + 'gmail_full_sync_stage_attachments', + 'gmail_full_sync_checkpoint', + 'gmail_sync_pacing_state', + 'gmail_sync_run_history', + 'gmail_sync_run_summary', + 'gmail_incremental_sync_stage_delete_ids', + 'gmail_incremental_sync_stage_messages', + 'gmail_incremental_sync_stage_message_labels', + 'gmail_incremental_sync_stage_attachments', + 'gmail_full_sync_stage_pages', + 'gmail_full_sync_stage_page_messages' + )", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(substrate_tables, 20); +} + +#[test] +fn store_doctor_reports_absent_database_without_creating_it() { + let repo_root = TempDir::with_prefix("mailroom-store-doctor").unwrap(); + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + + let report = inspect(resolve(&paths).unwrap()).unwrap(); + + assert!(!report.database_exists); + assert!(report.pragmas.is_none()); + assert!(report.schema_version.is_none()); +} + +#[test] +fn store_doctor_reports_persisted_drift_without_rewriting_it() { + let repo_root = TempDir::with_prefix("mailroom-store-doctor-drift").unwrap(); + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + + let mut config_report = resolve(&paths).unwrap(); + let init_report = init(&config_report).unwrap(); + + { + let connection = Connection::open(&init_report.database_path).unwrap(); + connection + .pragma_update(None, "application_id", 7_i64) + .unwrap(); + connection + .pragma_update_and_check(None, "journal_mode", "DELETE", |row| { + row.get::<_, String>(0) + }) + .unwrap(); + connection + .pragma_update(None, "synchronous", "FULL") + .unwrap(); + } + + config_report.config.store.database_path = init_report.database_path.clone(); + let report = inspect(config_report).unwrap(); + + let pragmas = report.pragmas.unwrap(); + assert_eq!(pragmas.application_id, 7); + assert_eq!(pragmas.journal_mode, "delete"); + assert!(pragmas.foreign_keys); + assert!(!pragmas.trusted_schema); + // The read-only diagnostics connection used by `inspect` applies its own + // runtime PRAGMAs, so `report.pragmas.synchronous` reflects that path + // (synchronous=1). The value persisted in the file from the earlier + // `pragma_update(..., "synchronous", "FULL")` is still 2, as read below via + // `Connection::open` and `pragma_query_value` on `init_report.database_path`. + // The test checks both and asserts the on-disk `synchronous` was not overwritten. + assert_eq!(pragmas.synchronous, 1); + + let connection = Connection::open(&init_report.database_path).unwrap(); + let application_id: i64 = connection + .pragma_query_value(None, "application_id", |row| row.get(0)) + .unwrap(); + let journal_mode: String = connection + .pragma_query_value(None, "journal_mode", |row| row.get(0)) + .unwrap(); + let synchronous: i64 = connection + .pragma_query_value(None, "synchronous", |row| row.get(0)) + .unwrap(); + + assert_eq!(application_id, 7); + assert_eq!(journal_mode, "delete"); + assert_eq!(synchronous, 2); +} + +#[test] +fn store_init_rejects_foreign_database_before_mutating_it() { + let repo_root = TempDir::with_prefix("mailroom-store-init-foreign").unwrap(); + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + + let config_report = resolve(&paths).unwrap(); + { + let connection = Connection::open(&config_report.config.store.database_path).unwrap(); + connection + .pragma_update(None, "application_id", 7_i64) + .unwrap(); + connection + .pragma_update(None, "user_version", 0_i64) + .unwrap(); + } + + let error = init(&config_report).unwrap_err(); + let error_message = error.to_string(); + assert!(error_message.contains("application_id 7")); + assert!(error_message.contains("expected 0 or")); + + let connection = Connection::open(&config_report.config.store.database_path).unwrap(); + let application_id: i64 = connection + .pragma_query_value(None, "application_id", |row| row.get(0)) + .unwrap(); + let user_version: i64 = connection + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + + assert_eq!(application_id, 7); + assert_eq!(user_version, 0); +} + +#[test] +fn pending_migrations_errors_when_database_is_ahead() { + let error = super::pending_migrations(12, 13).unwrap_err(); + assert!( + error + .to_string() + .contains("database schema version 13 is newer than embedded migrations (12)") + ); +} + +#[cfg(unix)] +#[test] +fn store_doctor_can_inspect_read_only_database_copy() { + let repo_root = TempDir::with_prefix("mailroom-store-doctor-readonly").unwrap(); + let repo_root_path = repo_root.path().to_path_buf(); + let paths = WorkspacePaths::from_repo_root(repo_root_path.clone()); + paths.ensure_runtime_dirs().unwrap(); + + let mut config_report = resolve(&paths).unwrap(); + let init_report = init(&config_report).unwrap(); + let read_only_db = repo_root_path.join("readonly.sqlite3"); + + fs::copy(&init_report.database_path, &read_only_db).unwrap(); + fs::set_permissions(&read_only_db, fs::Permissions::from_mode(0o400)).unwrap(); + + config_report.config.store.database_path = read_only_db.clone(); + let report = inspect(config_report).unwrap(); + + assert!(report.database_exists); + assert_eq!(report.database_path, read_only_db); + let pragmas = report.pragmas.unwrap(); + assert_eq!(pragmas.application_id, SQLITE_APPLICATION_ID); + assert!(pragmas.foreign_keys); + assert!(!pragmas.trusted_schema); + assert_eq!(pragmas.synchronous, 1); +} + +#[cfg(unix)] +#[test] +fn harden_database_permissions_updates_sqlite_sidecars() { + let repo_root = TempDir::with_prefix("mailroom-store-permissions").unwrap(); + let database_path = repo_root.path().join("store.sqlite3"); + let wal_path = repo_root.path().join("store.sqlite3-wal"); + let shm_path = repo_root.path().join("store.sqlite3-shm"); + + fs::write(&database_path, b"").unwrap(); + fs::write(&wal_path, b"").unwrap(); + fs::write(&shm_path, b"").unwrap(); + + fs::set_permissions(&database_path, fs::Permissions::from_mode(0o644)).unwrap(); + fs::set_permissions(&wal_path, fs::Permissions::from_mode(0o644)).unwrap(); + fs::set_permissions(&shm_path, fs::Permissions::from_mode(0o644)).unwrap(); + + harden_database_permissions(&database_path).unwrap(); + + let database_mode = fs::metadata(&database_path).unwrap().permissions().mode() & 0o777; + let wal_mode = fs::metadata(&wal_path).unwrap().permissions().mode() & 0o777; + let shm_mode = fs::metadata(&shm_path).unwrap().permissions().mode() & 0o777; + + assert_eq!(database_mode, 0o600); + assert_eq!(wal_mode, 0o600); + assert_eq!(shm_mode, 0o600); +} + +#[test] +fn migration_from_v6_backfills_attachment_account_scope_for_realistic_fixture() { + const MESSAGE_COUNT_PER_ACCOUNT: usize = 160; + const ATTACHMENTS_PER_MESSAGE: usize = 2; + let repo_root = TempDir::with_prefix("mailroom-store-migration-v6-backfill").unwrap(); + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + let config_report = resolve(&paths).unwrap(); + init(&config_report).unwrap(); + + let account_specs = [ + ("operator@example.com", "gmail:operator@example.com", "op"), + ("other@example.com", "gmail:other@example.com", "other"), + ]; + for (email, account_id, prefix) in account_specs { + accounts::upsert_active( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &accounts::UpsertAccountInput { + email_address: email.to_owned(), + history_id: String::from("100"), + messages_total: MESSAGE_COUNT_PER_ACCOUNT as i64, + threads_total: MESSAGE_COUNT_PER_ACCOUNT as i64, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 100, + }, + ) + .unwrap(); + mailbox::replace_labels( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + account_id, + &[crate::gmail::GmailLabel { + id: String::from("INBOX"), + name: String::from("INBOX"), + label_type: String::from("system"), + message_list_visibility: None, + label_list_visibility: None, + messages_total: None, + messages_unread: None, + threads_total: None, + threads_unread: None, + }], + 100, + ) + .unwrap(); + + let messages = (0..MESSAGE_COUNT_PER_ACCOUNT) + .map(|index| mailbox::GmailMessageUpsertInput { + account_id: account_id.to_owned(), + message_id: format!("{prefix}-m-{index}"), + thread_id: format!("{prefix}-t-{index}"), + history_id: format!("{}", 200 + index), + internal_date_epoch_ms: 1_700_000_000_000 + i64::try_from(index).unwrap(), + snippet: format!("Mailbox fixture message {index}"), + subject: format!("Fixture {index}"), + from_header: format!("Fixture <{prefix}@example.com>"), + from_address: Some(format!("{prefix}@example.com")), + recipient_headers: email.to_owned(), + to_header: email.to_owned(), + cc_header: String::new(), + bcc_header: String::new(), + reply_to_header: String::new(), + size_estimate: 2048, + automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), + label_ids: vec![String::from("INBOX")], + label_names_text: String::from("INBOX"), + attachments: (0..ATTACHMENTS_PER_MESSAGE) + .map(|part_index| mailbox::GmailAttachmentUpsertInput { + attachment_key: format!("{prefix}-m-{index}:1.{}", part_index + 1), + part_id: format!("1.{}", part_index + 1), + gmail_attachment_id: Some(format!("att-{prefix}-{index}-{part_index}")), + filename: format!("fixture-{index}-{part_index}.bin"), + mime_type: String::from("application/octet-stream"), + size_bytes: 256, + content_disposition: Some(String::from("attachment")), + content_id: None, + is_inline: false, + }) + .collect(), + }) + .collect::>(); + mailbox::upsert_messages( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &messages, + 200, + ) + .unwrap(); + } + + let expected_attachment_count = + i64::try_from(account_specs.len() * MESSAGE_COUNT_PER_ACCOUNT * ATTACHMENTS_PER_MESSAGE) + .unwrap(); + let connection = Connection::open(&config_report.config.store.database_path).unwrap(); + let seeded_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM gmail_message_attachments", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(seeded_count, expected_attachment_count); + + connection + .execute_batch(include_str!( + "../../migrations/15-sync-run-history/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/14-sync-pipeline-telemetry-and-page-manifests/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/13-bounded-sync-pipeline/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/12-sync-pacing-state-hardening/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/11-sync-pacing-state/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/10-full-sync-checkpoints/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/09-mailbox-full-sync-staging/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/08-automation-rules-and-bulk-actions/down.sql" + )) + .unwrap(); + connection + .execute_batch(include_str!( + "../../migrations/07-account-scoped-attachment-keys/down.sql" + )) + .unwrap(); + connection + .pragma_update(None, "user_version", 6_i64) + .unwrap(); + let account_column_count: i64 = connection + .query_row( + "SELECT COUNT(*) + FROM pragma_table_info('gmail_message_attachments') + WHERE name = 'account_id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(account_column_count, 0); + drop(connection); + + let migration_report = init(&config_report).unwrap(); + assert_eq!(migration_report.schema_version, 16); + assert_eq!(migration_report.pending_migrations, 0); + + let connection = Connection::open(&config_report.config.store.database_path).unwrap(); + let migrated_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM gmail_message_attachments", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(migrated_count, expected_attachment_count); + + let null_account_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM gmail_message_attachments WHERE account_id IS NULL", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(null_account_count, 0); + + let mismatched_account_count: i64 = connection + .query_row( + "SELECT COUNT(*) + FROM gmail_message_attachments gma + INNER JOIN gmail_messages gm + ON gm.message_rowid = gma.message_rowid + WHERE gma.account_id != gm.account_id", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(mismatched_account_count, 0); + + let shared_key = String::from("post-migration-shared:1.1"); + let operator_message = mailbox::GmailMessageUpsertInput { + account_id: String::from("gmail:operator@example.com"), + message_id: String::from("post-op-m-1"), + thread_id: String::from("post-op-t-1"), + history_id: String::from("9991"), + internal_date_epoch_ms: 1_800_000_000_001, + snippet: String::from("Post migration"), + subject: String::from("Post migration"), + from_header: String::from("Fixture "), + from_address: Some(String::from("operator@example.com")), + recipient_headers: String::from("operator@example.com"), + to_header: String::from("operator@example.com"), + cc_header: String::new(), + bcc_header: String::new(), + reply_to_header: String::new(), + size_estimate: 123, + automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), + label_ids: vec![String::from("INBOX")], + label_names_text: String::from("INBOX"), + attachments: vec![mailbox::GmailAttachmentUpsertInput { + attachment_key: shared_key.clone(), + part_id: String::from("1.1"), + gmail_attachment_id: Some(String::from("att-post-op")), + filename: String::from("post.bin"), + mime_type: String::from("application/octet-stream"), + size_bytes: 1, + content_disposition: Some(String::from("attachment")), + content_id: None, + is_inline: false, + }], + }; + let other_message = mailbox::GmailMessageUpsertInput { + account_id: String::from("gmail:other@example.com"), + message_id: String::from("post-other-m-1"), + thread_id: String::from("post-other-t-1"), + history_id: String::from("9992"), + internal_date_epoch_ms: 1_800_000_000_002, + snippet: String::from("Post migration"), + subject: String::from("Post migration"), + from_header: String::from("Fixture "), + from_address: Some(String::from("other@example.com")), + recipient_headers: String::from("other@example.com"), + to_header: String::from("other@example.com"), + cc_header: String::new(), + bcc_header: String::new(), + reply_to_header: String::new(), + size_estimate: 123, + automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), + label_ids: vec![String::from("INBOX")], + label_names_text: String::from("INBOX"), + attachments: vec![mailbox::GmailAttachmentUpsertInput { + attachment_key: shared_key.clone(), + part_id: String::from("1.1"), + gmail_attachment_id: Some(String::from("att-post-other")), + filename: String::from("post.bin"), + mime_type: String::from("application/octet-stream"), + size_bytes: 1, + content_disposition: Some(String::from("attachment")), + content_id: None, + is_inline: false, + }], + }; + mailbox::upsert_messages( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &[operator_message], + 300, + ) + .unwrap(); + mailbox::upsert_messages( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &[other_message], + 300, + ) + .unwrap(); + + let shared_key_count: i64 = Connection::open(&config_report.config.store.database_path) + .unwrap() + .query_row( + "SELECT COUNT(*) + FROM gmail_message_attachments + WHERE attachment_key = ?1", + [&shared_key], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(shared_key_count, 2); +} + +#[test] +fn migration_v16_round_trip_rebuilds_and_preserves_sync_run_summaries() { + let repo_root = TempDir::with_prefix("mailroom-store-migration-v16-sync-history").unwrap(); + let paths = WorkspacePaths::from_repo_root(repo_root.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + let config_report = resolve(&paths).unwrap(); + init(&config_report).unwrap(); + + let account = accounts::upsert_active( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &accounts::UpsertAccountInput { + email_address: String::from("operator@example.com"), + history_id: String::from("500"), + messages_total: 0, + threads_total: 0, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 500, + }, + ) + .unwrap(); + + let sync_state = mailbox::upsert_sync_state( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &mailbox::SyncStateUpdate { + account_id: account.account_id.clone(), + cursor_history_id: Some(String::from("500")), + bootstrap_query: String::from("in:anywhere -in:spam -in:trash newer_than:30d"), + last_sync_mode: mailbox::SyncMode::Incremental, + last_sync_status: mailbox::SyncStatus::Ok, + last_error: None, + last_sync_epoch_s: 500, + last_full_sync_success_epoch_s: Some(490), + last_incremental_sync_success_epoch_s: Some(500), + pipeline_enabled: false, + pipeline_list_queue_high_water: 0, + pipeline_write_queue_high_water: 0, + pipeline_write_batch_count: 0, + pipeline_writer_wait_ms: 0, + pipeline_fetch_batch_count: 0, + pipeline_fetch_batch_avg_ms: 0, + pipeline_fetch_batch_max_ms: 0, + pipeline_writer_tx_count: 0, + pipeline_writer_tx_avg_ms: 0, + pipeline_writer_tx_max_ms: 0, + pipeline_reorder_buffer_high_water: 0, + pipeline_staged_message_count: 0, + pipeline_staged_delete_count: 0, + pipeline_staged_attachment_count: 0, + }, + ) + .unwrap(); + + let make_outcome = |started_at_epoch_s: i64, finished_at_epoch_s: i64, messages_listed: i64| { + let comparability = mailbox::comparability_for_incremental_workload(messages_listed, 0); + mailbox::SyncRunOutcomeInput { + account_id: account.account_id.clone(), + sync_mode: mailbox::SyncMode::Incremental, + status: mailbox::SyncStatus::Ok, + comparability_kind: comparability.kind, + comparability_key: comparability.key, + startup_seed_run_id: None, + started_at_epoch_s, + finished_at_epoch_s, + bootstrap_query: String::from("in:anywhere -in:spam -in:trash newer_than:30d"), + cursor_history_id: Some(String::from("500")), + fallback_from_history: false, + resumed_from_checkpoint: false, + pages_fetched: 1, + messages_listed, + messages_upserted: messages_listed, + messages_deleted: 0, + labels_synced: 10, + checkpoint_reused_pages: 0, + checkpoint_reused_messages_upserted: 0, + pipeline_enabled: true, + pipeline_list_queue_high_water: 1, + pipeline_write_queue_high_water: 1, + pipeline_write_batch_count: 1, + pipeline_writer_wait_ms: 10, + pipeline_fetch_batch_count: 1, + pipeline_fetch_batch_avg_ms: 10, + pipeline_fetch_batch_max_ms: 10, + pipeline_writer_tx_count: 1, + pipeline_writer_tx_avg_ms: 5, + pipeline_writer_tx_max_ms: 5, + pipeline_reorder_buffer_high_water: 1, + pipeline_staged_message_count: messages_listed, + pipeline_staged_delete_count: 0, + pipeline_staged_attachment_count: 0, + adaptive_pacing_enabled: true, + quota_units_budget_per_minute: 12_000, + message_fetch_concurrency: 4, + quota_units_cap_per_minute: 12_000, + message_fetch_concurrency_cap: 4, + starting_quota_units_per_minute: 12_000, + starting_message_fetch_concurrency: 4, + effective_quota_units_per_minute: 12_000, + effective_message_fetch_concurrency: 4, + adaptive_downshift_count: 0, + estimated_quota_units_reserved: messages_listed * 5, + http_attempt_count: messages_listed, + retry_count: 0, + quota_pressure_retry_count: 0, + concurrency_pressure_retry_count: 0, + backend_retry_count: 0, + throttle_wait_count: 0, + throttle_wait_ms: 0, + retry_after_wait_ms: 0, + duration_ms: messages_listed * 10, + pages_per_second: 1.0, + messages_per_second: messages_listed as f64, + error_message: None, + } + }; + + let (_, tiny_history, _) = mailbox::persist_successful_sync_outcome( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &sync_state, + &make_outcome(500, 510, 10), + ) + .unwrap(); + let (_, large_history, _) = mailbox::persist_successful_sync_outcome( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &sync_state, + &make_outcome(520, 530, 600), + ) + .unwrap(); + + let connection = Connection::open(&config_report.config.store.database_path).unwrap(); + let pre_down_summary_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", + [&account.account_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(pre_down_summary_count, 2); + + connection + .execute_batch(include_str!( + "../../migrations/16-sync-history-comparability/down.sql" + )) + .unwrap(); + connection + .pragma_update(None, "user_version", 15_i64) + .unwrap(); + + let post_down_summary_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", + [&account.account_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(post_down_summary_count, 1); + let post_down_latest_run_id: i64 = connection + .query_row( + "SELECT latest_run_id FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", + [&account.account_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(post_down_latest_run_id, large_history.run_id); + + connection + .execute_batch(include_str!( + "../../migrations/16-sync-history-comparability/up.sql" + )) + .unwrap(); + connection + .pragma_update(None, "user_version", 16_i64) + .unwrap(); + + let post_up_summary_count: i64 = connection + .query_row( + "SELECT COUNT(*) FROM gmail_sync_run_summary WHERE account_id = ?1 AND sync_mode = 'incremental'", + [&account.account_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(post_up_summary_count, 2); + + let tiny_best_clean_run_id: i64 = connection + .query_row( + "SELECT best_clean_run_id + FROM gmail_sync_run_summary + WHERE account_id = ?1 + AND sync_mode = 'incremental' + AND comparability_key = 'tiny'", + [&account.account_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tiny_best_clean_run_id, tiny_history.run_id); + + let large_best_clean_run_id: i64 = connection + .query_row( + "SELECT best_clean_run_id + FROM gmail_sync_run_summary + WHERE account_id = ?1 + AND sync_mode = 'incremental' + AND comparability_key = 'large'", + [&account.account_id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(large_best_clean_run_id, large_history.run_id); + + drop(connection); +}