Skip to main content

bookforge_core/
style.rs

1//! Style sheets — per-book/series/global rendering hints injected into
2//! the translation prompt.
3//!
4//! A style sheet captures the per-book stylistic decisions that
5//! `--prompt-extra` was a thin proxy for: narrative register, dialogue
6//! formality, loanword policy, and free-form custom instructions. Several
7//! sheets at different scopes can be active simultaneously; they merge
8//! `book > series > global` with the same precedence as glossary terms
9//! (see [`merge_style_sheets`]).
10//!
11//! The merged sheet renders as a single prompt block; the prompt
12//! template references it via `{{style_guide_block}}`. When no sheet is
13//! active, the block renders to an empty string.
14
15use serde::{Deserialize, Serialize};
16use sha2::{Digest, Sha256};
17
18use crate::glossary::GlossaryScopeKind;
19
20/// Narrative register: maps to source-language → target-language voice
21/// conventions. Values are strings so the user can pick conventions
22/// outside this enum (`passato_remoto` etc.) without us pre-enumerating
23/// every target-language idiom.
24#[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
72/// Merge multiple style sheets following `book > series > global`
73/// precedence (mirrors [`crate::glossary::merge_scope_terms`]). For
74/// scalar Optional fields, the highest-priority non-`None` wins. For
75/// `do_not.translate_terms` and `do_not.preserve_punctuation`, vectors
76/// concatenate with deduplication. `free_text_instructions` concatenates
77/// global-first → book-last so book-level instructions appear most
78/// prominently to the model. Returns `None` if no input sheets carry any
79/// content.
80pub 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
180/// Render the merged style sheet as a prompt block. Empty input returns
181/// an empty string so the placeholder substitutes to nothing in
182/// templates that don't reference style guides.
183pub 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
241/// Stable fingerprint of the merged style sheet for cache namespacing
242/// and snapshot integrity. When `merged` is `None`, returns the
243/// fingerprint of the empty payload — stable across runs and across
244/// upgrades, so users without `--style` see no cache invalidation.
245pub 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}