use serde::Deserialize;
use crate::Error;
#[derive(Debug, Clone, Default, Deserialize)]
#[non_exhaustive]
pub struct DaemonConfig {
#[serde(default)]
pub kafka: Option<KafkaConfig>,
#[serde(default = "default_daemon_bind")]
pub bind: String,
#[serde(default = "default_max_concurrent")]
pub max_concurrent_tasks: usize,
#[serde(default)]
pub metrics: Option<MetricsConfig>,
#[serde(default)]
pub database_url: Option<String>,
pub auth: Option<AuthConfig>,
#[serde(default)]
pub memory: DaemonMemoryConfig,
#[serde(default)]
pub audit: DaemonAuditConfig,
#[serde(default)]
pub idempotency: IdempotencyConfig,
#[cfg(feature = "ghost-domain-config")]
#[serde(default)]
pub persona_mentions: Vec<PersonaMentionsConfig>,
#[cfg(feature = "ghost-domain-config")]
#[serde(default)]
pub persona_posts: Vec<PersonaPostsConfig>,
#[cfg(feature = "ghost-domain-config")]
#[serde(default)]
pub persona_quotes: Vec<PersonaQuotesConfig>,
#[cfg(feature = "ghost-domain-config")]
#[serde(default)]
pub persona_blog: Option<PersonaBlogConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DaemonMcpServerConfig {
#[serde(default = "default_mcp_server_name")]
pub name: String,
#[serde(default = "super::default_true")]
pub expose_tools: bool,
#[serde(default = "super::default_true")]
pub expose_resources: bool,
#[serde(default)]
pub expose_prompts: bool,
}
fn default_mcp_server_name() -> String {
"heartbit".into()
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DaemonAuditConfig {
#[serde(default)]
pub retain_days: Option<u32>,
#[serde(default)]
pub prune_interval_minutes: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct IdempotencyConfig {
#[serde(default)]
pub ttl_hours: Option<u32>,
#[serde(default)]
pub sweep_interval_minutes: Option<u32>,
}
#[cfg(feature = "ghost-domain-config")]
#[derive(Debug, Clone, Deserialize)]
pub struct PersonaMentionsConfig {
pub persona: String,
#[serde(default = "super::default_true")]
pub enabled: bool,
#[serde(default = "default_poll_interval_seconds")]
pub poll_interval_seconds: u64,
pub user_id: String,
#[serde(default = "default_candidates_per_reply")]
pub candidates_per_reply: usize,
#[serde(default = "default_mention_store")]
pub mention_store: String,
#[serde(default)]
pub mention_store_path: Option<String>,
#[serde(default = "super::default_true")]
pub enable_thread_depth_guard: bool,
#[serde(default = "super::default_true")]
pub enable_bot_heuristic_guard: bool,
#[serde(default)]
pub suspicious_handle_patterns: Vec<String>,
#[serde(default = "default_min_follower_following_ratio")]
pub min_follower_following_ratio: f32,
#[serde(default = "default_min_account_age_days")]
pub min_account_age_days: i64,
#[serde(default = "default_bot_heuristic_threshold")]
pub bot_heuristic_threshold: usize,
#[serde(default = "default_per_conversation_max_replies")]
pub per_conversation_max_replies: usize,
#[serde(default)]
pub daily_token_budget: Option<u64>,
#[serde(default = "default_p1_7_budget_store")]
pub budget_store: String,
#[serde(default)]
pub budget_path: Option<String>,
}
#[cfg(feature = "ghost-domain-config")]
fn default_poll_interval_seconds() -> u64 {
300
}
#[cfg(feature = "ghost-domain-config")]
fn default_candidates_per_reply() -> usize {
2
}
#[cfg(feature = "ghost-domain-config")]
fn default_mention_store() -> String {
"in_memory".into()
}
#[cfg(feature = "ghost-domain-config")]
fn default_min_follower_following_ratio() -> f32 {
0.05
}
#[cfg(feature = "ghost-domain-config")]
fn default_min_account_age_days() -> i64 {
7
}
#[cfg(feature = "ghost-domain-config")]
fn default_bot_heuristic_threshold() -> usize {
2
}
#[cfg(feature = "ghost-domain-config")]
fn default_per_conversation_max_replies() -> usize {
2
}
#[cfg(feature = "ghost-domain-config")]
fn default_p1_7_budget_store() -> String {
"in_memory".into()
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DaemonMemoryConfig {
#[serde(default)]
pub shared_write_roles: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthConfig {
#[serde(default)]
pub bearer_tokens: Vec<String>,
pub jwks_url: Option<String>,
pub issuer: Option<String>,
pub audience: Option<String>,
pub user_id_claim: Option<String>,
pub tenant_id_claim: Option<String>,
pub roles_claim: Option<String>,
pub token_exchange: Option<TokenExchangeConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TokenExchangeConfig {
pub exchange_url: String,
pub client_id: String,
pub client_secret: String,
pub tenant_id: Option<String>,
#[serde(default)]
pub agent_token: String,
#[serde(default)]
pub scopes: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HeartbitPulseConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_pulse_interval")]
pub interval_seconds: u64,
pub active_hours: Option<ActiveHoursConfig>,
pub prompt: Option<String>,
#[serde(default = "default_idle_backoff_threshold")]
pub idle_backoff_threshold: u32,
}
fn default_pulse_interval() -> u64 {
1800
}
fn default_idle_backoff_threshold() -> u32 {
6
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActiveHoursConfig {
pub start: String,
pub end: String,
}
impl ActiveHoursConfig {
pub fn parse_start(&self) -> Result<(u32, u32), Error> {
parse_hhmm(&self.start)
}
pub fn parse_end(&self) -> Result<(u32, u32), Error> {
parse_hhmm(&self.end)
}
}
fn parse_hhmm(s: &str) -> Result<(u32, u32), Error> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 2 {
return Err(Error::Config(format!(
"invalid time format '{}': expected HH:MM",
s
)));
}
let hour: u32 = parts[0]
.parse()
.map_err(|_| Error::Config(format!("invalid hour in '{s}'")))?;
let minute: u32 = parts[1]
.parse()
.map_err(|_| Error::Config(format!("invalid minute in '{s}'")))?;
if hour > 23 || minute > 59 {
return Err(Error::Config(format!(
"time '{}' out of range (00:00-23:59)",
s
)));
}
Ok((hour, minute))
}
#[cfg(feature = "ghost-domain-config")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ImageSource {
#[default]
Online,
Ai,
None,
}
#[cfg(feature = "ghost-domain-config")]
#[derive(Debug, Clone, Deserialize)]
pub struct PersonaPostsConfig {
pub persona: String,
#[serde(default = "super::default_true")]
pub enabled: bool,
#[serde(default = "default_post_interval_seconds")]
pub post_interval_seconds: u64,
#[serde(default = "default_post_interval_jitter_pct")]
pub interval_jitter_pct: u32,
#[serde(default)]
pub active_hours: Option<ActiveHoursConfig>,
#[serde(default = "default_post_candidates")]
pub candidates_per_draft: usize,
#[serde(default = "default_post_history_store")]
pub post_history_store: String,
#[serde(default)]
pub post_history_path: Option<String>,
#[serde(default = "default_post_history_lookback_days")]
pub post_history_lookback_days: i64,
#[serde(default)]
pub topic_brief: Option<String>,
#[serde(default = "default_engagement_refresh_seconds")]
pub engagement_refresh_seconds: u64,
#[serde(default = "default_engagement_top_n")]
pub engagement_top_n: usize,
#[serde(default = "default_engagement_max_age_days")]
pub engagement_max_age_days: i64,
#[serde(default = "default_engagement_min_age_hours")]
pub engagement_min_age_hours: i64,
#[serde(default)]
pub writer_provider: Option<super::agent::AgentProviderConfig>,
#[serde(default)]
pub image_source: ImageSource,
}
#[cfg(feature = "ghost-domain-config")]
fn default_post_interval_seconds() -> u64 {
14400
}
#[cfg(feature = "ghost-domain-config")]
fn default_post_interval_jitter_pct() -> u32 {
25
}
#[cfg(feature = "ghost-domain-config")]
fn default_post_candidates() -> usize {
3
}
#[cfg(feature = "ghost-domain-config")]
fn default_post_history_store() -> String {
"in_memory".into()
}
#[cfg(feature = "ghost-domain-config")]
fn default_post_history_lookback_days() -> i64 {
30
}
#[cfg(feature = "ghost-domain-config")]
fn default_engagement_refresh_seconds() -> u64 {
21600
}
#[cfg(feature = "ghost-domain-config")]
fn default_engagement_top_n() -> usize {
5
}
#[cfg(feature = "ghost-domain-config")]
fn default_engagement_max_age_days() -> i64 {
30
}
#[cfg(feature = "ghost-domain-config")]
fn default_engagement_min_age_hours() -> i64 {
24
}
#[cfg(feature = "ghost-domain-config")]
#[derive(Debug, Clone, Deserialize)]
pub struct PersonaQuotesConfig {
pub persona: String,
#[serde(default = "super::default_true")]
pub enabled: bool,
#[serde(default = "default_quote_poll_interval_seconds")]
pub poll_interval_seconds: u64,
#[serde(default = "default_quote_interval_jitter_pct")]
pub interval_jitter_pct: u32,
#[serde(default)]
pub active_hours: Option<ActiveHoursConfig>,
pub source_user_ids: Vec<String>,
#[serde(default = "default_quote_candidates_per_draft")]
pub candidates_per_draft: usize,
#[serde(default = "default_quote_seen_store")]
pub seen_store: String,
#[serde(default)]
pub seen_store_path: Option<String>,
#[serde(default = "default_quote_max_age_hours")]
pub max_age_hours: i64,
#[serde(default = "default_quote_max_candidates_per_tick")]
pub max_candidates_per_tick: usize,
#[serde(default)]
pub writer_provider: Option<super::agent::AgentProviderConfig>,
}
#[cfg(feature = "ghost-domain-config")]
fn default_quote_poll_interval_seconds() -> u64 {
5400 }
#[cfg(feature = "ghost-domain-config")]
fn default_quote_interval_jitter_pct() -> u32 {
25
}
#[cfg(feature = "ghost-domain-config")]
fn default_quote_candidates_per_draft() -> usize {
3
}
#[cfg(feature = "ghost-domain-config")]
fn default_quote_seen_store() -> String {
"in_memory".into()
}
#[cfg(feature = "ghost-domain-config")]
fn default_quote_max_age_hours() -> i64 {
12
}
#[cfg(feature = "ghost-domain-config")]
fn default_quote_max_candidates_per_tick() -> usize {
1
}
#[cfg(feature = "ghost-domain-config")]
#[derive(Debug, Clone, Deserialize)]
pub struct PersonaBlogConfig {
pub persona: String,
#[serde(default = "super::default_true")]
pub enabled: bool,
#[serde(default = "default_blog_poll_interval_seconds")]
pub poll_interval_seconds: u64,
#[serde(default = "default_blog_interval_jitter_pct")]
pub interval_jitter_pct: u32,
#[serde(default)]
pub active_hours: Option<ActiveHoursConfig>,
#[serde(default = "default_blog_posts_dir")]
pub posts_dir: String,
#[serde(default = "default_blog_out_dir")]
pub out_dir: String,
#[serde(default = "default_blog_seed_lookback_days")]
pub seed_lookback_days: i64,
#[serde(default = "default_blog_candidates_per_draft")]
pub candidates_per_draft: usize,
pub site_url: String,
#[serde(default = "default_blog_site_title")]
pub site_title: String,
#[serde(default)]
pub writer_provider: Option<super::agent::AgentProviderConfig>,
#[serde(default)]
pub deploy_command: Option<String>,
#[serde(default)]
pub x_announce: Option<XAnnounceConfig>,
#[serde(default)]
pub github_readme: Option<GithubReadmeConfig>,
}
#[cfg(feature = "ghost-domain-config")]
fn default_blog_poll_interval_seconds() -> u64 {
604_800 }
#[cfg(feature = "ghost-domain-config")]
fn default_blog_interval_jitter_pct() -> u32 {
10
}
#[cfg(feature = "ghost-domain-config")]
fn default_blog_posts_dir() -> String {
"blog-site/posts".into()
}
#[cfg(feature = "ghost-domain-config")]
fn default_blog_out_dir() -> String {
"blog-site/public".into()
}
#[cfg(feature = "ghost-domain-config")]
fn default_blog_seed_lookback_days() -> i64 {
7
}
#[cfg(feature = "ghost-domain-config")]
fn default_blog_candidates_per_draft() -> usize {
2
}
#[cfg(feature = "ghost-domain-config")]
fn default_blog_site_title() -> String {
"pascal.heartbit.ai".into()
}
#[cfg(feature = "ghost-domain-config")]
#[derive(Debug, Clone, Deserialize)]
pub struct XAnnounceConfig {
#[serde(default = "super::default_true")]
pub enabled: bool,
}
#[cfg(feature = "ghost-domain-config")]
#[derive(Debug, Clone, Deserialize)]
pub struct GithubReadmeConfig {
#[serde(default = "super::default_true")]
pub enabled: bool,
pub local_repo_path: String,
#[serde(default = "default_bio_template_path")]
pub bio_template_path: String,
pub git_author_name: String,
pub git_author_email: String,
}
#[cfg(feature = "ghost-domain-config")]
fn default_bio_template_path() -> String {
"bio.md".into()
}
#[derive(Debug, Clone, Deserialize)]
pub struct WsConfig {
#[serde(default = "super::default_true")]
pub enabled: bool,
#[serde(default = "default_interaction_timeout")]
pub interaction_timeout_seconds: u64,
#[serde(default = "default_max_ws_connections")]
pub max_connections: usize,
#[serde(default)]
pub database_url: Option<String>,
}
fn default_interaction_timeout() -> u64 {
120
}
fn default_max_ws_connections() -> usize {
100
}
#[derive(Debug, Clone, Deserialize)]
pub struct KafkaConfig {
pub brokers: String,
#[serde(default = "default_consumer_group")]
pub consumer_group: String,
#[serde(default = "default_commands_topic")]
pub commands_topic: String,
#[serde(default = "default_events_topic")]
pub events_topic: String,
#[serde(default = "default_dead_letter_topic")]
pub dead_letter_topic: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ScheduleEntry {
pub name: String,
pub cron: String,
pub task: String,
#[serde(default = "super::default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct MetricsConfig {
#[serde(default = "super::default_true")]
pub enabled: bool,
}
fn default_daemon_bind() -> String {
"127.0.0.1:3000".into()
}
fn default_max_concurrent() -> usize {
4
}
fn default_consumer_group() -> String {
"heartbit-daemon".into()
}
fn default_commands_topic() -> String {
"heartbit.commands".into()
}
fn default_events_topic() -> String {
"heartbit.events".into()
}
fn default_dead_letter_topic() -> String {
"heartbit.dead-letter".into()
}
#[cfg(all(test, feature = "ghost-domain-config"))]
mod tests {
use super::*;
#[test]
fn persona_posts_config_parses_with_defaults() {
let toml = r#"
[[persona_posts]]
persona = "heartbit-ghost:x"
"#;
#[derive(Deserialize)]
struct Shim {
persona_posts: Vec<PersonaPostsConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
assert_eq!(cfg.persona_posts.len(), 1);
let p = &cfg.persona_posts[0];
assert_eq!(p.persona, "heartbit-ghost:x");
assert!(p.enabled);
assert_eq!(p.post_interval_seconds, 14400);
assert_eq!(p.candidates_per_draft, 3);
assert_eq!(p.post_history_store, "in_memory");
assert_eq!(p.post_history_lookback_days, 30);
assert!(p.active_hours.is_none());
assert!(p.topic_brief.is_none());
assert!(p.post_history_path.is_none());
assert_eq!(p.engagement_refresh_seconds, 21600);
assert_eq!(p.engagement_top_n, 5);
assert_eq!(p.engagement_max_age_days, 30);
assert_eq!(p.engagement_min_age_hours, 24);
assert!(p.writer_provider.is_none());
}
#[test]
fn persona_posts_config_parses_writer_provider_override() {
let toml = r#"
[[persona_posts]]
persona = "heartbit-ghost:x"
[persona_posts.writer_provider]
name = "openrouter"
model = "x-ai/grok-4"
"#;
#[derive(Deserialize)]
struct Shim {
persona_posts: Vec<PersonaPostsConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
let p = &cfg.persona_posts[0];
let wp = p.writer_provider.as_ref().expect("writer_provider present");
assert_eq!(wp.name, "openrouter");
assert_eq!(wp.model, "x-ai/grok-4");
}
#[test]
fn persona_posts_config_parses_engagement_overrides() {
let toml = r#"
[[persona_posts]]
persona = "heartbit-ghost:x"
engagement_refresh_seconds = 3600
engagement_top_n = 0
engagement_max_age_days = 7
engagement_min_age_hours = 6
"#;
#[derive(Deserialize)]
struct Shim {
persona_posts: Vec<PersonaPostsConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
let p = &cfg.persona_posts[0];
assert_eq!(p.engagement_refresh_seconds, 3600);
assert_eq!(p.engagement_top_n, 0);
assert_eq!(p.engagement_max_age_days, 7);
assert_eq!(p.engagement_min_age_hours, 6);
}
#[test]
fn persona_posts_image_source_defaults_to_online() {
let toml = r#"
[[persona_posts]]
persona = "heartbit-ghost:x"
"#;
#[derive(Deserialize)]
struct Shim {
persona_posts: Vec<PersonaPostsConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
assert_eq!(cfg.persona_posts[0].image_source, ImageSource::Online);
}
#[test]
fn persona_posts_image_source_parses_explicit() {
let toml = r#"
[[persona_posts]]
persona = "heartbit-ghost:x"
image_source = "none"
"#;
#[derive(Deserialize)]
struct Shim {
persona_posts: Vec<PersonaPostsConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
assert_eq!(cfg.persona_posts[0].image_source, ImageSource::None);
}
#[test]
fn persona_posts_image_source_parses_ai() {
let toml = r#"
[[persona_posts]]
persona = "heartbit-ghost:x"
image_source = "ai"
"#;
#[derive(Deserialize)]
struct Shim {
persona_posts: Vec<PersonaPostsConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
assert_eq!(cfg.persona_posts[0].image_source, ImageSource::Ai);
}
#[test]
fn persona_quotes_config_parses_with_defaults() {
let toml = r#"
[[persona_quotes]]
persona = "heartbit-ghost:x"
source_user_ids = ["44196397", "16884623"]
"#;
#[derive(Deserialize)]
struct Shim {
persona_quotes: Vec<PersonaQuotesConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
assert_eq!(cfg.persona_quotes.len(), 1);
let q = &cfg.persona_quotes[0];
assert_eq!(q.persona, "heartbit-ghost:x");
assert!(q.enabled);
assert_eq!(q.poll_interval_seconds, 5400); assert_eq!(q.interval_jitter_pct, 25);
assert!(q.active_hours.is_none());
assert_eq!(q.candidates_per_draft, 3);
assert_eq!(q.seen_store, "in_memory");
assert!(q.seen_store_path.is_none());
assert_eq!(q.max_age_hours, 12);
assert_eq!(q.max_candidates_per_tick, 1);
assert_eq!(q.source_user_ids, vec!["44196397", "16884623"]);
assert!(q.writer_provider.is_none());
}
#[test]
fn persona_quotes_config_rejects_missing_required_fields() {
let toml = r#"
[[persona_quotes]]
persona = "heartbit-ghost:x"
"#;
#[derive(Debug, Deserialize)]
struct Shim {
#[allow(dead_code)]
persona_quotes: Vec<PersonaQuotesConfig>,
}
let err = toml::from_str::<Shim>(toml).unwrap_err();
assert!(
err.to_string().contains("source_user_ids"),
"expected missing-field error for source_user_ids; got: {err}"
);
}
#[test]
fn persona_blog_config_parses_with_defaults() {
let toml = r#"
[persona_blog]
persona = "heartbit-ghost:x"
site_url = "https://pascal.heartbit.ai"
"#;
#[derive(Deserialize)]
struct Shim {
persona_blog: PersonaBlogConfig,
}
let cfg: Shim = toml::from_str(toml).unwrap();
let b = &cfg.persona_blog;
assert_eq!(b.persona, "heartbit-ghost:x");
assert!(b.enabled);
assert_eq!(b.poll_interval_seconds, 604_800); assert_eq!(b.interval_jitter_pct, 10);
assert_eq!(b.posts_dir, "blog-site/posts");
assert_eq!(b.out_dir, "blog-site/public");
assert_eq!(b.seed_lookback_days, 7);
assert_eq!(b.candidates_per_draft, 2);
assert_eq!(b.site_url, "https://pascal.heartbit.ai");
assert_eq!(b.site_title, "pascal.heartbit.ai");
assert!(b.writer_provider.is_none());
assert!(b.deploy_command.is_none());
}
#[test]
fn persona_blog_config_parses_deploy_command() {
let toml = r#"
[persona_blog]
persona = "heartbit-ghost:x"
site_url = "https://pascal.heartbit.ai"
deploy_command = "npx wrangler pages deploy blog-site/public --project-name=pascal-heartbit-ai --commit-dirty=true --branch=main"
"#;
#[derive(Deserialize)]
struct Shim {
persona_blog: PersonaBlogConfig,
}
let cfg: Shim = toml::from_str(toml).unwrap();
let cmd = cfg.persona_blog.deploy_command.as_deref().unwrap();
assert!(cmd.contains("wrangler pages deploy"));
assert!(cmd.contains("pascal-heartbit-ai"));
}
#[test]
fn persona_blog_config_parses_x_announce_block() {
let toml = r#"
[persona_blog]
persona = "heartbit-ghost:x"
site_url = "https://pascal.heartbit.ai"
[persona_blog.x_announce]
enabled = true
"#;
#[derive(Deserialize)]
struct Shim {
persona_blog: PersonaBlogConfig,
}
let cfg: Shim = toml::from_str(toml).unwrap();
let x = cfg.persona_blog.x_announce.as_ref().unwrap();
assert!(x.enabled);
}
#[test]
fn persona_blog_config_parses_github_readme_block() {
let toml = r#"
[persona_blog]
persona = "heartbit-ghost:x"
site_url = "https://pascal.heartbit.ai"
[persona_blog.github_readme]
enabled = true
local_repo_path = "/home/pleclech/projects/100-tokens-profile"
git_author_name = "Pascal Le Clech"
git_author_email = "pascal@heartbit.ai"
"#;
#[derive(Deserialize)]
struct Shim {
persona_blog: PersonaBlogConfig,
}
let cfg: Shim = toml::from_str(toml).unwrap();
let gh = cfg.persona_blog.github_readme.as_ref().unwrap();
assert!(gh.enabled);
assert_eq!(
gh.local_repo_path,
"/home/pleclech/projects/100-tokens-profile"
);
assert_eq!(gh.bio_template_path, "bio.md"); assert_eq!(gh.git_author_name, "Pascal Le Clech");
}
#[test]
fn persona_blog_config_amp_blocks_default_to_none() {
let toml = r#"
[persona_blog]
persona = "heartbit-ghost:x"
site_url = "https://pascal.heartbit.ai"
"#;
#[derive(Deserialize)]
struct Shim {
persona_blog: PersonaBlogConfig,
}
let cfg: Shim = toml::from_str(toml).unwrap();
assert!(cfg.persona_blog.x_announce.is_none());
assert!(cfg.persona_blog.github_readme.is_none());
}
#[test]
fn persona_blog_config_rejects_missing_required_fields() {
let toml = r#"
[persona_blog]
persona = "heartbit-ghost:x"
"#;
#[derive(Deserialize, Debug)]
struct Shim {
#[allow(dead_code)]
persona_blog: PersonaBlogConfig,
}
let err = toml::from_str::<Shim>(toml).unwrap_err();
assert!(
err.to_string().contains("site_url"),
"expected missing-field error for site_url; got: {err}"
);
}
#[test]
fn persona_posts_config_parses_full() {
let toml = r#"
[[persona_posts]]
persona = "heartbit-ghost:x"
enabled = true
post_interval_seconds = 7200
candidates_per_draft = 5
post_history_store = "jsonl"
post_history_path = "~/.heartbit/posts.jsonl"
post_history_lookback_days = 14
topic_brief = "agents, Rust, LLMs"
[persona_posts.active_hours]
start = "09:00"
end = "22:00"
"#;
#[derive(Deserialize)]
struct Shim {
persona_posts: Vec<PersonaPostsConfig>,
}
let cfg: Shim = toml::from_str(toml).unwrap();
let p = &cfg.persona_posts[0];
assert_eq!(p.persona, "heartbit-ghost:x");
assert!(p.enabled);
assert_eq!(p.post_interval_seconds, 7200);
assert_eq!(p.candidates_per_draft, 5);
assert_eq!(p.post_history_store, "jsonl");
assert_eq!(
p.post_history_path.as_deref(),
Some("~/.heartbit/posts.jsonl")
);
assert_eq!(p.post_history_lookback_days, 14);
assert_eq!(p.topic_brief.as_deref(), Some("agents, Rust, LLMs"));
assert!(p.active_hours.is_some());
let h = p.active_hours.as_ref().unwrap();
assert_eq!(h.start, "09:00");
assert_eq!(h.end, "22:00");
}
}