refactor: split chat/transport/matrix/config.rs into mod.rs + loading.rs

The 1260-line config.rs is split into:

- mod.rs: BotConfig struct + small impl + default helpers + tests (1047 lines)
- loading.rs: BotConfig::load + save_ambient_rooms (223 lines)

Tests stay co-located.

No behaviour change. All 41 matrix::config tests pass; full suite green.
This commit is contained in:
dave
2026-04-26 21:37:39 +00:00
parent ca72f36c78
commit a86448f6a6
2 changed files with 225 additions and 215 deletions
@@ -0,0 +1,223 @@
//! BotConfig::load implementation and the save_ambient_rooms helper.
use std::path::Path;
use super::BotConfig;
impl BotConfig {
/// Load bot configuration from `.huskies/bot.toml`.
///
/// Returns `None` if the file does not exist, fails to parse, has
/// `enabled = false`, or specifies no room IDs.
pub fn load(project_root: &Path) -> Option<Self> {
let path = project_root.join(".huskies").join("bot.toml");
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path)
.map_err(|e| eprintln!("[matrix-bot] Failed to read bot.toml: {e}"))
.ok()?;
let mut config: BotConfig = toml::from_str(&content)
.map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}"))
.ok()?;
if !config.enabled {
return None;
}
// Merge deprecated `room_id` (single string) into `room_ids` (list).
if let Some(single) = config.room_id.take()
&& !config.room_ids.contains(&single)
{
config.room_ids.push(single);
}
if config.transport == "whatsapp" {
if config.whatsapp_provider == "twilio" {
// Validate Twilio-specific fields.
if config
.twilio_account_sid
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_account_sid"
);
return None;
}
if config
.twilio_auth_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_auth_token"
);
return None;
}
if config
.twilio_whatsapp_number
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_whatsapp_number"
);
return None;
}
} else {
// Validate Meta (default) WhatsApp fields.
if config
.whatsapp_phone_number_id
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_phone_number_id"
);
return None;
}
if config
.whatsapp_access_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_access_token"
);
return None;
}
if config
.whatsapp_verify_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_verify_token"
);
return None;
}
}
} else if config.transport == "slack" {
// Validate Slack-specific fields.
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
slack_bot_token"
);
return None;
}
if config
.slack_signing_secret
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
slack_signing_secret"
);
return None;
}
if config.slack_channel_ids.is_empty() {
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
at least one slack_channel_ids entry"
);
return None;
}
} else if config.transport == "discord" {
// Validate Discord-specific fields.
if config
.discord_bot_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"discord\" requires \
discord_bot_token"
);
return None;
}
if config.discord_channel_ids.is_empty() {
eprintln!(
"[bot] bot.toml: transport=\"discord\" requires \
at least one discord_channel_ids entry"
);
return None;
}
} else {
// Default transport is Matrix — validate Matrix-specific fields.
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!("[bot] bot.toml: transport=\"matrix\" requires homeserver");
return None;
}
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!("[bot] bot.toml: transport=\"matrix\" requires username");
return None;
}
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!("[bot] bot.toml: transport=\"matrix\" requires password");
return None;
}
if config.room_ids.is_empty() {
eprintln!(
"[matrix-bot] bot.toml has no room_ids configured — \
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
);
return None;
}
}
Some(config)
}
/// Returns all configured room IDs as a flat list. Combines `room_ids`
/// and (after loading) any merged `room_id` value.
pub fn effective_room_ids(&self) -> &[String] {
&self.room_ids
}
}
/// Persist the current set of ambient room IDs back to `bot.toml`.
///
/// Reads the existing file as a TOML document, updates the `ambient_rooms`
/// array, and writes the result back. Errors are logged but not propagated
/// so a persistence failure never interrupts the bot's message handling.
pub fn save_ambient_rooms(project_root: &Path, room_ids: &[String]) {
let path = project_root.join(".huskies").join("bot.toml");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("[matrix-bot] save_ambient_rooms: failed to read bot.toml: {e}");
return;
}
};
let mut doc: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("[matrix-bot] save_ambient_rooms: failed to parse bot.toml: {e}");
return;
}
};
if let toml::Value::Table(ref mut t) = doc {
let arr = toml::Value::Array(
room_ids
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect(),
);
t.insert("ambient_rooms".to_string(), arr);
}
match toml::to_string_pretty(&doc) {
Ok(new_content) => {
if let Err(e) = std::fs::write(&path, new_content) {
eprintln!("[matrix-bot] save_ambient_rooms: failed to write bot.toml: {e}");
}
}
Err(e) => eprintln!("[matrix-bot] save_ambient_rooms: failed to serialise bot.toml: {e}"),
}
}
@@ -184,224 +184,11 @@ fn default_whatsapp_provider() -> String {
"meta".to_string() "meta".to_string()
} }
impl BotConfig {
/// Load bot configuration from `.huskies/bot.toml`.
///
/// Returns `None` if the file does not exist, fails to parse, has
/// `enabled = false`, or specifies no room IDs.
pub fn load(project_root: &Path) -> Option<Self> {
let path = project_root.join(".huskies").join("bot.toml");
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path)
.map_err(|e| eprintln!("[matrix-bot] Failed to read bot.toml: {e}"))
.ok()?;
let mut config: BotConfig = toml::from_str(&content)
.map_err(|e| eprintln!("[matrix-bot] Invalid bot.toml: {e}"))
.ok()?;
if !config.enabled {
return None;
}
// Merge deprecated `room_id` (single string) into `room_ids` (list).
if let Some(single) = config.room_id.take()
&& !config.room_ids.contains(&single)
{
config.room_ids.push(single);
}
if config.transport == "whatsapp" { mod loading;
if config.whatsapp_provider == "twilio" {
// Validate Twilio-specific fields.
if config
.twilio_account_sid
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_account_sid"
);
return None;
}
if config
.twilio_auth_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_auth_token"
);
return None;
}
if config
.twilio_whatsapp_number
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
twilio_whatsapp_number"
);
return None;
}
} else {
// Validate Meta (default) WhatsApp fields.
if config
.whatsapp_phone_number_id
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_phone_number_id"
);
return None;
}
if config
.whatsapp_access_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_access_token"
);
return None;
}
if config
.whatsapp_verify_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"whatsapp\" requires \
whatsapp_verify_token"
);
return None;
}
}
} else if config.transport == "slack" {
// Validate Slack-specific fields.
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
slack_bot_token"
);
return None;
}
if config
.slack_signing_secret
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
slack_signing_secret"
);
return None;
}
if config.slack_channel_ids.is_empty() {
eprintln!(
"[bot] bot.toml: transport=\"slack\" requires \
at least one slack_channel_ids entry"
);
return None;
}
} else if config.transport == "discord" {
// Validate Discord-specific fields.
if config
.discord_bot_token
.as_ref()
.is_none_or(|s| s.is_empty())
{
eprintln!(
"[bot] bot.toml: transport=\"discord\" requires \
discord_bot_token"
);
return None;
}
if config.discord_channel_ids.is_empty() {
eprintln!(
"[bot] bot.toml: transport=\"discord\" requires \
at least one discord_channel_ids entry"
);
return None;
}
} else {
// Default transport is Matrix — validate Matrix-specific fields.
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!("[bot] bot.toml: transport=\"matrix\" requires homeserver");
return None;
}
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!("[bot] bot.toml: transport=\"matrix\" requires username");
return None;
}
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
eprintln!("[bot] bot.toml: transport=\"matrix\" requires password");
return None;
}
if config.room_ids.is_empty() {
eprintln!(
"[matrix-bot] bot.toml has no room_ids configured — \
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
);
return None;
}
}
Some(config)
}
/// Returns all configured room IDs as a flat list. Combines `room_ids` pub use loading::save_ambient_rooms;
/// and (after loading) any merged `room_id` value.
pub fn effective_room_ids(&self) -> &[String] {
&self.room_ids
}
}
/// Persist the current set of ambient room IDs back to `bot.toml`.
///
/// Reads the existing file as a TOML document, updates the `ambient_rooms`
/// array, and writes the result back. Errors are logged but not propagated
/// so a persistence failure never interrupts the bot's message handling.
pub fn save_ambient_rooms(project_root: &Path, room_ids: &[String]) {
let path = project_root.join(".huskies").join("bot.toml");
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("[matrix-bot] save_ambient_rooms: failed to read bot.toml: {e}");
return;
}
};
let mut doc: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(e) => {
eprintln!("[matrix-bot] save_ambient_rooms: failed to parse bot.toml: {e}");
return;
}
};
if let toml::Value::Table(ref mut t) = doc {
let arr = toml::Value::Array(
room_ids
.iter()
.map(|s| toml::Value::String(s.clone()))
.collect(),
);
t.insert("ambient_rooms".to_string(), arr);
}
match toml::to_string_pretty(&doc) {
Ok(new_content) => {
if let Err(e) = std::fs::write(&path, new_content) {
eprintln!("[matrix-bot] save_ambient_rooms: failed to write bot.toml: {e}");
}
}
Err(e) => eprintln!("[matrix-bot] save_ambient_rooms: failed to serialise bot.toml: {e}"),
}
}
#[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs; use std::fs;