systemprompt_cli/commands/web/validate/
template_validation.rs1use 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}