Skip to main content

miwa/
site_diff.rs

1//! `miwa site --diff`: a single-page HTML view of document changes.
2//!
3//! The documents of the merge-base and of the working tree are loaded with
4//! the same code as `miwa site`, paired (FR-112), and every changed one is
5//! shown as a frame of aligned blocks (FR-108 .. FR-120).
6
7mod 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
25/// Default output path when `--diff` is given without `--out`.
26pub 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/// Options for `miwa site --diff`.
31#[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
39/// Hashes and embeds the images of one side. Each file is read once for its
40/// hash and once for its data URI. A read failure is remembered (the first
41/// one) and reported by [`Images::finish`], so that nothing is written.
42struct Images<'a> {
43    source: &'a dyn Source,
44    /// Where the side's files come from, for error messages.
45    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    /// The SHA-256 of the image file, `None` when it cannot be read.
67    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    /// The image as a `data:` URI (built once per file), `None` when it
81    /// cannot be read.
82    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    /// Fails with the first image read error, if any.
101    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
132/// One side of the comparison.
133struct Side<'a> {
134    /// `b` (base) or `h` (head).
135    tag: char,
136    model: SideModel,
137    /// Typed files that the side does not publish.
138    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    /// The published images the page references, with their content hashes.
176    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
188/// `--link-base`: `http:` / `https:` only, with a trailing `/`.
189fn 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
201/// Nothing may exist at the output path (not even a broken symbolic link).
202fn 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
211/// The first seven characters of a commit id.
212fn short(sha: &str) -> &str {
213    sha.get(..7).unwrap_or(sha)
214}
215
216/// The current branch name, `HEAD` when detached.
217fn 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
237/// The configuration at the merge-base; `None` when there is none.
238fn 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
251/// Orders paths as the file tree lists them: directories first, then by name.
252fn 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
278/// Builds the file tree from items sorted by [`tree_order`].
279fn 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
329/// Writes the diff HTML (FR-108..120). Ok(()) also when nothing changed (no file written).
330pub 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
452/// The values of the page header.
453struct 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}