Skip to main content

systemprompt_generator/sitemap/
default_provider.rs

1use anyhow::{Result, anyhow};
2use async_trait::async_trait;
3use chrono::Utc;
4use std::collections::HashMap;
5use systemprompt_models::{AppPaths, ContentConfigRaw};
6use systemprompt_provider_contracts::{
7    PlaceholderMapping, SitemapContext, SitemapProvider, SitemapSourceSpec, SitemapUrlEntry,
8};
9use tokio::fs;
10
11#[derive(Debug)]
12pub struct DefaultSitemapProvider {
13    content_config: ContentConfigRaw,
14}
15
16impl DefaultSitemapProvider {
17    pub async fn new() -> Result<Self> {
18        let content_config = load_content_config().await?;
19        Ok(Self { content_config })
20    }
21
22    #[must_use]
23    pub const fn from_config(content_config: ContentConfigRaw) -> Self {
24        Self { content_config }
25    }
26}
27
28async fn load_content_config() -> Result<ContentConfigRaw> {
29    let paths = AppPaths::get().map_err(|e| anyhow!("{}", e))?;
30    let config_path = paths.system().content_config();
31
32    let yaml_content = fs::read_to_string(&config_path)
33        .await
34        .map_err(|e| anyhow!("Failed to read content config: {}", e))?;
35
36    serde_yaml::from_str(&yaml_content)
37        .map_err(|e| anyhow!("Failed to parse content config: {}", e))
38}
39
40#[async_trait]
41impl SitemapProvider for DefaultSitemapProvider {
42    fn provider_id(&self) -> &'static str {
43        "default-sitemap"
44    }
45
46    fn source_specs(&self) -> Vec<SitemapSourceSpec> {
47        self.content_config
48            .content_sources
49            .iter()
50            .filter(|(_, source)| source.enabled)
51            .filter_map(|(_, source)| {
52                source.sitemap.as_ref().and_then(|sitemap| {
53                    sitemap.enabled.then(|| SitemapSourceSpec {
54                        source_id: source.source_id.clone(),
55                        url_pattern: sitemap.url_pattern.clone(),
56                        placeholders: vec![PlaceholderMapping {
57                            placeholder: "{slug}".to_string(),
58                            field: "slug".to_string(),
59                        }],
60                        priority: sitemap.priority,
61                        changefreq: sitemap.changefreq.clone(),
62                    })
63                })
64            })
65            .collect()
66    }
67
68    fn static_urls(&self, base_url: &str) -> Vec<SitemapUrlEntry> {
69        let today = Utc::now().format("%Y-%m-%d").to_string();
70
71        self.content_config
72            .content_sources
73            .iter()
74            .filter(|(_, source)| source.enabled)
75            .filter_map(|(_, source)| {
76                source.sitemap.as_ref().and_then(|sitemap| {
77                    sitemap.parent_route.as_ref().and_then(|parent| {
78                        parent.enabled.then(|| SitemapUrlEntry {
79                            loc: format!("{}{}", base_url, parent.url),
80                            lastmod: today.clone(),
81                            changefreq: parent.changefreq.clone(),
82                            priority: parent.priority,
83                        })
84                    })
85                })
86            })
87            .collect()
88    }
89
90    async fn resolve_placeholders(
91        &self,
92        _ctx: &SitemapContext<'_>,
93        content: &serde_json::Value,
94        placeholders: &[PlaceholderMapping],
95    ) -> Result<HashMap<String, String>> {
96        let mut resolved = HashMap::new();
97
98        for mapping in placeholders {
99            if let Some(value) = content.get(&mapping.field) {
100                let string_value = match value {
101                    serde_json::Value::String(s) => s.clone(),
102                    _ => value.to_string().trim_matches('"').to_string(),
103                };
104                resolved.insert(mapping.placeholder.clone(), string_value);
105            }
106        }
107
108        Ok(resolved)
109    }
110}