systemprompt_content/config/
validated.rs1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use systemprompt_identifiers::{CategoryId, SourceId};
15use systemprompt_models::{
16 Category, ContentConfigError, ContentConfigErrors, ContentConfigRaw, ContentRouting,
17 ContentSourceConfigRaw, IndexingConfig, Metadata, SitemapConfig, SourceBranding,
18};
19
20const SOURCE_WEB: &str = "web";
21const SOURCE_UNKNOWN: &str = "unknown";
22
23#[derive(Debug, Clone)]
24pub struct ContentConfigValidated {
25 content_sources: HashMap<String, ContentSourceConfigValidated>,
26 metadata: Metadata,
27 categories: HashMap<String, Category>,
28 base_path: PathBuf,
29}
30
31#[derive(Debug, Clone)]
32pub struct ContentSourceConfigValidated {
33 pub path: PathBuf,
34 pub source_id: SourceId,
35 pub category_id: CategoryId,
36 pub enabled: bool,
37 pub description: String,
38 pub allowed_content_types: Vec<String>,
39 pub indexing: IndexingConfig,
40 pub sitemap: Option<SitemapConfig>,
41 pub branding: Option<SourceBranding>,
42}
43
44pub type ValidationResult = Result<ContentConfigValidated, ContentConfigErrors>;
45
46impl ContentConfigValidated {
47 pub fn from_raw(raw: ContentConfigRaw, base_path: PathBuf) -> ValidationResult {
48 let mut errors = ContentConfigErrors::new();
49
50 let categories = validate_categories(&raw.categories, &mut errors);
51 let content_sources = validate_sources(&raw, &categories, &base_path, &mut errors);
52
53 errors.into_result(Self {
54 content_sources,
55 metadata: raw.metadata,
56 categories,
57 base_path,
58 })
59 }
60
61 pub const fn content_sources(&self) -> &HashMap<String, ContentSourceConfigValidated> {
62 &self.content_sources
63 }
64
65 pub const fn metadata(&self) -> &Metadata {
66 &self.metadata
67 }
68
69 pub const fn categories(&self) -> &HashMap<String, Category> {
70 &self.categories
71 }
72
73 pub const fn base_path(&self) -> &PathBuf {
74 &self.base_path
75 }
76
77 pub fn is_html_page(&self, path: &str) -> bool {
78 if path == "/" {
79 return true;
80 }
81
82 self.content_sources
83 .values()
84 .filter(|source| source.enabled)
85 .filter_map(|source| source.sitemap.as_ref())
86 .filter(|sitemap| sitemap.enabled)
87 .any(|sitemap| Self::matches_url_pattern(&sitemap.url_pattern, path))
88 }
89
90 fn matches_url_pattern(pattern: &str, path: &str) -> bool {
91 let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
92 let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
93
94 if pattern_parts.len() != path_parts.len() {
95 return false;
96 }
97
98 pattern_parts
99 .iter()
100 .zip(path_parts.iter())
101 .all(|(pattern_part, path_part)| *pattern_part == "{slug}" || pattern_part == path_part)
102 }
103
104 pub fn determine_source(&self, path: &str) -> String {
105 if path == "/" {
106 return SOURCE_WEB.to_owned();
107 }
108
109 self.content_sources
110 .iter()
111 .filter(|(_, source)| source.enabled)
112 .find_map(|(name, source)| {
113 source.sitemap.as_ref().and_then(|sitemap| {
114 (sitemap.enabled && Self::matches_url_pattern(&sitemap.url_pattern, path))
115 .then(|| name.clone())
116 })
117 })
118 .unwrap_or_else(|| SOURCE_UNKNOWN.to_owned())
119 }
120
121 pub fn resolve_slug(&self, path: &str) -> Option<String> {
122 self.content_sources
123 .values()
124 .filter(|source| source.enabled)
125 .filter_map(|source| source.sitemap.as_ref())
126 .filter(|sitemap| sitemap.enabled)
127 .find_map(|sitemap| extract_slug_from_pattern(path, &sitemap.url_pattern))
128 }
129}
130
131fn extract_slug_from_pattern(path: &str, pattern: &str) -> Option<String> {
132 let prefix = pattern.split('{').next()?;
133 let raw = path.strip_prefix(prefix)?.trim_end_matches('/');
134 let raw = raw.split('?').next().unwrap_or(raw);
135 let raw = raw.split('#').next().unwrap_or(raw);
136 (!raw.is_empty()).then(|| raw.to_owned())
137}
138
139impl ContentRouting for ContentConfigValidated {
140 fn is_html_page(&self, path: &str) -> bool {
141 ContentConfigValidated::is_html_page(self, path)
142 }
143
144 fn determine_source(&self, path: &str) -> String {
145 ContentConfigValidated::determine_source(self, path)
146 }
147
148 fn resolve_slug(&self, path: &str) -> Option<String> {
149 ContentConfigValidated::resolve_slug(self, path)
150 }
151}
152
153fn validate_categories(
154 raw: &HashMap<String, Category>,
155 errors: &mut ContentConfigErrors,
156) -> HashMap<String, Category> {
157 let mut validated = HashMap::new();
158
159 for (id, cat) in raw {
160 if cat.name.is_empty() {
161 errors.push(ContentConfigError::Validation {
162 field: format!("categories.{id}.name"),
163 message: "Category name cannot be empty".to_owned(),
164 suggestion: Some("Provide a non-empty name".to_owned()),
165 });
166 continue;
167 }
168 validated.insert(id.clone(), cat.clone());
169 }
170
171 validated
172}
173
174fn validate_sources(
175 raw: &ContentConfigRaw,
176 categories: &HashMap<String, Category>,
177 base_path: &Path,
178 errors: &mut ContentConfigErrors,
179) -> HashMap<String, ContentSourceConfigValidated> {
180 let mut validated = HashMap::new();
181
182 for (name, source) in &raw.content_sources {
183 if let Some(validated_source) =
184 validate_single_source(name, source, categories, base_path, errors)
185 {
186 validated.insert(name.clone(), validated_source);
187 }
188 }
189
190 validated
191}
192
193fn validate_single_source(
194 name: &str,
195 source: &ContentSourceConfigRaw,
196 categories: &HashMap<String, Category>,
197 base_path: &Path,
198 errors: &mut ContentConfigErrors,
199) -> Option<ContentSourceConfigValidated> {
200 let field_prefix = format!("content_sources.{name}");
201
202 if source.path.is_empty() {
203 errors.push(ContentConfigError::Validation {
204 field: format!("{field_prefix}.path"),
205 message: "Source path is required".to_owned(),
206 suggestion: Some("Add a path to the content directory".to_owned()),
207 });
208 return None;
209 }
210
211 if source.source_id.as_str().is_empty() {
212 errors.push(ContentConfigError::Validation {
213 field: format!("{field_prefix}.source_id"),
214 message: "source_id is required".to_owned(),
215 suggestion: Some("Add a unique source_id".to_owned()),
216 });
217 return None;
218 }
219
220 if source.category_id.as_str().is_empty() {
221 errors.push(ContentConfigError::Validation {
222 field: format!("{field_prefix}.category_id"),
223 message: "category_id is required".to_owned(),
224 suggestion: Some("Add a category_id that references a defined category".to_owned()),
225 });
226 return None;
227 }
228
229 if !categories.contains_key(source.category_id.as_str()) {
230 errors.push(ContentConfigError::Validation {
231 field: format!("{field_prefix}.category_id"),
232 message: format!("Referenced category '{}' not found", source.category_id),
233 suggestion: Some("Add this category to the categories section".to_owned()),
234 });
235 }
236
237 let resolved_path = if source.path.starts_with('/') {
238 PathBuf::from(&source.path)
239 } else {
240 base_path.join(&source.path)
241 };
242
243 let Ok(canonical_path) = std::fs::canonicalize(&resolved_path) else {
244 errors.push(ContentConfigError::Validation {
245 field: format!("{field_prefix}.path"),
246 message: "Content source directory does not exist".to_owned(),
247 suggestion: Some("Create the directory or fix the path".to_owned()),
248 });
249 return None;
250 };
251
252 Some(ContentSourceConfigValidated {
253 path: canonical_path,
254 source_id: source.source_id.clone(),
255 category_id: source.category_id.clone(),
256 enabled: source.enabled,
257 description: source.description.clone(),
258 allowed_content_types: source.allowed_content_types.clone(),
259 indexing: source.indexing.unwrap_or(IndexingConfig {
260 clear_before: false,
261 recursive: false,
262 override_existing: false,
263 }),
264 sitemap: source.sitemap.clone(),
265 branding: source.branding.clone(),
266 })
267}