1use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::glossary::GlossaryScopeKind;
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct RegisterFields {
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub narration: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub narration_tense: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub dialogue_default: Option<String>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub loanword_policy: Option<String>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct VoiceFields {
38 #[serde(skip_serializing_if = "Option::is_none")]
39 pub narrator_register: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub preserve_anglicisms: Option<bool>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub target_audience: Option<String>,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub gender_of_unspecified_narrator: Option<String>,
46}
47
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub struct DoNotFields {
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub translate_terms: Vec<String>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub preserve_punctuation: Vec<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct StyleSheet {
58 pub scope_kind: GlossaryScopeKind,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub scope_id: Option<String>,
61 pub target_language: String,
62 #[serde(default)]
63 pub register: RegisterFields,
64 #[serde(default)]
65 pub voice: VoiceFields,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub free_text_instructions: Option<String>,
68 #[serde(default)]
69 pub do_not: DoNotFields,
70}
71
72pub fn merge_style_sheets(sheets: &[StyleSheet]) -> Option<StyleSheet> {
81 if sheets.is_empty() {
82 return None;
83 }
84 let target_language = sheets[0].target_language.clone();
85 let mut ordered: Vec<&StyleSheet> = sheets.iter().collect();
86 ordered.sort_by_key(|sheet| sheet.scope_kind.priority());
87
88 let mut merged = StyleSheet {
89 scope_kind: GlossaryScopeKind::Global,
90 scope_id: None,
91 target_language,
92 register: RegisterFields::default(),
93 voice: VoiceFields::default(),
94 free_text_instructions: None,
95 do_not: DoNotFields::default(),
96 };
97 let mut instructions: Vec<String> = Vec::new();
98 let mut translate_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
99 let mut punct_seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
100
101 for sheet in ordered {
102 if let Some(value) = sheet.register.narration.as_ref() {
103 merged.register.narration = Some(value.clone());
104 }
105 if let Some(value) = sheet.register.narration_tense.as_ref() {
106 merged.register.narration_tense = Some(value.clone());
107 }
108 if let Some(value) = sheet.register.dialogue_default.as_ref() {
109 merged.register.dialogue_default = Some(value.clone());
110 }
111 if let Some(value) = sheet.register.loanword_policy.as_ref() {
112 merged.register.loanword_policy = Some(value.clone());
113 }
114 if let Some(value) = sheet.voice.narrator_register.as_ref() {
115 merged.voice.narrator_register = Some(value.clone());
116 }
117 if let Some(value) = sheet.voice.preserve_anglicisms {
118 merged.voice.preserve_anglicisms = Some(value);
119 }
120 if let Some(value) = sheet.voice.target_audience.as_ref() {
121 merged.voice.target_audience = Some(value.clone());
122 }
123 if let Some(value) = sheet.voice.gender_of_unspecified_narrator.as_ref() {
124 merged.voice.gender_of_unspecified_narrator = Some(value.clone());
125 }
126 if let Some(value) = sheet
127 .free_text_instructions
128 .as_ref()
129 .filter(|s| !s.trim().is_empty())
130 {
131 instructions.push(value.trim().to_string());
132 }
133 for term in &sheet.do_not.translate_terms {
134 if translate_seen.insert(term.clone()) {
135 merged.do_not.translate_terms.push(term.clone());
136 }
137 }
138 for token in &sheet.do_not.preserve_punctuation {
139 if punct_seen.insert(token.clone()) {
140 merged.do_not.preserve_punctuation.push(token.clone());
141 }
142 }
143 }
144 if !instructions.is_empty() {
145 merged.free_text_instructions = Some(instructions.join("\n\n"));
146 }
147 if has_content(&merged) {
148 Some(merged)
149 } else {
150 None
151 }
152}
153
154fn has_content(s: &StyleSheet) -> bool {
155 let RegisterFields {
156 narration,
157 narration_tense,
158 dialogue_default,
159 loanword_policy,
160 } = &s.register;
161 let VoiceFields {
162 narrator_register,
163 preserve_anglicisms,
164 target_audience,
165 gender_of_unspecified_narrator,
166 } = &s.voice;
167 narration.is_some()
168 || narration_tense.is_some()
169 || dialogue_default.is_some()
170 || loanword_policy.is_some()
171 || narrator_register.is_some()
172 || preserve_anglicisms.is_some()
173 || target_audience.is_some()
174 || gender_of_unspecified_narrator.is_some()
175 || s.free_text_instructions.is_some()
176 || !s.do_not.translate_terms.is_empty()
177 || !s.do_not.preserve_punctuation.is_empty()
178}
179
180pub fn render_style_block(merged: Option<&StyleSheet>) -> String {
184 let Some(sheet) = merged else {
185 return String::new();
186 };
187 let mut out = String::from("=== Active style guide ===\n");
188 if let Some(narration) = &sheet.register.narration {
189 let mut line = format!("Register: {narration}");
190 if let Some(tense) = &sheet.register.narration_tense {
191 line.push_str(&format!(", narration in {tense}"));
192 }
193 out.push_str(&line);
194 out.push_str(".\n");
195 } else if let Some(tense) = &sheet.register.narration_tense {
196 out.push_str(&format!("Narration tense: {tense}.\n"));
197 }
198 if let Some(dialogue) = &sheet.register.dialogue_default {
199 out.push_str(&format!("Dialogue default: {dialogue}.\n"));
200 }
201 if let Some(policy) = &sheet.register.loanword_policy {
202 out.push_str(&format!("Loanword policy: {policy}.\n"));
203 }
204 if let Some(voice) = &sheet.voice.narrator_register {
205 out.push_str(&format!("Narrator voice: {voice}.\n"));
206 }
207 if let Some(audience) = &sheet.voice.target_audience {
208 out.push_str(&format!("Target audience: {audience}.\n"));
209 }
210 if let Some(gender) = &sheet.voice.gender_of_unspecified_narrator {
211 out.push_str(&format!("Unspecified-narrator gender: {gender}.\n"));
212 }
213 if let Some(preserve) = sheet.voice.preserve_anglicisms {
214 out.push_str(&format!("Preserve anglicisms: {preserve}.\n"));
215 }
216 if !sheet.do_not.preserve_punctuation.is_empty() {
217 out.push_str(&format!(
218 "Preserve punctuation: {}.\n",
219 sheet.do_not.preserve_punctuation.join(" ")
220 ));
221 }
222 if !sheet.do_not.translate_terms.is_empty() {
223 out.push_str(&format!(
224 "Do not translate: {}.\n",
225 sheet.do_not.translate_terms.join(", ")
226 ));
227 }
228 if let Some(instructions) = sheet
229 .free_text_instructions
230 .as_ref()
231 .filter(|s| !s.trim().is_empty())
232 {
233 out.push_str("\nCustom instructions:\n");
234 out.push_str(instructions.trim());
235 out.push('\n');
236 }
237 out.push_str("=== End style guide ===\n");
238 out
239}
240
241pub fn style_fingerprint(merged: Option<&StyleSheet>) -> String {
246 let payload = serde_json::json!({
247 "schema": 1,
248 "merged": merged,
249 });
250 let serialized = serde_json::to_vec(&payload).unwrap_or_default();
251 let digest = Sha256::digest(serialized);
252 let mut hex = String::with_capacity(digest.len() * 2);
253 for byte in digest {
254 use std::fmt::Write as _;
255 write!(&mut hex, "{byte:02x}").expect("write to string");
256 }
257 hex
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn sheet(scope: GlossaryScopeKind, target: &str) -> StyleSheet {
265 StyleSheet {
266 scope_kind: scope,
267 scope_id: Some("test".to_string()),
268 target_language: target.to_string(),
269 register: RegisterFields::default(),
270 voice: VoiceFields::default(),
271 free_text_instructions: None,
272 do_not: DoNotFields::default(),
273 }
274 }
275
276 #[test]
277 fn merge_returns_none_for_empty_input() {
278 assert!(merge_style_sheets(&[]).is_none());
279 }
280
281 #[test]
282 fn merge_returns_none_when_all_sheets_are_empty() {
283 let sheets = vec![sheet(GlossaryScopeKind::Global, "Italian")];
284 assert!(merge_style_sheets(&sheets).is_none());
285 }
286
287 #[test]
288 fn merge_applies_book_over_series_over_global_precedence_for_scalars() {
289 let mut global = sheet(GlossaryScopeKind::Global, "Italian");
290 global.register.narration = Some("neutral".to_string());
291 global.register.dialogue_default = Some("Lei".to_string());
292 let mut series = sheet(GlossaryScopeKind::Series, "Italian");
293 series.register.narration = Some("literary".to_string());
294 let mut book = sheet(GlossaryScopeKind::Book, "Italian");
295 book.register.dialogue_default = Some("tu".to_string());
296
297 let merged = merge_style_sheets(&[global, series, book]).expect("merged");
298 assert_eq!(merged.register.narration.as_deref(), Some("literary"));
299 assert_eq!(merged.register.dialogue_default.as_deref(), Some("tu"));
300 }
301
302 #[test]
303 fn merge_concatenates_free_text_global_first_book_last() {
304 let mut global = sheet(GlossaryScopeKind::Global, "Italian");
305 global.free_text_instructions = Some("global hint".to_string());
306 let mut book = sheet(GlossaryScopeKind::Book, "Italian");
307 book.free_text_instructions = Some("book hint".to_string());
308
309 let merged = merge_style_sheets(&[book, global]).expect("merged");
310 let text = merged.free_text_instructions.expect("instructions");
311 let g = text.find("global hint").expect("global");
312 let b = text.find("book hint").expect("book");
313 assert!(g < b, "global instructions render before book-level ones");
314 }
315
316 #[test]
317 fn merge_dedupes_do_not_translate_terms() {
318 let mut global = sheet(GlossaryScopeKind::Global, "Italian");
319 global.do_not.translate_terms = vec!["mithril".to_string()];
320 let mut book = sheet(GlossaryScopeKind::Book, "Italian");
321 book.do_not.translate_terms = vec!["mithril".to_string(), "lembas".to_string()];
322 let merged = merge_style_sheets(&[global, book]).expect("merged");
323 assert_eq!(merged.do_not.translate_terms.len(), 2);
324 assert!(
325 merged
326 .do_not
327 .translate_terms
328 .contains(&"mithril".to_string())
329 );
330 assert!(
331 merged
332 .do_not
333 .translate_terms
334 .contains(&"lembas".to_string())
335 );
336 }
337
338 #[test]
339 fn render_block_returns_empty_when_no_sheet() {
340 assert_eq!(render_style_block(None), "");
341 }
342
343 #[test]
344 fn render_block_skips_unset_fields() {
345 let mut s = sheet(GlossaryScopeKind::Book, "Italian");
346 s.register.dialogue_default = Some("tu".to_string());
347 let rendered = render_style_block(Some(&s));
348 assert!(rendered.contains("Dialogue default: tu."));
349 assert!(!rendered.contains("Register:"));
350 assert!(!rendered.contains("Narrator voice:"));
351 }
352
353 #[test]
354 fn render_block_includes_free_text_instructions_after_structured_fields() {
355 let mut s = sheet(GlossaryScopeKind::Book, "Italian");
356 s.register.narration = Some("literary".to_string());
357 s.free_text_instructions = Some("Maintain a literary register.".to_string());
358 let rendered = render_style_block(Some(&s));
359 let reg = rendered.find("Register:").expect("register present");
360 let instr = rendered
361 .find("Maintain a literary register.")
362 .expect("instructions present");
363 assert!(reg < instr);
364 }
365
366 #[test]
367 fn style_fingerprint_is_stable_across_field_order() {
368 let mut a = sheet(GlossaryScopeKind::Book, "Italian");
369 a.register.narration = Some("literary".to_string());
370 a.register.dialogue_default = Some("tu".to_string());
371 let mut b = sheet(GlossaryScopeKind::Book, "Italian");
372 b.register.dialogue_default = Some("tu".to_string());
373 b.register.narration = Some("literary".to_string());
374 assert_eq!(style_fingerprint(Some(&a)), style_fingerprint(Some(&b)));
375 }
376
377 #[test]
378 fn style_fingerprint_of_none_is_stable() {
379 let f1 = style_fingerprint(None);
380 let f2 = style_fingerprint(None);
381 assert_eq!(f1, f2);
382 assert_ne!(f1, "");
383 }
384}