systemprompt_generator/rss/
default_provider.rs1use async_trait::async_trait;
8use systemprompt_content::ContentRepository;
9use systemprompt_identifiers::{LocaleCode, SourceId};
10use systemprompt_models::{AppPaths, Config, ContentConfigRaw, WebConfig};
11use systemprompt_provider_contracts::{
12 ProviderError, ProviderResult, RssFeedContext, RssFeedItem, RssFeedMetadata, RssFeedProvider,
13 RssFeedSpec,
14};
15use tokio::fs;
16
17use crate::error::{GeneratorResult, PublishError};
18use crate::templates::load_web_config;
19
20const DEFAULT_MAX_ITEMS: i64 = 20;
21
22pub struct DefaultRssFeedProvider {
23 content_repo: ContentRepository,
24 content_config: ContentConfigRaw,
25 web_config: WebConfig,
26}
27
28impl std::fmt::Debug for DefaultRssFeedProvider {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 f.debug_struct("DefaultRssFeedProvider")
31 .field(
32 "content_sources",
33 &self.content_config.content_sources.keys(),
34 )
35 .finish_non_exhaustive()
36 }
37}
38
39impl DefaultRssFeedProvider {
40 pub async fn new(content_repo: ContentRepository, paths: &AppPaths) -> GeneratorResult<Self> {
41 let content_config = load_content_config(paths).await?;
42 let web_config = load_web_config(paths).await?;
43
44 Ok(Self {
45 content_repo,
46 content_config,
47 web_config,
48 })
49 }
50
51 fn get_source_branding(&self, source_name: &str) -> (String, String) {
52 let default_title = &self.web_config.branding.title;
53 let default_description = &self.web_config.branding.description;
54
55 self.content_config
56 .content_sources
57 .get(source_name)
58 .and_then(|source| source.branding.as_ref())
59 .map_or_else(
60 || (default_title.clone(), default_description.clone()),
61 |branding| {
62 (
63 branding
64 .name
65 .clone()
66 .unwrap_or_else(|| default_title.clone()),
67 branding
68 .description
69 .clone()
70 .unwrap_or_else(|| default_description.clone()),
71 )
72 },
73 )
74 }
75}
76
77pub(super) async fn load_content_config(paths: &AppPaths) -> GeneratorResult<ContentConfigRaw> {
78 let config_path = paths.system().content_config();
79
80 let yaml_content = fs::read_to_string(&config_path).await.map_err(|source| {
81 PublishError::ContentConfigRead {
82 path: config_path.to_path_buf(),
83 source,
84 }
85 })?;
86
87 serde_yaml::from_str(&yaml_content).map_err(|source| PublishError::ContentConfigParse {
88 path: config_path.to_path_buf(),
89 source,
90 })
91}
92
93#[async_trait]
94impl RssFeedProvider for DefaultRssFeedProvider {
95 fn provider_id(&self) -> &'static str {
96 "default-rss"
97 }
98
99 fn feed_specs(&self) -> Vec<RssFeedSpec> {
100 self.content_config
101 .content_sources
102 .iter()
103 .filter(|(_, source)| source.enabled)
104 .filter(|(_, source)| source.sitemap.as_ref().is_some_and(|s| s.enabled))
105 .map(|(name, source)| RssFeedSpec {
106 source_id: source.source_id.clone(),
107 max_items: DEFAULT_MAX_ITEMS,
108 output_filename: format!("{}.xml", name),
109 })
110 .collect()
111 }
112
113 async fn feed_metadata(&self, ctx: &RssFeedContext<'_>) -> ProviderResult<RssFeedMetadata> {
114 let (title, description) = self.get_source_branding(ctx.source_name);
115 let global_config = Config::get().map_err(|e| {
116 ProviderError::Configuration(format!("Failed to load global config: {e}"))
117 })?;
118
119 Ok(RssFeedMetadata {
120 title,
121 link: global_config.api_external_url.clone(),
122 description,
123 language: Some("en".to_owned()),
124 })
125 }
126
127 async fn fetch_items(
128 &self,
129 ctx: &RssFeedContext<'_>,
130 limit: i64,
131 ) -> ProviderResult<Vec<RssFeedItem>> {
132 let source_config = self
133 .content_config
134 .content_sources
135 .values()
136 .find(|s| s.source_id.as_str() == ctx.source_name)
137 .ok_or_else(|| {
138 ProviderError::NotFound(format!("Source not found: {}", ctx.source_name))
139 })?;
140
141 let url_pattern = source_config
142 .sitemap
143 .as_ref()
144 .map_or("/{slug}", |s| s.url_pattern.as_str());
145
146 let repo = &self.content_repo;
147
148 let source_id = SourceId::new(ctx.source_name);
149 let content_items = repo
150 .list_by_source_limited(&source_id, &LocaleCode::new("en"), limit)
151 .await
152 .map_err(|e| {
153 ProviderError::RenderFailed(format!("Failed to fetch content for RSS feed: {e}"))
154 })?;
155
156 let items = content_items
157 .into_iter()
158 .map(|content| {
159 let relative_url = url_pattern.replace("{slug}", &content.slug);
160 let link = format!("{}{}", ctx.base_url, relative_url);
161 RssFeedItem {
162 title: content.title,
163 link: link.clone(),
164 description: content.description,
165 pub_date: content.published_at,
166 guid: link,
167 author: Some(content.author),
168 }
169 })
170 .collect();
171
172 Ok(items)
173 }
174}