Skip to main content

systemprompt_models/
content_config.rs

1//! Content-configuration model and routing trait.
2//!
3//! The deserialized `content.yaml` shape — sources, categories,
4//! organization metadata, sitemap and structured-data settings — plus
5//! the [`ContentRouting`] trait that maps request paths to content
6//! sources. Parsing returns [`ContentConfigError`].
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::Arc;
15use systemprompt_identifiers::{CategoryId, SourceId};
16use thiserror::Error;
17
18pub trait ContentRouting: Send + Sync {
19    fn is_html_page(&self, path: &str) -> bool;
20    fn determine_source(&self, path: &str) -> String;
21    fn resolve_slug(&self, _path: &str) -> Option<String> {
22        None
23    }
24}
25
26impl<T: ContentRouting + ?Sized> ContentRouting for Arc<T> {
27    fn is_html_page(&self, path: &str) -> bool {
28        (**self).is_html_page(path)
29    }
30
31    fn determine_source(&self, path: &str) -> String {
32        (**self).determine_source(path)
33    }
34
35    fn resolve_slug(&self, path: &str) -> Option<String> {
36        (**self).resolve_slug(path)
37    }
38}
39
40#[derive(Debug, Clone, Error)]
41pub enum ContentConfigError {
42    #[error("IO error reading {path}: {message}")]
43    Io { path: PathBuf, message: String },
44
45    #[error("YAML parse error in {path}: {message}")]
46    Parse { path: PathBuf, message: String },
47
48    #[error("Validation error in {field}: {message}")]
49    Validation {
50        field: String,
51        message: String,
52        suggestion: Option<String>,
53    },
54}
55
56#[derive(Debug, Default)]
57pub struct ContentConfigErrors {
58    errors: Vec<ContentConfigError>,
59}
60
61impl ContentConfigErrors {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    pub fn push(&mut self, error: ContentConfigError) {
67        self.errors.push(error);
68    }
69
70    pub const fn is_empty(&self) -> bool {
71        self.errors.is_empty()
72    }
73
74    pub fn errors(&self) -> &[ContentConfigError] {
75        &self.errors
76    }
77
78    pub fn into_result<T>(self, value: T) -> Result<T, Self> {
79        if self.is_empty() {
80            Ok(value)
81        } else {
82            Err(self)
83        }
84    }
85}
86
87impl std::fmt::Display for ContentConfigErrors {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        for (i, error) in self.errors.iter().enumerate() {
90            if i > 0 {
91                writeln!(f)?;
92            }
93            write!(f, "  - {error}")?;
94        }
95        Ok(())
96    }
97}
98
99impl std::error::Error for ContentConfigErrors {}
100
101#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct ContentConfigRaw {
103    #[serde(default)]
104    pub content_sources: HashMap<String, ContentSourceConfigRaw>,
105    #[serde(default)]
106    pub metadata: Metadata,
107    #[serde(default)]
108    pub categories: HashMap<String, Category>,
109}
110
111impl ContentConfigRaw {
112    pub fn matches_url_pattern(pattern: &str, path: &str) -> bool {
113        let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
114        let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
115
116        if pattern_parts.len() != path_parts.len() {
117            return false;
118        }
119
120        pattern_parts
121            .iter()
122            .zip(path_parts.iter())
123            .all(|(pattern_part, path_part)| *pattern_part == "{slug}" || pattern_part == path_part)
124    }
125}
126
127impl ContentRouting for ContentConfigRaw {
128    fn is_html_page(&self, path: &str) -> bool {
129        if path == "/" {
130            return true;
131        }
132
133        let matches_sitemap = self
134            .content_sources
135            .values()
136            .filter(|source| source.enabled)
137            .filter_map(|source| source.sitemap.as_ref())
138            .filter(|sitemap| sitemap.enabled)
139            .any(|sitemap| Self::matches_url_pattern(&sitemap.url_pattern, path));
140
141        if matches_sitemap {
142            return true;
143        }
144
145        !path.contains('.')
146            && !path.starts_with("/api/")
147            && !path.starts_with("/track/")
148            && !path.starts_with("/.well-known/")
149    }
150
151    fn determine_source(&self, path: &str) -> String {
152        if path == "/" {
153            return "web".to_owned();
154        }
155
156        self.content_sources
157            .iter()
158            .filter(|(_, source)| source.enabled)
159            .find_map(|(name, source)| {
160                source.sitemap.as_ref().and_then(|sitemap| {
161                    (sitemap.enabled && Self::matches_url_pattern(&sitemap.url_pattern, path))
162                        .then(|| name.clone())
163                })
164            })
165            .unwrap_or_else(|| "unknown".to_owned())
166    }
167
168    fn resolve_slug(&self, path: &str) -> Option<String> {
169        self.content_sources
170            .values()
171            .filter(|source| source.enabled)
172            .filter_map(|source| source.sitemap.as_ref())
173            .filter(|sitemap| sitemap.enabled)
174            .find_map(|sitemap| extract_slug_from_pattern(path, &sitemap.url_pattern))
175    }
176}
177
178fn extract_slug_from_pattern(path: &str, pattern: &str) -> Option<String> {
179    let prefix = pattern.split('{').next()?;
180    let raw = path.strip_prefix(prefix)?.trim_end_matches('/');
181    let raw = raw.split('?').next().unwrap_or(raw);
182    let raw = raw.split('#').next().unwrap_or(raw);
183    (!raw.is_empty()).then(|| raw.to_owned())
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ContentSourceConfigRaw {
188    pub path: String,
189    pub source_id: SourceId,
190    pub category_id: CategoryId,
191    pub enabled: bool,
192    #[serde(default)]
193    pub description: String,
194    #[serde(default)]
195    pub allowed_content_types: Vec<String>,
196    #[serde(default)]
197    pub indexing: Option<IndexingConfig>,
198    #[serde(default)]
199    pub sitemap: Option<SitemapConfig>,
200    #[serde(default)]
201    pub branding: Option<SourceBranding>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
205pub struct SourceBranding {
206    #[serde(default)]
207    pub name: Option<String>,
208    #[serde(default)]
209    pub description: Option<String>,
210    #[serde(default)]
211    pub image: Option<String>,
212    #[serde(default)]
213    pub keywords: Option<String>,
214}
215
216#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
217pub struct IndexingConfig {
218    #[serde(default)]
219    pub clear_before: bool,
220    #[serde(default)]
221    pub recursive: bool,
222    #[serde(default)]
223    pub override_existing: bool,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct SitemapConfig {
228    pub enabled: bool,
229    pub url_pattern: String,
230    pub priority: f32,
231    pub changefreq: String,
232    #[serde(default)]
233    pub fetch_from: String,
234    #[serde(default)]
235    pub parent_route: Option<ParentRoute>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct ParentRoute {
240    pub enabled: bool,
241    pub url: String,
242    pub priority: f32,
243    pub changefreq: String,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, Default)]
247pub struct Metadata {
248    #[serde(default)]
249    pub default_author: String,
250    #[serde(default)]
251    pub structured_data: StructuredData,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, Default)]
255pub struct StructuredData {
256    #[serde(default)]
257    pub organization: OrganizationData,
258    #[serde(default)]
259    pub article: ArticleDefaults,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, Default)]
263pub struct OrganizationData {
264    #[serde(default)]
265    pub name: String,
266    #[serde(default)]
267    pub url: String,
268    #[serde(default)]
269    pub logo: String,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
273pub struct ArticleDefaults {
274    #[serde(default, rename = "type")]
275    pub article_type: String,
276    #[serde(default)]
277    pub article_section: String,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, Default)]
281pub struct Category {
282    #[serde(default)]
283    pub name: String,
284    #[serde(default)]
285    pub description: String,
286}