Skip to main content

systemprompt_generator/rss/
generator.rs

1//! Top-level RSS feed generation: discovers feed providers, runs each, and
2//! writes the resulting XML files to the build output directory.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use super::xml::{RssChannel, RssItem, build_rss_xml};
8use std::path::Path;
9use std::sync::Arc;
10use systemprompt_content::ContentRepository;
11use systemprompt_models::{AppPaths, Config};
12use systemprompt_provider_contracts::{RssFeedContext, RssFeedProvider};
13use tokio::fs;
14
15use super::default_provider::DefaultRssFeedProvider;
16use crate::error::{GeneratorResult as Result, PublishError};
17
18#[derive(Debug, Clone)]
19pub struct GeneratedFeed {
20    pub filename: String,
21    pub xml: String,
22    pub item_count: usize,
23}
24
25pub async fn generate_feed(content_repo: ContentRepository, paths: &AppPaths) -> Result<()> {
26    let provider = DefaultRssFeedProvider::new(content_repo, paths).await?;
27    let providers: Vec<Arc<dyn RssFeedProvider>> = vec![Arc::new(provider)];
28    let feeds = generate_feed_with_providers(&providers).await?;
29
30    let web_dir = paths.web().dist().to_path_buf();
31
32    for feed in feeds {
33        let feed_path = web_dir.join(&feed.filename);
34        ensure_parent_exists(&feed_path).await?;
35        fs::write(&feed_path, &feed.xml).await?;
36        tracing::info!(
37            path = %feed_path.display(),
38            items = feed.item_count,
39            "Generated RSS feed"
40        );
41    }
42
43    Ok(())
44}
45
46pub async fn generate_feed_with_providers(
47    providers: &[Arc<dyn RssFeedProvider>],
48) -> Result<Vec<GeneratedFeed>> {
49    let global_config = Config::get()?;
50    let base_url = &global_config.api_external_url;
51
52    let mut feeds = Vec::new();
53
54    for provider in providers {
55        for spec in provider.feed_specs() {
56            let ctx = RssFeedContext {
57                base_url,
58                source_name: spec.source_id.as_str(),
59            };
60
61            let metadata = provider.feed_metadata(&ctx).await.map_err(|e| {
62                PublishError::provider_failed(provider.provider_id(), e.to_string())
63            })?;
64
65            let items = provider
66                .fetch_items(&ctx, spec.max_items)
67                .await
68                .map_err(|e| {
69                    PublishError::provider_failed(provider.provider_id(), e.to_string())
70                })?;
71
72            let rss_items: Vec<RssItem> = items
73                .into_iter()
74                .map(|item| RssItem {
75                    title: item.title,
76                    link: item.link,
77                    description: item.description,
78                    pub_date: item.pub_date,
79                    guid: item.guid,
80                    author: item.author,
81                })
82                .collect();
83
84            let channel = RssChannel {
85                title: metadata.title,
86                link: metadata.link,
87                description: metadata.description,
88                items: rss_items.clone(),
89            };
90
91            let xml = build_rss_xml(&channel);
92
93            feeds.push(GeneratedFeed {
94                filename: spec.output_filename,
95                xml,
96                item_count: rss_items.len(),
97            });
98        }
99    }
100
101    if feeds.is_empty() {
102        return Err(PublishError::config(
103            "No RSS feeds generated. Ensure at least one RssFeedProvider is registered.",
104        ));
105    }
106
107    Ok(feeds)
108}
109
110async fn ensure_parent_exists(path: &Path) -> Result<()> {
111    if let Some(parent) = path.parent()
112        && !parent.exists()
113    {
114        fs::create_dir_all(parent).await?;
115    }
116    Ok(())
117}