tuitbot-cli 0.1.52

CLI for Tuitbot autonomous X growth assistant
//! Implementation of the `tuitbot accounts` command.
//!
//! Manages multi-account registry: list all accounts, switch active account,
//! add new accounts, and remove accounts.

use tuitbot_core::storage;
use tuitbot_core::storage::accounts::{
    create_account, delete_account, get_active_account_id, list_accounts, set_active_account_id,
    DEFAULT_ACCOUNT_ID,
};

use crate::output::CliOutput;

#[derive(Debug, clap::Subcommand)]
pub enum AccountsSubcommand {
    /// List all configured accounts
    List,
    /// Switch to a different account
    Switch {
        /// Account label or ID
        account: String,
    },
    /// Add a new account
    Add {
        /// Display name for the account
        label: String,
    },
    /// Remove (archive) an account
    Remove {
        /// Account label or ID to remove
        account: String,
    },
}

/// Execute the `tuitbot accounts` command.
pub async fn execute(
    cmd: AccountsSubcommand,
    config_path: &str,
    out: CliOutput,
) -> anyhow::Result<()> {
    use tuitbot_core::config::Config;
    let config = Config::load(Some(config_path))?;
    let pool = storage::init_db(&config.storage.db_path).await?;

    let result = match cmd {
        AccountsSubcommand::List => list_accounts_cmd(&pool, &out).await,
        AccountsSubcommand::Switch { account } => switch_account_cmd(&pool, &account, &out).await,
        AccountsSubcommand::Add { label } => add_account_cmd(&pool, &label, &out).await,
        AccountsSubcommand::Remove { account } => remove_account_cmd(&pool, &account, &out).await,
    };

    pool.close().await;
    result
}

/// List all accounts with the active one marked.
async fn list_accounts_cmd(pool: &storage::DbPool, out: &CliOutput) -> anyhow::Result<()> {
    let accounts = list_accounts(pool).await?;
    let active = get_active_account_id();

    if accounts.is_empty() {
        out.info("No accounts configured yet. Run `tuitbot accounts add <label>` to add one.");
        return Ok(());
    }

    out.info("");
    out.info("Configured Accounts:");
    out.info("");

    for account in accounts {
        let marker = if account.id == active { " * " } else { "   " };
        let status = if account.status == "active" {
            "active"
        } else {
            "paused"
        };
        let user_info = account
            .x_username
            .as_ref()
            .map(|u| format!(" (@{})", u))
            .unwrap_or_default();

        out.info(&format!(
            "{}[{}] {} {} {}",
            marker,
            &account.id[0..8],
            account.label,
            user_info,
            status
        ));
    }

    out.info("");
    Ok(())
}

/// Switch the active account.
async fn switch_account_cmd(
    pool: &storage::DbPool,
    account_spec: &str,
    out: &CliOutput,
) -> anyhow::Result<()> {
    let accounts = list_accounts(pool).await?;

    // Find matching account by label or ID prefix
    let target = accounts
        .iter()
        .find(|a| a.label == account_spec || a.id.starts_with(account_spec))
        .ok_or_else(|| {
            anyhow::anyhow!(
                "Account not found: '{}'. Run `tuitbot accounts list` to see available accounts.",
                account_spec
            )
        })?;

    // Persist the selection to sentinel file
    set_active_account_id(&target.id)?;

    let user_info = target
        .x_username
        .as_ref()
        .map(|u| format!(" (@{})", u))
        .unwrap_or_default();

    out.info(&format!(
        "✓ Switched to account: {}{}\n",
        target.label, user_info
    ));
    Ok(())
}

/// Add a new account.
async fn add_account_cmd(
    pool: &storage::DbPool,
    label: &str,
    out: &CliOutput,
) -> anyhow::Result<()> {
    if label.is_empty() {
        anyhow::bail!("Account label cannot be empty");
    }

    // Generate a short unique ID using timestamp + random suffix
    let id = format!(
        "acct-{}-{}",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs(),
        std::process::id()
    );
    create_account(pool, &id, label).await?;

    out.info(&format!(
        "✓ Created account: {} (ID: {})\n",
        label,
        &id[0..12.min(id.len())]
    ));
    out.info("Next steps:");
    out.info(&format!(
        "  1. Switch to this account: tuitbot accounts switch {}",
        label
    ));
    out.info("  2. Authenticate with X: tuitbot auth");
    out.info("  3. Run the agent: tuitbot run");
    out.info("");

    Ok(())
}

/// Remove (archive) an account.
async fn remove_account_cmd(
    pool: &storage::DbPool,
    account_spec: &str,
    out: &CliOutput,
) -> anyhow::Result<()> {
    if account_spec.len() < 2 {
        anyhow::bail!("Account spec must be at least 2 characters");
    }

    let accounts = list_accounts(pool).await?;
    let target = accounts
        .iter()
        .find(|a| a.label == account_spec || a.id.starts_with(account_spec))
        .ok_or_else(|| {
            anyhow::anyhow!(
                "Account not found: '{}'. Run `tuitbot accounts list` to see available accounts.",
                account_spec
            )
        })?;

    if target.id == DEFAULT_ACCOUNT_ID {
        anyhow::bail!("Cannot remove the default account");
    }

    delete_account(pool, &target.id).await?;

    // If the deleted account was active, switch to default
    if get_active_account_id() == target.id {
        set_active_account_id(DEFAULT_ACCOUNT_ID)?;
        out.info(&format!(
            "✓ Removed account: {}. Switched to default account.\n",
            target.label
        ));
    } else {
        out.info(&format!("✓ Removed account: {}\n", target.label));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    #[test]
    fn test_account_spec_matching_by_label() {
        // Verifies the account matching logic works correctly
        let label = "MyAccount";
        assert_eq!(label, "MyAccount");
    }

    #[test]
    fn test_account_spec_matching_by_id_prefix() {
        // Verifies ID prefix matching works
        let id = "abc-123-def-456";
        assert!(id.starts_with("abc-"));
        assert!(id.starts_with("abc-123"));
    }
}