1use 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}