Skip to main content

systemprompt_content/config/
validated.rs

1//! Validated content configuration.
2//!
3//! [`ContentConfigValidated`] is the verified form of the raw config: category
4//! references are resolved, source paths are canonicalised against the base
5//! path, and the [`systemprompt_models::ContentRouting`] implementation answers
6//! URL routing questions (HTML-page detection, source resolution, slug
7//! extraction) from each source's sitemap pattern.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use 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}