Skip to main content

systemprompt_models/validators/
web.rs

1//! Web configuration validator.
2
3use super::ValidationConfigProvider;
4use super::validation_config_provider::{WebConfigRaw, WebMetadataRaw};
5use std::path::Path;
6use systemprompt_traits::validation_report::{
7    ValidationError, ValidationReport, ValidationWarning,
8};
9use systemprompt_traits::{ConfigProvider, DomainConfig, DomainConfigError};
10
11#[derive(Debug, Default)]
12pub struct WebConfigValidator {
13    config: Option<WebConfigRaw>,
14    metadata: Option<WebMetadataRaw>,
15    config_path: Option<String>,
16    system_path: Option<String>,
17}
18
19impl DomainConfig for WebConfigValidator {
20    fn domain_id(&self) -> &'static str {
21        "web"
22    }
23
24    fn priority(&self) -> u32 {
25        10
26    }
27
28    fn load(&mut self, config: &dyn ConfigProvider) -> Result<(), DomainConfigError> {
29        let provider = config
30            .as_any()
31            .downcast_ref::<ValidationConfigProvider>()
32            .ok_or_else(|| DomainConfigError::LoadError {
33                message: "Expected ValidationConfigProvider with pre-loaded configs".into(),
34            })?;
35
36        self.config = provider.web_config().cloned();
37        self.metadata = provider.web_metadata().cloned();
38        self.config_path = config.get("web_config_path");
39        self.system_path = Some(config.system_path().to_owned());
40        Ok(())
41    }
42
43    fn validate(&self) -> Result<ValidationReport, DomainConfigError> {
44        let mut report = ValidationReport::new("web");
45
46        let Some(cfg) = self.config.as_ref() else {
47            return Ok(report);
48        };
49
50        if let Some(ref base_url) = cfg.base_url
51            && !base_url.starts_with("http://")
52            && !base_url.starts_with("https://")
53        {
54            report.add_error(
55                ValidationError::new(
56                    "web_config.base_url",
57                    format!("Invalid URL format: {}", base_url),
58                )
59                .with_suggestion("URL must start with http:// or https://"),
60            );
61        }
62
63        if let Some(ref site_name) = cfg.site_name
64            && site_name.is_empty()
65        {
66            report.add_error(ValidationError::new(
67                "web_config.site_name",
68                "Site name cannot be empty",
69            ));
70        }
71
72        if let Some(ref path) = self.config_path {
73            let parent = Path::new(path).parent();
74            if let Some(dir) = parent
75                && !dir.exists()
76            {
77                report.add_error(
78                    ValidationError::new("web_config", "Web config directory does not exist")
79                        .with_path(dir),
80                );
81            }
82        }
83
84        self.validate_branding(&mut report);
85        self.validate_paths(&mut report);
86
87        Ok(report)
88    }
89}
90
91impl WebConfigValidator {
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    fn validate_paths(&self, report: &mut ValidationReport) {
97        let Some(cfg) = self.config.as_ref() else {
98            return;
99        };
100
101        let Some(paths) = cfg.paths.as_ref() else {
102            report.add_warning(
103                ValidationWarning::new(
104                    "web_config.paths",
105                    "Missing 'paths' section - using defaults",
106                )
107                .with_suggestion("Add paths.templates and paths.assets to web_config.yaml"),
108            );
109            return;
110        };
111
112        if let Some(templates) = &paths.templates
113            && !templates.is_empty()
114        {
115            let resolved = self.resolve_path(templates);
116            let path = Path::new(&resolved);
117            if !path.exists() {
118                report.add_error(
119                    ValidationError::new(
120                        "web_config.paths.templates",
121                        format!("Templates directory does not exist: {}", resolved),
122                    )
123                    .with_path(path)
124                    .with_suggestion(
125                        "Create the templates directory or update paths.templates in \
126                             web_config.yaml",
127                    ),
128                );
129            } else if !path.is_dir() {
130                report.add_error(
131                    ValidationError::new(
132                        "web_config.paths.templates",
133                        "Templates path is not a directory",
134                    )
135                    .with_path(path),
136                );
137            }
138        }
139    }
140
141    fn resolve_path(&self, path: &str) -> String {
142        let p = Path::new(path);
143        if p.is_absolute() {
144            return path.to_owned();
145        }
146
147        if let Some(ref system_path) = self.system_path {
148            let base = Path::new(system_path);
149            let resolved = base.join(p);
150            return resolved.canonicalize().map_or_else(
151                |_| resolved.to_string_lossy().to_string(),
152                |c| c.to_string_lossy().to_string(),
153            );
154        }
155
156        path.to_owned()
157    }
158
159    fn validate_branding(&self, report: &mut ValidationReport) {
160        let Some(cfg) = self.config.as_ref() else {
161            return;
162        };
163
164        let Some(branding) = cfg.branding.as_ref() else {
165            report.add_error(
166                ValidationError::new(
167                    "web_config.branding",
168                    "Missing 'branding' section in web.yaml",
169                )
170                .with_suggestion(
171                    "Add a 'branding' section with copyright, logo, favicon, twitter_handle, and \
172                     display_sitename",
173                ),
174            );
175            return;
176        };
177
178        require_branding_field(
179            report,
180            branding.copyright.as_ref().is_none_or(String::is_empty),
181            "web_config.branding.copyright",
182            "Missing required field 'copyright'",
183            "Add 'copyright: \"© 2024 Your Company\"' under branding",
184        );
185        require_branding_field(
186            report,
187            branding
188                .twitter_handle
189                .as_ref()
190                .is_none_or(String::is_empty),
191            "web_config.branding.twitter_handle",
192            "Missing required field 'twitter_handle'",
193            "Add 'twitter_handle: \"@yourhandle\"' under branding",
194        );
195        require_branding_field(
196            report,
197            branding.display_sitename.is_none(),
198            "web_config.branding.display_sitename",
199            "Missing required field 'display_sitename'",
200            "Add 'display_sitename: true' under branding",
201        );
202        require_branding_field(
203            report,
204            branding.favicon.as_ref().is_none_or(String::is_empty),
205            "web_config.branding.favicon",
206            "Missing required field 'favicon'",
207            "Add 'favicon: \"/favicon.ico\"' under branding",
208        );
209
210        let logo_svg = branding
211            .logo
212            .as_ref()
213            .and_then(|l| l.primary.as_ref())
214            .and_then(|p| p.svg.as_ref());
215        require_branding_field(
216            report,
217            logo_svg.is_none_or(String::is_empty),
218            "web_config.branding.logo.primary.svg",
219            "Missing required field 'logo.primary.svg'",
220            "Add 'logo: { primary: { svg: \"/logo.svg\" } }' under branding",
221        );
222    }
223}
224
225fn require_branding_field(
226    report: &mut ValidationReport,
227    missing: bool,
228    field: &str,
229    message: &str,
230    suggestion: &str,
231) {
232    if missing {
233        report.add_error(ValidationError::new(field, message).with_suggestion(suggestion));
234    }
235}