Skip to main content

specfold_core/
translation.rs

1//! Translated documents: a translation names its source with `rel.translation_of` and records
2//! the source it was made from as `source_rev`, a hash of the source body (the main file and the
3//! bundle's chapters). No git history is needed to see that a translation is behind: the hash is
4//! compared with the source as it is now. Where git has the recorded revision, the sections and
5//! paragraphs that changed since are named as well.
6
7use crate::corpus::Corpus;
8use crate::corpus::scan::hex;
9use crate::graph::{Graph, Resolution};
10use crate::markdown::frontmatter;
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13use std::collections::BTreeMap;
14
15pub const RELATION: &str = "translation_of";
16pub const KEY: &str = "source_rev";
17
18/// `sha256:<12 hex>` of a document's body with the frontmatter removed and every run of
19/// whitespace collapsed to one space, so line endings, wrapping and blank lines do not count.
20pub fn rev_of_text(text: &str) -> String {
21    let body = frontmatter::split(text).body;
22    let norm = body.split_whitespace().collect::<Vec<_>>().join(" ");
23    format!("sha256:{}", &hex(&Sha256::digest(norm.as_bytes()))[..12])
24}
25
26/// The revision of a whole source: its main file, then its chapters in path order. A document
27/// without chapters has the revision of its main file.
28pub fn rev_of_parts(parts: &[Part]) -> String {
29    let norm = parts.iter().flat_map(|p| p.body.split_whitespace()).collect::<Vec<_>>().join(" ");
30    format!("sha256:{}", &hex(&Sha256::digest(norm.as_bytes()))[..12])
31}
32
33/// One file of a source: `label` is empty for the main file and the chapter path otherwise.
34#[derive(Debug, Clone)]
35pub struct Part {
36    pub label: String,
37    pub body: String,
38}
39
40/// The files of a document read with `read` (the working tree, the index or a commit), main
41/// file first; None when the main file cannot be read. Unreadable chapters are left out.
42pub fn parts_of(c: &Corpus, idx: usize, read: impl Fn(&str) -> Option<String>) -> Option<Vec<Part>> {
43    let d = &c.docs[idx];
44    let main = read(&d.path)?;
45    let mut parts = vec![Part { label: String::new(), body: frontmatter::split(&main).body.to_string() }];
46    let mut chapters = d.chapters.clone();
47    chapters.sort();
48    for ch in chapters {
49        if let Some(t) = read(&ch) {
50            let label = ch.rsplit('/').next().unwrap_or(&ch).to_string();
51            parts.push(Part { label, body: frontmatter::split(&t).body.to_string() });
52        }
53    }
54    Some(parts)
55}
56
57/// The current revision of a document, read from disk.
58pub fn rev_of(c: &Corpus, idx: usize) -> Option<String> {
59    let root = c.cfg.root.clone();
60    let parts = parts_of(c, idx, |p| crate::corpus::overlay::read(&root.join(p)).ok())?;
61    Some(rev_of_parts(&parts))
62}
63
64/// How one section of a source differs from the revision a translation was made from.
65#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
66pub struct SectionChange {
67    /// The heading text, prefixed with the chapter file for chapters; `(top)` before the first
68    /// heading.
69    pub section: String,
70    /// `changed`, `added` or `removed`.
71    pub change: String,
72    /// Paragraphs that differ (for `changed`) or that the section has (otherwise).
73    pub paragraphs: usize,
74}
75
76/// Sections of one body: heading text and normalized paragraphs, fenced code kept whole.
77fn sections(label: &str, body: &str) -> Vec<(String, Vec<String>)> {
78    let name = |h: &str| if label.is_empty() { h.to_string() } else { format!("{label}: {h}") };
79    let mut out: Vec<(String, Vec<String>)> = vec![(name("(top)"), vec![])];
80    let mut para: Vec<&str> = vec![];
81    let mut fence = false;
82    let flush = |para: &mut Vec<&str>, out: &mut Vec<(String, Vec<String>)>| {
83        let t = para.iter().flat_map(|l| l.split_whitespace()).collect::<Vec<_>>().join(" ");
84        if !t.is_empty() {
85            out.last_mut().expect("one section").1.push(t);
86        }
87        para.clear();
88    };
89    for line in body.lines() {
90        let t = line.trim_start();
91        if t.starts_with("```") || t.starts_with("~~~") {
92            fence = !fence;
93            para.push(line);
94            continue;
95        }
96        if !fence && t.starts_with('#') && t.trim_start_matches('#').starts_with([' ', '\t']) {
97            flush(&mut para, &mut out);
98            out.push((name(t.trim_start_matches('#').trim()), vec![]));
99        } else if !fence && t.is_empty() {
100            flush(&mut para, &mut out);
101        } else {
102            para.push(line);
103        }
104    }
105    flush(&mut para, &mut out);
106    out.retain(|(h, ps)| !(ps.is_empty() && h.ends_with("(top)")));
107    out
108}
109
110/// The sections that differ between two versions of a source, in the order of the new one
111/// (removed sections last).
112pub fn section_changes(old: &[Part], new: &[Part]) -> Vec<SectionChange> {
113    let all = |parts: &[Part]| parts.iter().flat_map(|p| sections(&p.label, &p.body)).collect::<Vec<_>>();
114    let (old, new) = (all(old), all(new));
115    let mut out = vec![];
116    for (h, ps) in &new {
117        match old.iter().find(|(o, _)| o == h) {
118            None => out.push(SectionChange { section: h.clone(), change: "added".into(), paragraphs: ps.len() }),
119            Some((_, was)) => {
120                let gone = was.iter().filter(|p| !ps.contains(p)).count();
121                let came = ps.iter().filter(|p| !was.contains(p)).count();
122                let n = gone.max(came);
123                if n > 0 || was.len() != ps.len() {
124                    out.push(SectionChange { section: h.clone(), change: "changed".into(), paragraphs: n.max(1) });
125                }
126            }
127        }
128    }
129    for (h, ps) in &old {
130        if !new.iter().any(|(n, _)| n == h) {
131            out.push(SectionChange { section: h.clone(), change: "removed".into(), paragraphs: ps.len() });
132        }
133    }
134    out
135}
136
137/// The source as it was at `rev`, found in git history (the commits that touched its files,
138/// newest first, at most 200); None without git or when no commit has that revision.
139pub fn parts_at_rev(c: &Corpus, idx: usize, rev: &str) -> Option<Vec<Part>> {
140    let root = &c.cfg.root;
141    let d = &c.docs[idx];
142    let mut commits: Vec<String> = vec![];
143    for f in std::iter::once(&d.path).chain(&d.chapters) {
144        for r in crate::workspace::vcs::history(root, f).unwrap_or_default().into_iter().rev() {
145            if !commits.contains(&r.hash) {
146                commits.push(r.hash);
147            }
148        }
149    }
150    commits.into_iter().take(200).find_map(|h| {
151        let parts = parts_of(c, idx, |p| crate::workspace::vcs::show(root, &h, p))?;
152        (rev_of_parts(&parts) == rev).then_some(parts)
153    })
154}
155
156/// What changed in a translation's source since `rev`, when git has that revision.
157pub fn changes_since(c: &Corpus, src: usize, rev: &str, now: &[Part]) -> Option<Vec<SectionChange>> {
158    Some(section_changes(&parts_at_rev(c, src, rev)?, now))
159}
160
161/// `Decision (2 paragraphs changed), Risks (new)`: for messages.
162pub fn describe(changes: &[SectionChange]) -> String {
163    changes
164        .iter()
165        .map(|ch| match ch.change.as_str() {
166            "added" => format!("{} (new)", ch.section),
167            "removed" => format!("{} (removed)", ch.section),
168            _ => format!(
169                "{} ({} paragraph{} changed)",
170                ch.section,
171                ch.paragraphs,
172                if ch.paragraphs == 1 { "" } else { "s" }
173            ),
174        })
175        .collect::<Vec<_>>()
176        .join(", ")
177}
178
179/// Why `lang` is not a language tag (BCP 47 shape: a 2-3 letter language, then subtags of 1-8
180/// letters or digits, `ko`, `pt-BR`, `zh-Hant-TW`), or `None` when it is one.
181pub fn lang_problem(lang: &str) -> Option<String> {
182    let mut parts = lang.split('-');
183    let first = parts.next().unwrap_or("");
184    let ok = (2..=3).contains(&first.len())
185        && first.chars().all(|c| c.is_ascii_alphabetic())
186        && parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric()));
187    (!ok).then(|| format!("lang '{lang}' is not a language tag (BCP 47) like ko, ja or pt-BR"))
188}
189
190/// Why a `source_rev` value is not `sha256:<12 hex>`, or `None` when it is.
191pub fn rev_problem(rev: &str) -> Option<String> {
192    let ok = rev
193        .strip_prefix("sha256:")
194        .is_some_and(|h| h.len() == 12 && h.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
195    (!ok).then(|| format!("source_rev '{rev}' is not sha256:<12 hex>"))
196}
197
198/// What a document's `rel.translation_of` names, raw.
199pub fn source_ref(c: &Corpus, idx: usize) -> Option<String> {
200    c.docs[idx].rel().get(RELATION).and_then(|l| l.first().cloned())
201}
202
203/// The source document of a translation, when it resolves: `rel.translation_of` when set,
204/// otherwise the source the `[translations]` layout pairs it with.
205pub fn source_of(c: &Corpus, g: &Graph, idx: usize) -> Option<usize> {
206    match source_ref(c, idx) {
207        Some(r) => match g.resolve(&r) {
208            Resolution::Doc { idx, .. } => Some(idx),
209            _ => None,
210        },
211        None => inferred(c, idx).and_then(|p| p.source),
212    }
213}
214
215/// Whether a document is a translation: it names a source, or the layout places it as one.
216pub fn is_translation(c: &Corpus, idx: usize) -> bool {
217    source_ref(c, idx).is_some() || position(c, idx).is_some()
218}
219
220/// The language of a document: frontmatter `lang`, else the layout's language marker, else
221/// `[translations] source_lang` for a document that is not a translation.
222pub fn lang_of(c: &Corpus, idx: usize) -> Option<String> {
223    c.docs[idx]
224        .str_field("lang")
225        .or_else(|| position(c, idx).map(|p| p.0))
226        .or_else(|| (!is_translation(c, idx)).then(|| c.cfg.raw.translations.source_lang.clone()).flatten())
227}
228
229/// The directory that holds a document's type directories (`docs/.spec`), from its path.
230fn spec_root(c: &Corpus, idx: usize) -> String {
231    let d = &c.docs[idx];
232    let marker = format!("/{}/", d.id.ty);
233    let p = format!("/{}", d.dir.as_deref().unwrap_or(&d.path));
234    match p.rfind(&marker) {
235        Some(i) => p[1..i.max(1)].to_string(),
236        None => String::new(),
237    }
238}
239
240/// Where the layout places a document as a translation: its language and the (type, spec root,
241/// slug) its source has.
242fn position(c: &Corpus, idx: usize) -> Option<(String, (String, String, String))> {
243    use crate::config::types::TranslationLayout as L;
244    let t = &c.cfg.raw.translations;
245    let d = &c.docs[idx];
246    let root = spec_root(c, idx);
247    match t.layout {
248        L::None => None,
249        L::Suffix => t.languages.iter().find_map(|l| {
250            let base = d.slug.strip_suffix(&format!("-{}", l.to_ascii_lowercase()))?;
251            (!base.is_empty()).then(|| (l.clone(), (d.id.ty.clone(), root.clone(), base.to_string())))
252        }),
253        L::Dir => {
254            let segs: Vec<&str> = root.split('/').collect();
255            let at = segs.iter().rposition(|s| t.languages.iter().any(|l| l == s))?;
256            let lang = segs[at].to_string();
257            let rest: Vec<&str> = segs.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, s)| *s).collect();
258            Some((lang, (d.id.ty.clone(), rest.join("/"), d.slug.clone())))
259        }
260    }
261}
262
263/// A pair the layout infers for a document in a translation position.
264#[derive(Debug, Clone)]
265pub struct Inferred {
266    pub lang: String,
267    /// The source, when exactly one document is in the source position.
268    pub source: Option<usize>,
269    /// Every document in the source position (more than one is ambiguous).
270    pub candidates: Vec<usize>,
271}
272
273/// The pair the `[translations]` layout infers for a document, when it is in a translation
274/// position.
275pub fn inferred(c: &Corpus, idx: usize) -> Option<Inferred> {
276    let (lang, want) = position(c, idx)?;
277    let candidates: Vec<usize> = (0..c.docs.len())
278        .filter(|&j| j != idx && position(c, j).is_none())
279        .filter(|&j| {
280            let d = &c.docs[j];
281            d.id.ty == want.0 && d.slug == want.2 && spec_root(c, j) == want.1
282        })
283        .collect();
284    let source = (candidates.len() == 1).then(|| candidates[0]);
285    Some(Inferred { lang, source, candidates })
286}
287
288#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
289#[serde(rename_all = "snake_case")]
290pub enum State {
291    /// source_rev matches the source.
292    Current,
293    /// The source changed after source_rev.
294    Behind,
295    /// No source_rev recorded.
296    Unrecorded,
297    /// rel.translation_of does not name a document.
298    NoSource,
299}
300
301#[derive(Debug, Clone, Serialize)]
302pub struct Translation {
303    pub id: String,
304    pub path: String,
305    pub lang: Option<String>,
306    pub source: String,
307    pub state: State,
308    /// For a translation behind its source: the sections changed since source_rev, when git has
309    /// that revision.
310    #[serde(skip_serializing_if = "Vec::is_empty")]
311    pub sections: Vec<SectionChange>,
312}
313
314#[derive(Debug, Clone, Serialize, Default)]
315pub struct Report {
316    pub translations: Vec<Translation>,
317    /// Per language: sources with no translation in it.
318    pub missing: BTreeMap<String, Vec<String>>,
319    pub sources: usize,
320}
321
322/// The state of one translation.
323pub fn state(c: &Corpus, g: &Graph, idx: usize) -> State {
324    let Some(src) = source_of(c, g, idx) else { return State::NoSource };
325    match c.docs[idx].str_field(KEY) {
326        None => State::Unrecorded,
327        Some(r) if Some(r.clone()) == rev_of(c, src) => State::Current,
328        Some(_) => State::Behind,
329    }
330}
331
332/// Every translation with its state, and the sources each language still lacks. `langs` limits
333/// the coverage to those languages; empty means every language a translation declares.
334pub fn report(c: &Corpus, g: &Graph, langs: &[String]) -> Report {
335    let mut r = Report::default();
336    let mut have: BTreeMap<String, Vec<usize>> = BTreeMap::new();
337    let mut sources: Vec<usize> = vec![];
338    for (i, d) in c.docs.iter().enumerate() {
339        if !is_translation(c, i) {
340            continue;
341        }
342        let lang = lang_of(c, i);
343        let src = source_of(c, g, i);
344        // A translation of a translation (or of itself) is not a source; R1 reports it.
345        if let (Some(l), Some(s)) = (&lang, src.filter(|&s| s != i && !is_translation(c, s))) {
346            have.entry(l.clone()).or_default().push(s);
347            if !sources.contains(&s) {
348                sources.push(s);
349            }
350        }
351        let st = state(c, g, i);
352        let sections = match (&st, src, d.str_field(KEY)) {
353            (State::Behind, Some(s), Some(rev)) => {
354                let root = c.cfg.root.clone();
355                parts_of(c, s, |p| crate::corpus::overlay::read(&root.join(p)).ok())
356                    .and_then(|now| changes_since(c, s, &rev, &now))
357                    .unwrap_or_default()
358            }
359            _ => vec![],
360        };
361        r.translations.push(Translation {
362            id: d.id.to_string(),
363            path: d.path.clone(),
364            lang,
365            source: src
366                .map(|s| c.docs[s].id.to_string())
367                .unwrap_or_else(|| source_ref(c, i).unwrap_or_else(|| "(none)".into())),
368            state: st,
369            sections,
370        });
371    }
372    let configured = &c.cfg.raw.translations.languages;
373    let wanted: Vec<String> = if !langs.is_empty() {
374        langs.to_vec()
375    } else if !configured.is_empty() {
376        configured.clone()
377    } else {
378        have.keys().cloned().collect()
379    };
380    for l in wanted {
381        let got = have.get(&l).cloned().unwrap_or_default();
382        let miss: Vec<String> =
383            sources.iter().filter(|s| !got.contains(s)).map(|&s| c.docs[s].id.to_string()).collect();
384        r.missing.insert(l, miss);
385    }
386    r.sources = sources.len();
387    r
388}
389
390#[cfg(test)]
391mod tests {
392    use super::{Part, rev_of_parts, rev_of_text, section_changes};
393
394    fn one(body: &str) -> Vec<Part> {
395        vec![Part { label: String::new(), body: body.into() }]
396    }
397
398    #[test]
399    fn sections_and_paragraphs_that_changed_are_named() {
400        let old = one("Intro.\n\n## Context\n\nA.\n\nB.\n\n## Decision\n\nUse X.\n\n## Old\n\nGone.\n");
401        let new = one("Intro.\n\n## Context\n\nA.\n\nB, longer.\n\n## Decision\n\nUse   X.\n\n## Risks\n\nR.\n");
402        let got: Vec<(String, String, usize)> =
403            section_changes(&old, &new).into_iter().map(|c| (c.section, c.change, c.paragraphs)).collect();
404        assert_eq!(
405            got,
406            vec![
407                ("Context".into(), "changed".into(), 1),
408                ("Risks".into(), "added".into(), 1),
409                ("Old".into(), "removed".into(), 1)
410            ]
411        );
412    }
413
414    #[test]
415    fn a_document_without_chapters_keeps_the_revision_of_its_body() {
416        let text = "---\nid: x\n---\n# Guide\n\nOne.\n";
417        assert_eq!(rev_of_parts(&one("# Guide\n\nOne.\n")), rev_of_text(text));
418        let with = vec![one("# Guide\n\nOne.\n").remove(0), Part { label: "a.md".into(), body: "More.".into() }];
419        assert_ne!(rev_of_parts(&with), rev_of_text(text));
420    }
421
422    #[test]
423    fn whitespace_and_frontmatter_do_not_count() {
424        let a = rev_of_text("---\nid: x\n---\n# Guide\n\nOne line\nwrapped.\n");
425        let b = rev_of_text("---\nid: x\nupdated: 2026\n---\r\n# Guide\r\n\r\n\r\nOne line wrapped.  \r\n");
426        assert_eq!(a, b);
427        assert!(a.starts_with("sha256:") && a.len() == 19);
428        assert_ne!(a, rev_of_text("# Guide\n\nOne line changed.\n"));
429    }
430}