use std::path::Path;
use anyhow::{bail, Context, Result};
use console::Style;
use tuitbot_core::config::Config;
use super::helpers::{parse_bool, parse_csv, ChangeTracker};
use super::render::{validate_config, write_config_with_backup};
fn set_string(
tracker: &mut ChangeTracker,
field: &mut String,
section: &str,
name: &str,
value: &str,
) {
tracker.record(section, name, field, value);
*field = value.to_string();
}
fn set_opt_string(
tracker: &mut ChangeTracker,
field: &mut Option<String>,
section: &str,
name: &str,
value: &str,
) {
let old = field.as_deref().unwrap_or("(none)").to_string();
if value == "none" || value.is_empty() {
*field = None;
tracker.record(section, name, &old, "(none)");
} else {
*field = Some(value.to_string());
tracker.record(section, name, &old, value);
}
}
fn set_csv(
tracker: &mut ChangeTracker,
field: &mut Vec<String>,
section: &str,
name: &str,
value: &str,
) {
let old = field.join(", ");
*field = parse_csv(value);
tracker.record(section, name, &old, value);
}
fn set_u32(
tracker: &mut ChangeTracker,
field: &mut u32,
section: &str,
name: &str,
value: &str,
) -> Result<()> {
let v: u32 = value.parse().context("must be a positive number")?;
tracker.record(section, name, &field.to_string(), value);
*field = v;
Ok(())
}
fn set_u64(
tracker: &mut ChangeTracker,
field: &mut u64,
section: &str,
name: &str,
value: &str,
) -> Result<()> {
let v: u64 = value.parse().context("must be a positive number")?;
tracker.record(section, name, &field.to_string(), value);
*field = v;
Ok(())
}
fn set_f32(
tracker: &mut ChangeTracker,
field: &mut f32,
section: &str,
name: &str,
value: &str,
) -> Result<()> {
let v: f32 = value.parse().context("must be a number")?;
tracker.record(section, name, &format!("{:.1}", *field), value);
*field = v;
Ok(())
}
fn set_f32_fmt2(
tracker: &mut ChangeTracker,
field: &mut f32,
section: &str,
name: &str,
value: &str,
ctx: &str,
) -> Result<()> {
let v: f32 = value.parse().context(ctx.to_string())?;
tracker.record(section, name, &format!("{:.2}", *field), value);
*field = v;
Ok(())
}
fn set_bool(
tracker: &mut ChangeTracker,
field: &mut bool,
section: &str,
name: &str,
value: &str,
) -> Result<()> {
let v: bool = parse_bool(value)?;
tracker.record(section, name, &field.to_string(), value);
*field = v;
Ok(())
}
fn set_u8_range(
tracker: &mut ChangeTracker,
field: &mut u8,
section: &str,
name: &str,
value: &str,
max: u8,
) -> Result<()> {
let v: u8 = value.parse().context("must be 0-23")?;
if v > max {
bail!("{name} must be 0-{max}");
}
tracker.record(section, name, &field.to_string(), value);
*field = v;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_string_updates_field() {
let mut tracker = ChangeTracker::new();
let mut field = "old_value".to_string();
set_string(&mut tracker, &mut field, "sec", "fld", "new_value");
assert_eq!(field, "new_value");
assert_eq!(tracker.changes.len(), 1);
assert_eq!(tracker.changes[0].old_value, "old_value");
assert_eq!(tracker.changes[0].new_value, "new_value");
}
#[test]
fn set_string_same_value_no_change() {
let mut tracker = ChangeTracker::new();
let mut field = "same".to_string();
set_string(&mut tracker, &mut field, "sec", "fld", "same");
assert_eq!(field, "same");
assert!(tracker.changes.is_empty());
}
#[test]
fn set_opt_string_sets_value() {
let mut tracker = ChangeTracker::new();
let mut field = None;
set_opt_string(&mut tracker, &mut field, "sec", "fld", "hello");
assert_eq!(field, Some("hello".to_string()));
}
#[test]
fn set_opt_string_clears_with_none() {
let mut tracker = ChangeTracker::new();
let mut field = Some("existing".to_string());
set_opt_string(&mut tracker, &mut field, "sec", "fld", "none");
assert_eq!(field, None);
assert_eq!(tracker.changes.len(), 1);
}
#[test]
fn set_opt_string_clears_with_empty() {
let mut tracker = ChangeTracker::new();
let mut field = Some("existing".to_string());
set_opt_string(&mut tracker, &mut field, "sec", "fld", "");
assert_eq!(field, None);
}
#[test]
fn set_opt_string_from_none_to_some() {
let mut tracker = ChangeTracker::new();
let mut field = None;
set_opt_string(&mut tracker, &mut field, "sec", "fld", "value");
assert_eq!(field, Some("value".to_string()));
assert_eq!(tracker.changes[0].old_value, "(none)");
}
#[test]
fn set_csv_parses_comma_separated() {
let mut tracker = ChangeTracker::new();
let mut field = vec!["old".to_string()];
set_csv(&mut tracker, &mut field, "sec", "fld", "a, b, c");
assert_eq!(field, vec!["a", "b", "c"]);
}
#[test]
fn set_csv_empty_input() {
let mut tracker = ChangeTracker::new();
let mut field = vec!["old".to_string()];
set_csv(&mut tracker, &mut field, "sec", "fld", "");
assert!(field.is_empty());
}
#[test]
fn set_u32_valid() {
let mut tracker = ChangeTracker::new();
let mut field: u32 = 10;
set_u32(&mut tracker, &mut field, "sec", "fld", "42").unwrap();
assert_eq!(field, 42);
}
#[test]
fn set_u32_invalid() {
let mut tracker = ChangeTracker::new();
let mut field: u32 = 10;
assert!(set_u32(&mut tracker, &mut field, "sec", "fld", "abc").is_err());
assert_eq!(field, 10); }
#[test]
fn set_u32_negative() {
let mut tracker = ChangeTracker::new();
let mut field: u32 = 10;
assert!(set_u32(&mut tracker, &mut field, "sec", "fld", "-5").is_err());
}
#[test]
fn set_u64_valid() {
let mut tracker = ChangeTracker::new();
let mut field: u64 = 100;
set_u64(&mut tracker, &mut field, "sec", "fld", "999").unwrap();
assert_eq!(field, 999);
}
#[test]
fn set_u64_invalid() {
let mut tracker = ChangeTracker::new();
let mut field: u64 = 100;
assert!(set_u64(&mut tracker, &mut field, "sec", "fld", "not_a_number").is_err());
}
#[test]
fn set_f32_valid() {
let mut tracker = ChangeTracker::new();
let mut field: f32 = 1.0;
set_f32(&mut tracker, &mut field, "sec", "fld", "3.14").unwrap();
assert!((field - 3.14).abs() < 0.01);
}
#[test]
fn set_f32_invalid() {
let mut tracker = ChangeTracker::new();
let mut field: f32 = 1.0;
assert!(set_f32(&mut tracker, &mut field, "sec", "fld", "abc").is_err());
}
#[test]
fn set_f32_fmt2_valid() {
let mut tracker = ChangeTracker::new();
let mut field: f32 = 0.5;
set_f32_fmt2(
&mut tracker,
&mut field,
"sec",
"fld",
"0.75",
"must be number",
)
.unwrap();
assert!((field - 0.75).abs() < 0.01);
assert_eq!(tracker.changes[0].old_value, "0.50");
}
#[test]
fn set_f32_fmt2_invalid() {
let mut tracker = ChangeTracker::new();
let mut field: f32 = 0.5;
let err = set_f32_fmt2(
&mut tracker,
&mut field,
"sec",
"fld",
"xyz",
"must be 0.0-1.0",
);
assert!(err.is_err());
}
#[test]
fn set_bool_true_values() {
for val in &["true", "yes", "1", "on"] {
let mut tracker = ChangeTracker::new();
let mut field = false;
set_bool(&mut tracker, &mut field, "sec", "fld", val).unwrap();
assert!(field, "failed for {val}");
}
}
#[test]
fn set_bool_false_values() {
for val in &["false", "no", "0", "off"] {
let mut tracker = ChangeTracker::new();
let mut field = true;
set_bool(&mut tracker, &mut field, "sec", "fld", val).unwrap();
assert!(!field, "failed for {val}");
}
}
#[test]
fn set_bool_invalid() {
let mut tracker = ChangeTracker::new();
let mut field = false;
assert!(set_bool(&mut tracker, &mut field, "sec", "fld", "maybe").is_err());
}
#[test]
fn set_u8_range_valid() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 8;
set_u8_range(&mut tracker, &mut field, "sec", "fld", "22", 23).unwrap();
assert_eq!(field, 22);
}
#[test]
fn set_u8_range_at_max() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 0;
set_u8_range(&mut tracker, &mut field, "sec", "fld", "23", 23).unwrap();
assert_eq!(field, 23);
}
#[test]
fn set_u8_range_over_max() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 0;
let err = set_u8_range(&mut tracker, &mut field, "sec", "start", "24", 23);
assert!(err.is_err());
assert_eq!(field, 0); }
#[test]
fn set_u8_range_not_a_number() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 8;
assert!(set_u8_range(&mut tracker, &mut field, "sec", "fld", "abc", 23).is_err());
}
#[test]
fn set_u8_range_zero() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 12;
set_u8_range(&mut tracker, &mut field, "sec", "fld", "0", 23).unwrap();
assert_eq!(field, 0);
}
#[test]
fn set_string_empty_to_value() {
let mut tracker = ChangeTracker::new();
let mut field = String::new();
set_string(&mut tracker, &mut field, "sec", "fld", "new_value");
assert_eq!(field, "new_value");
assert_eq!(tracker.changes.len(), 1);
}
#[test]
fn set_string_value_to_empty() {
let mut tracker = ChangeTracker::new();
let mut field = "old".to_string();
set_string(&mut tracker, &mut field, "sec", "fld", "");
assert_eq!(field, "");
assert_eq!(tracker.changes.len(), 1);
}
#[test]
fn set_opt_string_none_to_none_via_empty() {
let mut tracker = ChangeTracker::new();
let mut field = None;
set_opt_string(&mut tracker, &mut field, "sec", "fld", "");
assert_eq!(field, None);
assert!(tracker.changes.is_empty());
}
#[test]
fn set_opt_string_some_to_different_some() {
let mut tracker = ChangeTracker::new();
let mut field = Some("old_val".to_string());
set_opt_string(&mut tracker, &mut field, "sec", "fld", "new_val");
assert_eq!(field, Some("new_val".to_string()));
assert_eq!(tracker.changes.len(), 1);
assert_eq!(tracker.changes[0].old_value, "old_val");
assert_eq!(tracker.changes[0].new_value, "new_val");
}
#[test]
fn set_csv_with_spaces() {
let mut tracker = ChangeTracker::new();
let mut field = vec![];
set_csv(&mut tracker, &mut field, "sec", "fld", " x , y , z ");
assert_eq!(field, vec!["x", "y", "z"]);
}
#[test]
fn set_csv_single_item() {
let mut tracker = ChangeTracker::new();
let mut field = vec![];
set_csv(&mut tracker, &mut field, "sec", "fld", "single");
assert_eq!(field, vec!["single"]);
}
#[test]
fn set_u32_zero() {
let mut tracker = ChangeTracker::new();
let mut field: u32 = 10;
set_u32(&mut tracker, &mut field, "sec", "fld", "0").unwrap();
assert_eq!(field, 0);
}
#[test]
fn set_u32_large_value() {
let mut tracker = ChangeTracker::new();
let mut field: u32 = 0;
set_u32(&mut tracker, &mut field, "sec", "fld", "4294967295").unwrap();
assert_eq!(field, u32::MAX);
}
#[test]
fn set_u32_float_rejected() {
let mut tracker = ChangeTracker::new();
let mut field: u32 = 0;
assert!(set_u32(&mut tracker, &mut field, "sec", "fld", "3.14").is_err());
}
#[test]
fn set_u64_zero() {
let mut tracker = ChangeTracker::new();
let mut field: u64 = 100;
set_u64(&mut tracker, &mut field, "sec", "fld", "0").unwrap();
assert_eq!(field, 0);
}
#[test]
fn set_f32_negative() {
let mut tracker = ChangeTracker::new();
let mut field: f32 = 1.0;
set_f32(&mut tracker, &mut field, "sec", "fld", "-2.5").unwrap();
assert!((field - (-2.5)).abs() < 0.01);
}
#[test]
fn set_f32_zero() {
let mut tracker = ChangeTracker::new();
let mut field: f32 = 1.0;
set_f32(&mut tracker, &mut field, "sec", "fld", "0").unwrap();
assert!((field).abs() < 0.01);
}
#[test]
fn set_bool_same_value_records_change() {
let mut tracker = ChangeTracker::new();
let mut field = true;
set_bool(&mut tracker, &mut field, "sec", "fld", "true").unwrap();
assert!(field);
}
#[test]
fn set_u8_range_at_zero() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 10;
set_u8_range(&mut tracker, &mut field, "sec", "fld", "0", 23).unwrap();
assert_eq!(field, 0);
}
#[test]
fn set_u8_range_custom_max() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 0;
set_u8_range(&mut tracker, &mut field, "sec", "fld", "10", 10).unwrap();
assert_eq!(field, 10);
assert!(set_u8_range(&mut tracker, &mut field, "sec", "fld", "11", 10).is_err());
}
#[test]
fn set_u8_range_negative_rejected() {
let mut tracker = ChangeTracker::new();
let mut field: u8 = 0;
assert!(set_u8_range(&mut tracker, &mut field, "sec", "fld", "-1", 23).is_err());
}
}
pub(super) fn set_direct(config: &mut Config, kv: &str, config_path: &Path) -> Result<()> {
let (key, value) = kv.split_once('=').ok_or_else(|| {
anyhow::anyhow!("Invalid format. Use --set KEY=VALUE (e.g., --set scoring.threshold=80)")
})?;
let key = key.trim();
let value = value.trim();
let mut tracker = ChangeTracker::new();
match key {
"business.product_name" => set_string(
&mut tracker,
&mut config.business.product_name,
"business",
"product_name",
value,
),
"business.product_description" => set_string(
&mut tracker,
&mut config.business.product_description,
"business",
"product_description",
value,
),
"business.target_audience" => set_string(
&mut tracker,
&mut config.business.target_audience,
"business",
"target_audience",
value,
),
"business.product_url" => set_opt_string(
&mut tracker,
&mut config.business.product_url,
"business",
"product_url",
value,
),
"business.brand_voice" => set_opt_string(
&mut tracker,
&mut config.business.brand_voice,
"business",
"brand_voice",
value,
),
"business.reply_style" => set_opt_string(
&mut tracker,
&mut config.business.reply_style,
"business",
"reply_style",
value,
),
"business.content_style" => set_opt_string(
&mut tracker,
&mut config.business.content_style,
"business",
"content_style",
value,
),
"business.product_keywords" => set_csv(
&mut tracker,
&mut config.business.product_keywords,
"business",
"product_keywords",
value,
),
"business.competitor_keywords" => set_csv(
&mut tracker,
&mut config.business.competitor_keywords,
"business",
"competitor_keywords",
value,
),
"business.industry_topics" => set_csv(
&mut tracker,
&mut config.business.industry_topics,
"business",
"industry_topics",
value,
),
"business.persona_opinions" => set_csv(
&mut tracker,
&mut config.business.persona_opinions,
"business",
"persona_opinions",
value,
),
"business.persona_experiences" => set_csv(
&mut tracker,
&mut config.business.persona_experiences,
"business",
"persona_experiences",
value,
),
"business.content_pillars" => set_csv(
&mut tracker,
&mut config.business.content_pillars,
"business",
"content_pillars",
value,
),
"scoring.threshold" => {
let v: u32 = value.parse().context("threshold must be a number 0-100")?;
tracker.record(
"scoring",
"threshold",
&config.scoring.threshold.to_string(),
value,
);
config.scoring.threshold = v;
}
"scoring.keyword_relevance_max" => set_f32(
&mut tracker,
&mut config.scoring.keyword_relevance_max,
"scoring",
"keyword_relevance_max",
value,
)?,
"scoring.follower_count_max" => set_f32(
&mut tracker,
&mut config.scoring.follower_count_max,
"scoring",
"follower_count_max",
value,
)?,
"scoring.recency_max" => set_f32(
&mut tracker,
&mut config.scoring.recency_max,
"scoring",
"recency_max",
value,
)?,
"scoring.engagement_rate_max" => set_f32(
&mut tracker,
&mut config.scoring.engagement_rate_max,
"scoring",
"engagement_rate_max",
value,
)?,
"scoring.reply_count_max" => set_f32(
&mut tracker,
&mut config.scoring.reply_count_max,
"scoring",
"reply_count_max",
value,
)?,
"scoring.content_type_max" => set_f32(
&mut tracker,
&mut config.scoring.content_type_max,
"scoring",
"content_type_max",
value,
)?,
"limits.max_replies_per_day" => set_u32(
&mut tracker,
&mut config.limits.max_replies_per_day,
"limits",
"max_replies_per_day",
value,
)?,
"limits.max_tweets_per_day" => set_u32(
&mut tracker,
&mut config.limits.max_tweets_per_day,
"limits",
"max_tweets_per_day",
value,
)?,
"limits.max_threads_per_week" => set_u32(
&mut tracker,
&mut config.limits.max_threads_per_week,
"limits",
"max_threads_per_week",
value,
)?,
"limits.min_action_delay_seconds" => set_u64(
&mut tracker,
&mut config.limits.min_action_delay_seconds,
"limits",
"min_action_delay_seconds",
value,
)?,
"limits.max_action_delay_seconds" => set_u64(
&mut tracker,
&mut config.limits.max_action_delay_seconds,
"limits",
"max_action_delay_seconds",
value,
)?,
"limits.max_replies_per_author_per_day" => set_u32(
&mut tracker,
&mut config.limits.max_replies_per_author_per_day,
"limits",
"max_replies_per_author_per_day",
value,
)?,
"limits.product_mention_ratio" => set_f32_fmt2(
&mut tracker,
&mut config.limits.product_mention_ratio,
"limits",
"product_mention_ratio",
value,
"must be a number 0.0-1.0",
)?,
"limits.banned_phrases" => set_csv(
&mut tracker,
&mut config.limits.banned_phrases,
"limits",
"banned_phrases",
value,
),
"intervals.mentions_check_seconds" => set_u64(
&mut tracker,
&mut config.intervals.mentions_check_seconds,
"intervals",
"mentions_check_seconds",
value,
)?,
"intervals.discovery_search_seconds" => set_u64(
&mut tracker,
&mut config.intervals.discovery_search_seconds,
"intervals",
"discovery_search_seconds",
value,
)?,
"intervals.content_post_window_seconds" => set_u64(
&mut tracker,
&mut config.intervals.content_post_window_seconds,
"intervals",
"content_post_window_seconds",
value,
)?,
"intervals.thread_interval_seconds" => set_u64(
&mut tracker,
&mut config.intervals.thread_interval_seconds,
"intervals",
"thread_interval_seconds",
value,
)?,
"targets.accounts" => set_csv(
&mut tracker,
&mut config.targets.accounts,
"targets",
"accounts",
value,
),
"targets.max_target_replies_per_day" => set_u32(
&mut tracker,
&mut config.targets.max_target_replies_per_day,
"targets",
"max_target_replies_per_day",
value,
)?,
"llm.provider" => set_string(
&mut tracker,
&mut config.llm.provider,
"llm",
"provider",
value,
),
"llm.api_key" => {
tracker.record("llm", "api_key", "(hidden)", "(updated)");
config.llm.api_key = Some(value.to_string());
}
"llm.model" => set_string(&mut tracker, &mut config.llm.model, "llm", "model", value),
"llm.base_url" => set_opt_string(
&mut tracker,
&mut config.llm.base_url,
"llm",
"base_url",
value,
),
"x_api.client_id" => set_string(
&mut tracker,
&mut config.x_api.client_id,
"x_api",
"client_id",
value,
),
"x_api.client_secret" => {
tracker.record("x_api", "client_secret", "(hidden)", "(updated)");
config.x_api.client_secret = Some(value.to_string());
}
"approval_mode" => set_bool(
&mut tracker,
&mut config.approval_mode,
"",
"approval_mode",
value,
)?,
"storage.db_path" => set_string(
&mut tracker,
&mut config.storage.db_path,
"storage",
"db_path",
value,
),
"storage.retention_days" => set_u32(
&mut tracker,
&mut config.storage.retention_days,
"storage",
"retention_days",
value,
)?,
"logging.status_interval_seconds" => set_u64(
&mut tracker,
&mut config.logging.status_interval_seconds,
"logging",
"status_interval_seconds",
value,
)?,
"schedule.timezone" => {
value
.trim()
.parse::<chrono_tz::Tz>()
.map_err(|_| anyhow::anyhow!("Unknown timezone: {value}"))?;
tracker.record("schedule", "timezone", &config.schedule.timezone, value);
config.schedule.timezone = value.to_string();
}
"schedule.active_hours_start" => set_u8_range(
&mut tracker,
&mut config.schedule.active_hours_start,
"schedule",
"active_hours_start",
value,
23,
)?,
"schedule.active_hours_end" => set_u8_range(
&mut tracker,
&mut config.schedule.active_hours_end,
"schedule",
"active_hours_end",
value,
23,
)?,
"schedule.active_days" => set_csv(
&mut tracker,
&mut config.schedule.active_days,
"schedule",
"active_days",
value,
),
"schedule.preferred_times" => set_csv(
&mut tracker,
&mut config.schedule.preferred_times,
"schedule",
"preferred_times",
value,
),
"schedule.thread_preferred_day" => set_opt_string(
&mut tracker,
&mut config.schedule.thread_preferred_day,
"schedule",
"thread_preferred_day",
value,
),
"schedule.thread_preferred_time" => set_string(
&mut tracker,
&mut config.schedule.thread_preferred_time,
"schedule",
"thread_preferred_time",
value,
),
_ => bail!(
"Unknown setting: {key}\n\
Use 'tuitbot settings --show' to see all available settings."
),
}
validate_config(config)?;
let path_str = config_path.display().to_string();
write_config_with_backup(config, &path_str)?;
let bold = Style::new().bold();
eprintln!("{}", bold.apply_to("Updated:"));
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
);
}
Ok(())
}