Skip to main content

systemprompt_models/validators/
web.rs

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