Skip to main content

prosaic_project/
manifest.rs

1//! `prosaic.toml` schema — project manifest.
2
3use serde::{Deserialize, Serialize};
4
5use crate::style::StyleProfileConfig;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Manifest {
9    pub name: String,
10    pub version: String,
11    pub language: String,
12    #[serde(default)]
13    pub engine: EngineSettings,
14    #[serde(default)]
15    pub dependencies: Vec<VocabDependency>,
16    /// Optional declarative voice profile applied to the materialized
17    /// engine. Missing or all-default fields render byte-equivalent to
18    /// `StyleProfile::neutral()`.
19    #[serde(default)]
20    pub style_profile: Option<StyleProfileConfig>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(default)]
25pub struct EngineSettings {
26    pub strictness: String,
27    pub variation: String,
28    pub smart_quotes: bool,
29    pub max_sentence_length: usize,
30    pub faithfulness_min: f64,
31    pub salience_thresholds: Option<SalienceThresholdsConfig>,
32    pub style: Option<String>,
33}
34
35impl Default for EngineSettings {
36    fn default() -> Self {
37        Self {
38            strictness: "strict".to_string(),
39            variation: "fixed".to_string(),
40            smart_quotes: false,
41            max_sentence_length: 0,
42            faithfulness_min: 0.0,
43            salience_thresholds: None,
44            style: None,
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SalienceThresholdsConfig {
51    pub low_max: i64,
52    pub high_min: i64,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct VocabDependency {
57    #[serde(rename = "crate")]
58    pub crate_name: String,
59    pub version: String,
60    #[serde(default)]
61    pub languages: Vec<String>,
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67
68    #[test]
69    fn parse_minimal_manifest() {
70        let toml_str = r#"
71            name = "demo"
72            version = "0.1.0"
73            language = "en"
74        "#;
75        let m: Manifest = toml::from_str(toml_str).unwrap();
76        assert_eq!(m.name, "demo");
77        assert_eq!(m.version, "0.1.0");
78        assert_eq!(m.language, "en");
79        assert!(m.dependencies.is_empty());
80        assert_eq!(m.engine.strictness, "strict");
81        assert_eq!(m.engine.variation, "fixed");
82    }
83
84    #[test]
85    fn parse_full_manifest() {
86        let toml_str = r#"
87            name = "changelog"
88            version = "1.2.0"
89            language = "en"
90
91            [engine]
92            strictness = "lenient"
93            variation = "round_robin"
94            smart_quotes = true
95            max_sentence_length = 120
96            faithfulness_min = 0.85
97            style = "executive"
98
99            [engine.salience_thresholds]
100            low_max = 2
101            high_min = 30
102
103            [[dependencies]]
104            crate = "prosaic-vocab-code"
105            version = "0.3"
106            languages = ["en", "es"]
107
108            [[dependencies]]
109            crate = "prosaic-vocab-git"
110            version = "0.3"
111        "#;
112        let m: Manifest = toml::from_str(toml_str).unwrap();
113        assert_eq!(m.engine.max_sentence_length, 120);
114        assert_eq!(m.engine.faithfulness_min, 0.85);
115        assert_eq!(m.engine.style.as_deref(), Some("executive"));
116        let st = m.engine.salience_thresholds.unwrap();
117        assert_eq!(st.low_max, 2);
118        assert_eq!(st.high_min, 30);
119        assert_eq!(m.dependencies.len(), 2);
120        assert_eq!(m.dependencies[0].crate_name, "prosaic-vocab-code");
121        assert_eq!(m.dependencies[0].languages, vec!["en", "es"]);
122        assert!(m.dependencies[1].languages.is_empty());
123    }
124
125    #[test]
126    fn missing_required_fields_errors() {
127        let toml_str = r#"version = "0.1.0""#;
128        let res = toml::from_str::<Manifest>(toml_str);
129        assert!(res.is_err(), "expected error for missing `name` field");
130    }
131
132    #[test]
133    fn parse_manifest_with_style_profile_section() {
134        let toml_str = r#"
135            name = "demo"
136            version = "0.1.0"
137            language = "en"
138
139            [style_profile]
140            name = "concise-professional"
141            verbosity = "terse"
142            list_style_bias = "bracketed"
143            pronoun_density = "high"
144
145            [style_profile.connectives.allowed]
146            elaboration = ["Furthermore,", "Additionally,"]
147            contrast = ["However,"]
148
149            [style_profile.hedging]
150            offset = -10
151            forbid = ["perhaps"]
152        "#;
153        let m: Manifest = toml::from_str(toml_str).unwrap();
154        let p = m.style_profile.unwrap();
155        assert_eq!(p.name.as_deref(), Some("concise-professional"));
156        assert_eq!(p.verbosity.as_deref(), Some("terse"));
157        assert_eq!(p.list_style_bias.as_deref(), Some("bracketed"));
158        let connectives = p.connectives.unwrap();
159        let allowed = connectives.allowed.unwrap();
160        assert_eq!(allowed.get("elaboration").map(Vec::len), Some(2));
161        assert_eq!(allowed.get("contrast").map(Vec::len), Some(1));
162        let hedging = p.hedging.unwrap();
163        assert_eq!(hedging.offset, Some(-10));
164        assert_eq!(hedging.forbid.as_ref().map(Vec::len), Some(1));
165    }
166
167    #[test]
168    fn parse_manifest_with_style_profile_extends_only() {
169        let toml_str = r#"
170            name = "demo"
171            version = "0.1.0"
172            language = "en"
173
174            [style_profile]
175            extends = "profiles/concise-professional.toml"
176        "#;
177        let m: Manifest = toml::from_str(toml_str).unwrap();
178        let p = m.style_profile.unwrap();
179        assert_eq!(
180            p.extends.as_deref(),
181            Some("profiles/concise-professional.toml")
182        );
183        assert!(p.name.is_none());
184    }
185
186    #[test]
187    fn manifest_without_style_profile_section_omits_field() {
188        let toml_str = r#"
189            name = "demo"
190            version = "0.1.0"
191            language = "en"
192        "#;
193        let m: Manifest = toml::from_str(toml_str).unwrap();
194        assert!(m.style_profile.is_none());
195    }
196
197    #[test]
198    fn invalid_strictness_preserved_for_downstream_validation() {
199        let toml_str = r#"
200            name = "demo"
201            version = "0.1.0"
202            language = "en"
203            [engine]
204            strictness = "yolo"
205        "#;
206        let m: Manifest = toml::from_str(toml_str).unwrap();
207        assert_eq!(m.engine.strictness, "yolo");
208    }
209}