Skip to main content

prosaic_project/
project.rs

1//! Project root: load a folder, validate, materialize an engine.
2
3use 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    /// Walk every template; report unknown pipes and unknown partial
115    /// references as validation issues. Does not error.
116    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    /// Write the named template back to disk as TOML.
158    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    /// Write the named partial back to disk as TOML.
185    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    /// Write the named scenario back to disk as TOML.
212    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    /// Materialize a configured Engine from this project.
241    /// v1: English only; multi-grammar selection by manifest is plumbed
242    /// at the variant level (per-variant `language` field) but the
243    /// engine itself is single-grammar in v1.
244    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}