1use std::collections::HashMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use prosaic_core::Context;
8
9use crate::error::ProjectError;
10use crate::fixture::parse_fixture;
11use crate::manifest::Manifest;
12use crate::partial::PartialFile;
13use crate::scenario::Scenario;
14use crate::template::TemplateFile;
15
16#[derive(Debug, Clone)]
17pub struct Project {
18 pub root: PathBuf,
19 pub manifest: Manifest,
20 pub templates: HashMap<String, TemplateFile>,
21 pub partials: HashMap<String, PartialFile>,
22 pub fixtures: HashMap<String, Context>,
23 pub scenarios: HashMap<String, Scenario>,
24}
25
26#[derive(Debug, Clone)]
27pub struct ValidationIssue {
28 pub level: ValidationLevel,
29 pub location: String,
30 pub message: String,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ValidationLevel {
35 Error,
36 Warning,
37}
38
39impl Project {
40 pub fn load_from_dir(path: impl AsRef<Path>) -> Result<Self, ProjectError> {
41 let root = path.as_ref().to_path_buf();
42
43 let manifest_path = root.join("prosaic.toml");
44 if !manifest_path.exists() {
45 return Err(ProjectError::ManifestMissing {
46 path: manifest_path.display().to_string(),
47 });
48 }
49 let manifest_str = fs::read_to_string(&manifest_path).map_err(|e| ProjectError::Io {
50 path: manifest_path.display().to_string(),
51 cause: e.to_string(),
52 })?;
53 let manifest: Manifest =
54 toml::from_str(&manifest_str).map_err(|e| ProjectError::TomlParse {
55 file: "prosaic.toml".to_string(),
56 cause: e.to_string(),
57 })?;
58
59 let templates =
60 load_toml_dir::<TemplateFile, _>(&root.join("templates"), |t| t.key.clone())?;
61 let partials = load_toml_dir::<PartialFile, _>(&root.join("partials"), |p| p.name.clone())?;
62 let scenarios = load_toml_dir::<Scenario, _>(&root.join("tests"), |s| s.name.clone())?;
63 let fixtures = load_fixtures_dir(&root.join("fixtures"))?;
64
65 Ok(Project {
66 root,
67 manifest,
68 templates,
69 partials,
70 fixtures,
71 scenarios,
72 })
73 }
74}
75
76fn load_toml_dir<T, F>(dir: &Path, key_fn: F) -> Result<HashMap<String, T>, ProjectError>
77where
78 T: serde::de::DeserializeOwned,
79 F: Fn(&T) -> String,
80{
81 let mut out = HashMap::new();
82 if !dir.exists() {
83 return Ok(out);
84 }
85 for entry in fs::read_dir(dir).map_err(|e| ProjectError::Io {
86 path: dir.display().to_string(),
87 cause: e.to_string(),
88 })? {
89 let entry = entry.map_err(|e| ProjectError::Io {
90 path: dir.display().to_string(),
91 cause: e.to_string(),
92 })?;
93 let path = entry.path();
94 if path.extension().map(|e| e == "toml").unwrap_or(false) {
95 let text = fs::read_to_string(&path).map_err(|e| ProjectError::Io {
96 path: path.display().to_string(),
97 cause: e.to_string(),
98 })?;
99 let parsed: T = toml::from_str(&text).map_err(|e| ProjectError::TomlParse {
100 file: path.file_name().unwrap().to_string_lossy().to_string(),
101 cause: e.to_string(),
102 })?;
103 let key = key_fn(&parsed);
104 out.insert(key, parsed);
105 }
106 }
107 Ok(out)
108}
109
110use prosaic_core::{Engine, Salience, SalienceThresholds, Strictness, Variation, pipe_spec};
111use prosaic_grammar_en::English;
112
113impl Project {
114 pub fn validate(&self) -> Vec<ValidationIssue> {
117 let mut issues = Vec::new();
118 let known_partials: std::collections::HashSet<_> = self.partials.keys().cloned().collect();
119
120 for (key, template) in &self.templates {
121 for (vi, variant) in template.variants.iter().enumerate() {
122 let parsed = match prosaic_core::Template::parse(&variant.body) {
123 Ok(p) => p,
124 Err(e) => {
125 issues.push(ValidationIssue {
126 level: ValidationLevel::Error,
127 location: format!("templates/{key}.toml#variant[{vi}]"),
128 message: format!("template parse error: {e}"),
129 });
130 continue;
131 }
132 };
133 for pipe_name in parsed.pipe_names() {
134 if pipe_spec(&pipe_name).is_none() {
135 issues.push(ValidationIssue {
136 level: ValidationLevel::Error,
137 location: format!("templates/{key}.toml#variant[{vi}]"),
138 message: format!("unknown pipe `{pipe_name}`"),
139 });
140 }
141 }
142 for partial_name in parsed.partial_names() {
143 if !known_partials.contains(&partial_name) {
144 issues.push(ValidationIssue {
145 level: ValidationLevel::Error,
146 location: format!("templates/{key}.toml#variant[{vi}]"),
147 message: format!("unknown partial `{partial_name}`"),
148 });
149 }
150 }
151 }
152 }
153
154 issues
155 }
156
157 pub fn save_template(&self, key: &str) -> Result<(), ProjectError> {
159 let template = self
160 .templates
161 .get(key)
162 .ok_or_else(|| ProjectError::TemplateValidation {
163 key: key.to_string(),
164 reason: "template not present in project".to_string(),
165 })?;
166 let dir = self.root.join("templates");
167 if !dir.exists() {
168 fs::create_dir_all(&dir).map_err(|e| ProjectError::Io {
169 path: dir.display().to_string(),
170 cause: e.to_string(),
171 })?;
172 }
173 let serialized = toml::to_string_pretty(template).map_err(|e| ProjectError::TomlParse {
174 file: format!("{key}.toml"),
175 cause: e.to_string(),
176 })?;
177 let path = dir.join(format!("{key}.toml"));
178 fs::write(&path, serialized).map_err(|e| ProjectError::Io {
179 path: path.display().to_string(),
180 cause: e.to_string(),
181 })
182 }
183
184 pub fn save_partial(&self, name: &str) -> Result<(), ProjectError> {
186 let partial = self
187 .partials
188 .get(name)
189 .ok_or_else(|| ProjectError::PartialValidation {
190 name: name.to_string(),
191 reason: "partial not present in project".to_string(),
192 })?;
193 let dir = self.root.join("partials");
194 if !dir.exists() {
195 fs::create_dir_all(&dir).map_err(|e| ProjectError::Io {
196 path: dir.display().to_string(),
197 cause: e.to_string(),
198 })?;
199 }
200 let serialized = toml::to_string_pretty(partial).map_err(|e| ProjectError::TomlParse {
201 file: format!("{name}.toml"),
202 cause: e.to_string(),
203 })?;
204 let path = dir.join(format!("{name}.toml"));
205 fs::write(&path, serialized).map_err(|e| ProjectError::Io {
206 path: path.display().to_string(),
207 cause: e.to_string(),
208 })
209 }
210
211 pub fn save_scenario(&self, name: &str) -> Result<(), ProjectError> {
213 let scenario =
214 self.scenarios
215 .get(name)
216 .ok_or_else(|| ProjectError::ScenarioValidation {
217 name: name.to_string(),
218 reason: "scenario not present in project".to_string(),
219 })?;
220 let dir = self.root.join("tests");
221 if !dir.exists() {
222 fs::create_dir_all(&dir).map_err(|e| ProjectError::Io {
223 path: dir.display().to_string(),
224 cause: e.to_string(),
225 })?;
226 }
227 let serialized = toml::to_string_pretty(scenario).map_err(|e| ProjectError::TomlParse {
228 file: format!("{name}.toml"),
229 cause: e.to_string(),
230 })?;
231 let path = dir.join(format!("{name}.toml"));
232 fs::write(&path, serialized).map_err(|e| ProjectError::Io {
233 path: path.display().to_string(),
234 cause: e.to_string(),
235 })
236 }
237}
238
239impl Project {
240 pub fn into_engine(&self) -> Result<Engine, ProjectError> {
245 let mut engine = Engine::new(English::new());
246
247 let s = &self.manifest.engine;
248 engine = match s.strictness.as_str() {
249 "strict" => engine.strictness(Strictness::Strict),
250 "lenient" => engine.strictness(Strictness::Lenient),
251 "silent" => engine.strictness(Strictness::Silent),
252 other => {
253 return Err(ProjectError::TemplateValidation {
254 key: "(manifest)".to_string(),
255 reason: format!("unknown strictness `{other}`"),
256 });
257 }
258 };
259 engine = match s.variation.as_str() {
260 "fixed" => engine.variation(Variation::Fixed),
261 "round_robin" => engine.variation(Variation::RoundRobin),
262 "random" => engine.variation(Variation::Random),
263 other => {
264 return Err(ProjectError::TemplateValidation {
265 key: "(manifest)".to_string(),
266 reason: format!("unknown variation `{other}`"),
267 });
268 }
269 };
270 if s.smart_quotes {
271 engine = engine.smart_quotes(true);
272 }
273 if s.max_sentence_length > 0 {
274 engine = engine.max_sentence_length(s.max_sentence_length);
275 }
276 if s.faithfulness_min > 0.0 {
277 engine = engine.with_faithfulness_gate(s.faithfulness_min as f32);
278 }
279 if let Some(thr) = &s.salience_thresholds {
280 engine = engine.salience_thresholds(SalienceThresholds {
281 low_max: thr.low_max,
282 high_min: thr.high_min,
283 });
284 }
285 if let Some(style) = &s.style {
286 engine = engine.style_preference(style);
287 }
288 if let Some(profile_cfg) = &self.manifest.style_profile {
289 let profile = profile_cfg.clone().into_style_profile(&self.root)?;
290 engine = engine.style_profile(profile);
291 }
292 engine = engine.language_preference(&self.manifest.language);
293
294 for (name, partial) in &self.partials {
295 engine.register_partial(name, &partial.body).map_err(|e| {
296 ProjectError::PartialValidation {
297 name: name.clone(),
298 reason: e.to_string(),
299 }
300 })?;
301 }
302
303 for (key, template) in &self.templates {
304 for variant in &template.variants {
305 let salience = match variant.salience.as_str() {
306 "low" => Salience::Low,
307 "medium" => Salience::Medium,
308 "high" => Salience::High,
309 other => {
310 return Err(ProjectError::TemplateValidation {
311 key: key.clone(),
312 reason: format!("unknown salience `{other}`"),
313 });
314 }
315 };
316 let language = variant.language.as_deref();
317 let style = variant.style.as_deref();
318 engine
319 .register_template_with_language_and_style_at(
320 key,
321 &variant.body,
322 salience,
323 language,
324 style,
325 )
326 .map_err(|e| ProjectError::TemplateValidation {
327 key: key.clone(),
328 reason: e.to_string(),
329 })?;
330 }
331 }
332
333 Ok(engine)
334 }
335}
336
337fn load_fixtures_dir(dir: &Path) -> Result<HashMap<String, Context>, ProjectError> {
338 let mut out = HashMap::new();
339 if !dir.exists() {
340 return Ok(out);
341 }
342 for entry in fs::read_dir(dir).map_err(|e| ProjectError::Io {
343 path: dir.display().to_string(),
344 cause: e.to_string(),
345 })? {
346 let entry = entry.map_err(|e| ProjectError::Io {
347 path: dir.display().to_string(),
348 cause: e.to_string(),
349 })?;
350 let path = entry.path();
351 if path.extension().map(|e| e == "json").unwrap_or(false) {
352 let stem = path
353 .file_stem()
354 .ok_or_else(|| ProjectError::Io {
355 path: path.display().to_string(),
356 cause: "file has no stem".to_string(),
357 })?
358 .to_string_lossy()
359 .to_string();
360 let text = fs::read_to_string(&path).map_err(|e| ProjectError::Io {
361 path: path.display().to_string(),
362 cause: e.to_string(),
363 })?;
364 let ctx = parse_fixture(&stem, &text)?;
365 out.insert(stem, ctx);
366 }
367 }
368 Ok(out)
369}