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 = 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
109pub 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
136pub 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
155pub 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
160pub 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
178pub 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
189pub 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
197pub fn source_ref(c: &Corpus, idx: usize) -> Option<String> {
199 c.docs[idx].rel().get(RELATION).and_then(|l| l.first().cloned())
200}
201
202pub 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
214pub fn is_translation(c: &Corpus, idx: usize) -> bool {
216 source_ref(c, idx).is_some() || position(c, idx).is_some()
217}
218
219pub 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
228fn 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
239fn 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#[derive(Debug, Clone)]
264pub struct Inferred {
265 pub lang: String,
266 pub source: Option<usize>,
268 pub candidates: Vec<usize>,
270}
271
272pub 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 Current,
292 Behind,
294 Unrecorded,
296 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 #[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 pub missing: BTreeMap<String, Vec<String>>,
318 pub sources: usize,
319}
320
321pub 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
331pub 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 if let (Some(l), Some(s)) = (&lang, src.filter(|&s| s != i && !is_translation(c, s))) {
345 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}