1use 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
18pub 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
26pub 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#[derive(Debug, Clone)]
35pub struct Part {
36 pub label: String,
37 pub body: String,
38}
39
40pub 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
57pub 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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
66pub struct SectionChange {
67 pub section: String,
70 pub change: String,
72 pub paragraphs: usize,
74}
75
76fn 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
110pub 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
137pub 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
156pub 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
161pub 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
179pub 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
190pub 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
198pub fn source_ref(c: &Corpus, idx: usize) -> Option<String> {
200 c.docs[idx].rel().get(RELATION).and_then(|l| l.first().cloned())
201}
202
203pub 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
215pub fn is_translation(c: &Corpus, idx: usize) -> bool {
217 source_ref(c, idx).is_some() || position(c, idx).is_some()
218}
219
220pub 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
229fn 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
240fn 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#[derive(Debug, Clone)]
265pub struct Inferred {
266 pub lang: String,
267 pub source: Option<usize>,
269 pub candidates: Vec<usize>,
271}
272
273pub 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 Current,
293 Behind,
295 Unrecorded,
297 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 #[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 pub missing: BTreeMap<String, Vec<String>>,
319 pub sources: usize,
320}
321
322pub 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
332pub 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 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}