tuitbot-cli 0.1.52

CLI for Tuitbot autonomous X growth assistant
use std::fs;
use std::path::Path;

use anyhow::{bail, Context, Result};
use console::Style;
use dialoguer::Confirm;
use tuitbot_core::config::Config;

use super::helpers::{escape_toml, format_toml_array, ChangeTracker};

pub(super) fn render_config(config: &Config) -> String {
    let client_secret_line = match &config.x_api.client_secret {
        Some(secret) => format!("client_secret = \"{}\"", escape_toml(secret)),
        None => "# client_secret = \"your-client-secret-here\"".to_string(),
    };

    let product_url_line = match &config.business.product_url {
        Some(url) => format!("product_url = \"{}\"", escape_toml(url)),
        None => "# product_url = \"https://example.com\"".to_string(),
    };

    let brand_voice_line = match &config.business.brand_voice {
        Some(v) => format!("brand_voice = \"{}\"", escape_toml(v)),
        None => {
            "# brand_voice = \"Friendly technical expert. Casual, occasionally witty.\"".to_string()
        }
    };

    let reply_style_line = match &config.business.reply_style {
        Some(s) => format!("reply_style = \"{}\"", escape_toml(s)),
        None => "# reply_style = \"Lead with genuine help. Only mention our product if relevant.\""
            .to_string(),
    };

    let content_style_line = match &config.business.content_style {
        Some(s) => format!("content_style = \"{}\"", escape_toml(s)),
        None => "# content_style = \"Share practical tips with real examples.\"".to_string(),
    };

    let persona_opinions_line = if config.business.persona_opinions.is_empty() {
        "# persona_opinions = [\"Your strong opinion here\"]".to_string()
    } else {
        format!(
            "persona_opinions = {}",
            format_toml_array(&config.business.persona_opinions)
        )
    };

    let persona_experiences_line = if config.business.persona_experiences.is_empty() {
        "# persona_experiences = [\"Your personal experience here\"]".to_string()
    } else {
        format!(
            "persona_experiences = {}",
            format_toml_array(&config.business.persona_experiences)
        )
    };

    let content_pillars_line = if config.business.content_pillars.is_empty() {
        "# content_pillars = [\"Your core topic here\"]".to_string()
    } else {
        format!(
            "content_pillars = {}",
            format_toml_array(&config.business.content_pillars)
        )
    };

    let targets_section = if config.targets.accounts.is_empty() {
        "# --- Target Accounts ---\n\
         # Monitor specific accounts and reply to their conversations.\n\
         [targets]\n\
         # accounts = [\"elonmusk\", \"levelsio\"]\n\
         accounts = []"
            .to_string()
    } else {
        format!(
            "# --- Target Accounts ---\n\
             # Monitor specific accounts and reply to their conversations.\n\
             [targets]\n\
             accounts = {accounts}\n\
             max_target_replies_per_day = {max_target}",
            accounts = format_toml_array(&config.targets.accounts),
            max_target = config.targets.max_target_replies_per_day,
        )
    };

    let api_key_line = match &config.llm.api_key {
        Some(key) => format!("api_key = \"{}\"", escape_toml(key)),
        None => "# api_key = \"your-api-key-here\"".to_string(),
    };

    let base_url_line = match &config.llm.base_url {
        Some(url) => format!("base_url = \"{}\"", escape_toml(url)),
        None => "# base_url = \"http://localhost:11434/v1\"".to_string(),
    };

    format!(
        r#"# =============================================================================
# Tuitbot Configuration
# =============================================================================
# Generated by `tuitbot settings`.
# Edit this file to tune scoring, limits, and intervals.
# Docs: https://github.com/your-org/tuitbot
# =============================================================================

# Queue posts for review before posting (use `tuitbot approve` to review).
approval_mode = {approval_mode}

# --- X API Credentials ---
# Get your credentials from https://developer.x.com/en/portal/dashboard
[x_api]
client_id = "{client_id}"
{client_secret_line}

# --- Authentication Settings ---
[auth]
# Auth mode: "local_callback" (auto-catch via local server) or "manual" (paste code from browser).
mode = "{auth_mode}"
callback_host = "{callback_host}"
callback_port = {callback_port}

# --- Business Profile ---
# Describe your product so Tuitbot can find relevant conversations
# and generate on-brand content.
[business]
product_name = "{product_name}"
product_description = "{product_description}"
{product_url_line}
target_audience = "{target_audience}"

# Keywords for tweet discovery (Tuitbot searches for tweets containing these).
product_keywords = {product_keywords}

# Optional: competitor keywords for discovery.
competitor_keywords = {competitor_keywords}

# Topics for original content generation (tweets and threads).
industry_topics = {industry_topics}

# Brand voice and style — shapes how the bot sounds in all generated content.
{brand_voice_line}
{reply_style_line}
{content_style_line}

# Persona — strong opinions, experiences, and pillars make content more authentic.
{persona_opinions_line}
{persona_experiences_line}
{content_pillars_line}

# --- Scoring Engine ---
# Controls how tweets are scored for reply-worthiness (0-100 scale).
# Weights should sum to ~100 for balanced scoring.
[scoring]
threshold = {threshold}
keyword_relevance_max = {keyword_relevance_max:.1}
follower_count_max = {follower_count_max:.1}
recency_max = {recency_max:.1}
engagement_rate_max = {engagement_rate_max:.1}
reply_count_max = {reply_count_max:.1}
content_type_max = {content_type_max:.1}

# --- Safety Limits ---
# Prevent aggressive posting that could trigger account restrictions.
[limits]
max_replies_per_day = {max_replies_per_day}
max_tweets_per_day = {max_tweets_per_day}
max_threads_per_week = {max_threads_per_week}
min_action_delay_seconds = {min_action_delay_seconds}
max_action_delay_seconds = {max_action_delay_seconds}
max_replies_per_author_per_day = {max_replies_per_author_per_day}
product_mention_ratio = {product_mention_ratio}
banned_phrases = {banned_phrases}

# --- Automation Intervals ---
# How often each loop runs. Shorter intervals use more API quota.
[intervals]
mentions_check_seconds = {mentions_check_seconds}
discovery_search_seconds = {discovery_search_seconds}
content_post_window_seconds = {content_post_window_seconds}
thread_interval_seconds = {thread_interval_seconds}

{targets_section}

# --- LLM Provider ---
# Supported: "openai", "anthropic", "ollama"
[llm]
provider = "{llm_provider}"
{api_key_line}
model = "{llm_model}"
{base_url_line}

# --- Data Storage ---
[storage]
db_path = "{db_path}"
retention_days = {retention_days}

# --- Logging ---
[logging]
# Seconds between periodic status summaries (0 = disabled).
status_interval_seconds = {status_interval_seconds}

# --- Active Hours Schedule ---
# The bot sleeps outside these hours. Wrapping ranges (e.g. 22-06) are supported.
[schedule]
timezone = "{timezone}"
active_hours_start = {active_hours_start}
active_hours_end = {active_hours_end}
active_days = {active_days}
"#,
        approval_mode = config.approval_mode,
        client_id = escape_toml(&config.x_api.client_id),
        client_secret_line = client_secret_line,
        auth_mode = escape_toml(&config.auth.mode),
        callback_host = escape_toml(&config.auth.callback_host),
        callback_port = config.auth.callback_port,
        product_name = escape_toml(&config.business.product_name),
        product_description = escape_toml(&config.business.product_description),
        product_url_line = product_url_line,
        target_audience = escape_toml(&config.business.target_audience),
        product_keywords = format_toml_array(&config.business.product_keywords),
        competitor_keywords = format_toml_array(&config.business.competitor_keywords),
        industry_topics = format_toml_array(&config.business.industry_topics),
        brand_voice_line = brand_voice_line,
        reply_style_line = reply_style_line,
        content_style_line = content_style_line,
        persona_opinions_line = persona_opinions_line,
        persona_experiences_line = persona_experiences_line,
        content_pillars_line = content_pillars_line,
        threshold = config.scoring.threshold,
        keyword_relevance_max = config.scoring.keyword_relevance_max,
        follower_count_max = config.scoring.follower_count_max,
        recency_max = config.scoring.recency_max,
        engagement_rate_max = config.scoring.engagement_rate_max,
        reply_count_max = config.scoring.reply_count_max,
        content_type_max = config.scoring.content_type_max,
        max_replies_per_day = config.limits.max_replies_per_day,
        max_tweets_per_day = config.limits.max_tweets_per_day,
        max_threads_per_week = config.limits.max_threads_per_week,
        min_action_delay_seconds = config.limits.min_action_delay_seconds,
        max_action_delay_seconds = config.limits.max_action_delay_seconds,
        max_replies_per_author_per_day = config.limits.max_replies_per_author_per_day,
        product_mention_ratio = config.limits.product_mention_ratio,
        banned_phrases = format_toml_array(&config.limits.banned_phrases),
        mentions_check_seconds = config.intervals.mentions_check_seconds,
        discovery_search_seconds = config.intervals.discovery_search_seconds,
        content_post_window_seconds = config.intervals.content_post_window_seconds,
        thread_interval_seconds = config.intervals.thread_interval_seconds,
        targets_section = targets_section,
        llm_provider = escape_toml(&config.llm.provider),
        api_key_line = api_key_line,
        llm_model = escape_toml(&config.llm.model),
        base_url_line = base_url_line,
        db_path = escape_toml(&config.storage.db_path),
        retention_days = config.storage.retention_days,
        status_interval_seconds = config.logging.status_interval_seconds,
        timezone = escape_toml(&config.schedule.timezone),
        active_hours_start = config.schedule.active_hours_start,
        active_hours_end = config.schedule.active_hours_end,
        active_days = format_toml_array(&config.schedule.active_days),
    )
}

