forks/sorcery

git clone https://git.gae.moe/forks/sorcery

Charlotte Somrender static pages for tag tips as well38b4205

main
28.6 KiB820 linesraw
1use std::collections::BTreeMap;
2use std::fmt::Write as _;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{Context, Result};
8
9use crate::highlight::Cache as GrammarCache;
10use crate::render::{self, RefKind, RefTip, Site, StaticMode};
11
12pub const MANIFEST_FILE: &str = ".sorcery-blobs.json";
13pub const STATE_FILE: &str = ".sorcery-state";
14pub const STYLESHEET: &str = include_str!("style.css");
15pub(crate) const OUTPUT_FORMAT_VERSION: u32 = 62;
16const MAX_STATIC_BRANCHES: usize = 8;
17const MAX_STATIC_TAGS: usize = 8;
18const MAX_HIGHLIGHTED_FILES: usize = 10_000;
19
20#[derive(Clone)]
21pub struct Config {
22    pub repo: PathBuf,
23    pub out: PathBuf,
24    pub instance_name: String,
25    pub name: Option<String>,
26    pub clone_url: Option<String>,
27}
28
29#[derive(serde::Deserialize, serde::Serialize)]
30struct BlobManifest {
31    format: u32,
32    instance_name: String,
33    name: String,
34    refs: BTreeMap<String, RefManifest>,
35}
36
37#[derive(serde::Deserialize, serde::Serialize)]
38struct RefManifest {
39    tip: String,
40    files: BTreeMap<String, render::BlobVersion>,
41}
42
43struct PendingBlob {
44    oid: String,
45    mode: u16,
46    touched: Option<String>,
47}
48
49impl BlobManifest {
50    fn load(out: &Path, instance_name: &str, name: &str) -> Option<Self> {
51        let data = fs::read(out.join(MANIFEST_FILE)).ok()?;
52        let manifest = serde_json::from_slice::<Self>(&data).ok()?;
53        (manifest.format == OUTPUT_FORMAT_VERSION
54            && manifest.instance_name == instance_name
55            && manifest.name == name)
56            .then_some(manifest)
57    }
58}
59
60/// The complete input identity of generated output. Bump
61/// `OUTPUT_FORMAT_VERSION` whenever templates or styling change incompatibly.
62pub fn expected_state(config: &Config) -> Result<String> {
63    let repo = gix::open(&config.repo)
64        .with_context(|| format!("opening git repo at {}", config.repo.display()))?;
65    let name = config.name.clone().unwrap_or_else(|| repo_name(&repo));
66    let description = description(&repo);
67    let tips = render::list_refs(&repo)?;
68    Ok(state_for(config, &repo, &tips, &name, description.as_deref()))
69}
70
71pub fn full(config: &Config, previous: Option<&Path>, highlights: Arc<GrammarCache>) -> Result<()> {
72    full_cancellable(config, previous, highlights, None)
73}
74
75pub fn full_cancellable(
76    config: &Config,
77    previous: Option<&Path>,
78    highlights: Arc<GrammarCache>,
79    cancel: Option<render::BuildCancel>,
80) -> Result<()> {
81    render::bail_if_cancelled(&cancel)?;
82    let repo = gix::open(&config.repo)
83        .with_context(|| format!("opening git repo at {}", config.repo.display()))?;
84    let name = config.name.clone().unwrap_or_else(|| repo_name(&repo));
85    let description = description(&repo);
86    let mut tips = render::list_refs(&repo)?;
87    let state = state_for(config, &repo, &tips, &name, description.as_deref());
88    plan_rendering(&repo, &mut tips)?;
89    let base_url = format!("/{}/", render::encode_path(&name));
90    let site = Site {
91        repo: &repo,
92        instance_name: config.instance_name.clone(),
93        name,
94        base_url,
95        description,
96        clone_url: config.clone_url.clone(),
97    };
98
99    fs::create_dir_all(&config.out)?;
100    let previous_manifest = previous
101        .and_then(|out| BlobManifest::load(out, &site.instance_name, &site.name));
102    let refs = rebuild_all(
103        &site,
104        &tips,
105        &config.out,
106        previous,
107        previous_manifest.as_ref(),
108        highlights.clone(),
109        cancel.clone(),
110    )?;
111    render::bail_if_cancelled(&cancel)?;
112    render::render_refs_page(&site, &tips, &config.out)?;
113    render::render_site_index(&site, &tips, &config.out, highlights, cancel)?;
114    render::write_file(&config.out.join("gitinfo.json"), gitinfo(&repo, &tips)?)?;
115    let manifest = BlobManifest {
116        format: OUTPUT_FORMAT_VERSION,
117        instance_name: site.instance_name.clone(),
118        name: site.name.clone(),
119        refs,
120    };
121    render::write_file(
122        &config.out.join(MANIFEST_FILE),
123        serde_json::to_vec(&manifest)?,
124    )?;
125    render::write_file(&config.out.join(STATE_FILE), state)
126}
127
128/// Picks the refs that get static pages: every branch while there are few
129/// enough of them, and the newest tags while the repo is not tag-heavy. Each
130/// static ref costs a full tree of pages and one pass over its history, so
131/// release-heavy repositories only get their recent tags.
132fn plan_rendering(repo: &gix::Repository, tips: &mut [RefTip]) -> Result<()> {
133    let branch_count = tips.iter().filter(|tip| tip.kind == RefKind::Branch).count();
134    let head = render::head_tip(repo, tips).map(|tip| tip.name.clone());
135
136    let mut tags = tips
137        .iter()
138        .filter(|tip| tip.kind == RefKind::Tag)
139        .map(|tip| {
140            let seconds = repo
141                .find_object(tip.commit_id)
142                .ok()
143                .and_then(|object| object.try_into_commit().ok())
144                .and_then(|commit| render::author_time(&commit).map(|time| time.seconds))
145                .unwrap_or(0);
146            (tip.name.clone(), seconds)
147        })
148        .collect::<Vec<_>>();
149    tags.sort_by_key(|&(_, seconds)| std::cmp::Reverse(seconds));
150    let static_tags = tags
151        .iter()
152        .take(MAX_STATIC_TAGS)
153        .map(|(name, _)| name.as_str())
154        .collect::<Vec<_>>();
155
156    for tip in tips.iter_mut() {
157        let wanted = match tip.kind {
158            RefKind::Branch => {
159                branch_count <= MAX_STATIC_BRANCHES || Some(&tip.name) == head.as_ref()
160            }
161            RefKind::Tag => static_tags.contains(&tip.name.as_str()),
162        };
163        if !wanted {
164            continue;
165        }
166        let commit = repo.find_object(tip.commit_id)?.try_into_commit()?;
167        tip.static_mode = Some(if exceeds_file_limit(repo, &commit.tree()?, MAX_HIGHLIGHTED_FILES)? {
168            StaticMode::Plain
169        } else {
170            StaticMode::Highlighted
171        });
172    }
173    Ok(())
174}
175
176fn exceeds_file_limit(
177    repo: &gix::Repository,
178    tree: &gix::Tree<'_>,
179    limit: usize,
180) -> Result<bool> {
181    fn walk(
182        repo: &gix::Repository,
183        tree: &gix::Tree<'_>,
184        files: &mut usize,
185        limit: usize,
186    ) -> Result<bool> {
187        for entry in tree.iter() {
188            let entry = entry?;
189            let mode = entry.mode();
190            if mode.is_tree() {
191                let tree = repo.find_object(entry.oid())?.try_into_tree()?;
192                if walk(repo, &tree, files, limit)? {
193                    return Ok(true);
194                }
195            } else if !mode.is_commit() {
196                *files += 1;
197                if *files > limit {
198                    return Ok(true);
199                }
200            }
201        }
202        Ok(false)
203    }
204
205    let mut files = 0;
206    walk(repo, tree, &mut files, limit)
207}
208
209/// Discovery manifest for the in-browser git client: HEAD, refs (peeled to
210/// commits, branch-shadows-tag like the rest of the site), and pack names so
211/// the client can find `.idx` files without directory listings.
212fn gitinfo(repo: &gix::Repository, tips: &[RefTip]) -> Result<String> {
213    let head = render::head_tip(repo, tips).map(|t| t.name.clone());
214    let refs = tips
215        .iter()
216        .map(|t| {
217            serde_json::json!({
218                "kind": match t.kind {
219                    RefKind::Branch => "branch",
220                    RefKind::Tag => "tag",
221                },
222                "name": t.name,
223                "oid": t.commit_id.to_string(),
224            })
225        })
226        .collect::<Vec<_>>();
227    Ok(serde_json::json!({
228        "head": head,
229        "refs": refs,
230        "packs": pack_names(repo),
231    })
232    .to_string())
233}
234
235/// Sorted `pack-*` stems in the repo's object store.
236fn pack_names(repo: &gix::Repository) -> Vec<String> {
237    let mut packs: Vec<String> = fs::read_dir(repo.git_dir().join("objects/pack"))
238        .into_iter()
239        .flatten()
240        .flatten()
241        .filter_map(|entry| {
242            let name = entry.file_name().to_string_lossy().into_owned();
243            name.strip_suffix(".pack")
244                .filter(|stem| stem.starts_with("pack-"))
245                .map(str::to_owned)
246        })
247        .collect();
248    packs.sort();
249    packs
250}
251
252pub(crate) fn description(repo: &gix::Repository) -> Option<String> {
253    fs::read_to_string(repo.git_dir().join("description"))
254        .ok()
255        .map(|d| d.trim().to_string())
256        .filter(|d| !d.is_empty() && !d.starts_with("Unnamed repository"))
257}
258
259fn state_for(
260    config: &Config,
261    repo: &gix::Repository,
262    tips: &[RefTip],
263    name: &str,
264    description: Option<&str>,
265) -> String {
266    let mut refs = tips.iter().collect::<Vec<_>>();
267    refs.sort_by(|a, b| a.name.cmp(&b.name));
268    let mut state = format!(
269        "format {OUTPUT_FORMAT_VERSION}\ninstance {:?}\nname {name:?}\nclone {:?}\ndescription {description:?}\nhead {:?}\n",
270        config.instance_name,
271        config.clone_url,
272        repo.head_name().ok().flatten().map(|n| n.to_string()),
273    );
274    for tip in refs {
275        let kind = match tip.kind {
276            RefKind::Branch => "branch",
277            RefKind::Tag => "tag",
278        };
279        let _ = writeln!(state, "{kind} {:?} {}", tip.name, tip.commit_id);
280    }
281    // a gc changes packs without changing refs; the git-dir client cares
282    for pack in pack_names(repo) {
283        let _ = writeln!(state, "pack {pack}");
284    }
285    state
286}
287
288/// `/srv/git/foo.git` → `foo`, `/home/x/foo/.git` → `foo`
289fn repo_name(repo: &gix::Repository) -> String {
290    let dir = repo.git_dir().canonicalize().unwrap_or_else(|_| repo.git_dir().into());
291    let dir = if dir.file_name() == Some(".git".as_ref()) {
292        dir.parent().unwrap_or(&dir)
293    } else {
294        &dir
295    };
296    dir.file_name()
297        .map(|n| n.to_string_lossy().trim_end_matches(".git").to_string())
298        .filter(|n| !n.is_empty())
299        .unwrap_or_else(|| "repository".into())
300}
301
302fn blob_versions(
303    repo: &gix::Repository,
304    tip: &gix::Commit,
305    previous: Option<&RefManifest>,
306    cancel: &Option<render::BuildCancel>,
307) -> Result<BTreeMap<String, render::BlobVersion>> {
308    let mut files = BTreeMap::new();
309    collect_blobs(repo, tip.tree_id()?.detach(), &mut Vec::new(), &mut files)?;
310    let mut unresolved = files.len();
311    let mut commit_id = tip.id;
312    let mut diff_state = gix::diff::tree::State::default();
313
314    // Match the browser's first-parent path history. Once its previous tip is
315    // reached, paths untouched by newer commits can inherit their old result.
316    while unresolved > 0 {
317        render::bail_if_cancelled(cancel)?;
318        let commit = repo.find_object(commit_id)?.try_into_commit()?;
319        let commit_id_string = commit_id.to_string();
320        if let Some(previous) = previous.filter(|previous| previous.tip == commit_id_string) {
321            for (path, file) in &mut files {
322                if file.touched.is_some() {
323                    continue;
324                }
325                if let Some(old) = previous.files.get(path)
326                    && old.oid == file.oid
327                    && old.mode == file.mode
328                {
329                    file.touched = Some(old.touched.clone());
330                    unresolved -= 1;
331                }
332            }
333            if unresolved == 0 {
334                break;
335            }
336        }
337
338        let tree = commit.tree()?;
339        let parent = commit.parent_ids().next().map(|id| id.detach());
340        let parent_tree = match parent {
341            Some(id) => repo.find_commit(id)?.tree()?,
342            None => repo.empty_tree(),
343        };
344        // repo.diff_tree_to_tree() sets up blob diffing per call, which in a
345        // bare repo means rebuilding an index from HEAD every commit
346        let mut changes = gix::diff::tree::Recorder::default();
347        gix::diff::tree(
348            gix::objs::TreeRefIter::from_bytes(&parent_tree.data, repo.object_hash()),
349            gix::objs::TreeRefIter::from_bytes(&tree.data, repo.object_hash()),
350            &mut diff_state,
351            &repo.objects,
352            &mut changes,
353        )?;
354        for change in changes.records {
355            use gix::diff::tree::recorder::Change;
356            let (Change::Addition { path, .. }
357            | Change::Deletion { path, .. }
358            | Change::Modification { path, .. }) = change;
359            if let Some(file) = files.get_mut(&path.to_string())
360                && file.touched.is_none()
361            {
362                file.touched = Some(commit_id_string.clone());
363                unresolved -= 1;
364            }
365        }
366        let Some(parent) = parent else {
367            break;
368        };
369        commit_id = parent;
370    }
371
372    files
373        .into_iter()
374        .map(|(path, file)| {
375            Ok((
376                path,
377                render::BlobVersion {
378                    oid: file.oid,
379                    mode: file.mode,
380                    touched: file.touched.context("file has no introducing commit")?,
381                },
382            ))
383        })
384        .collect()
385}
386
387fn collect_blobs(
388    repo: &gix::Repository,
389    tree_id: gix::ObjectId,
390    path: &mut Vec<String>,
391    files: &mut BTreeMap<String, PendingBlob>,
392) -> Result<()> {
393    let tree = repo.find_object(tree_id)?.try_into_tree()?;
394    for entry in tree.iter() {
395        let entry = entry?;
396        let mode = entry.mode();
397        path.push(entry.filename().to_string());
398        if mode.is_tree() {
399            collect_blobs(repo, entry.oid().to_owned(), path, files)?;
400        } else if !mode.is_commit() {
401            files.insert(
402                path.join("/"),
403                PendingBlob {
404                    oid: entry.oid().to_string(),
405                    mode: mode.value(),
406                    touched: None,
407                },
408            );
409        }
410        path.pop();
411    }
412    Ok(())
413}
414
415/// Rebuild all visible refs, replacing `ref/` wholesale (which also prunes
416/// refs deleted since the last run).
417fn rebuild_all(
418    site: &Site,
419    tips: &[RefTip],
420    out: &Path,
421    previous: Option<&Path>,
422    previous_manifest: Option<&BlobManifest>,
423    highlights: Arc<GrammarCache>,
424    cancel: Option<render::BuildCancel>,
425) -> Result<BTreeMap<String, RefManifest>> {
426    let staging = out.join(".new-ref");
427    if staging.exists() {
428        fs::remove_dir_all(&staging)?;
429    }
430    fs::create_dir_all(&staging)?;
431    let mut refs = BTreeMap::new();
432    for tip in tips.iter().filter(|tip| tip.static_mode.is_some()) {
433        render::bail_if_cancelled(&cancel)?;
434        let commit = site.repo.find_object(tip.commit_id)?.try_into_commit()?;
435        let previous_ref = previous.map(|out| out.join("ref").join(&tip.name));
436        let previous_version = previous_manifest.and_then(|manifest| manifest.refs.get(&tip.name));
437        let files = if tip.static_mode == Some(StaticMode::Highlighted) {
438            Some(blob_versions(site.repo, &commit, previous_version, &cancel)?)
439        } else {
440            None
441        };
442        let reuse = files.as_ref().map(|versions| render::BlobReuse {
443            versions,
444            previous: previous_ref
445                .as_deref()
446                .zip(previous_version.map(|version| &version.files)),
447        });
448        render::render_ref(
449            site,
450            tips,
451            tip,
452            &commit,
453            &staging.join(&tip.name),
454            reuse,
455            highlights.clone(),
456            cancel.clone(),
457        )
458        .with_context(|| format!("rendering {}", tip.name))?;
459        if let Some(files) = files {
460            refs.insert(
461                tip.name.clone(),
462                RefManifest {
463                    tip: tip.commit_id.to_string(),
464                    files,
465                },
466            );
467        }
468    }
469    swap_dir(&staging, &out.join("ref"))?;
470    Ok(refs)
471}
472
473/// Move `new` into place at `dst`, displacing any existing tree with only a
474/// brief window where `dst` is missing (rename is atomic, recursive delete
475/// is not, so the old tree is renamed aside before removal, and restored if
476/// the install rename fails).
477pub(crate) fn swap_dir(new: &Path, dst: &Path) -> Result<()> {
478    if let Some(parent) = dst.parent() {
479        fs::create_dir_all(parent)?;
480    }
481    let trash = new.with_extension("old");
482    if trash.exists() {
483        fs::remove_dir_all(&trash)?;
484    }
485    let displaced = dst.exists();
486    if displaced {
487        fs::rename(dst, &trash)?;
488    }
489    if let Err(error) = fs::rename(new, dst) {
490        if displaced {
491            let _ = fs::rename(&trash, dst);
492        }
493        return Err(error).with_context(|| format!("installing {}", dst.display()));
494    }
495    if displaced {
496        if let Err(error) = fs::remove_dir_all(&trash) {
497            eprintln!("sorcery: removing {}: {error}", trash.display());
498        }
499    }
500    Ok(())
501}
502
503#[cfg(test)]
504mod tests {
505    use std::collections::BTreeMap;
506    use std::fs;
507    use std::path::{Path, PathBuf};
508
509    use anyhow::Result;
510    use percent_encoding::percent_decode_str;
511
512    use crate::testutil::{TempDir, commit, git, grammar_cache, init_repo, init_sha256_repo};
513
514    use super::{
515        BlobManifest, Config, MANIFEST_FILE, OUTPUT_FORMAT_VERSION, RefManifest, blob_versions,
516        full, full_cancellable,
517    };
518
519    #[test]
520    fn tracks_last_touch_across_incremental_rebuilds_and_reverts() -> Result<()> {
521        let root = TempDir::new("last-touch");
522        let repo_path = root.join("repo");
523        init_repo(&repo_path)?;
524        fs::create_dir(repo_path.join("src"))?;
525        fs::write(repo_path.join("src/stable.txt"), "stable")?;
526        fs::write(repo_path.join("changed.txt"), "one")?;
527        let first = commit(&repo_path, "first")?;
528        fs::write(repo_path.join("changed.txt"), "two")?;
529        let second = commit(&repo_path, "second")?;
530        fs::write(repo_path.join("changed.txt"), "one")?;
531        let third = commit(&repo_path, "revert")?;
532
533        let repo = gix::open(&repo_path)?;
534        let commit_at = |hex: &str| -> Result<gix::Commit<'_>> {
535            Ok(repo
536                .find_object(gix::ObjectId::from_hex(hex.as_bytes())?)?
537                .try_into_commit()?)
538        };
539        let first_files = blob_versions(&repo, &commit_at(&first)?, None, &None)?;
540        assert_eq!(first_files["src/stable.txt"].touched, first);
541        assert_eq!(first_files["changed.txt"].touched, first);
542
543        let first_manifest = RefManifest {
544            tip: first.clone(),
545            files: first_files.clone(),
546        };
547        let second_files = blob_versions(&repo, &commit_at(&second)?, Some(&first_manifest), &None)?;
548        assert_eq!(second_files["src/stable.txt"].touched, first);
549        assert_eq!(second_files["changed.txt"].touched, second);
550
551        let third_files = blob_versions(&repo, &commit_at(&third)?, Some(&first_manifest), &None)?;
552        assert_eq!(third_files["changed.txt"].oid, first_files["changed.txt"].oid);
553        assert_eq!(third_files["changed.txt"].touched, third);
554        Ok(())
555    }
556
557    #[test]
558    fn cancellable_build_aborts_before_rendering() -> Result<()> {
559        let root = TempDir::new("cancelled");
560        let repo = root.join("repo");
561        init_repo(&repo)?;
562        fs::write(repo.join("README.md"), "# hi\n")?;
563        commit(&repo, "initial")?;
564        let config = Config {
565            repo,
566            out: root.join("out"),
567            instance_name: "test".into(),
568            name: Some("u/r".into()),
569            clone_url: None,
570        };
571        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
572        assert!(full_cancellable(&config, None, grammar_cache(), Some(cancel)).is_err());
573        assert!(!config.out.exists());
574        Ok(())
575    }
576
577    #[test]
578    fn renders_sha256_repositories() -> Result<()> {
579        let root = TempDir::new("sha256");
580        let repo = root.join("repo");
581        init_sha256_repo(&repo)?;
582        fs::write(repo.join("README.md"), "# SHA-256\n")?;
583        let head = commit(&repo, "initial")?;
584        assert_eq!(head.len(), 64);
585
586        let out = root.join("out");
587        full(
588            &Config {
589                repo,
590                out: out.clone(),
591                instance_name: "test".into(),
592                name: Some("sha256".into()),
593                clone_url: None,
594            },
595            None,
596            grammar_cache(),
597        )?;
598
599        let info: serde_json::Value = serde_json::from_slice(&fs::read(out.join("gitinfo.json"))?)?;
600        assert_eq!(info["refs"][0]["oid"].as_str(), Some(head.as_str()));
601        assert!(out.join("ref/main/blob/README.md").is_file());
602        Ok(())
603    }
604
605    #[test]
606    fn loads_only_compatible_blob_manifests() -> Result<()> {
607        let root = TempDir::new("blob-manifest");
608        let files = BTreeMap::from([(
609            "odd\npath.rs".into(),
610            crate::render::BlobVersion {
611                oid: "abc123".into(),
612                mode: 0o100644,
613                touched: "def456".into(),
614            },
615        )]);
616        let manifest = BlobManifest {
617            format: OUTPUT_FORMAT_VERSION,
618            instance_name: "example".into(),
619            name: "alice/repo".into(),
620            refs: BTreeMap::from([(
621                "main".into(),
622                RefManifest {
623                    tip: "def456".into(),
624                    files,
625                },
626            )]),
627        };
628        fs::write(root.join(MANIFEST_FILE), serde_json::to_vec(&manifest)?)?;
629
630        let loaded = BlobManifest::load(&root, "example", "alice/repo").unwrap();
631        assert_eq!(loaded.refs["main"].files["odd\npath.rs"].oid, "abc123");
632        assert_eq!(loaded.refs["main"].files["odd\npath.rs"].touched, "def456");
633        assert!(BlobManifest::load(&root, "other", "alice/repo").is_none());
634        assert!(BlobManifest::load(&root, "example", "other/repo").is_none());
635
636        let incompatible = BlobManifest {
637            format: OUTPUT_FORMAT_VERSION + 1,
638            ..manifest
639        };
640        fs::write(root.join(MANIFEST_FILE), serde_json::to_vec(&incompatible)?)?;
641        assert!(BlobManifest::load(&root, "example", "alice/repo").is_none());
642
643        fs::write(root.join(MANIFEST_FILE), "not json")?;
644        assert!(BlobManifest::load(&root, "example", "alice/repo").is_none());
645        Ok(())
646    }
647
648    fn html_files(dir: &Path, into: &mut Vec<PathBuf>) -> Result<()> {
649        for entry in fs::read_dir(dir)? {
650            let path = entry?.path();
651            if path.is_dir() {
652                html_files(&path, into)?;
653            } else if fs::read(&path)?.starts_with(b"<!doctype html>") {
654                into.push(path);
655            }
656        }
657        Ok(())
658    }
659
660    #[test]
661    fn previews_images_and_resolves_readme_images_at_each_revision() -> Result<()> {
662        let root = TempDir::new("images");
663        let repo = root.join("repo");
664        init_repo(&repo)?;
665        fs::create_dir_all(repo.join("demos"))?;
666        fs::create_dir_all(repo.join("docs"))?;
667        let image = "demos/a #.png";
668        fs::write(repo.join(image), b"\x89PNG\r\n\x1a\nold")?;
669        fs::write(repo.join("large.PNG"), vec![0; (1 << 20) + 1])?;
670        fs::write(repo.join("archive.bin"), b"binary\0data")?;
671        fs::write(repo.join("large.txt"), vec![b'x'; (1 << 20) + 1])?;
672        fs::write(repo.join("README.md"), "![demo](./demos/a%20%23.png)\n")?;
673        fs::write(
674            repo.join("docs/README.md"),
675            "![demo](../demos/a%20%23.png)\n\n![external](https://example.org/demo.png)\n",
676        )?;
677        commit(&repo, "first")?;
678        let config = Config {
679            repo: repo.clone(),
680            out: root.join("first"),
681            instance_name: "test".into(),
682            name: Some("u/r".into()),
683            clone_url: None,
684        };
685        full(&config, None, grammar_cache())?;
686        let first_oid = git(&repo, &["rev-parse", "HEAD:demos/a #.png"])?;
687        let first_url = format!("/u/r/raw/{first_oid}/demos/a%20%23.png");
688        for page in [
689            "index.html",
690            "ref/main/index.html",
691            "ref/main/blob/README.md",
692            "ref/main/tree/docs/index.html",
693            "ref/main/blob/docs/README.md",
694            "ref/main/blob/demos/a #.png",
695        ] {
696            let html = fs::read_to_string(config.out.join(page))?;
697            assert!(
698                html.contains(&format!("src=\"{first_url}\"")),
699                "{page}: {html}"
700            );
701        }
702        let nested = fs::read_to_string(config.out.join("ref/main/blob/docs/README.md"))?;
703        assert!(nested.contains("src=\"https://example.org/demo.png\""));
704        let large = fs::read_to_string(config.out.join("ref/main/blob/large.PNG"))?;
705        assert!(large.contains("<img src=\"/u/r/raw/"));
706        for name in ["archive.bin", "large.txt"] {
707            let html = fs::read_to_string(config.out.join(format!("ref/main/blob/{name}")))?;
708            let oid = git(&repo, &["rev-parse", &format!("HEAD:{name}")])?;
709            assert!(html.contains(&format!(
710                "href=\"/u/r/raw/{oid}/{name}\" download=\"{name}\""
711            )));
712            assert!(html.contains("cannot be previewed"));
713            assert!(!html.contains("<img"));
714        }
715
716        fs::write(repo.join(image), b"\x89PNG\r\n\x1a\nnew")?;
717        commit(&repo, "image changed, markdown unchanged")?;
718        let second = Config {
719            out: root.join("second"),
720            ..config.clone()
721        };
722        full(&second, Some(&config.out), grammar_cache())?;
723        let second_oid = git(&repo, &["rev-parse", "HEAD:demos/a #.png"])?;
724        for page in ["ref/main/blob/README.md", "ref/main/blob/docs/README.md"] {
725            let html = fs::read_to_string(second.out.join(page))?;
726            assert!(html.contains(&format!("src=\"/u/r/raw/{second_oid}/demos/a%20%23.png\"")));
727            assert!(!html.contains(&first_url));
728        }
729        Ok(())
730    }
731
732    /// The generator's links and its output paths are produced by separate
733    /// code, so a mismatch in encoding or layout between them only shows up
734    /// by following the links.
735    #[test]
736    fn every_internal_link_resolves() -> Result<()> {
737        let root = TempDir::new("links");
738        let repo = root.join("repo");
739        init_repo(&repo)?;
740        fs::create_dir_all(repo.join("dir with space/nested"))?;
741        fs::write(
742            repo.join("README.md"),
743            "[folder](dir%20with%20space) [file](<dir with space/nested/f%231%3F.txt#L1>) \
744             [symlink](./link) [anchor](#top)\n",
745        )?;
746        fs::write(
747            repo.join("dir with space/nested/notes.md"),
748            "[root](../..) [up](../../README.md) [sibling](f%231%3F.txt) [parent](../\u{e9}.txt)\n",
749        )?;
750        fs::write(repo.join("dir with space/nested/f#1?.txt"), "one")?;
751        fs::write(repo.join("dir with space/\u{e9}.txt"), "two")?;
752        std::os::unix::fs::symlink("README.md", repo.join("link"))?;
753        commit(&repo, "first")?;
754        git(&repo, &["tag", "v1"])?;
755        git(&repo, &["branch", "feature/x"])?;
756
757        let out = root.join("out");
758        let config = Config {
759            repo: repo.clone(),
760            out: out.clone(),
761            instance_name: "test".into(),
762            name: Some("alice/my repo".into()),
763            clone_url: None,
764        };
765        full(&config, None, grammar_cache())?;
766
767        let base = "/alice/my%20repo/";
768        let mut pages = Vec::new();
769        html_files(&out, &mut pages)?;
770        assert!(!pages.is_empty());
771        let mut checked = 0;
772        for page in pages {
773            let html = fs::read_to_string(&page)?;
774            let links = ["href=\"", "src=\""]
775                .iter()
776                .flat_map(|attr| html.split(attr).skip(1))
777                .map(|rest| rest.split('"').next().unwrap());
778            for link in links {
779                assert!(link.starts_with(['/', '#']), "{}: relative link {link}", page.display());
780                let Some(rel) = link.strip_prefix(base) else {
781                    continue;
782                };
783                let rel = rel.split('#').next().unwrap();
784                // Raw blobs are served by the daemon straight from the repo.
785                if rel.starts_with("raw/") {
786                    continue;
787                }
788                let rel = percent_decode_str(rel).decode_utf8()?;
789                let target = if rel.is_empty() || rel.ends_with('/') {
790                    out.join(&*rel).join("index.html")
791                } else {
792                    out.join(&*rel)
793                };
794                assert!(
795                    target.is_file(),
796                    "{}: {link} -> missing {}",
797                    page.display(),
798                    target.display(),
799                );
800                checked += 1;
801            }
802        }
803        assert!(checked > 0);
804        Ok(())
805    }
806
807    fn build(repo: &Path, out: &Path) -> Result<()> {
808        full(
809            &Config {
810                repo: repo.to_owned(),
811                out: out.to_owned(),
812                instance_name: "test".into(),
813                name: Some("u/r".into()),
814                clone_url: None,
815            },
816            None,
817            grammar_cache(),
818        )
819    }
820}