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);
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!();
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(())
}