pub(super) fn write_config_with_backup(config: &Config, config_path: &str) -> Result<()> {
    let path = super::expand_tilde(config_path);

    // Create backup
    if path.exists() {
        let backup_path = path.with_extension("toml.bak");
        fs::copy(&path, &backup_path)
            .with_context(|| format!("Failed to create backup at {}", backup_path.display()))?;
    }

    let toml_str = render_config(config);
    fs::write(&path, toml_str)
        .with_context(|| format!("Failed to write config to {}", path.display()))?;

    Ok(())
}

pub(super) fn validate_config(config: &Config) -> Result<()> {
    if let Err(errors) = config.validate() {
        let messages: Vec<String> = errors.iter().map(|e| format!("  - {e}")).collect();
        bail!("Configuration validation failed:\n{}", messages.join("\n"));
    }
    Ok(())
}

pub(super) fn save_flow(
    config: &Config,
    config_path: &Path,
    tracker: &ChangeTracker,
) -> Result<()> {
    let bold = Style::new().bold();
    let dim = Style::new().dim();

    eprintln!();
    eprintln!("{}", bold.apply_to("Changes to save:"));
    eprintln!("{}", dim.apply_to("────────────────"));

    for change in &tracker.changes {
        let section_prefix = if change.section.is_empty() {
            String::new()
        } else {
            format!("[{}] ", change.section)
        };
        eprintln!(
            "  {}{}: \"{}\" -> \"{}\"",
            section_prefix, change.field, change.old_value, change.new_value
        );
    }
    eprintln!();

    // Validate before writing
    if let Err(errors) = config.validate() {
        eprintln!("Validation errors:");
        for e in &errors {
            eprintln!("  - {e}");
        }
        eprintln!();
        let proceed = Confirm::new()
            .with_prompt("Save anyway? (config may not work correctly)")
            .default(false)
            .interact()?;
        if !proceed {
            eprintln!("Aborted. No changes written.");
            return Ok(());
        }
    }

    let confirm = Confirm::new()
        .with_prompt(format!("Write changes to {}?", config_path.display()))
        .default(true)
        .interact()?;

    if !confirm {
        eprintln!("Aborted. No changes written.");
        return Ok(());
    }

    let path_str = config_path.display().to_string();
    write_config_with_backup(config, &path_str)?;

    eprintln!("Saved to {}", config_path.display());

    Ok(())
}