use super::helpers::{escape_toml, format_toml_array};
use super::wizard::WizardResult;
fn optional_toml_string(key: &str, value: &Option<String>, placeholder: &str) -> String {
match value {
Some(v) => format!("{key} = \"{}\"", escape_toml(v)),
None => format!("# {key} = \"{placeholder}\""),
}
}
fn optional_toml_array(key: &str, items: &[String], placeholder: &str) -> String {
if items.is_empty() {
format!("# {key} = [\"{placeholder}\"]")
} else {
format!("{key} = {}", format_toml_array(items))
}
}
pub(super) fn render_config_toml(r: &WizardResult) -> String {
let client_secret_line =
optional_toml_string("client_secret", &r.client_secret, "your-client-secret-here");
let product_url_line =
optional_toml_string("product_url", &r.product_url, "https://example.com");
let product_description_line = if r.product_description.is_empty() {
"# product_description = \"One-line description of your product\"".to_string()
} else {
format!(
"product_description = \"{}\"",
escape_toml(&r.product_description)
)
};
let target_audience_line = if r.target_audience.is_empty() {
"# target_audience = \"Who is your target audience?\"".to_string()
} else {
format!("target_audience = \"{}\"", escape_toml(&r.target_audience))
};
let industry_topics_line = if r.industry_topics.is_empty() {
"# industry_topics — defaults to product_keywords".to_string()
} else {
format!(
"industry_topics = {}",
format_toml_array(&r.industry_topics)
)
};
let brand_voice_line = optional_toml_string(
"brand_voice",
&r.brand_voice,
"Friendly technical expert. Casual, occasionally witty.",
);
let reply_style_line = optional_toml_string(
"reply_style",
&r.reply_style,
"Lead with genuine help. Only mention our product if relevant.",
);
let content_style_line = optional_toml_string(
"content_style",
&r.content_style,
"Share practical tips with real examples.",
);
let persona_opinions_line = optional_toml_array(
"persona_opinions",
&r.persona_opinions,
"Your strong opinion here",
);
let persona_experiences_line = optional_toml_array(
"persona_experiences",
&r.persona_experiences,
"Your personal experience here",
);
let content_pillars_line = optional_toml_array(
"content_pillars",
&r.content_pillars,
"Your core topic here",
);
let targets_section = if r.target_accounts.is_empty() {
"# --- Target Accounts ---\n\
# Monitor specific accounts and reply to their conversations.\n\
# [targets]\n\
# accounts = [\"elonmusk\", \"levelsio\"]"
.to_string()
} else {
format!(
"# --- Target Accounts ---\n\
# Monitor specific accounts and reply to their conversations.\n\
[targets]\n\
accounts = {accounts}",
accounts = format_toml_array(&r.target_accounts),
)
};
let api_key_line = optional_toml_string("api_key", &r.llm_api_key, "your-api-key-here");
let base_url_line =
optional_toml_string("base_url", &r.llm_base_url, "http://localhost:11434/v1");
format!(
r#"# =============================================================================
# Tuitbot Configuration
# =============================================================================
# Generated by `tuitbot init` setup wizard.
# 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: "manual" (paste code from browser — works on VPS/headless)
# or "local_callback" (auto-catch via local server — requires a desktop browser).
mode = "manual"
# callback_host = "127.0.0.1"
# callback_port = 8080
# --- Business Profile ---
# Describe your product so Tuitbot can find relevant conversations
# and generate on-brand content.
[business]
# ---- Quickstart (required) ----
product_name = "{product_name}"
product_keywords = {product_keywords}
# ---- Optional context ----
{product_description_line}
{product_url_line}
{target_audience_line}
competitor_keywords = []
{industry_topics_line}
# ---- Enrichment (shape voice and persona) ----
{brand_voice_line}
{reply_style_line}
{content_style_line}
{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 = 70
keyword_relevance_max = 40.0
follower_count_max = 20.0
recency_max = 15.0
engagement_rate_max = 25.0
# --- Safety Limits ---
# Prevent aggressive posting that could trigger account restrictions.
[limits]
max_replies_per_day = 5
max_tweets_per_day = 6
max_threads_per_week = 1
min_action_delay_seconds = 45
max_action_delay_seconds = 180
max_replies_per_author_per_day = 1
product_mention_ratio = 0.2
banned_phrases = ["check out", "you should try", "I recommend", "link in bio"]
# --- Automation Intervals ---
# How often each loop runs. Shorter intervals use more API quota.
[intervals]
mentions_check_seconds = 300
discovery_search_seconds = 900
content_post_window_seconds = 10800
thread_interval_seconds = 604800
{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 = "~/.tuitbot/tuitbot.db"
retention_days = 90
# --- Logging ---
[logging]
# Seconds between periodic status summaries (0 = disabled).
status_interval_seconds = 0
# --- 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 = r.approval_mode,
client_id = escape_toml(&r.client_id),
client_secret_line = client_secret_line,
product_name = escape_toml(&r.product_name),
product_keywords = format_toml_array(&r.product_keywords),
product_description_line = product_description_line,
product_url_line = product_url_line,
target_audience_line = target_audience_line,
industry_topics_line = industry_topics_line,
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,
targets_section = targets_section,
llm_provider = escape_toml(&r.llm_provider),
api_key_line = api_key_line,
llm_model = escape_toml(&r.llm_model),
base_url_line = base_url_line,
timezone = escape_toml(&r.timezone),
active_hours_start = r.active_hours_start,
active_hours_end = r.active_hours_end,
active_days = format_toml_array(&r.active_days),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn optional_toml_string_with_value() {
let val = Some("hello".to_string());
let result = optional_toml_string("key", &val, "placeholder");
assert_eq!(result, r#"key = "hello""#);
}
#[test]
fn optional_toml_string_none() {
let result = optional_toml_string("brand_voice", &None, "Friendly expert");
assert_eq!(result, r#"# brand_voice = "Friendly expert""#);
}
#[test]
fn optional_toml_string_with_special_chars() {
let val = Some("say \"hi\"".to_string());
let result = optional_toml_string("key", &val, "placeholder");
assert_eq!(result, r#"key = "say \"hi\"""#);
}
#[test]
fn optional_toml_array_with_items() {
let items = vec!["a".to_string(), "b".to_string()];
let result = optional_toml_array("topics", &items, "Your topic");
assert_eq!(result, r#"topics = ["a", "b"]"#);
}
#[test]
fn optional_toml_array_empty() {
let result = optional_toml_array("topics", &[], "Your topic");
assert_eq!(result, r#"# topics = ["Your topic"]"#);
}
#[test]
fn render_config_toml_minimal() {
let r = WizardResult {
client_id: "test-client".to_string(),
client_secret: None,
product_name: "MyApp".to_string(),
product_description: String::new(),
product_url: None,
target_audience: String::new(),
product_keywords: vec!["rust".to_string()],
industry_topics: vec![],
brand_voice: None,
reply_style: None,
content_style: None,
persona_opinions: vec![],
persona_experiences: vec![],
content_pillars: vec![],
target_accounts: vec![],
approval_mode: true,
timezone: "UTC".to_string(),
active_hours_start: 8,
active_hours_end: 22,
active_days: vec!["Mon".to_string(), "Tue".to_string()],
llm_provider: "ollama".to_string(),
llm_api_key: None,
llm_model: "llama3.2".to_string(),
llm_base_url: Some("http://localhost:11434/v1".to_string()),
};
let toml_str = render_config_toml(&r);
assert!(toml_str.contains("approval_mode = true"));
assert!(toml_str.contains(r#"client_id = "test-client""#));
assert!(toml_str.contains(r#"product_name = "MyApp""#));
assert!(toml_str.contains(r#"["rust"]"#));
assert!(toml_str.contains(r#"provider = "ollama""#));
assert!(toml_str.contains(r#"model = "llama3.2""#));
assert!(toml_str.contains(r#"timezone = "UTC""#));
assert!(toml_str.contains("# product_url"));
assert!(toml_str.contains("# brand_voice"));
}
#[test]
fn render_config_toml_full() {
let r = WizardResult {
client_id: "cid".to_string(),
client_secret: Some("secret".to_string()),
product_name: "FullApp".to_string(),
product_description: "Complete app".to_string(),
product_url: Some("https://example.com".to_string()),
target_audience: "developers".to_string(),
product_keywords: vec!["test".to_string()],
industry_topics: vec!["topic1".to_string()],
brand_voice: Some("Friendly".to_string()),
reply_style: Some("Helpful".to_string()),
content_style: Some("Practical".to_string()),
persona_opinions: vec!["opinion".to_string()],
persona_experiences: vec!["experience".to_string()],
content_pillars: vec!["pillar".to_string()],
target_accounts: vec!["user1".to_string()],
approval_mode: false,
timezone: "America/New_York".to_string(),
active_hours_start: 9,
active_hours_end: 21,
active_days: vec!["Mon".to_string()],
llm_provider: "openai".to_string(),
llm_api_key: Some("sk-test".to_string()),
llm_model: "gpt-4o-mini".to_string(),
llm_base_url: None,
};
let toml_str = render_config_toml(&r);
assert!(toml_str.contains("approval_mode = false"));
assert!(toml_str.contains(r#"product_url = "https://example.com""#));
assert!(toml_str.contains(r#"brand_voice = "Friendly""#));
assert!(toml_str.contains(r#"reply_style = "Helpful""#));
assert!(toml_str.contains(r#"content_style = "Practical""#));
assert!(toml_str.contains("persona_opinions"));
assert!(toml_str.contains("[targets]"));
assert!(toml_str.contains("user1"));
}
#[test]
fn render_config_toml_with_targets_creates_section() {
let r = WizardResult {
client_id: "cid".to_string(),
client_secret: None,
product_name: "App".to_string(),
product_description: String::new(),
product_url: None,
target_audience: String::new(),
product_keywords: vec!["kw".to_string()],
industry_topics: vec![],
brand_voice: None,
reply_style: None,
content_style: None,
persona_opinions: vec![],
persona_experiences: vec![],
content_pillars: vec![],
target_accounts: vec!["alice".to_string(), "bob".to_string()],
approval_mode: true,
timezone: "UTC".to_string(),
active_hours_start: 8,
active_hours_end: 22,
active_days: vec!["Mon".to_string()],
llm_provider: "ollama".to_string(),
llm_api_key: None,
llm_model: "llama3.2".to_string(),
llm_base_url: None,
};
let toml_str = render_config_toml(&r);
assert!(toml_str.contains("[targets]"));
assert!(toml_str.contains("alice"));
assert!(toml_str.contains("bob"));
}
}