prosaic_project/
manifest.rs1use 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 #[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}