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 = crate::markdown::Fence::default();
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 fence.step(t) {
92            para.push(line);
93            continue;
94        }
95        if let Some((_, h)) = crate::markdown::atx(line).filter(|_| !fence.inside()) {
96            flush(&mut para, &mut out);
97            out.push((name(h), vec![]));
98        } else if !fence.inside() && t.is_empty() {
99            flush(&mut para, &mut out);
100        } else {
101            para.push(line);
102        }
103    }
104    flush(&mut para, &mut out);
105    out.retain(|(h, ps)| !(ps.is_empty() && h.ends_with("(top)")));
106    out
107}
108
109/// The sections that differ between two versions of a source, in the order of the new one
110/// (removed sections last).
111pub fn section_changes(old: &[Part], new: &[Part]) -> Vec<SectionChange> {
112    let all = |parts: &[Part]| parts.iter().flat_map(|p| sections(&p.label, &p.body)).collect::<Vec<_>>();
113    let (old, new) = (all(old), all(new));
114    let mut out = vec![];
115    for (h, ps) in &new {
116        match old.iter().find(|(o, _)| o == h) {
117            None => out.push(SectionChange { section: h.clone(), change: "added".into(), paragraphs: ps.len() }),
118            Some((_, was)) => {
119                let gone = was.iter().filter(|p| !ps.contains(p)).count();
120                let came = ps.iter().filter(|p| !was.contains(p)).count();
121                let n = gone.max(came);
122                if n > 0 || was.len() != ps.len() {
123                    out.push(SectionChange { section: h.clone(), change: "changed".into(), paragraphs: n.max(1) });
124                }
125            }
126        }
127    }
128    for (h, ps) in &old {
129        if !new.iter().any(|(n, _)| n == h) {
130            out.push(SectionChange { section: h.clone(), change: "removed".into(), paragraphs: ps.len() });
131        }
132    }
133    out
134}
135
136/// The source as it was at `rev`, found in git history (the commits that touched its files,
137/// newest first, at most 200); None without git or when no commit has that revision.
138pub fn parts_at_rev(c: &Corpus, idx: usize, rev: &str) -> Option<Vec<Part>> {
139    let root = &c.cfg.root;
140    let d = &c.docs[idx];
141    let mut commits: Vec<String> = vec![];
142    for f in std::iter::once(&d.path).chain(&d.chapters) {
143        for r in crate::workspace::vcs::history(root, f).unwrap_or_default().into_iter().rev() {
144            if !commits.contains(&r.hash) {
145                commits.push(r.hash);
146            }
147        }
148    }
149    commits.into_iter().take(200).find_map(|h| {
150        let parts = parts_of(c, idx, |p| crate::workspace::vcs::show(root, &h, p))?;
151        (rev_of_parts(&parts) == rev).then_some(parts)
152    })
153}
154
155/// What changed in a translation's source since `rev`, when git has that revision.
156pub fn changes_since(c: &Corpus, src: usize, rev: &str, now: &[Part]) -> Option<Vec<SectionChange>> {
157    Some(section_changes(&parts_at_rev(c, src, rev)?, now))
158}
159
160/// `Decision (2 paragraphs changed), Risks (new)`: for messages.
161pub fn describe(changes: &[SectionChange]) -> String {
162    changes
163        .iter()
164        .map(|ch| match ch.change.as_str() {
165            "added" => format!("{} (new)", ch.section),
166            "removed" => format!("{} (removed)", ch.section),
167            _ => format!(
168                "{} ({} paragraph{} changed)",
169                ch.section,
170                ch.paragraphs,
171                if ch.paragraphs == 1 { "" } else { "s" }
172            ),
173        })
174        .collect::<Vec<_>>()
175        .join(", ")
176}
177
178/// Why `lang` is not a language tag (BCP 47 shape: a 2-3 letter language, then subtags of 1-8
179/// letters or digits, `ko`, `pt-BR`, `zh-Hant-TW`), or `None` when it is one.
180pub fn lang_problem(lang: &str) -> Option<String> {
181    let mut parts = lang.split('-');
182    let first = parts.next().unwrap_or("");
183    let ok = (2..=3).contains(&first.len())
184        && first.chars().all(|c| c.is_ascii_alphabetic())
185        && parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric()));
186    (!ok).then(|| format!("lang '{lang}' is not a language tag (BCP 47) like ko, ja or pt-BR"))
187}
188
189/// Why a `source_rev` value is not `sha256:<12 hex>`, or `None` when it is.
190pub fn rev_problem(rev: &str) -> Option<String> {
191    let ok = rev
192        .strip_prefix("sha256:")
193        .is_some_and(|h| h.len() == 12 && h.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
194    (!ok).then(|| format!("source_rev '{rev}' is not sha256:<12 hex>"))
195}
196
197/// What a document's `rel.translation_of` names, raw.
198pub fn source_ref(c: &Corpus, idx: usize) -> Option<String> {
199    c.docs[idx].rel().get(RELATION).and_then(|l| l.first().cloned())
200}
201
202/// The source document of a translation, when it resolves: `rel.translation_of` when set,
203/// otherwise the source the `[translations]` layout pairs it with.
204pub fn source_of(c: &Corpus, g: &Graph, idx: usize) -> Option<usize> {
205    match source_ref(c, idx) {
206        Some(r) => match g.resolve(&r) {
207            Resolution::Doc { idx, .. } => Some(idx),
208            _ => None,
209        },
210        None => inferred(c, idx).and_then(|p| p.source),
211    }
212}
213
214/// Whether a document is a translation: it names a source, or the layout places it as one.
215pub fn is_translation(c: &Corpus, idx: usize) -> bool {
216    source_ref(c, idx).is_some() || position(c, idx).is_some()
217}
218
219/// The language of a document: frontmatter `lang`, else the layout's language marker, else
220/// `[translations] source_lang` for a document that is not a translation.
221pub fn lang_of(c: &Corpus, idx: usize) -> Option<String> {
222    c.docs[idx]
223        .str_field("lang")
224        .or_else(|| position(c, idx).map(|p| p.0))
225        .or_else(|| (!is_translation(c, idx)).then(|| c.cfg.raw.translations.source_lang.clone()).flatten())
226}
227
228/// The directory that holds a document's type directories (`docs/.spec`), from its path.
229fn spec_root(c: &Corpus, idx: usize) -> String {
230    let d = &c.docs[idx];
231    let marker = format!("/{}/", d.id.ty);
232    let p = format!("/{}", d.dir.as_deref().unwrap_or(&d.path));
233    match p.rfind(&marker) {
234        Some(i) => p[1..i.max(1)].to_string(),
235        None => String::new(),
236    }
237}
238
239/// Where the layout places a document as a translation: its language and the (type, spec root,
240/// slug) its source has.
241fn position(c: &Corpus, idx: usize) -> Option<(String, (String, String, String))> {
242    use crate::config::types::TranslationLayout as L;
243    let t = &c.cfg.raw.translations;
244    let d = &c.docs[idx];
245    let root = spec_root(c, idx);
246    match t.layout {
247        L::None => None,
248        L::Suffix => t.languages.iter().find_map(|l| {
249            let base = d.slug.strip_suffix(&format!("-{}", l.to_ascii_lowercase()))?;
250            (!base.is_empty()).then(|| (l.clone(), (d.id.ty.clone(), root.clone(), base.to_string())))
251        }),
252        L::Dir => {
253            let segs: Vec<&str> = root.split('/').collect();
254            let at = segs.iter().rposition(|s| t.languages.iter().any(|l| l == s))?;
255            let lang = segs[at].to_string();
256            let rest: Vec<&str> = segs.iter().enumerate().filter(|(i, _)| *i != at).map(|(_, s)| *s).collect();
257            Some((lang, (d.id.ty.clone(), rest.join("/"), d.slug.clone())))
258        }
259    }
260}
261
262/// A pair the layout infers for a document in a translation position.
263#[derive(Debug, Clone)]
264pub struct Inferred {
265    pub lang: String,
266    /// The source, when exactly one document is in the source position.
267    pub source: Option<usize>,
268    /// Every document in the source position (more than one is ambiguous).
269    pub candidates: Vec<usize>,
270}
271
272/// The pair the `[translations]` layout infers for a document, when it is in a translation
273/// position.
274pub fn inferred(c: &Corpus, idx: usize) -> Option<Inferred> {
275    let (lang, want) = position(c, idx)?;
276    let candidates: Vec<usize> = (0..c.docs.len())
277        .filter(|&j| j != idx && position(c, j).is_none())
278        .filter(|&j| {
279            let d = &c.docs[j];
280            d.id.ty == want.0 && d.slug == want.2 && spec_root(c, j) == want.1
281        })
282        .collect();
283    let source = (candidates.len() == 1).then(|| candidates[0]);
284    Some(Inferred { lang, source, candidates })
285}
286
287#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
288#[serde(rename_all = "snake_case")]
289pub enum State {
290    /// source_rev matches the source.
291    Current,
292    /// The source changed after source_rev.
293    Behind,
294    /// No source_rev recorded.
295    Unrecorded,
296    /// rel.translation_of does not name a document.
297    NoSource,
298}
299
300#[derive(Debug, Clone, Serialize)]
301pub struct Translation {
302    pub id: String,
303    pub path: String,
304    pub lang: Option<String>,
305    pub source: String,
306    pub state: State,
307    /// For a translation behind its source: the sections changed since source_rev, when git has
308    /// that revision.
309    #[serde(skip_serializing_if = "Vec::is_empty")]
310    pub sections: Vec<SectionChange>,
311}
312
313#[derive(Debug, Clone, Serialize, Default)]
314pub struct Report {
315    pub translations: Vec<Translation>,
316    /// Per language: sources with no translation in it.
317    pub missing: BTreeMap<String, Vec<String>>,
318    pub sources: usize,
319}
320
321/// The state of one translation.
322pub fn state(c: &Corpus, g: &Graph, idx: usize) -> State {
323    let Some(src) = source_of(c, g, idx) else { return State::NoSource };
324    match c.docs[idx].str_field(KEY) {
325        None => State::Unrecorded,
326        Some(r) if Some(r.clone()) == rev_of(c, src) => State::Current,
327        Some(_) => State::Behind,
328    }
329}
330
331/// Every translation with its state, and the sources each language still lacks. `langs` limits
332/// the coverage to those languages; empty means every language a translation declares.
333pub fn report(c: &Corpus, g: &Graph, langs: &[String]) -> Report {
334    let mut r = Report::default();
335    let mut have: BTreeMap<String, Vec<usize>> = BTreeMap::new();
336    let mut sources: Vec<usize> = vec![];
337    for (i, d) in c.docs.iter().enumerate() {
338        if !is_translation(c, i) {
339            continue;
340        }
341        let lang = lang_of(c, i);
342        let src = source_of(c, g, i);
343        // A translation of a translation (or of itself) is not a source; R1 reports it.
344        if let (Some(l), Some(s)) = (&lang, src.filter(|&s| s != i && !is_translation(c, s))) {
345            // C236: language tags match case-insensitively (pt-BR is pt-br), as `spec new` does.
346            have.entry(l.to_ascii_lowercase()).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.to_ascii_lowercase()).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}