Skip to main content

systemprompt_cli/commands/web/validate/
template_validation.rs

1//! Template validation for `web validate`.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::collections::HashSet;
7use std::fs;
8
9use systemprompt_models::content_config::ContentConfigRaw;
10
11use super::super::paths::WebPaths;
12use super::super::types::{TemplatesConfig, ValidationIssue};
13
14pub fn validate_templates(
15    profile: &systemprompt_models::Profile,
16    web_paths: &WebPaths,
17    errors: &mut Vec<ValidationIssue>,
18    warnings: &mut Vec<ValidationIssue>,
19) {
20    let templates_dir = &web_paths.templates;
21    let templates_yaml_path = templates_dir.join("templates.yaml");
22
23    if !templates_yaml_path.exists() {
24        warnings.push(ValidationIssue {
25            source: "templates".to_owned(),
26            message: format!(
27                "templates.yaml not found at {}",
28                templates_yaml_path.display()
29            ),
30            suggestion: Some("Create a templates.yaml file".to_owned()),
31        });
32        return;
33    }
34
35    let Ok(content) = fs::read_to_string(&templates_yaml_path) else {
36        errors.push(ValidationIssue {
37            source: "templates".to_owned(),
38            message: "Failed to read templates.yaml".to_owned(),
39            suggestion: None,
40        });
41        return;
42    };
43
44    let Ok(templates_config) = serde_yaml::from_str::<TemplatesConfig>(&content) else {
45        errors.push(ValidationIssue {
46            source: "templates".to_owned(),
47            message: "Failed to parse templates.yaml".to_owned(),
48            suggestion: Some("Check YAML syntax".to_owned()),
49        });
50        return;
51    };
52
53    for name in templates_config.templates.keys() {
54        let html_path = templates_dir.join(format!("{}.html", name));
55        if !html_path.exists() {
56            errors.push(ValidationIssue {
57                source: "templates".to_owned(),
58                message: format!("Missing HTML file for template '{}'", name),
59                suggestion: Some(format!("Create {}", html_path.display())),
60            });
61        }
62    }
63
64    let content_config_path = profile.paths.content_config();
65    let Ok(content) = fs::read_to_string(&content_config_path) else {
66        return;
67    };
68    let Ok(content_config) = serde_yaml::from_str::<ContentConfigRaw>(&content) else {
69        return;
70    };
71
72    let known = known_content_types(&content_config);
73    warn_unknown_references(&templates_config, &known, warnings);
74    warn_orphan_content_types(&templates_config, &known, warnings);
75}
76
77fn warn_unknown_references(
78    templates_config: &TemplatesConfig,
79    known: &HashSet<String>,
80    warnings: &mut Vec<ValidationIssue>,
81) {
82    for (template_name, entry) in &templates_config.templates {
83        for ct in &entry.content_types {
84            if !known.contains(ct) {
85                warnings.push(ValidationIssue {
86                    source: "templates".to_owned(),
87                    message: format!(
88                        "Template '{}' references unknown content type '{}'",
89                        template_name, ct
90                    ),
91                    suggestion: Some("Add the content type to content config".to_owned()),
92                });
93            }
94        }
95    }
96}
97
98fn warn_orphan_content_types(
99    templates_config: &TemplatesConfig,
100    known: &HashSet<String>,
101    warnings: &mut Vec<ValidationIssue>,
102) {
103    let templated: HashSet<&str> = templates_config
104        .templates
105        .values()
106        .flat_map(|e| e.content_types.iter())
107        .map(String::as_str)
108        .collect();
109
110    let mut orphans: Vec<&str> = known
111        .iter()
112        .map(String::as_str)
113        .filter(|name| !templated.contains(name))
114        .collect();
115    orphans.sort_unstable();
116
117    for name in orphans {
118        warnings.push(ValidationIssue {
119            source: "templates".to_owned(),
120            message: format!("Content type '{}' has no associated template", name),
121            suggestion: Some("Link a template to this content type".to_owned()),
122        });
123    }
124}
125
126fn known_content_types(content_config: &ContentConfigRaw) -> HashSet<String> {
127    let mut names: HashSet<String> = content_config
128        .content_sources
129        .values()
130        .flat_map(|source| source.allowed_content_types.iter().cloned())
131        .collect();
132
133    for (source_name, source) in &content_config.content_sources {
134        let renders_index = source
135            .sitemap
136            .as_ref()
137            .and_then(|sitemap| sitemap.parent_route.as_ref())
138            .is_some_and(|parent| parent.enabled);
139        if renders_index {
140            names.insert(format!("{source_name}-list"));
141        }
142    }
143
144    names
145}