1use serde::Deserialize;
2
3use crate::Error;
4
5#[derive(Debug, Clone, Default, Deserialize)]
17#[non_exhaustive]
18pub struct DaemonConfig {
19 #[serde(default)]
21 pub kafka: Option<KafkaConfig>,
22 #[serde(default = "default_daemon_bind")]
24 pub bind: String,
25 #[serde(default = "default_max_concurrent")]
27 pub max_concurrent_tasks: usize,
28 #[serde(default)]
30 pub metrics: Option<MetricsConfig>,
31 #[serde(default)]
34 pub database_url: Option<String>,
35 pub auth: Option<AuthConfig>,
37 #[serde(default)]
39 pub memory: DaemonMemoryConfig,
40 #[serde(default)]
42 pub audit: DaemonAuditConfig,
43 #[serde(default)]
45 pub idempotency: IdempotencyConfig,
46 #[cfg(feature = "ghost-domain-config")]
50 #[serde(default)]
51 pub persona_mentions: Vec<PersonaMentionsConfig>,
52 #[cfg(feature = "ghost-domain-config")]
55 #[serde(default)]
56 pub persona_posts: Vec<PersonaPostsConfig>,
57 #[cfg(feature = "ghost-domain-config")]
60 #[serde(default)]
61 pub persona_quotes: Vec<PersonaQuotesConfig>,
62 #[cfg(feature = "ghost-domain-config")]
65 #[serde(default)]
66 pub persona_blog: Option<PersonaBlogConfig>,
67}
68
69#[derive(Debug, Clone, Deserialize)]
74pub struct DaemonMcpServerConfig {
75 #[serde(default = "default_mcp_server_name")]
77 pub name: String,
78 #[serde(default = "super::default_true")]
80 pub expose_tools: bool,
81 #[serde(default = "super::default_true")]
83 pub expose_resources: bool,
84 #[serde(default)]
86 pub expose_prompts: bool,
87}
88
89fn default_mcp_server_name() -> String {
90 "heartbit".into()
91}
92
93#[derive(Debug, Clone, Default, Deserialize)]
99pub struct DaemonAuditConfig {
100 #[serde(default)]
103 pub retain_days: Option<u32>,
104 #[serde(default)]
106 pub prune_interval_minutes: Option<u64>,
107}
108
109#[derive(Debug, Clone, Default, Deserialize)]
115pub struct IdempotencyConfig {
116 #[serde(default)]
119 pub ttl_hours: Option<u32>,
120 #[serde(default)]
122 pub sweep_interval_minutes: Option<u32>,
123}
124
125#[cfg(feature = "ghost-domain-config")]
132#[derive(Debug, Clone, Deserialize)]
133pub struct PersonaMentionsConfig {
134 pub persona: String,
136 #[serde(default = "super::default_true")]
138 pub enabled: bool,
139 #[serde(default = "default_poll_interval_seconds")]
141 pub poll_interval_seconds: u64,
142 pub user_id: String,
144 #[serde(default = "default_candidates_per_reply")]
146 pub candidates_per_reply: usize,
147 #[serde(default = "default_mention_store")]
150 pub mention_store: String,
151 #[serde(default)]
155 pub mention_store_path: Option<String>,
156
157 #[serde(default = "super::default_true")]
161 pub enable_thread_depth_guard: bool,
162 #[serde(default = "super::default_true")]
164 pub enable_bot_heuristic_guard: bool,
165 #[serde(default)]
168 pub suspicious_handle_patterns: Vec<String>,
169 #[serde(default = "default_min_follower_following_ratio")]
172 pub min_follower_following_ratio: f32,
173 #[serde(default = "default_min_account_age_days")]
176 pub min_account_age_days: i64,
177 #[serde(default = "default_bot_heuristic_threshold")]
179 pub bot_heuristic_threshold: usize,
180 #[serde(default = "default_per_conversation_max_replies")]
182 pub per_conversation_max_replies: usize,
183 #[serde(default)]
185 pub daily_token_budget: Option<u64>,
186 #[serde(default = "default_p1_7_budget_store")]
189 pub budget_store: String,
190 #[serde(default)]
193 pub budget_path: Option<String>,
194}
195
196#[cfg(feature = "ghost-domain-config")]
197fn default_poll_interval_seconds() -> u64 {
198 300
199}
200
201#[cfg(feature = "ghost-domain-config")]
202fn default_candidates_per_reply() -> usize {
203 2
204}
205
206#[cfg(feature = "ghost-domain-config")]
207fn default_mention_store() -> String {
208 "in_memory".into()
209}
210
211#[cfg(feature = "ghost-domain-config")]
212fn default_min_follower_following_ratio() -> f32 {
213 0.05
214}
215
216#[cfg(feature = "ghost-domain-config")]
217fn default_min_account_age_days() -> i64 {
218 7
219}
220
221#[cfg(feature = "ghost-domain-config")]
222fn default_bot_heuristic_threshold() -> usize {
223 2
224}
225
226#[cfg(feature = "ghost-domain-config")]
227fn default_per_conversation_max_replies() -> usize {
228 2
229}
230
231#[cfg(feature = "ghost-domain-config")]
232fn default_p1_7_budget_store() -> String {
233 "in_memory".into()
234}
235
236#[derive(Debug, Clone, Default, Deserialize)]
240pub struct DaemonMemoryConfig {
241 #[serde(default)]
248 pub shared_write_roles: Vec<String>,
249}
250
251#[derive(Debug, Clone, Deserialize)]
253pub struct AuthConfig {
254 #[serde(default)]
256 pub bearer_tokens: Vec<String>,
257 pub jwks_url: Option<String>,
260 pub issuer: Option<String>,
262 pub audience: Option<String>,
264 pub user_id_claim: Option<String>,
266 pub tenant_id_claim: Option<String>,
268 pub roles_claim: Option<String>,
270 pub token_exchange: Option<TokenExchangeConfig>,
273}
274
275#[derive(Debug, Clone, Deserialize)]
281pub struct TokenExchangeConfig {
282 pub exchange_url: String,
284 pub client_id: String,
286 pub client_secret: String,
288 pub tenant_id: Option<String>,
291 #[serde(default)]
294 pub agent_token: String,
295 #[serde(default)]
297 pub scopes: Vec<String>,
298}
299
300#[derive(Debug, Clone, Deserialize)]
305pub struct HeartbitPulseConfig {
306 #[serde(default)]
308 pub enabled: bool,
309 #[serde(default = "default_pulse_interval")]
311 pub interval_seconds: u64,
312 pub active_hours: Option<ActiveHoursConfig>,
314 pub prompt: Option<String>,
317 #[serde(default = "default_idle_backoff_threshold")]
320 pub idle_backoff_threshold: u32,
321}
322
323fn default_pulse_interval() -> u64 {
324 1800
325}
326
327fn default_idle_backoff_threshold() -> u32 {
328 6
329}
330
331#[derive(Debug, Clone, Deserialize)]
333pub struct ActiveHoursConfig {
334 pub start: String,
336 pub end: String,
338}
339
340impl ActiveHoursConfig {
341 pub fn parse_start(&self) -> Result<(u32, u32), Error> {
343 parse_hhmm(&self.start)
344 }
345
346 pub fn parse_end(&self) -> Result<(u32, u32), Error> {
348 parse_hhmm(&self.end)
349 }
350}
351
352fn parse_hhmm(s: &str) -> Result<(u32, u32), Error> {
353 let parts: Vec<&str> = s.split(':').collect();
354 if parts.len() != 2 {
355 return Err(Error::Config(format!(
356 "invalid time format '{}': expected HH:MM",
357 s
358 )));
359 }
360 let hour: u32 = parts[0]
361 .parse()
362 .map_err(|_| Error::Config(format!("invalid hour in '{s}'")))?;
363 let minute: u32 = parts[1]
364 .parse()
365 .map_err(|_| Error::Config(format!("invalid minute in '{s}'")))?;
366 if hour > 23 || minute > 59 {
367 return Err(Error::Config(format!(
368 "time '{}' out of range (00:00-23:59)",
369 s
370 )));
371 }
372 Ok((hour, minute))
373}
374
375#[cfg(feature = "ghost-domain-config")]
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
378#[serde(rename_all = "snake_case")]
379pub enum ImageSource {
380 #[default]
382 Online,
383 Ai,
385 None,
387}
388
389#[cfg(feature = "ghost-domain-config")]
399#[derive(Debug, Clone, Deserialize)]
400pub struct PersonaPostsConfig {
401 pub persona: String,
403 #[serde(default = "super::default_true")]
405 pub enabled: bool,
406 #[serde(default = "default_post_interval_seconds")]
410 pub post_interval_seconds: u64,
411 #[serde(default = "default_post_interval_jitter_pct")]
417 pub interval_jitter_pct: u32,
418 #[serde(default)]
422 pub active_hours: Option<ActiveHoursConfig>,
423 #[serde(default = "default_post_candidates")]
426 pub candidates_per_draft: usize,
427 #[serde(default = "default_post_history_store")]
429 pub post_history_store: String,
430 #[serde(default)]
434 pub post_history_path: Option<String>,
435 #[serde(default = "default_post_history_lookback_days")]
437 pub post_history_lookback_days: i64,
438 #[serde(default)]
441 pub topic_brief: Option<String>,
442
443 #[serde(default = "default_engagement_refresh_seconds")]
448 pub engagement_refresh_seconds: u64,
449 #[serde(default = "default_engagement_top_n")]
453 pub engagement_top_n: usize,
454 #[serde(default = "default_engagement_max_age_days")]
457 pub engagement_max_age_days: i64,
458 #[serde(default = "default_engagement_min_age_hours")]
461 pub engagement_min_age_hours: i64,
462
463 #[serde(default)]
473 pub writer_provider: Option<super::agent::AgentProviderConfig>,
474
475 #[serde(default)]
479 pub image_source: ImageSource,
480}
481
482#[cfg(feature = "ghost-domain-config")]
483fn default_post_interval_seconds() -> u64 {
484 14400
485}
486
487#[cfg(feature = "ghost-domain-config")]
488fn default_post_interval_jitter_pct() -> u32 {
489 25
490}
491
492#[cfg(feature = "ghost-domain-config")]
493fn default_post_candidates() -> usize {
494 3
495}
496
497#[cfg(feature = "ghost-domain-config")]
498fn default_post_history_store() -> String {
499 "in_memory".into()
500}
501
502#[cfg(feature = "ghost-domain-config")]
503fn default_post_history_lookback_days() -> i64 {
504 30
505}
506
507#[cfg(feature = "ghost-domain-config")]
508fn default_engagement_refresh_seconds() -> u64 {
509 21600
510}
511
512#[cfg(feature = "ghost-domain-config")]
513fn default_engagement_top_n() -> usize {
514 5
515}
516
517#[cfg(feature = "ghost-domain-config")]
518fn default_engagement_max_age_days() -> i64 {
519 30
520}
521
522#[cfg(feature = "ghost-domain-config")]
523fn default_engagement_min_age_hours() -> i64 {
524 24
525}
526
527#[cfg(feature = "ghost-domain-config")]
539#[derive(Debug, Clone, Deserialize)]
540pub struct PersonaQuotesConfig {
541 pub persona: String,
543 #[serde(default = "super::default_true")]
545 pub enabled: bool,
546 #[serde(default = "default_quote_poll_interval_seconds")]
551 pub poll_interval_seconds: u64,
552 #[serde(default = "default_quote_interval_jitter_pct")]
556 pub interval_jitter_pct: u32,
557 #[serde(default)]
560 pub active_hours: Option<ActiveHoursConfig>,
561 pub source_user_ids: Vec<String>,
564 #[serde(default = "default_quote_candidates_per_draft")]
570 pub candidates_per_draft: usize,
571 #[serde(default = "default_quote_seen_store")]
574 pub seen_store: String,
575 #[serde(default)]
578 pub seen_store_path: Option<String>,
579 #[serde(default = "default_quote_max_age_hours")]
583 pub max_age_hours: i64,
584 #[serde(default = "default_quote_max_candidates_per_tick")]
589 pub max_candidates_per_tick: usize,
590 #[serde(default)]
594 pub writer_provider: Option<super::agent::AgentProviderConfig>,
595}
596
597#[cfg(feature = "ghost-domain-config")]
598fn default_quote_poll_interval_seconds() -> u64 {
599 5400 }
601
602#[cfg(feature = "ghost-domain-config")]
603fn default_quote_interval_jitter_pct() -> u32 {
604 25
605}
606
607#[cfg(feature = "ghost-domain-config")]
608fn default_quote_candidates_per_draft() -> usize {
609 3
610}
611
612#[cfg(feature = "ghost-domain-config")]
613fn default_quote_seen_store() -> String {
614 "in_memory".into()
615}
616
617#[cfg(feature = "ghost-domain-config")]
618fn default_quote_max_age_hours() -> i64 {
619 12
620}
621
622#[cfg(feature = "ghost-domain-config")]
623fn default_quote_max_candidates_per_tick() -> usize {
624 1
625}
626
627#[cfg(feature = "ghost-domain-config")]
640#[derive(Debug, Clone, Deserialize)]
641pub struct PersonaBlogConfig {
642 pub persona: String,
647 #[serde(default = "super::default_true")]
649 pub enabled: bool,
650 #[serde(default = "default_blog_poll_interval_seconds")]
654 pub poll_interval_seconds: u64,
655 #[serde(default = "default_blog_interval_jitter_pct")]
659 pub interval_jitter_pct: u32,
660 #[serde(default)]
662 pub active_hours: Option<ActiveHoursConfig>,
663 #[serde(default = "default_blog_posts_dir")]
667 pub posts_dir: String,
668 #[serde(default = "default_blog_out_dir")]
673 pub out_dir: String,
674 #[serde(default = "default_blog_seed_lookback_days")]
678 pub seed_lookback_days: i64,
679 #[serde(default = "default_blog_candidates_per_draft")]
683 pub candidates_per_draft: usize,
684 pub site_url: String,
687 #[serde(default = "default_blog_site_title")]
690 pub site_title: String,
691 #[serde(default)]
695 pub writer_provider: Option<super::agent::AgentProviderConfig>,
696 #[serde(default)]
701 pub deploy_command: Option<String>,
702 #[serde(default)]
708 pub x_announce: Option<XAnnounceConfig>,
709 #[serde(default)]
714 pub github_readme: Option<GithubReadmeConfig>,
715}
716
717#[cfg(feature = "ghost-domain-config")]
718fn default_blog_poll_interval_seconds() -> u64 {
719 604_800 }
721
722#[cfg(feature = "ghost-domain-config")]
723fn default_blog_interval_jitter_pct() -> u32 {
724 10
725}
726
727#[cfg(feature = "ghost-domain-config")]
728fn default_blog_posts_dir() -> String {
729 "blog-site/posts".into()
730}
731
732#[cfg(feature = "ghost-domain-config")]
733fn default_blog_out_dir() -> String {
734 "blog-site/public".into()
735}
736
737#[cfg(feature = "ghost-domain-config")]
738fn default_blog_seed_lookback_days() -> i64 {
739 7
740}
741
742#[cfg(feature = "ghost-domain-config")]
743fn default_blog_candidates_per_draft() -> usize {
744 2
745}
746
747#[cfg(feature = "ghost-domain-config")]
748fn default_blog_site_title() -> String {
749 "pascal.heartbit.ai".into()
750}
751
752#[cfg(feature = "ghost-domain-config")]
754#[derive(Debug, Clone, Deserialize)]
755pub struct XAnnounceConfig {
756 #[serde(default = "super::default_true")]
758 pub enabled: bool,
759}
760
761#[cfg(feature = "ghost-domain-config")]
763#[derive(Debug, Clone, Deserialize)]
764pub struct GithubReadmeConfig {
765 #[serde(default = "super::default_true")]
767 pub enabled: bool,
768 pub local_repo_path: String,
773 #[serde(default = "default_bio_template_path")]
777 pub bio_template_path: String,
778 pub git_author_name: String,
780 pub git_author_email: String,
782}
783
784#[cfg(feature = "ghost-domain-config")]
785fn default_bio_template_path() -> String {
786 "bio.md".into()
787}
788
789#[derive(Debug, Clone, Deserialize)]
791pub struct WsConfig {
792 #[serde(default = "super::default_true")]
794 pub enabled: bool,
795 #[serde(default = "default_interaction_timeout")]
798 pub interaction_timeout_seconds: u64,
799 #[serde(default = "default_max_ws_connections")]
801 pub max_connections: usize,
802 #[serde(default)]
805 pub database_url: Option<String>,
806}
807
808fn default_interaction_timeout() -> u64 {
809 120
810}
811
812fn default_max_ws_connections() -> usize {
813 100
814}
815
816#[derive(Debug, Clone, Deserialize)]
818pub struct KafkaConfig {
819 pub brokers: String,
821 #[serde(default = "default_consumer_group")]
823 pub consumer_group: String,
824 #[serde(default = "default_commands_topic")]
826 pub commands_topic: String,
827 #[serde(default = "default_events_topic")]
829 pub events_topic: String,
830 #[serde(default = "default_dead_letter_topic")]
832 pub dead_letter_topic: String,
833}
834
835#[derive(Debug, Clone, Deserialize)]
837pub struct ScheduleEntry {
838 pub name: String,
840 pub cron: String,
842 pub task: String,
844 #[serde(default = "super::default_true")]
846 pub enabled: bool,
847}
848
849#[derive(Debug, Clone, Deserialize)]
851pub struct MetricsConfig {
852 #[serde(default = "super::default_true")]
854 pub enabled: bool,
855}
856
857fn default_daemon_bind() -> String {
858 "127.0.0.1:3000".into()
859}
860
861fn default_max_concurrent() -> usize {
862 4
863}
864
865fn default_consumer_group() -> String {
866 "heartbit-daemon".into()
867}
868
869fn default_commands_topic() -> String {
870 "heartbit.commands".into()
871}
872
873fn default_events_topic() -> String {
874 "heartbit.events".into()
875}
876
877fn default_dead_letter_topic() -> String {
878 "heartbit.dead-letter".into()
879}
880
881#[cfg(all(test, feature = "ghost-domain-config"))]
882mod tests {
883 use super::*;
884
885 #[test]
886 fn persona_posts_config_parses_with_defaults() {
887 let toml = r#"
888[[persona_posts]]
889persona = "heartbit-ghost:x"
890"#;
891 #[derive(Deserialize)]
892 struct Shim {
893 persona_posts: Vec<PersonaPostsConfig>,
894 }
895 let cfg: Shim = toml::from_str(toml).unwrap();
896 assert_eq!(cfg.persona_posts.len(), 1);
897 let p = &cfg.persona_posts[0];
898 assert_eq!(p.persona, "heartbit-ghost:x");
899 assert!(p.enabled);
900 assert_eq!(p.post_interval_seconds, 14400);
901 assert_eq!(p.candidates_per_draft, 3);
902 assert_eq!(p.post_history_store, "in_memory");
903 assert_eq!(p.post_history_lookback_days, 30);
904 assert!(p.active_hours.is_none());
905 assert!(p.topic_brief.is_none());
906 assert!(p.post_history_path.is_none());
907 assert_eq!(p.engagement_refresh_seconds, 21600);
909 assert_eq!(p.engagement_top_n, 5);
910 assert_eq!(p.engagement_max_age_days, 30);
911 assert_eq!(p.engagement_min_age_hours, 24);
912 assert!(p.writer_provider.is_none());
914 }
915
916 #[test]
917 fn persona_posts_config_parses_writer_provider_override() {
918 let toml = r#"
919[[persona_posts]]
920persona = "heartbit-ghost:x"
921
922[persona_posts.writer_provider]
923name = "openrouter"
924model = "x-ai/grok-4"
925"#;
926 #[derive(Deserialize)]
927 struct Shim {
928 persona_posts: Vec<PersonaPostsConfig>,
929 }
930 let cfg: Shim = toml::from_str(toml).unwrap();
931 let p = &cfg.persona_posts[0];
932 let wp = p.writer_provider.as_ref().expect("writer_provider present");
933 assert_eq!(wp.name, "openrouter");
934 assert_eq!(wp.model, "x-ai/grok-4");
935 }
936
937 #[test]
938 fn persona_posts_config_parses_engagement_overrides() {
939 let toml = r#"
940[[persona_posts]]
941persona = "heartbit-ghost:x"
942engagement_refresh_seconds = 3600
943engagement_top_n = 0
944engagement_max_age_days = 7
945engagement_min_age_hours = 6
946"#;
947 #[derive(Deserialize)]
948 struct Shim {
949 persona_posts: Vec<PersonaPostsConfig>,
950 }
951 let cfg: Shim = toml::from_str(toml).unwrap();
952 let p = &cfg.persona_posts[0];
953 assert_eq!(p.engagement_refresh_seconds, 3600);
954 assert_eq!(p.engagement_top_n, 0);
955 assert_eq!(p.engagement_max_age_days, 7);
956 assert_eq!(p.engagement_min_age_hours, 6);
957 }
958
959 #[test]
960 fn persona_posts_image_source_defaults_to_online() {
961 let toml = r#"
962[[persona_posts]]
963persona = "heartbit-ghost:x"
964"#;
965 #[derive(Deserialize)]
966 struct Shim {
967 persona_posts: Vec<PersonaPostsConfig>,
968 }
969 let cfg: Shim = toml::from_str(toml).unwrap();
970 assert_eq!(cfg.persona_posts[0].image_source, ImageSource::Online);
971 }
972
973 #[test]
974 fn persona_posts_image_source_parses_explicit() {
975 let toml = r#"
976[[persona_posts]]
977persona = "heartbit-ghost:x"
978image_source = "none"
979"#;
980 #[derive(Deserialize)]
981 struct Shim {
982 persona_posts: Vec<PersonaPostsConfig>,
983 }
984 let cfg: Shim = toml::from_str(toml).unwrap();
985 assert_eq!(cfg.persona_posts[0].image_source, ImageSource::None);
986 }
987
988 #[test]
989 fn persona_posts_image_source_parses_ai() {
990 let toml = r#"
991[[persona_posts]]
992persona = "heartbit-ghost:x"
993image_source = "ai"
994"#;
995 #[derive(Deserialize)]
996 struct Shim {
997 persona_posts: Vec<PersonaPostsConfig>,
998 }
999 let cfg: Shim = toml::from_str(toml).unwrap();
1000 assert_eq!(cfg.persona_posts[0].image_source, ImageSource::Ai);
1001 }
1002
1003 #[test]
1004 fn persona_quotes_config_parses_with_defaults() {
1005 let toml = r#"
1006[[persona_quotes]]
1007persona = "heartbit-ghost:x"
1008source_user_ids = ["44196397", "16884623"]
1009"#;
1010 #[derive(Deserialize)]
1011 struct Shim {
1012 persona_quotes: Vec<PersonaQuotesConfig>,
1013 }
1014 let cfg: Shim = toml::from_str(toml).unwrap();
1015 assert_eq!(cfg.persona_quotes.len(), 1);
1016 let q = &cfg.persona_quotes[0];
1017 assert_eq!(q.persona, "heartbit-ghost:x");
1018 assert!(q.enabled);
1019 assert_eq!(q.poll_interval_seconds, 5400); assert_eq!(q.interval_jitter_pct, 25);
1021 assert!(q.active_hours.is_none());
1022 assert_eq!(q.candidates_per_draft, 3);
1023 assert_eq!(q.seen_store, "in_memory");
1024 assert!(q.seen_store_path.is_none());
1025 assert_eq!(q.max_age_hours, 12);
1026 assert_eq!(q.max_candidates_per_tick, 1);
1027 assert_eq!(q.source_user_ids, vec!["44196397", "16884623"]);
1028 assert!(q.writer_provider.is_none());
1029 }
1030
1031 #[test]
1032 fn persona_quotes_config_rejects_missing_required_fields() {
1033 let toml = r#"
1034[[persona_quotes]]
1035persona = "heartbit-ghost:x"
1036"#;
1037 #[derive(Debug, Deserialize)]
1039 struct Shim {
1040 #[allow(dead_code)]
1041 persona_quotes: Vec<PersonaQuotesConfig>,
1042 }
1043 let err = toml::from_str::<Shim>(toml).unwrap_err();
1044 assert!(
1045 err.to_string().contains("source_user_ids"),
1046 "expected missing-field error for source_user_ids; got: {err}"
1047 );
1048 }
1049
1050 #[test]
1051 fn persona_blog_config_parses_with_defaults() {
1052 let toml = r#"
1053[persona_blog]
1054persona = "heartbit-ghost:x"
1055site_url = "https://pascal.heartbit.ai"
1056"#;
1057 #[derive(Deserialize)]
1058 struct Shim {
1059 persona_blog: PersonaBlogConfig,
1060 }
1061 let cfg: Shim = toml::from_str(toml).unwrap();
1062 let b = &cfg.persona_blog;
1063 assert_eq!(b.persona, "heartbit-ghost:x");
1064 assert!(b.enabled);
1065 assert_eq!(b.poll_interval_seconds, 604_800); assert_eq!(b.interval_jitter_pct, 10);
1067 assert_eq!(b.posts_dir, "blog-site/posts");
1068 assert_eq!(b.out_dir, "blog-site/public");
1069 assert_eq!(b.seed_lookback_days, 7);
1070 assert_eq!(b.candidates_per_draft, 2);
1071 assert_eq!(b.site_url, "https://pascal.heartbit.ai");
1072 assert_eq!(b.site_title, "pascal.heartbit.ai");
1073 assert!(b.writer_provider.is_none());
1074 assert!(b.deploy_command.is_none());
1075 }
1076
1077 #[test]
1078 fn persona_blog_config_parses_deploy_command() {
1079 let toml = r#"
1080[persona_blog]
1081persona = "heartbit-ghost:x"
1082site_url = "https://pascal.heartbit.ai"
1083deploy_command = "npx wrangler pages deploy blog-site/public --project-name=pascal-heartbit-ai --commit-dirty=true --branch=main"
1084"#;
1085 #[derive(Deserialize)]
1086 struct Shim {
1087 persona_blog: PersonaBlogConfig,
1088 }
1089 let cfg: Shim = toml::from_str(toml).unwrap();
1090 let cmd = cfg.persona_blog.deploy_command.as_deref().unwrap();
1091 assert!(cmd.contains("wrangler pages deploy"));
1092 assert!(cmd.contains("pascal-heartbit-ai"));
1093 }
1094
1095 #[test]
1096 fn persona_blog_config_parses_x_announce_block() {
1097 let toml = r#"
1098[persona_blog]
1099persona = "heartbit-ghost:x"
1100site_url = "https://pascal.heartbit.ai"
1101
1102[persona_blog.x_announce]
1103enabled = true
1104"#;
1105 #[derive(Deserialize)]
1106 struct Shim {
1107 persona_blog: PersonaBlogConfig,
1108 }
1109 let cfg: Shim = toml::from_str(toml).unwrap();
1110 let x = cfg.persona_blog.x_announce.as_ref().unwrap();
1111 assert!(x.enabled);
1112 }
1113
1114 #[test]
1115 fn persona_blog_config_parses_github_readme_block() {
1116 let toml = r#"
1117[persona_blog]
1118persona = "heartbit-ghost:x"
1119site_url = "https://pascal.heartbit.ai"
1120
1121[persona_blog.github_readme]
1122enabled = true
1123local_repo_path = "/home/pleclech/projects/100-tokens-profile"
1124git_author_name = "Pascal Le Clech"
1125git_author_email = "pascal@heartbit.ai"
1126"#;
1127 #[derive(Deserialize)]
1128 struct Shim {
1129 persona_blog: PersonaBlogConfig,
1130 }
1131 let cfg: Shim = toml::from_str(toml).unwrap();
1132 let gh = cfg.persona_blog.github_readme.as_ref().unwrap();
1133 assert!(gh.enabled);
1134 assert_eq!(
1135 gh.local_repo_path,
1136 "/home/pleclech/projects/100-tokens-profile"
1137 );
1138 assert_eq!(gh.bio_template_path, "bio.md"); assert_eq!(gh.git_author_name, "Pascal Le Clech");
1140 }
1141
1142 #[test]
1143 fn persona_blog_config_amp_blocks_default_to_none() {
1144 let toml = r#"
1145[persona_blog]
1146persona = "heartbit-ghost:x"
1147site_url = "https://pascal.heartbit.ai"
1148"#;
1149 #[derive(Deserialize)]
1150 struct Shim {
1151 persona_blog: PersonaBlogConfig,
1152 }
1153 let cfg: Shim = toml::from_str(toml).unwrap();
1154 assert!(cfg.persona_blog.x_announce.is_none());
1155 assert!(cfg.persona_blog.github_readme.is_none());
1156 }
1157
1158 #[test]
1159 fn persona_blog_config_rejects_missing_required_fields() {
1160 let toml = r#"
1161[persona_blog]
1162persona = "heartbit-ghost:x"
1163"#;
1164 #[derive(Deserialize, Debug)]
1166 struct Shim {
1167 #[allow(dead_code)]
1168 persona_blog: PersonaBlogConfig,
1169 }
1170 let err = toml::from_str::<Shim>(toml).unwrap_err();
1171 assert!(
1172 err.to_string().contains("site_url"),
1173 "expected missing-field error for site_url; got: {err}"
1174 );
1175 }
1176
1177 #[test]
1178 fn persona_posts_config_parses_full() {
1179 let toml = r#"
1180[[persona_posts]]
1181persona = "heartbit-ghost:x"
1182enabled = true
1183post_interval_seconds = 7200
1184candidates_per_draft = 5
1185post_history_store = "jsonl"
1186post_history_path = "~/.heartbit/posts.jsonl"
1187post_history_lookback_days = 14
1188topic_brief = "agents, Rust, LLMs"
1189
1190[persona_posts.active_hours]
1191start = "09:00"
1192end = "22:00"
1193"#;
1194 #[derive(Deserialize)]
1195 struct Shim {
1196 persona_posts: Vec<PersonaPostsConfig>,
1197 }
1198 let cfg: Shim = toml::from_str(toml).unwrap();
1199 let p = &cfg.persona_posts[0];
1200 assert_eq!(p.persona, "heartbit-ghost:x");
1201 assert!(p.enabled);
1202 assert_eq!(p.post_interval_seconds, 7200);
1203 assert_eq!(p.candidates_per_draft, 5);
1204 assert_eq!(p.post_history_store, "jsonl");
1205 assert_eq!(
1206 p.post_history_path.as_deref(),
1207 Some("~/.heartbit/posts.jsonl")
1208 );
1209 assert_eq!(p.post_history_lookback_days, 14);
1210 assert_eq!(p.topic_brief.as_deref(), Some("agents, Rust, LLMs"));
1211 assert!(p.active_hours.is_some());
1212 let h = p.active_hours.as_ref().unwrap();
1213 assert_eq!(h.start, "09:00");
1214 assert_eq!(h.end, "22:00");
1215 }
1216}