1mod pair;
8mod render;
9mod style;
10
11use std::cell::RefCell;
12use std::cmp::Ordering;
13use std::collections::{HashMap, HashSet};
14use std::path::{Path, PathBuf};
15
16use anyhow::Context;
17use sha2::Digest;
18
19use crate::config::{Config, CONFIG_PATH};
20use crate::site::{esc, load_side, unpublished_candidates, Candidate, SideModel};
21use crate::source::{is_not_found, merge_base, run_git, FsSource, GitTreeSource, Source};
22use pair::{Item, Status};
23use render::{icon, render_frame, Ctx};
24
25pub const DEFAULT_OUT: &str = "_site-diff.html";
27
28const CSP: &str = "default-src 'none'; img-src 'self' data: https:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'";
29
30#[derive(Debug, Clone, Default)]
32pub struct DiffOptions {
33 pub base_ref: Option<String>,
34 pub out: Option<String>,
35 pub link_base: Option<String>,
36 pub head_label: Option<String>,
37}
38
39struct Images<'a> {
43 source: &'a dyn Source,
44 origin: String,
46 hashes: RefCell<HashMap<String, Option<String>>>,
47 uris: RefCell<HashMap<String, Option<String>>>,
48 failure: RefCell<Option<anyhow::Error>>,
49}
50
51impl Images<'_> {
52 fn read(&self, path: &str) -> Option<Vec<u8>> {
53 match self.source.read(path) {
54 Ok(bytes) => Some(bytes),
55 Err(e) => {
56 let mut failure = self.failure.borrow_mut();
57 if failure.is_none() {
58 *failure =
59 Some(e.context(format!("failed to read image {path} at {}", self.origin)));
60 }
61 None
62 }
63 }
64 }
65
66 fn hash(&self, path: &str) -> Option<String> {
68 if let Some(h) = self.hashes.borrow().get(path) {
69 return h.clone();
70 }
71 let hash = self
72 .read(path)
73 .map(|bytes| hex::encode(sha2::Sha256::digest(&bytes)));
74 self.hashes
75 .borrow_mut()
76 .insert(path.to_string(), hash.clone());
77 hash
78 }
79
80 fn data_uri(&self, path: &str) -> Option<String> {
83 if let Some(u) = self.uris.borrow().get(path) {
84 return u.clone();
85 }
86 let mime = match path.rsplit_once('.')?.1.to_ascii_lowercase().as_str() {
87 "png" => "image/png",
88 "jpg" | "jpeg" => "image/jpeg",
89 "gif" => "image/gif",
90 "webp" => "image/webp",
91 _ => return None,
92 };
93 let uri = self
94 .read(path)
95 .map(|bytes| format!("data:{mime};base64,{}", base64(&bytes)));
96 self.uris.borrow_mut().insert(path.to_string(), uri.clone());
97 uri
98 }
99
100 fn finish(&self) -> anyhow::Result<()> {
102 match self.failure.borrow_mut().take() {
103 Some(e) => Err(e),
104 None => Ok(()),
105 }
106 }
107}
108
109fn base64(bytes: &[u8]) -> String {
110 const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
111 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
112 for chunk in bytes.chunks(3) {
113 let n = (chunk[0] as u32) << 16
114 | (*chunk.get(1).unwrap_or(&0) as u32) << 8
115 | *chunk.get(2).unwrap_or(&0) as u32;
116 out.push(TABLE[(n >> 18) as usize & 63] as char);
117 out.push(TABLE[(n >> 12) as usize & 63] as char);
118 out.push(if chunk.len() > 1 {
119 TABLE[(n >> 6) as usize & 63] as char
120 } else {
121 '='
122 });
123 out.push(if chunk.len() > 2 {
124 TABLE[n as usize & 63] as char
125 } else {
126 '='
127 });
128 }
129 out
130}
131
132struct Side<'a> {
134 tag: char,
136 model: SideModel,
137 candidates: Vec<Candidate>,
139 images: Images<'a>,
140}
141
142impl<'a> Side<'a> {
143 fn load(
144 tag: char,
145 origin: &str,
146 source: &'a dyn Source,
147 config: &Config,
148 ) -> anyhow::Result<Side<'a>> {
149 let model = load_side(source, config)?;
150 let candidates = unpublished_candidates(source, config, &model)?;
151 Ok(Side::new(tag, origin, source, model, candidates))
152 }
153
154 fn new(
155 tag: char,
156 origin: &str,
157 source: &'a dyn Source,
158 model: SideModel,
159 candidates: Vec<Candidate>,
160 ) -> Side<'a> {
161 Side {
162 tag,
163 model,
164 candidates,
165 images: Images {
166 source,
167 origin: origin.to_string(),
168 hashes: RefCell::new(HashMap::new()),
169 uris: RefCell::new(HashMap::new()),
170 failure: RefCell::new(None),
171 },
172 }
173 }
174
175 fn image_hashes(&self, path: &str, body: &str) -> Vec<(String, Option<String>)> {
177 self.model
178 .referenced_images(path, body)
179 .into_iter()
180 .map(|image| {
181 let hash = self.images.hash(&image);
182 (image, hash)
183 })
184 .collect()
185 }
186}
187
188fn normalize_link_base(raw: &str) -> anyhow::Result<String> {
190 let lower = raw.to_ascii_lowercase();
191 if !(lower.starts_with("http:") || lower.starts_with("https:")) {
192 anyhow::bail!("--link-base must start with http: or https:");
193 }
194 Ok(if raw.ends_with('/') {
195 raw.to_string()
196 } else {
197 format!("{raw}/")
198 })
199}
200
201fn check_out_path(out: &Path) -> anyhow::Result<()> {
203 match std::fs::symlink_metadata(out) {
204 Ok(_) => anyhow::bail!("output path {} already exists", out.display()),
205 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
206 Err(e) => Err(anyhow::Error::new(e)
207 .context(format!("failed to inspect output path {}", out.display()))),
208 }
209}
210
211fn short(sha: &str) -> &str {
213 sha.get(..7).unwrap_or(sha)
214}
215
216fn current_branch(repo_root: &Path) -> String {
218 run_git(repo_root, &["symbolic-ref", "--short", "-q", "HEAD"])
219 .ok()
220 .filter(|o| o.status.success())
221 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
222 .filter(|name| !name.is_empty())
223 .unwrap_or_else(|| "HEAD".to_string())
224}
225
226fn head_sha(repo_root: &Path) -> anyhow::Result<String> {
227 let output = run_git(repo_root, &["rev-parse", "HEAD"])?;
228 if !output.status.success() {
229 anyhow::bail!(
230 "git rev-parse HEAD failed: {}",
231 String::from_utf8_lossy(&output.stderr)
232 );
233 }
234 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
235}
236
237fn base_config(source: &dyn Source, merge_base_rev: &str) -> anyhow::Result<Option<Config>> {
239 match source.read(CONFIG_PATH) {
240 Ok(bytes) => {
241 let text = String::from_utf8(bytes)?;
242 let config = Config::parse(&text)
243 .with_context(|| format!("invalid {CONFIG_PATH} at merge-base {merge_base_rev}"))?;
244 Ok(Some(config))
245 }
246 Err(e) if is_not_found(&e) => Ok(None),
247 Err(e) => Err(e),
248 }
249}
250
251fn tree_order(a: &str, b: &str) -> Ordering {
253 let (sa, sb): (Vec<&str>, Vec<&str>) = (a.split('/').collect(), b.split('/').collect());
254 for i in 0..sa.len().min(sb.len()) {
255 let (a_dir, b_dir) = (i + 1 < sa.len(), i + 1 < sb.len());
256 if a_dir != b_dir {
257 return b_dir.cmp(&a_dir);
258 }
259 match sa[i].cmp(sb[i]) {
260 Ordering::Equal => {}
261 other => return other,
262 }
263 }
264 sa.len().cmp(&sb.len())
265}
266
267enum TreeNode {
268 Dir {
269 name: String,
270 children: Vec<TreeNode>,
271 },
272 File {
273 name: String,
274 item: usize,
275 },
276}
277
278fn build_tree(items: &[Item]) -> Vec<TreeNode> {
280 fn insert(nodes: &mut Vec<TreeNode>, segments: &[&str], item: usize) {
281 if let [name] = segments {
282 nodes.push(TreeNode::File {
283 name: name.to_string(),
284 item,
285 });
286 return;
287 }
288 let continues =
289 matches!(nodes.last(), Some(TreeNode::Dir { name, .. }) if name == segments[0]);
290 if !continues {
291 nodes.push(TreeNode::Dir {
292 name: segments[0].to_string(),
293 children: Vec::new(),
294 });
295 }
296 if let Some(TreeNode::Dir { children, .. }) = nodes.last_mut() {
297 insert(children, &segments[1..], item);
298 }
299 }
300 let mut root = Vec::new();
301 for (i, item) in items.iter().enumerate() {
302 let segments: Vec<&str> = item.path().split('/').collect();
303 insert(&mut root, &segments, i);
304 }
305 root
306}
307
308fn tree_html(nodes: &[TreeNode], items: &[Item]) -> String {
309 let mut out = String::from("<ul>");
310 for node in nodes {
311 match node {
312 TreeNode::Dir { name, children } => out.push_str(&format!(
313 "<li><details open><summary class=\"dir\">{}</summary>{}</details></li>",
314 esc(name),
315 tree_html(children, items)
316 )),
317 TreeNode::File { name, item } => out.push_str(&format!(
318 "<li><a href=\"#d{}\"><span class=\"fn\">{}</span>{}</a></li>",
319 item + 1,
320 esc(name),
321 icon(items[*item].status)
322 )),
323 }
324 }
325 out.push_str("</ul>");
326 out
327}
328
329pub fn run(repo_root: &Path, config: &Config, opts: &DiffOptions) -> anyhow::Result<()> {
331 let out: PathBuf = repo_root
332 .join(opts.out.as_deref().unwrap_or(DEFAULT_OUT))
333 .components()
334 .collect();
335 check_out_path(&out)?;
336 if config.index.is_empty() {
337 anyhow::bail!("index is not configured in .miwa/config.yaml");
338 }
339 let link_base = opts
340 .link_base
341 .as_deref()
342 .map(normalize_link_base)
343 .transpose()?;
344 if let Some(label) = &opts.head_label {
345 if label.chars().any(char::is_control) {
346 anyhow::bail!("--head-label must not contain line breaks or control characters");
347 }
348 }
349
350 let base_ref = opts
351 .base_ref
352 .clone()
353 .unwrap_or_else(|| config.diff_base.clone());
354 let merge_base_rev = merge_base(repo_root, &base_ref)?;
355 let base_source = GitTreeSource::new(repo_root, &merge_base_rev)?;
356 let head_source = FsSource::new(repo_root);
357 let base = match base_config(&base_source, &merge_base_rev)? {
358 Some(cfg) => Side::load(
359 'b',
360 &format!("merge-base {merge_base_rev}"),
361 &base_source,
362 &cfg,
363 )?,
364 None => Side::new(
365 'b',
366 &merge_base_rev,
367 &base_source,
368 SideModel::default(),
369 Vec::new(),
370 ),
371 };
372 let head = Side::load('h', "working tree", &head_source, config)?;
373
374 let mut items = pair::pair_documents(&base, &head)?;
375 items.extend(pair::pair_indexes(&base, &head));
376 base.images.finish()?;
377 head.images.finish()?;
378 if items.is_empty() {
379 return Ok(());
380 }
381 items.sort_by(|a, b| tree_order(a.path(), b.path()).then_with(|| a.status.cmp(&b.status)));
382
383 let mut base_anchors = HashMap::new();
384 let mut head_anchors = HashMap::new();
385 let mut base_omit_body = HashSet::new();
386 let mut head_omit_body = HashSet::new();
387 for (i, item) in items.iter().enumerate() {
388 if let Some(p) = &item.base {
389 base_anchors.insert(p.clone(), format!("d{}", i + 1));
390 }
391 if let Some(p) = &item.head {
392 head_anchors.insert(p.clone(), format!("d{}", i + 1));
393 }
394 if item.omit_body {
395 base_omit_body.extend(item.base.clone());
396 head_omit_body.extend(item.head.clone());
397 }
398 }
399 let ctx = Ctx {
400 base: &base,
401 head: &head,
402 base_anchors,
403 head_anchors,
404 base_omit_body,
405 head_omit_body,
406 link_base: link_base.as_deref(),
407 };
408 let frames: String = items
409 .iter()
410 .enumerate()
411 .map(|(i, item)| render_frame(&ctx, item, &format!("d{}", i + 1)))
412 .collect();
413
414 base.images.finish()?;
415 head.images.finish()?;
416
417 let site_title = config
418 .site
419 .as_ref()
420 .and_then(|s| s.title())
421 .map(str::to_string)
422 .unwrap_or_else(|| {
423 repo_root
424 .file_name()
425 .map(|n| n.to_string_lossy().into_owned())
426 .unwrap_or_default()
427 });
428 let head_label = opts
429 .head_label
430 .clone()
431 .unwrap_or_else(|| current_branch(repo_root));
432 let head_rev = head_sha(repo_root)?;
433 let html = document(
434 &Page {
435 site_title: &site_title,
436 base_ref: &base_ref,
437 base_sha: short(&merge_base_rev),
438 head_label: &head_label,
439 head_sha: short(&head_rev),
440 },
441 &items,
442 &frames,
443 );
444
445 if let Some(parent) = out.parent() {
446 std::fs::create_dir_all(parent)
447 .with_context(|| format!("failed to create {}", parent.display()))?;
448 }
449 std::fs::write(&out, html).with_context(|| format!("failed to write {}", out.display()))
450}
451
452struct Page<'a> {
454 site_title: &'a str,
455 base_ref: &'a str,
456 base_sha: &'a str,
457 head_label: &'a str,
458 head_sha: &'a str,
459}
460
461fn document(page: &Page, items: &[Item], frames: &str) -> String {
462 let counts: String = [Status::Mod, Status::Add, Status::Del, Status::Move]
463 .into_iter()
464 .filter_map(|status| {
465 let n = items.iter().filter(|i| i.status == status).count();
466 (n > 0).then(|| format!("<span>{}{n}</span>", icon(status)))
467 })
468 .collect();
469 format!(
470 "<!DOCTYPE html>\n<html lang=\"ja\">\n<head>\n<meta charset=\"utf-8\">\n\
471<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
472<meta http-equiv=\"Content-Security-Policy\" content=\"{CSP}\">\n\
473<meta name=\"referrer\" content=\"no-referrer\">\n\
474<title>{base} → {head} — {site}</title>\n<style>{style}</style>\n</head>\n<body>\n\
475<header class=\"top\">{mark}<span class=\"site\">{site}</span>\
476<span class=\"cmp\">{base} ({bsha}) → {head} ({hsha})</span><span class=\"counts\">{counts}</span></header>\n\
477<div class=\"layout\">\n<aside aria-label=\"ファイル一覧\"><div class=\"tree\">{tree}</div></aside>\n<main>\n{frames}</main>\n</div>\n</body>\n</html>\n",
478 base = esc(page.base_ref),
479 head = esc(page.head_label),
480 site = esc(page.site_title),
481 bsha = esc(page.base_sha),
482 hsha = esc(page.head_sha),
483 style = style::STYLE,
484 mark = style::MARK,
485 tree = tree_html(&build_tree(items), items),
486 )
487}