Skip to main content

systemprompt_generator/rss/
default_provider.rs

1//! Default `RssFeedProvider` that emits a feed for every enabled content
2//! source, sourcing items directly from the content repository.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use 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}