use crate::error::{HeraldError, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub twitter: TwitterConfig,
#[serde(default)]
pub llm: LlmConfig,
#[serde(default)]
pub defaults: TweetDefaults,
#[serde(default)]
pub schedule: ScheduleConfig,
#[serde(default)]
pub projects: Vec<ProjectConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
twitter: TwitterConfig::default(),
llm: LlmConfig::default(),
defaults: TweetDefaults::default(),
schedule: ScheduleConfig::default(),
projects: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TwitterConfig {
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub api_secret: String,
#[serde(default)]
pub access_token: String,
#[serde(default)]
pub access_token_secret: String,
#[serde(default)]
pub bearer_token: String,
}
impl TwitterConfig {
pub fn is_configured(&self) -> bool {
!self.api_key.is_empty()
&& !self.api_secret.is_empty()
&& !self.access_token.is_empty()
&& !self.access_token_secret.is_empty()
}
pub fn from_env() -> Self {
Self {
api_key: std::env::var("TWITTER_API_KEY").unwrap_or_default(),
api_secret: std::env::var("TWITTER_API_SECRET").unwrap_or_default(),
access_token: std::env::var("TWITTER_ACCESS_TOKEN").unwrap_or_default(),
access_token_secret: std::env::var("TWITTER_ACCESS_TOKEN_SECRET").unwrap_or_default(),
bearer_token: std::env::var("TWITTER_BEARER_TOKEN").unwrap_or_default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
#[serde(default = "default_provider")]
pub provider: String,
#[serde(default)]
pub api_key: String,
#[serde(default = "default_model")]
pub model: String,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default = "default_temperature")]
pub temperature: f32,
#[serde(default = "default_max_tokens")]
pub max_tokens: usize,
}
fn default_provider() -> String {
"anthropic".to_string()
}
fn default_model() -> String {
"claude-sonnet-4-20250514".to_string()
}
fn default_temperature() -> f32 {
0.8
}
fn default_max_tokens() -> usize {
500
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
provider: default_provider(),
api_key: std::env::var("ANTHROPIC_API_KEY")
.or_else(|_| std::env::var("OPENAI_API_KEY"))
.unwrap_or_default(),
model: default_model(),
base_url: None,
temperature: default_temperature(),
max_tokens: default_max_tokens(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TweetDefaults {
#[serde(default = "default_true")]
pub emojis: bool,
#[serde(default)]
pub hashtags: bool,
#[serde(default = "default_tone")]
pub tone: String,
#[serde(default = "default_max_length")]
pub max_length: usize,
#[serde(default = "default_true")]
pub include_link: bool,
#[serde(default)]
pub author_handle: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_tone() -> String {
"casual".to_string()
}
fn default_max_length() -> usize {
280
}
impl Default for TweetDefaults {
fn default() -> Self {
Self {
emojis: true,
hashtags: false,
tone: default_tone(),
max_length: default_max_length(),
include_link: true,
author_handle: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduleConfig {
#[serde(default = "default_min_hours")]
pub min_hours_between: u32,
#[serde(default = "default_posting_times")]
pub preferred_times: Vec<String>,
#[serde(default = "default_timezone")]
pub timezone: String,
#[serde(default = "default_max_per_day")]
pub max_per_day: u32,
#[serde(default)]
pub queue_file: Option<PathBuf>,
}
fn default_min_hours() -> u32 {
4
}
fn default_posting_times() -> Vec<String> {
vec![
"09:00".to_string(),
"12:00".to_string(),
"15:00".to_string(),
"18:00".to_string(),
]
}
fn default_timezone() -> String {
"America/New_York".to_string()
}
fn default_max_per_day() -> u32 {
5
}
impl Default for ScheduleConfig {
fn default() -> Self {
Self {
min_hours_between: default_min_hours(),
preferred_times: default_posting_times(),
timezone: default_timezone(),
max_per_day: default_max_per_day(),
queue_file: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub name: String,
#[serde(default)]
pub path: Option<PathBuf>,
#[serde(default)]
pub github: Option<String>,
#[serde(default)]
pub crates_io: Option<String>,
#[serde(default)]
pub npm: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_events")]
pub events: Vec<EventType>,
}
fn default_events() -> Vec<EventType> {
vec![EventType::Release, EventType::MajorFeature]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EventType {
Release,
Commit,
PullRequest,
MajorFeature,
BugFix,
Docs,
Breaking,
Security,
Performance,
Custom(String),
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::default_path()?;
if config_path.exists() {
Self::load_from(&config_path)
} else {
Ok(Self::default())
}
}
pub fn load_from(path: &PathBuf) -> Result<Self> {
let content = std::fs::read_to_string(path)?;
toml::from_str(&content).map_err(|e| HeraldError::Config(e.to_string()))
}
pub fn save(&self) -> Result<()> {
let config_path = Self::default_path()?;
self.save_to(&config_path)
}
pub fn save_to(&self, path: &PathBuf) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self).map_err(|e| HeraldError::Config(e.to_string()))?;
std::fs::write(path, content)?;
Ok(())
}
pub fn default_path() -> Result<PathBuf> {
let proj_dirs = directories::ProjectDirs::from("io", "moltenlabs", "herald")
.ok_or_else(|| HeraldError::Config("Could not determine config directory".to_string()))?;
Ok(proj_dirs.config_dir().join("config.toml"))
}
pub fn example() -> Self {
Self {
twitter: TwitterConfig::default(),
llm: LlmConfig::default(),
defaults: TweetDefaults {
emojis: true,
hashtags: false,
tone: "casual".to_string(),
max_length: 280,
include_link: true,
author_handle: Some("@mikifranz".to_string()),
},
schedule: ScheduleConfig::default(),
projects: vec![
ProjectConfig {
name: "warhorn".to_string(),
path: None,
github: Some("moltenlabs/warhorn".to_string()),
crates_io: Some("warhorn".to_string()),
npm: None,
description: Some("Protocol types for AI agent communication".to_string()),
events: vec![EventType::Release, EventType::MajorFeature],
},
],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.defaults.emojis);
assert!(!config.defaults.hashtags);
assert_eq!(config.defaults.max_length, 280);
}
#[test]
fn test_twitter_config_from_env() {
let config = TwitterConfig::default();
assert!(!config.is_configured());
}
#[test]
fn test_config_serialization() {
let config = Config::example();
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.projects.len(), 1);
assert_eq!(parsed.projects[0].name, "warhorn");
}
#[test]
fn test_event_types() {
let events = vec![
EventType::Release,
EventType::Commit,
EventType::Custom("launch".to_string()),
];
let json = serde_json::to_string(&events).unwrap();
let parsed: Vec<EventType> = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.len(), 3);
}
}