reviewloop 0.2.0

Reproducible, guardrailed automation for academic review workflows on paperreview.ai
Documentation
use crate::config::Config;
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailAccount {
    pub id: String,
    pub provider: String,
    pub email: String,
    pub token_path: String,
    pub created_at: String,
    pub updated_at: String,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EmailAccountStore {
    pub active_account_id: Option<String>,
    pub accounts: Vec<EmailAccount>,
}

pub fn store_path(config: &Config) -> PathBuf {
    config.state_dir().join("email_accounts.json")
}

pub fn load_store(config: &Config) -> Result<EmailAccountStore> {
    let path = store_path(config);
    if !path.exists() {
        return Ok(EmailAccountStore::default());
    }
    let raw = std::fs::read_to_string(&path)
        .with_context(|| format!("failed to read account store {}", path.display()))?;
    let parsed: EmailAccountStore = serde_json::from_str(&raw)
        .with_context(|| format!("failed to parse account store {}", path.display()))?;
    Ok(parsed)
}

pub fn save_store(config: &Config, store: &EmailAccountStore) -> Result<()> {
    let path = store_path(config);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).with_context(|| {
            format!(
                "failed to create account store directory {}",
                parent.display()
            )
        })?;
    }
    let raw = serde_json::to_string_pretty(store)?;
    std::fs::write(&path, raw)
        .with_context(|| format!("failed to write account store {}", path.display()))?;
    Ok(())
}

pub fn list_accounts(config: &Config) -> Result<Vec<EmailAccount>> {
    let store = load_store(config)?;
    Ok(store.accounts)
}

pub fn active_account(config: &Config) -> Result<Option<EmailAccount>> {
    let store = load_store(config)?;
    let Some(active_id) = store.active_account_id else {
        return Ok(None);
    };
    Ok(store.accounts.into_iter().find(|a| a.id == active_id))
}

pub fn active_email(config: &Config) -> Result<Option<String>> {
    Ok(active_account(config)?.map(|a| a.email))
}

pub fn upsert_account(
    config: &Config,
    provider: &str,
    email: &str,
    token_path: &Path,
) -> Result<EmailAccount> {
    let mut store = load_store(config)?;
    let now = Utc::now().to_rfc3339();
    let token_path_str = token_path.to_string_lossy().to_string();

    let account = if let Some(existing) = store
        .accounts
        .iter_mut()
        .find(|a| a.provider == provider && a.email.eq_ignore_ascii_case(email))
    {
        existing.token_path = token_path_str;
        existing.updated_at = now.clone();
        existing.clone()
    } else {
        let account = EmailAccount {
            id: Uuid::new_v4().to_string(),
            provider: provider.to_string(),
            email: email.to_string(),
            token_path: token_path_str,
            created_at: now.clone(),
            updated_at: now,
        };
        store.accounts.push(account.clone());
        account
    };

    store.active_account_id = Some(account.id.clone());
    save_store(config, &store)?;
    Ok(account)
}

pub fn switch_account(config: &Config, selector: &str) -> Result<EmailAccount> {
    let mut store = load_store(config)?;
    let Some(found) = store
        .accounts
        .iter()
        .find(|a| a.id == selector || a.email.eq_ignore_ascii_case(selector))
        .cloned()
    else {
        return Err(anyhow!("email account not found: {selector}"));
    };
    store.active_account_id = Some(found.id.clone());
    save_store(config, &store)?;
    Ok(found)
}

pub fn remove_account(config: &Config, selector: Option<&str>) -> Result<Option<EmailAccount>> {
    let mut store = load_store(config)?;
    if store.accounts.is_empty() {
        return Ok(None);
    }

    let remove_idx = if let Some(sel) = selector {
        store
            .accounts
            .iter()
            .position(|a| a.id == sel || a.email.eq_ignore_ascii_case(sel))
            .ok_or_else(|| anyhow!("email account not found: {sel}"))?
    } else if let Some(active_id) = &store.active_account_id {
        store
            .accounts
            .iter()
            .position(|a| &a.id == active_id)
            .ok_or_else(|| anyhow!("active account not found in store"))?
    } else {
        0
    };

    let removed = store.accounts.remove(remove_idx);
    if store.active_account_id.as_deref() == Some(removed.id.as_str()) {
        store.active_account_id = store.accounts.first().map(|a| a.id.clone());
    }
    save_store(config, &store)?;
    Ok(Some(removed))
}

pub fn resolve_submission_email(
    config: &Config,
    backend: &str,
    explicit_email: Option<&str>,
) -> Result<String> {
    if let Some(email) = explicit_email
        && !email.trim().is_empty()
    {
        return Ok(email.to_string());
    }

    match backend {
        "stanford" => {
            if !config.providers.stanford.email.trim().is_empty() {
                return Ok(config.providers.stanford.email.clone());
            }
            if let Some(email) = active_email(config)? {
                return Ok(email);
            }
            Err(anyhow!(
                "no email available for backend=stanford. set providers.stanford.email or run `reviewloop email login`"
            ))
        }
        _ => Ok(explicit_email.unwrap_or_default().to_string()),
    }
}