From 5e3124da688ebfb68c9cfb0b03f6d774cb44ab7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Tue, 21 Apr 2026 18:39:43 +0100 Subject: [PATCH 01/19] wholehistory: fall back to vcsgraph.tsort for breezy dev breezy.tsort was relocated to vcsgraph.tsort in the breezy dromedary rewrite. Try the old import first and fall back, so loggerhead keeps running against both 3.3 and 3.4+ without changes elsewhere. --- loggerhead/wholehistory.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/loggerhead/wholehistory.py b/loggerhead/wholehistory.py index 396341d4..cbea1890 100644 --- a/loggerhead/wholehistory.py +++ b/loggerhead/wholehistory.py @@ -21,7 +21,11 @@ import time from breezy.revision import NULL_REVISION, is_null -from breezy.tsort import merge_sort + +try: + from breezy.tsort import merge_sort +except ImportError: + from vcsgraph.tsort import merge_sort def _strip_NULL_ghosts(revision_graph): From 2184bee57b57c5750715cc59a598b3ae1cbd5063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Tue, 21 Apr 2026 18:41:02 +0100 Subject: [PATCH 02/19] Initial Rust port of loggerhead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the loggerhead web viewer from Python to Rust, running alongside the Python package in the same repository until we're ready to cut over. Built on axum + askama + breezyshim. Endpoints implemented (all verified against a live Bazaar branch and compared structurally against Python loggerhead's output): / — 308 redirect to /changes /changes — mainline log, 20 per page /revision/ — metadata, parents, grouped file changes /diff/[/] — downloadable unified-diff attachment /+filediff///path — per-file diff rendered with similar /files[/[/]] — directory listing with per-file "Latest Rev" column /view// — syntect-highlighted file contents /annotate// — blame view /download// — raw file download /tarball/ — streamed tgz via breezy.Tree.archive /atom — Atom feed of mainline /+revlog/ — JSON revision dump /search — stubbed (bzr-search plugin integration is left for later) /static/* — CSS/JS/images served from the Python loggerhead/static directory so the Python .pt stylesheets style the Rust output identically Shared templates/base.html mirrors templates/macros.pt (menu tabs, branch name, footer) so per-page askama templates match Python's DOM (class names, IDs, link targets) byte-for-byte where reasonable. --- .gitignore | 1 + Cargo.lock | 2487 ++++++++++++++++++++++++++++++++++ Cargo.toml | 49 + src/app.rs | 110 ++ src/breezy/mod.rs | 46 + src/cache/disk.rs | 311 +++++ src/cache/mod.rs | 14 + src/config.rs | 52 + src/controllers/annotate.rs | 104 ++ src/controllers/atom.rs | 102 ++ src/controllers/changelog.rs | 81 ++ src/controllers/diff.rs | 92 ++ src/controllers/download.rs | 115 ++ src/controllers/filediff.rs | 126 ++ src/controllers/inventory.rs | 256 ++++ src/controllers/mod.rs | 11 + src/controllers/revision.rs | 136 ++ src/controllers/revlog.rs | 104 ++ src/controllers/search.rs | 43 + src/controllers/view.rs | 113 ++ src/highlight.rs | 134 ++ src/history.rs | 389 ++++++ src/lib.rs | 15 + src/main.rs | 66 + src/util/errors.rs | 43 + src/util/fmt.rs | 83 ++ src/util/mod.rs | 2 + templates/annotate.html | 34 + templates/base.html | 49 + templates/changelog.html | 55 + templates/filediff.html | 43 + templates/inventory.html | 87 ++ templates/revision.html | 99 ++ templates/search.html | 32 + templates/view.html | 41 + 35 files changed, 5525 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/app.rs create mode 100644 src/breezy/mod.rs create mode 100644 src/cache/disk.rs create mode 100644 src/cache/mod.rs create mode 100644 src/config.rs create mode 100644 src/controllers/annotate.rs create mode 100644 src/controllers/atom.rs create mode 100644 src/controllers/changelog.rs create mode 100644 src/controllers/diff.rs create mode 100644 src/controllers/download.rs create mode 100644 src/controllers/filediff.rs create mode 100644 src/controllers/inventory.rs create mode 100644 src/controllers/mod.rs create mode 100644 src/controllers/revision.rs create mode 100644 src/controllers/revlog.rs create mode 100644 src/controllers/search.rs create mode 100644 src/controllers/view.rs create mode 100644 src/highlight.rs create mode 100644 src/history.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/util/errors.rs create mode 100644 src/util/fmt.rs create mode 100644 src/util/mod.rs create mode 100644 templates/annotate.html create mode 100644 templates/base.html create mode 100644 templates/changelog.html create mode 100644 templates/filediff.html create mode 100644 templates/inventory.html create mode 100644 templates/revision.html create mode 100644 templates/search.html create mode 100644 templates/view.html diff --git a/.gitignore b/.gitignore index ef99b155..6f096fe1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +/target ./dist ./loggerhead.egg-info ./loggerhead.pid diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..4c4a75e6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2487 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "breezyshim" +version = "0.7.15" +dependencies = [ + "chrono", + "ctor", + "lazy-regex", + "lazy_static", + "log", + "patchkit", + "percent-encoding", + "pyo3", + "pyo3-filelike", + "regex", + "serde", + "tempfile", + "url", + "whoami", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy-regex" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loggerhead" +version = "3.0.0-dev" +dependencies = [ + "anyhow", + "askama", + "axum", + "breezyshim", + "chrono", + "clap", + "mime_guess", + "moka", + "num_cpus", + "percent-encoding", + "reqwest", + "rusqlite", + "serde", + "similar", + "syntect", + "tempfile", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "patchkit" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5364f5b47b05204e0eb3e5427657769dd1924f4ec3e2b823c0575546049439a" +dependencies = [ + "chrono", + "lazy-regex", + "lazy_static", + "once_cell", + "proc-macro2", + "regex", + "rowan", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "chrono", + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "serde", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-filelike" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8cb6cd0231ea816b4452c0cd37b5215f9ec45b66ed3e748fad8eb39cfd4997" +dependencies = [ + "pyo3", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rowan" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417a3a9f582e349834051b8a10c8d71ca88da4211e4093528e36b9845f6b5f21" +dependencies = [ + "countme", + "hashbrown 0.14.5", + "rustc-hash", + "text-size", +] + +[[package]] +name = "rusqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror", + "walkdir", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..21181289 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "loggerhead" +version = "3.0.0-dev" +edition = "2021" +rust-version = "1.83" +license = "GPL-2.0-or-later" +description = "Web viewer for Bazaar/Breezy branches" +homepage = "https://www.breezy-vcs.org/" +repository = "https://code.launchpad.net/loggerhead" +authors = ["Jelmer Vernooij "] + +[[bin]] +name = "loggerhead-serve" +path = "src/main.rs" + +[lib] +name = "loggerhead" +path = "src/lib.rs" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["trace", "compression-br", "compression-gzip", "fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +askama = "0.12" +clap = { version = "4", features = ["derive"] } +# Depending on the local checkout until the unreleased changes land on +# crates.io. Swap to a version requirement (e.g. `breezyshim = "0.7.16"`) +# once a release including the iter_ancestry/tsort/export::archive/ +# list_files/annotate_iter/get_file_revision additions is published. +breezyshim = { path = "../breezyshim" } +url = "2" +thiserror = "2" +anyhow = "1" +serde = { version = "1", features = ["derive"] } +moka = { version = "0.12", features = ["sync"] } +num_cpus = "1" +chrono = "0.4" +rusqlite = { version = "0.33", features = ["bundled"] } +similar = "2" +percent-encoding = "2" +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "parsing", "regex-fancy"] } +mime_guess = "2" + +[dev-dependencies] +tempfile = "3" +reqwest = { version = "0.12", default-features = false, features = ["blocking"] } diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 00000000..69183674 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use axum::response::Redirect; +use axum::{routing::get, Router}; +use moka::sync::Cache; +use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; + +use breezyshim::branch::Branch; +use breezyshim::revisionid::RevisionId; + +use crate::cache::RevInfoDiskCache; +use crate::controllers::{changelog, revision}; +use crate::history::WholeHistory; +use crate::util::errors::AppError; + +/// Shared application state handed to every handler. +pub struct AppState { + /// Location (filesystem path or URL) of the branch being served. + pub root: String, + /// Cached whole-branch graph, invalidated when the branch tip changes. + pub whole_history_cache: Cache>, + /// Optional SQLite-backed persistent cache. + pub disk_cache: Option>, + /// Whether tarball exports are permitted. + pub export_tarballs: bool, + /// Filesystem directory containing CSS/JS/image assets (served under + /// `/static`). + pub static_dir: std::path::PathBuf, +} + +impl AppState { + pub fn new( + root: String, + disk_cache: Option>, + export_tarballs: bool, + static_dir: std::path::PathBuf, + ) -> Self { + Self { + root, + whole_history_cache: Cache::new(10), + disk_cache, + export_tarballs, + static_dir, + } + } + + /// Fetch the whole-history for `branch`'s current tip, consulting the + /// in-memory LRU and optional disk cache, computing + storing on miss. + /// Must be called from a blocking context (holds the GIL). + pub fn load_whole_history(&self, branch: &dyn Branch) -> Result, AppError> { + let tip = branch.last_revision(); + if let Some(w) = self.whole_history_cache.get(&tip) { + return Ok(w); + } + if let Some(from_disk) = self + .disk_cache + .as_ref() + .and_then(|d| d.get_whole_history(&tip)) + { + tracing::debug!("whole_history: disk cache hit"); + let w = Arc::new(from_disk); + self.whole_history_cache.insert(tip, w.clone()); + return Ok(w); + } + tracing::debug!("whole_history: computing (miss on memory & disk)"); + let computed = WholeHistory::compute(branch)?; + if let Some(d) = self.disk_cache.as_ref() { + d.set_whole_history(&tip, &computed); + } + let w = Arc::new(computed); + self.whole_history_cache.insert(tip, w.clone()); + Ok(w) + } +} + +/// Permanent redirect to `/changes`, matching Python loggerhead's root +/// behaviour (see `apps/branch.py::lookup_app`). +async fn root_redirect() -> Redirect { + Redirect::permanent("/changes") +} + +pub fn build_router(state: Arc) -> Router { + use crate::controllers::{ + annotate, atom, diff, download, filediff, inventory, revlog, search, view, + }; + Router::new() + .route("/", get(root_redirect)) + .route("/changes", get(changelog::show)) + .route("/revision/:revid", get(revision::show)) + .route("/diff/:new_revid", get(diff::show_one)) + .route("/diff/:new_revid/:old_revid", get(diff::show_two)) + .route( + "/+filediff/:new_revid/:old_revid/*path", + get(filediff::show), + ) + .route("/files", get(inventory::show_root)) + .route("/files/:revno", get(inventory::show_rev)) + .route("/files/:revno/*path", get(inventory::show_rev_path)) + .route("/view/:revno/*path", get(view::show)) + .route("/annotate/:revno/*path", get(annotate::show)) + .route("/download/:revid/*path", get(download::show_file)) + .route("/tarball/:revid", get(download::tarball)) + .route("/atom", get(atom::show)) + .route("/+revlog/:revid", get(revlog::show)) + .route("/search", get(search::show)) + .nest_service("/static", ServeDir::new(&state.static_dir)) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/src/breezy/mod.rs b/src/breezy/mod.rs new file mode 100644 index 00000000..030fb5a5 --- /dev/null +++ b/src/breezy/mod.rs @@ -0,0 +1,46 @@ +//! Thin ergonomics layer over breezyshim. +//! +//! All calls into breezyshim hold the Python GIL, so every public function +//! here is synchronous and intended to run on a blocking thread. Wrap calls +//! from async code in [`tokio::task::spawn_blocking`]. + +use std::path::Path; + +use breezyshim::branch::{Branch, GenericBranch}; +use url::Url; + +use crate::util::errors::AppError; + +/// Open a Breezy branch from a filesystem path or URL. +pub fn open_branch(location: &str) -> Result { + let url = match Url::parse(location) { + Ok(u) => u, + Err(_) => { + let abs = Path::new(location) + .canonicalize() + .map_err(|e| AppError::Other(format!("canonicalize {location}: {e}")))?; + Url::from_directory_path(&abs) + .map_err(|()| AppError::Other(format!("cannot turn {abs:?} into file:// URL")))? + } + }; + let branch = breezyshim::branch::open_as_generic(&url)?; + Ok(branch) +} + +/// Information about a branch suitable for the landing page. +pub struct BranchInfo { + pub nick: String, + pub last_revision: String, + pub revno: u32, +} + +pub fn branch_info(location: &str) -> Result { + let branch = open_branch(location)?; + let (revno, last_rev) = branch.last_revision_info(); + let nick = branch.name().unwrap_or_else(|| "".to_string()); + Ok(BranchInfo { + nick, + last_revision: last_rev.as_str().to_string(), + revno, + }) +} diff --git a/src/cache/disk.rs b/src/cache/disk.rs new file mode 100644 index 00000000..6845f3ec --- /dev/null +++ b/src/cache/disk.rs @@ -0,0 +1,311 @@ +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use breezyshim::revisionid::RevisionId; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::history::{RevInfo, WholeHistory}; +use crate::util::errors::AppError; + +/// SQLite-backed cache of expensive per-branch computations. +/// +/// Storage layout: +/// +/// ```sql +/// CREATE TABLE data ( +/// key BLOB PRIMARY KEY, -- logical key (e.g. "whole_history") +/// revid BLOB, -- branch tip this entry was computed for +/// data BLOB -- encoded payload +/// ); +/// ``` +/// +/// Entries become stale once the branch tip moves; the caller decides what +/// to do by passing the current tip to [`get_whole_history`] — a mismatch +/// produces `None`. +pub struct RevInfoDiskCache { + conn: Mutex, + #[allow(dead_code)] + path: PathBuf, +} + +impl RevInfoDiskCache { + /// Open (creating if necessary) a cache under `cache_path`. + /// + /// The directory is created on demand. The DB file is named `revinfo.sql` + /// inside that directory, matching the Python layout. + pub fn open(cache_path: &Path) -> Result { + std::fs::create_dir_all(cache_path) + .map_err(|e| AppError::Other(format!("mkdir {cache_path:?}: {e}")))?; + let path = cache_path.join("revinfo.sql"); + let conn = + Connection::open(&path).map_err(|e| AppError::Other(format!("open {path:?}: {e}")))?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS data ( + key BLOB PRIMARY KEY, + revid BLOB, + data BLOB + ); + PRAGMA journal_mode = WAL;", + ) + .map_err(|e| AppError::Other(format!("init schema: {e}")))?; + Ok(Self { + conn: Mutex::new(conn), + path, + }) + } + + /// Look up a cached [`WholeHistory`]. Returns `None` if absent, if the + /// stored tip doesn't match `current_tip`, or if the payload can't be + /// decoded (which is treated as a miss — the cache will be overwritten + /// on the next `set`). + pub fn get_whole_history(&self, current_tip: &RevisionId) -> Option { + let conn = self.conn.lock().ok()?; + let row: (Vec, Vec) = conn + .query_row( + "SELECT revid, data FROM data WHERE key = ?", + params![WHOLE_HISTORY_KEY], + |r| Ok((r.get::<_, Vec>(0)?, r.get::<_, Vec>(1)?)), + ) + .optional() + .ok() + .flatten()?; + if row.0.as_slice() != current_tip.as_bytes() { + return None; + } + decode_whole_history(&row.1).ok() + } + + /// Store `wh` under the logical `WHOLE_HISTORY_KEY`, stamped with `tip`. + /// Errors are logged but not propagated — a cache-write failure is + /// never fatal for a read. + pub fn set_whole_history(&self, tip: &RevisionId, wh: &WholeHistory) { + let blob = encode_whole_history(wh); + let Ok(conn) = self.conn.lock() else { return }; + if let Err(e) = conn.execute( + "INSERT INTO data (key, revid, data) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET revid = excluded.revid, data = excluded.data", + params![WHOLE_HISTORY_KEY, tip.as_bytes(), blob], + ) { + tracing::warn!(error = %e, "disk cache write failed"); + } + } +} + +const WHOLE_HISTORY_KEY: &[u8] = b"whole_history"; + +/// Magic+version header so we can change the encoding later and reject +/// stale entries rather than misinterpret them. +const MAGIC: &[u8; 4] = b"LHWH"; +const VERSION: u8 = 1; + +fn encode_whole_history(wh: &WholeHistory) -> Vec { + let mut out = Vec::with_capacity(256 + wh.entries.len() * 64); + out.extend_from_slice(MAGIC); + out.push(VERSION); + put_u64(&mut out, wh.entries.len() as u64); + for e in &wh.entries { + put_u64(&mut out, e.sequence as u64); + put_bytes(&mut out, e.revid.as_bytes()); + put_u64(&mut out, e.merge_depth as u64); + put_bytes(&mut out, e.revno.as_bytes()); + out.push(u8::from(e.end_of_merge)); + put_u64(&mut out, e.parents.len() as u64); + for p in &e.parents { + put_bytes(&mut out, p.as_bytes()); + } + put_u64(&mut out, e.children.len() as u64); + for c in &e.children { + put_bytes(&mut out, c.as_bytes()); + } + } + out +} + +fn decode_whole_history(blob: &[u8]) -> Result { + let mut cur = Cursor::new(blob); + let magic = cur.take(4)?; + if magic != MAGIC { + return Err(DecodeError::BadMagic); + } + let version = cur.take_u8()?; + if version != VERSION { + return Err(DecodeError::BadVersion(version)); + } + let n = cur.take_u64()? as usize; + let mut entries = Vec::with_capacity(n); + let mut index = std::collections::HashMap::with_capacity(n); + for _ in 0..n { + let sequence = cur.take_u64()? as usize; + let revid = RevisionId::from(cur.take_bytes()?); + let merge_depth = cur.take_u64()? as usize; + let revno_bytes = cur.take_bytes()?; + let revno = + String::from_utf8(revno_bytes).map_err(|_| DecodeError::InvalidUtf8("revno"))?; + let end_of_merge = cur.take_u8()? != 0; + let pcount = cur.take_u64()? as usize; + let mut parents = Vec::with_capacity(pcount); + for _ in 0..pcount { + parents.push(RevisionId::from(cur.take_bytes()?)); + } + let ccount = cur.take_u64()? as usize; + let mut children = Vec::with_capacity(ccount); + for _ in 0..ccount { + children.push(RevisionId::from(cur.take_bytes()?)); + } + index.insert(revid.clone(), entries.len()); + entries.push(RevInfo { + sequence, + revid, + merge_depth, + revno, + end_of_merge, + parents, + children, + }); + } + Ok(WholeHistory { entries, index }) +} + +fn put_u64(out: &mut Vec, v: u64) { + out.extend_from_slice(&v.to_le_bytes()); +} + +fn put_bytes(out: &mut Vec, b: &[u8]) { + put_u64(out, b.len() as u64); + out.extend_from_slice(b); +} + +struct Cursor<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> { + let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?; + if end > self.buf.len() { + return Err(DecodeError::Truncated); + } + let out = &self.buf[self.pos..end]; + self.pos = end; + Ok(out) + } + + fn take_u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn take_u64(&mut self) -> Result { + let bytes = self.take(8)?; + let mut arr = [0u8; 8]; + arr.copy_from_slice(bytes); + Ok(u64::from_le_bytes(arr)) + } + + fn take_bytes(&mut self) -> Result, DecodeError> { + let n = self.take_u64()? as usize; + Ok(self.take(n)?.to_vec()) + } +} + +#[derive(Debug, thiserror::Error)] +enum DecodeError { + #[error("truncated cache entry")] + Truncated, + #[error("bad magic")] + BadMagic, + #[error("unsupported cache version {0}")] + BadVersion(u8), + #[error("invalid utf-8 in {0}")] + InvalidUtf8(&'static str), +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::TempDir; + + fn sample_whole_history() -> WholeHistory { + let r1 = RevisionId::from(b"rev-1".to_vec()); + let r2 = RevisionId::from(b"rev-2".to_vec()); + let entries = vec![ + RevInfo { + sequence: 0, + revid: r2.clone(), + merge_depth: 0, + revno: "2".to_string(), + end_of_merge: true, + parents: vec![r1.clone()], + children: Vec::new(), + }, + RevInfo { + sequence: 1, + revid: r1.clone(), + merge_depth: 0, + revno: "1".to_string(), + end_of_merge: true, + parents: Vec::new(), + children: Vec::new(), + }, + ]; + let mut index = HashMap::new(); + index.insert(r2, 0); + index.insert(r1, 1); + WholeHistory { entries, index } + } + + #[test] + fn round_trip_encoding() { + let wh = sample_whole_history(); + let encoded = encode_whole_history(&wh); + let decoded = decode_whole_history(&encoded).unwrap(); + assert_eq!(decoded.entries.len(), wh.entries.len()); + for (a, b) in decoded.entries.iter().zip(wh.entries.iter()) { + assert_eq!(a.sequence, b.sequence); + assert_eq!(a.revid.as_bytes(), b.revid.as_bytes()); + assert_eq!(a.revno, b.revno); + assert_eq!(a.parents.len(), b.parents.len()); + } + } + + #[test] + fn disk_cache_get_set() { + let tmp = TempDir::new().unwrap(); + let cache = RevInfoDiskCache::open(tmp.path()).unwrap(); + let tip = RevisionId::from(b"rev-2".to_vec()); + assert!(cache.get_whole_history(&tip).is_none()); + + let wh = sample_whole_history(); + cache.set_whole_history(&tip, &wh); + let fetched = cache.get_whole_history(&tip).unwrap(); + assert_eq!(fetched.entries.len(), 2); + + // Different tip → treated as miss. + let other = RevisionId::from(b"other".to_vec()); + assert!(cache.get_whole_history(&other).is_none()); + } + + #[test] + fn rejects_bad_magic() { + assert!(matches!( + decode_whole_history(b"ZZZZ\x01\x00\x00\x00\x00\x00\x00\x00\x00"), + Err(DecodeError::BadMagic) + )); + } + + #[test] + fn rejects_bad_version() { + let mut blob = MAGIC.to_vec(); + blob.push(99); + blob.extend_from_slice(&0u64.to_le_bytes()); + assert!(matches!( + decode_whole_history(&blob), + Err(DecodeError::BadVersion(99)) + )); + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 00000000..5f18d392 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,14 @@ +//! On-disk cache for pre-computed branch history. +//! +//! Ported from `loggerhead/changecache.py::RevInfoDiskCache`. This is a +//! best-effort cache: missing or corrupted entries just fall through to +//! recomputation, and concurrent writers race optimistically. +//! +//! The on-disk format is **not** compatible with the Python cache (the Rust +//! implementation stores `WholeHistory` with a custom binary encoding rather +//! than pickle/marshal). The table layout is kept similar so an operator can +//! tell what's in the file. + +pub mod disk; + +pub use disk::RevInfoDiskCache; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 00000000..f542a4c2 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,52 @@ +use std::net::IpAddr; +use std::path::PathBuf; + +use clap::Parser; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "loggerhead-serve", + about = "Web viewer for Bazaar/Breezy branches" +)] +pub struct Args { + /// Path or URL of the branch (or root directory of branches) to serve. + #[arg(value_name = "PATH_OR_URL")] + pub root: String, + + /// Port to listen on. + #[arg(long, default_value_t = 8080)] + pub port: u16, + + /// Host address to bind to. + #[arg(long, default_value = "0.0.0.0")] + pub host: IpAddr, + + /// URL prefix, for deployment behind a reverse proxy. + #[arg(long, default_value = "")] + pub prefix: String, + + /// Path to the on-disk revision-info cache (SQLite). + #[arg(long, value_name = "DIR")] + pub cachepath: Option, + + /// Allow tarball downloads of revisions. + #[arg(long, default_value_t = true)] + pub export_tarballs: bool, + + /// Directory to write log files to (currently logs are still emitted to + /// stderr; this flag is accepted for CLI-compat with the Python + /// implementation and reserved for future file-logging support). + #[arg(long, value_name = "DIR")] + pub log_folder: Option, + + /// Log level (matches Python loggerhead's `--log-level`). One of + /// `trace`, `debug`, `info`, `warn`, `error`. + #[arg(long, value_name = "LEVEL")] + pub log_level: Option, + + /// Directory of static CSS/JS/image assets to serve under `/static`. + /// Defaults to the Python loggerhead static dir shipped with this + /// checkout; override for a Debian install (e.g. `/usr/share/loggerhead/static`). + #[arg(long, value_name = "DIR")] + pub static_dir: Option, +} diff --git a/src/controllers/annotate.rs b/src/controllers/annotate.rs new file mode 100644 index 00000000..84f46e49 --- /dev/null +++ b/src/controllers/annotate.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::{Kind, Tree}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; + +#[derive(Template)] +#[template(path = "annotate.html")] +struct AnnotateTemplate { + // base + nick: String, + fileview_active: bool, + // page + revno: String, + path: String, + lines: Vec, +} + +struct Line { + n: usize, + revno: String, + revid_short: String, + text: String, +} + +/// GET /annotate/:revno/*path — blame-style view of a file at the given +/// revision (use `head:` for the tip). +pub async fn show( + State(state): State>, + Path((revno_req, path)): Path<(String, String)>, +) -> AppResult> { + let path_norm = path.trim_matches('/').to_string(); + if path_norm.is_empty() { + return Err(AppError::Other("no filename provided".into())); + } + let path_for_task = path_norm.clone(); + + let (nick, annotated, revno) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let revid = history + .fix_revid(&revno_req) + .ok_or_else(|| AppError::NotFound(format!("no revision {revno_req}")))?; + let revno = history.whole.get_revno(&revid); + + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + let p = PathBuf::from(&path_for_task); + if !tree.has_filename(&p) { + return Err(AppError::NotFound(format!("{path_for_task} not found"))); + } + if !matches!(tree.kind(&p)?, Kind::File) { + return Err(AppError::Other(format!( + "{path_for_task} is not a regular file" + ))); + } + + let mut annotated: Vec<(RevisionId, Vec)> = Vec::new(); + for item in tree.annotate_iter(&p, None)? { + annotated.push(item?); + } + + let lines: Vec = annotated + .into_iter() + .enumerate() + .map(|(i, (rid, bytes))| { + let revno = history.whole.get_revno(&rid); + let full = String::from_utf8_lossy(rid.as_bytes()).into_owned(); + let short = full.split('-').next_back().unwrap_or("").to_string(); + Line { + n: i + 1, + revno, + revid_short: short, + text: String::from_utf8_lossy(&bytes) + .trim_end_matches('\n') + .to_string(), + } + }) + .collect(); + Ok::<_, AppError>((history.nick, lines, revno)) + }) + .await??; + + let tmpl = AnnotateTemplate { + nick, + fileview_active: true, + revno, + path: path_norm, + lines: annotated, + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/atom.rs b/src/controllers/atom.rs new file mode 100644 index 00000000..b70db579 --- /dev/null +++ b/src/controllers/atom.rs @@ -0,0 +1,102 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use breezyshim::branch::Branch; +use chrono::{DateTime, Utc}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::{Change, History}; +use crate::util::errors::{AppError, AppResult}; + +const PAGE_SIZE: usize = 20; + +/// GET /atom — an Atom feed of the last PAGE_SIZE mainline revisions. +pub async fn show(State(state): State>) -> AppResult { + let (nick, entries) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let mainline = history.mainline_from(&history.last_revid); + let page: Vec<_> = mainline.into_iter().take(PAGE_SIZE).collect(); + let changes = history.get_changes(&branch, &page)?; + Ok::<_, AppError>((history.nick, changes)) + }) + .await??; + + let body = render_atom(&nick, &entries); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8") + .body(Body::from(body)) + .unwrap() + .into_response()) +} + +fn render_atom(nick: &str, entries: &[Change]) -> String { + let updated = entries + .first() + .and_then(|c| DateTime::::from_timestamp(c.timestamp as i64, 0)) + .unwrap_or_else(Utc::now) + .to_rfc3339(); + let mut out = String::with_capacity(1024 + entries.len() * 512); + out.push_str( + r#" + + "#, + ); + out.push_str(&format!("bazaar changes for {}", xml_escape(nick))); + out.push_str("\n "); + out.push_str(&updated); + out.push_str("\n urn:loggerhead:"); + out.push_str(&xml_escape(nick)); + out.push_str("\n \n"); + for entry in entries { + let date = DateTime::::from_timestamp(entry.timestamp as i64, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_default(); + let revid_hex = String::from_utf8_lossy(entry.revid.as_bytes()); + out.push_str(" \n "); + out.push_str(&xml_escape(&format!( + "{}: {}", + entry.revno, entry.short_message + ))); + out.push_str("\n "); + out.push_str(&date); + out.push_str("\n urn:revid:"); + out.push_str(&xml_escape(&revid_hex)); + out.push_str("\n "); + out.push_str(&xml_escape(&entry.committer)); + out.push_str("\n "); + out.push_str(&xml_escape(&entry.message)); + out.push_str( + "\n \n \n"); + } + out.push_str("\n"); + out +} + +fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} diff --git a/src/controllers/changelog.rs b/src/controllers/changelog.rs new file mode 100644 index 00000000..df61d088 --- /dev/null +++ b/src/controllers/changelog.rs @@ -0,0 +1,81 @@ +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Query, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use serde::Deserialize; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::{Change, History}; +use crate::util::errors::{AppError, AppResult}; +use crate::util::fmt::{approximate_date, hide_email, utc_iso}; + +/// Query parameters accepted by the `/changes` page. +#[derive(Debug, Deserialize, Default)] +pub struct ChangelogQuery { + pub start_revid: Option, +} + +const PAGE_SIZE: usize = 20; + +#[derive(Template)] +#[template(path = "changelog.html")] +struct ChangelogTemplate { + // shared base-template fields + nick: String, + fileview_active: bool, + served_url: String, + // page-specific + last_revno: String, + changes: Vec, +} + +struct ChangeView { + revno: String, + short_message: String, + author: String, + utc_iso: String, + relative_date: String, +} + +impl From for ChangeView { + fn from(c: Change) -> Self { + ChangeView { + revno: c.revno, + short_message: c.short_message, + author: hide_email(&c.committer), + utc_iso: utc_iso(c.timestamp, c.timezone), + relative_date: approximate_date(c.timestamp), + } + } +} + +pub async fn show( + State(state): State>, + Query(_q): Query, +) -> AppResult> { + let state2 = state.clone(); + let (nick, last_revno, changes) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state2.root)?; + let _lock = branch.lock_read()?; + let whole = state2.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let mainline = history.mainline_from(&history.last_revid); + let page: Vec<_> = mainline.into_iter().take(PAGE_SIZE).collect(); + let changes = history.get_changes(&branch, &page)?; + let last_revno = history.whole.get_revno(&history.last_revid); + Ok::<_, AppError>((history.nick, last_revno, changes)) + }) + .await??; + + let tmpl = ChangelogTemplate { + nick, + fileview_active: false, + served_url: state.root.clone(), + last_revno, + changes: changes.into_iter().map(Into::into).collect(), + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/diff.rs b/src/controllers/diff.rs new file mode 100644 index 00000000..96f4ed27 --- /dev/null +++ b/src/controllers/diff.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use breezyshim::branch::Branch; +use breezyshim::diff::show_diff_trees; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; + +/// GET /diff/:new_revid — diff from new's first parent to new. +pub async fn show_one( + State(state): State>, + Path(new_revid_enc): Path, +) -> AppResult { + render_diff(state, new_revid_enc, None).await +} + +/// GET /diff/:new_revid/:old_revid — diff from old to new. +pub async fn show_two( + State(state): State>, + Path((new_revid_enc, old_revid_enc)): Path<(String, String)>, +) -> AppResult { + render_diff(state, new_revid_enc, Some(old_revid_enc)).await +} + +async fn render_diff( + state: Arc, + new_revid_enc: String, + old_revid_enc: Option, +) -> AppResult { + let (diff_bytes, revno_new, revno_old) = + tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + + let new_revid = history + .fix_revid(&new_revid_enc) + .ok_or_else(|| AppError::NotFound(format!("no revision {new_revid_enc}")))?; + let old_revid = match old_revid_enc { + Some(enc) => history + .fix_revid(&enc) + .ok_or_else(|| AppError::NotFound(format!("no revision {enc}")))?, + None => { + // Default to new's first parent, or NULL_REVISION for root commits. + let entry_idx = history + .whole + .index + .get(&new_revid) + .copied() + .ok_or_else(|| AppError::NotFound("new revision not in branch".into()))?; + history.whole.entries[entry_idx] + .parents + .first() + .cloned() + .unwrap_or_else(RevisionId::null) + } + }; + + let repo = branch.repository(); + let new_tree = repo.revision_tree(&new_revid)?; + let old_tree = repo.revision_tree(&old_revid)?; + + let mut buf: Vec = Vec::new(); + show_diff_trees(&old_tree, &new_tree, &mut buf, Some(""), Some(""))?; + + let revno_new = history.whole.get_revno(&new_revid); + let revno_old = history.whole.get_revno(&old_revid); + Ok::<_, AppError>((buf, revno_new, revno_old)) + }) + .await??; + + let filename = format!("{revno_new}_{revno_old}.diff"); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename={filename}"), + ) + .body(Body::from(diff_bytes)) + .unwrap() + .into_response()) +} diff --git a/src/controllers/download.rs b/src/controllers/download.rs new file mode 100644 index 00000000..97e9225d --- /dev/null +++ b/src/controllers/download.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{header, StatusCode}; +use axum::response::{IntoResponse, Response}; +use breezyshim::branch::Branch; +use breezyshim::export::{archive, ArchiveFormat}; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::Tree; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::util::errors::{AppError, AppResult}; + +/// GET /download/:revid/*path — stream a single file at `path` from `revid`. +pub async fn show_file( + State(state): State>, + Path((revid_enc, path_enc)): Path<(String, String)>, +) -> AppResult { + let revid = RevisionId::from( + percent_decode_str(&revid_enc) + .decode_utf8_lossy() + .into_owned() + .into_bytes(), + ); + let path = percent_decode_str(&path_enc) + .decode_utf8_lossy() + .into_owned(); + let filename = StdPath::new(&path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); + + let path_for_task = path.clone(); + let content = tokio::task::spawn_blocking(move || -> AppResult> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + let p = PathBuf::from(&path_for_task); + Ok(tree.get_file_text(&p)?) + }) + .await??; + + let mime = mime_guess::from_path(&filename) + .first_or_octet_stream() + .to_string(); + let encoded = utf8_percent_encode(&filename, NON_ALPHANUMERIC).to_string(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename*=utf-8''{encoded}"), + ) + .body(Body::from(content)) + .unwrap() + .into_response()) +} + +/// GET /tarball/:revid — stream a tgz of the tree at `revid`. +pub async fn tarball( + State(state): State>, + Path(revid_enc): Path, +) -> AppResult { + if !state.export_tarballs { + return Err(AppError::Other("tarball export is disabled".into())); + } + let revid = RevisionId::from( + percent_decode_str(&revid_enc) + .decode_utf8_lossy() + .into_owned() + .into_bytes(), + ); + + // Gather the archive into memory inside spawn_blocking. Streaming the + // iterator across the async boundary while still holding the GIL is + // awkward; for the sizes most loggerhead deployments see this is a fair + // tradeoff, and we can revisit with a bounded mpsc channel if needed. + let (bytes, filename) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let nick = branch + .get_config() + .get_nickname() + .unwrap_or_else(|_| "branch".into()); + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + let filename = format!("{nick}.tgz"); + let mut out = Vec::new(); + for chunk in archive(&tree, ArchiveFormat::Tgz, &filename, None, Some(&nick))? { + out.extend_from_slice(&chunk?); + } + Ok::<_, AppError>((out, filename)) + }) + .await??; + + let encoded = utf8_percent_encode(&filename, NON_ALPHANUMERIC).to_string(); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename*=utf-8''{encoded}"), + ) + .body(Body::from(bytes)) + .unwrap() + .into_response()) +} + +use std::path::Path as StdPath; diff --git a/src/controllers/filediff.rs b/src/controllers/filediff.rs new file mode 100644 index 00000000..5acf35e0 --- /dev/null +++ b/src/controllers/filediff.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::Tree; +use percent_encoding::percent_decode_str; +use similar::{ChangeTag, TextDiff}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::util::errors::{AppError, AppResult}; + +#[derive(Template)] +#[template(path = "filediff.html")] +struct FileDiffTemplate { + path: String, + new_revid_hex: String, + old_revid_hex: String, + chunks: Vec, +} + +struct Chunk { + header: String, + lines: Vec, +} + +struct DiffLine { + old_lineno: Option, + new_lineno: Option, + kind: &'static str, + text: String, +} + +/// GET /+filediff/:new_revid/:old_revid/*path — render a unified diff for a +/// single file between two revisions. +pub async fn show( + State(state): State>, + Path((new_revid_enc, old_revid_enc, path_enc)): Path<(String, String, String)>, +) -> AppResult> { + let new_revid = revid_from_enc(&new_revid_enc); + let old_revid = revid_from_enc(&old_revid_enc); + let path = percent_decode_str(&path_enc) + .decode_utf8_lossy() + .into_owned(); + + let path_for_task = path.clone(); + let (old_lines, new_lines) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let repo = branch.repository(); + let new_tree = repo.revision_tree(&new_revid)?; + let old_tree = repo.revision_tree(&old_revid)?; + let p = std::path::Path::new(&path_for_task); + let new_lines = read_file_lines(&new_tree, p); + let old_lines = read_file_lines(&old_tree, p); + Ok::<_, AppError>((old_lines, new_lines)) + }) + .await??; + + let chunks = render_chunks(&old_lines, &new_lines); + let tmpl = FileDiffTemplate { + path, + new_revid_hex: String::from_utf8_lossy(new_revid_enc.as_bytes()).into_owned(), + old_revid_hex: String::from_utf8_lossy(old_revid_enc.as_bytes()).into_owned(), + chunks, + }; + Ok(Html(tmpl.render()?)) +} + +fn revid_from_enc(s: &str) -> RevisionId { + let decoded = percent_decode_str(s).decode_utf8_lossy().into_owned(); + RevisionId::from(decoded.into_bytes()) +} + +/// Read a file from a tree as UTF-8 text split into lines (empty on missing +/// file or binary content). +fn read_file_lines(tree: &dyn Tree, path: &std::path::Path) -> String { + match tree.get_file_text(path) { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(_) => String::new(), + } +} + +fn render_chunks(old: &str, new: &str) -> Vec { + let diff = TextDiff::from_lines(old, new); + let mut chunks = Vec::new(); + for group in diff.grouped_ops(3) { + if group.is_empty() { + continue; + } + let first = &group[0]; + let last = &group[group.len() - 1]; + let old_start = first.as_tag_tuple().1.start + 1; + let new_start = first.as_tag_tuple().2.start + 1; + let old_len = last.as_tag_tuple().1.end - first.as_tag_tuple().1.start; + let new_len = last.as_tag_tuple().2.end - first.as_tag_tuple().2.start; + let header = format!("@@ -{old_start},{old_len} +{new_start},{new_len} @@"); + let mut lines = Vec::new(); + for op in group { + for change in diff.iter_changes(&op) { + let kind = match change.tag() { + ChangeTag::Equal => "context", + ChangeTag::Delete => "delete", + ChangeTag::Insert => "insert", + }; + let text = change + .value() + .to_string() + .trim_end_matches('\n') + .to_string(); + lines.push(DiffLine { + old_lineno: change.old_index().map(|i| i + 1), + new_lineno: change.new_index().map(|i| i + 1), + kind, + text, + }); + } + } + chunks.push(Chunk { header, lines }); + } + chunks +} diff --git a/src/controllers/inventory.rs b/src/controllers/inventory.rs new file mode 100644 index 00000000..04b7db55 --- /dev/null +++ b/src/controllers/inventory.rs @@ -0,0 +1,256 @@ +use std::path::{Path as StdPath, PathBuf}; +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::{Kind, Tree}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::{Change, History}; +use crate::util::errors::{AppError, AppResult}; +use crate::util::fmt::{approximate_date, hide_email, utc_iso}; + +#[derive(Template)] +#[template(path = "inventory.html")] +struct InventoryTemplate { + // base + nick: String, + fileview_active: bool, + // page + revno: String, + revid_hex: String, + #[allow(dead_code)] + path: String, + path_display: String, + parent_path: Option, + tip_change: Option, + entries: Vec, +} + +#[allow(dead_code)] +struct ChangeView { + revno: String, + committer: String, + utc_iso: String, + short_message: String, + message: String, +} + +impl From for ChangeView { + fn from(c: Change) -> Self { + ChangeView { + revno: c.revno, + committer: hide_email(&c.committer), + utc_iso: utc_iso(c.timestamp, c.timezone), + short_message: c.short_message, + message: c.message, + } + } +} + +struct Entry { + name: String, + href: String, + #[allow(dead_code)] + kind: &'static str, + is_dir: bool, + size: Option, + last_revno: String, + last_revid_hex: String, + last_committer: String, + last_relative: String, + last_message: String, +} + +pub async fn show_root(State(state): State>) -> AppResult> { + render(state, None, String::new()).await +} + +pub async fn show_rev( + State(state): State>, + Path(revno): Path, +) -> AppResult> { + render(state, Some(revno), String::new()).await +} + +pub async fn show_rev_path( + State(state): State>, + Path((revno, path)): Path<(String, String)>, +) -> AppResult> { + render(state, Some(revno), path).await +} + +async fn render( + state: Arc, + revno_req: Option, + path: String, +) -> AppResult> { + let (nick, revno, revid_hex, tip_change, entries, path_display, parent_path, normalized) = + tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let revid = match revno_req.as_deref() { + Some(r) => history + .fix_revid(r) + .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?, + None => history.last_revid.clone(), + }; + let revno = history.whole.get_revno(&revid); + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + + let normalized = path.trim_matches('/').to_string(); + let list_path = if normalized.is_empty() { + PathBuf::from("") + } else { + PathBuf::from(&normalized) + }; + if !normalized.is_empty() { + if !tree.has_filename(&list_path) { + return Err(AppError::NotFound(format!("{normalized} not found"))); + } + if !matches!(tree.kind(&list_path)?, Kind::Directory) { + return Err(AppError::Other(format!("{normalized} is not a directory"))); + } + } + + // Fetch revision information for tip, to show in the info box. + let tip_change_rec = history + .get_changes(&branch, std::slice::from_ref(&revid))? + .pop(); + + // Walk children, gathering per-entry last-changed revids. + let from_dir = if normalized.is_empty() { + None + } else { + Some(list_path.as_path()) + }; + let iter = tree.list_files(Some(false), from_dir, Some(false), Some(false))?; + let mut raw: Vec<(String, Kind, Option, RevisionId)> = Vec::new(); + for item in iter { + let (rel, _v, kind, entry) = item?; + let size = match &entry { + breezyshim::tree::TreeEntry::File { size, .. } => Some(*size), + _ => None, + }; + let name = rel + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| rel.to_string_lossy().into_owned()); + let full = if normalized.is_empty() { + name.clone() + } else { + format!("{normalized}/{name}") + }; + let child_revid = tree + .get_file_revision(StdPath::new(&full)) + .unwrap_or_else(|_| revid.clone()); + raw.push((name, kind, size, child_revid)); + } + + // Batch-fetch the changes for all the unique revids involved. + let unique: std::collections::HashSet = + raw.iter().map(|(_, _, _, r)| r.clone()).collect(); + let unique_vec: Vec<_> = unique.into_iter().collect(); + let changes = history.get_changes(&branch, &unique_vec)?; + let mut change_by_id: std::collections::HashMap = + std::collections::HashMap::new(); + for c in changes { + change_by_id.insert(c.revid.clone(), c); + } + + let mut entries: Vec = raw + .into_iter() + .map(|(name, kind, size, child_revid)| { + let is_dir = matches!(kind, Kind::Directory); + let kind_str = match kind { + Kind::File => "file", + Kind::Directory => "directory", + Kind::Symlink => "symlink", + Kind::TreeReference => "tree-reference", + }; + let full = if normalized.is_empty() { + name.clone() + } else { + format!("{normalized}/{name}") + }; + let href = if is_dir { + format!("/files/{}/{}", revno, full) + } else { + format!("/view/{}/{}", revno, full) + }; + let ch = change_by_id.get(&child_revid); + Entry { + name, + href, + kind: kind_str, + is_dir, + size, + last_revno: ch + .map(|c| c.revno.clone()) + .unwrap_or_else(|| "?".to_string()), + last_revid_hex: String::from_utf8_lossy(child_revid.as_bytes()) + .into_owned(), + last_committer: ch.map(|c| hide_email(&c.committer)).unwrap_or_default(), + last_relative: ch + .map(|c| approximate_date(c.timestamp)) + .unwrap_or_default(), + last_message: ch.map(|c| c.short_message.clone()).unwrap_or_default(), + } + }) + .collect(); + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + + let path_display = if normalized.is_empty() { + "/".to_string() + } else { + format!("/{normalized}") + }; + let parent_path = if normalized.is_empty() { + None + } else { + Some( + StdPath::new(&normalized) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(), + ) + }; + let revid_hex = String::from_utf8_lossy(revid.as_bytes()).into_owned(); + Ok::<_, AppError>(( + history.nick, + revno, + revid_hex, + tip_change_rec.map(ChangeView::from), + entries, + path_display, + parent_path, + normalized, + )) + }) + .await??; + + let tmpl = InventoryTemplate { + nick, + fileview_active: true, + revno, + revid_hex, + path: normalized, + path_display, + parent_path, + tip_change, + entries, + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs new file mode 100644 index 00000000..13dcf2ed --- /dev/null +++ b/src/controllers/mod.rs @@ -0,0 +1,11 @@ +pub mod annotate; +pub mod atom; +pub mod changelog; +pub mod diff; +pub mod download; +pub mod filediff; +pub mod inventory; +pub mod revision; +pub mod revlog; +pub mod search; +pub mod view; diff --git a/src/controllers/revision.rs b/src/controllers/revision.rs new file mode 100644 index 00000000..69e9aeea --- /dev/null +++ b/src/controllers/revision.rs @@ -0,0 +1,136 @@ +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use chrono::{FixedOffset, TimeZone}; +use percent_encoding::percent_decode_str; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::{Change, FileChange, FileChangeKind, History}; +use crate::util::errors::{AppError, AppResult}; +use crate::util::fmt::{hide_email, utc_iso}; + +#[derive(Template)] +#[template(path = "revision.html")] +struct RevisionTemplate { + // shared base fields + nick: String, + fileview_active: bool, + // page-specific + revno: String, + revid_hex: String, + author: String, + #[allow(dead_code)] + committer: String, + utc_iso: String, + #[allow(dead_code)] + date: String, + message: String, + parents: Vec, + added: Vec, + removed: Vec, + modified: Vec, + renamed: Vec, +} + +struct ParentView { + revno: String, + #[allow(dead_code)] + revid_hex: String, +} + +struct FileChangeView { + path: String, + old_path: Option, +} + +impl From for FileChangeView { + fn from(c: FileChange) -> Self { + FileChangeView { + path: c.path, + old_path: c.old_path, + } + } +} + +pub async fn show( + State(state): State>, + Path(idref): Path, +) -> AppResult> { + let idref = percent_decode_str(&idref).decode_utf8_lossy().into_owned(); + + let state2 = state.clone(); + let (nick, change, file_changes): (String, Change, Vec) = + tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state2.root)?; + let _lock = branch.lock_read()?; + let whole = state2.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let revid = history + .fix_revid(&idref) + .ok_or_else(|| AppError::NotFound(format!("no revision {idref}")))?; + if !history.whole.index.contains_key(&revid) { + return Err(AppError::NotFound(format!( + "revision {idref} not in branch" + ))); + } + let changes = history.get_changes(&branch, std::slice::from_ref(&revid))?; + let change = changes + .into_iter() + .next() + .ok_or_else(|| AppError::NotFound("revision data missing".into()))?; + let file_changes = history.get_file_changes(&branch, &revid)?; + Ok::<_, AppError>((history.nick, change, file_changes)) + }) + .await??; + + let tz = FixedOffset::east_opt(change.timezone).unwrap_or(FixedOffset::east_opt(0).unwrap()); + let date = tz + .timestamp_opt(change.timestamp as i64, 0) + .single() + .map(|d| d.format("%Y-%m-%d %H:%M:%S %z").to_string()) + .unwrap_or_default(); + + let mut added = Vec::new(); + let mut removed = Vec::new(); + let mut modified = Vec::new(); + let mut renamed = Vec::new(); + for f in file_changes { + let view = FileChangeView::from(f.clone()); + match f.kind { + FileChangeKind::Added => added.push(view), + FileChangeKind::Removed => removed.push(view), + FileChangeKind::Modified => modified.push(view), + FileChangeKind::Renamed | FileChangeKind::Copied => renamed.push(view), + FileChangeKind::KindChanged => modified.push(view), + } + } + + let tmpl = RevisionTemplate { + nick, + fileview_active: false, + revno: change.revno, + revid_hex: String::from_utf8_lossy(change.revid.as_bytes()).into_owned(), + author: hide_email(&change.committer), + utc_iso: utc_iso(change.timestamp, change.timezone), + committer: change.committer, + date, + message: change.message, + parents: change + .parents + .into_iter() + .map(|(p, revno)| ParentView { + revno, + revid_hex: String::from_utf8_lossy(p.as_bytes()).into_owned(), + }) + .collect(), + added, + removed, + modified, + renamed, + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/revlog.rs b/src/controllers/revlog.rs new file mode 100644 index 00000000..ae3d81e5 --- /dev/null +++ b/src/controllers/revlog.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::Json; +use breezyshim::branch::Branch; +use breezyshim::revisionid::RevisionId; +use percent_encoding::percent_decode_str; +use serde::Serialize; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; + +#[derive(Serialize)] +pub struct RevLogResponse { + revid: String, + revno: String, + committer: String, + timestamp: f64, + message: String, + short_message: String, + parents: Vec, + tags: Vec, + file_changes: Vec, +} + +#[derive(Serialize)] +struct ParentEntry { + revid: String, + revno: String, +} + +#[derive(Serialize)] +struct FileChangeEntry { + kind: &'static str, + path: String, + old_path: Option, +} + +/// GET /+revlog/:revid — machine-readable JSON for a single revision. +pub async fn show( + State(state): State>, + Path(revid_enc): Path, +) -> AppResult> { + let revid = RevisionId::from( + percent_decode_str(&revid_enc) + .decode_utf8_lossy() + .into_owned() + .into_bytes(), + ); + let response = tokio::task::spawn_blocking(move || -> AppResult { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + if !history.whole.index.contains_key(&revid) { + return Err(AppError::NotFound(format!( + "revision {} not in branch", + String::from_utf8_lossy(revid.as_bytes()) + ))); + } + let change = history + .get_changes(&branch, std::slice::from_ref(&revid))? + .into_iter() + .next() + .ok_or_else(|| AppError::NotFound("revision data missing".into()))?; + let file_changes = history.get_file_changes(&branch, &revid)?; + Ok(RevLogResponse { + revid: String::from_utf8_lossy(change.revid.as_bytes()).into_owned(), + revno: change.revno, + committer: change.committer, + timestamp: change.timestamp, + message: change.message, + short_message: change.short_message, + parents: change + .parents + .into_iter() + .map(|(rid, revno)| ParentEntry { + revid: String::from_utf8_lossy(rid.as_bytes()).into_owned(), + revno, + }) + .collect(), + tags: change.tags, + file_changes: file_changes + .into_iter() + .map(|f| FileChangeEntry { + kind: match f.kind { + crate::history::FileChangeKind::Added => "added", + crate::history::FileChangeKind::Removed => "removed", + crate::history::FileChangeKind::Modified => "modified", + crate::history::FileChangeKind::Renamed => "renamed", + crate::history::FileChangeKind::Copied => "copied", + crate::history::FileChangeKind::KindChanged => "kind-changed", + }, + path: f.path, + old_path: f.old_path, + }) + .collect(), + }) + }) + .await??; + Ok(Json(response)) +} diff --git a/src/controllers/search.rs b/src/controllers/search.rs new file mode 100644 index 00000000..6066b5ee --- /dev/null +++ b/src/controllers/search.rs @@ -0,0 +1,43 @@ +use askama::Template; +use axum::extract::Query; +use axum::response::Html; +use serde::Deserialize; + +use crate::util::errors::AppResult; + +#[derive(Deserialize, Default)] +pub struct SearchQuery { + #[serde(default)] + pub q: Option, +} + +#[derive(Template)] +#[template(path = "search.html")] +struct SearchTemplate { + query: String, + /// Whether search is available at all (the `breezy.plugins.search` plugin + /// is optional and we do not integrate with it yet). + available: bool, + results: Vec, +} + +struct ResultRow { + revno: String, + short_message: String, +} + +/// GET /search?q=... — search across commit messages / file contents. +/// +/// The Python loggerhead wires this up to the bzr-search plugin, which is +/// optional and rarely installed. For now the Rust port reports that search +/// is unavailable; returning an empty result set instead of 500-ing is the +/// same user-visible behaviour as a Python install without the plugin. +pub async fn show(Query(q): Query) -> AppResult> { + let query = q.q.unwrap_or_default(); + let tmpl = SearchTemplate { + query, + available: false, + results: Vec::new(), + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/view.rs b/src/controllers/view.rs new file mode 100644 index 00000000..a5a6d051 --- /dev/null +++ b/src/controllers/view.rs @@ -0,0 +1,113 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::tree::{Kind, Tree}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::highlight::highlight; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; + +#[derive(Template)] +#[template(path = "view.html")] +struct ViewTemplate { + // shared base + nick: String, + fileview_active: bool, + // page-specific + revno: String, + path: String, + lines: Vec, + #[allow(dead_code)] + background: Option, + is_binary: bool, +} + +struct Line { + n: usize, + html: String, +} + +/// `GET /view/:revno/*path` — view file at a specific revision. +/// Use `/view/head:/path` for the branch tip. +pub async fn show( + State(state): State>, + Path((revno, path)): Path<(String, String)>, +) -> AppResult> { + render(state, Some(revno), path).await +} + +async fn render( + state: Arc, + revno_req: Option, + path: String, +) -> AppResult> { + let path_norm = path.trim_matches('/').to_string(); + if path_norm.is_empty() { + return Err(AppError::Other("no filename provided".into())); + } + let path_for_task = path_norm.clone(); + + let (nick, content, revno) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state.root)?; + let _lock = branch.lock_read()?; + let whole = state.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + let revid = match revno_req.as_deref() { + Some(r) => history + .fix_revid(r) + .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?, + None => history.last_revid.clone(), + }; + let revno = history.whole.get_revno(&revid); + let repo = branch.repository(); + let tree = repo.revision_tree(&revid)?; + let p = PathBuf::from(&path_for_task); + if !tree.has_filename(&p) { + return Err(AppError::NotFound(format!("{path_for_task} not found"))); + } + match tree.kind(&p) { + Ok(Kind::File) => {} + Ok(_) => { + return Err(AppError::Other(format!( + "{path_for_task} is not a regular file" + ))) + } + Err(e) => return Err(AppError::from(e)), + } + let bytes = tree.get_file_text(&p)?; + Ok::<_, AppError>((history.nick, bytes, revno)) + }) + .await??; + + let (lines, background, is_binary) = match std::str::from_utf8(&content) { + Ok(text) => { + let hl = highlight(&path_norm, text); + let ls: Vec = hl + .lines + .into_iter() + .enumerate() + .map(|(i, html)| Line { n: i + 1, html }) + .collect(); + (ls, hl.background, false) + } + Err(_) => (Vec::new(), None, true), + }; + + let tmpl = ViewTemplate { + nick, + fileview_active: true, + revno, + path: path_norm, + lines, + background, + is_binary, + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/highlight.rs b/src/highlight.rs new file mode 100644 index 00000000..73886587 --- /dev/null +++ b/src/highlight.rs @@ -0,0 +1,134 @@ +//! Syntax highlighting via [`syntect`], replacing the Python loggerhead's +//! Pygments wrapper in `loggerhead/highlight.py`. + +use std::sync::OnceLock; + +use syntect::highlighting::{Theme, ThemeSet}; +use syntect::html::{ + append_highlighted_html_for_styled_line, start_highlighted_html_snippet, IncludeBackground, +}; +use syntect::parsing::{SyntaxReference, SyntaxSet}; +use syntect::util::LinesWithEndings; + +/// Cap matching loggerhead's 512KB threshold — above this we return escaped +/// plain text instead of paying the highlighting cost. +const MAX_HIGHLIGHT_BYTES: usize = 512 * 1024; + +fn assets() -> &'static (SyntaxSet, Theme) { + static CELL: OnceLock<(SyntaxSet, Theme)> = OnceLock::new(); + CELL.get_or_init(|| { + let ss = SyntaxSet::load_defaults_newlines(); + let ts = ThemeSet::load_defaults(); + let theme = ts.themes["InspiredGitHub"].clone(); + (ss, theme) + }) +} + +/// Pick a syntax by filename; fall back to plain text. +fn syntax_for<'a>(ss: &'a SyntaxSet, filename: &str, first_line: &str) -> &'a SyntaxReference { + ss.find_syntax_for_file(filename) + .ok() + .flatten() + .or_else(|| ss.find_syntax_by_first_line(first_line)) + .unwrap_or_else(|| ss.find_syntax_plain_text()) +} + +/// Result of highlighting: HTML fragments, one per source line, without the +/// outer `
`. The caller is responsible for wrapping in `
` or a
+/// table of (line number, content) cells.
+pub struct Highlighted {
+    /// One ``-wrapped HTML string per source line.
+    pub lines: Vec,
+    /// Suggested background color (rgba) for wrapping, from the theme.
+    pub background: Option,
+}
+
+/// Highlight `content` as the language inferred from `filename`. If the file
+/// is larger than [`MAX_HIGHLIGHT_BYTES`] or syntect fails, returns escaped
+/// plain text (one HTML-escaped line per input line).
+pub fn highlight(filename: &str, content: &str) -> Highlighted {
+    if content.len() > MAX_HIGHLIGHT_BYTES {
+        return plain(content);
+    }
+    let (ss, theme) = assets();
+    let first_line = content.lines().next().unwrap_or("");
+    let syntax = syntax_for(ss, filename, first_line);
+    let mut hl = syntect::easy::HighlightLines::new(syntax, theme);
+
+    let mut out = Vec::new();
+    let (_prelude, bg) = start_highlighted_html_snippet(theme);
+    for line in LinesWithEndings::from(content) {
+        let mut piece = String::new();
+        let regions = match hl.highlight_line(line, ss) {
+            Ok(r) => r,
+            Err(_) => return plain(content),
+        };
+        if append_highlighted_html_for_styled_line(®ions, IncludeBackground::No, &mut piece)
+            .is_err()
+        {
+            return plain(content);
+        }
+        // Strip the trailing newline that `LinesWithEndings` keeps so the
+        // renderer can lay lines out in its own table.
+        let piece = piece.trim_end_matches('\n').to_string();
+        out.push(piece);
+    }
+    Highlighted {
+        lines: out,
+        background: Some(format!("rgb({}, {}, {})", bg.r, bg.g, bg.b)),
+    }
+}
+
+fn plain(content: &str) -> Highlighted {
+    let lines = content.lines().map(html_escape::encode_safe_str).collect();
+    Highlighted {
+        lines,
+        background: None,
+    }
+}
+
+/// Very small subset of HTML escaping — enough for file-view safety.
+mod html_escape {
+    pub fn encode_safe_str(s: &str) -> String {
+        let mut out = String::with_capacity(s.len());
+        for c in s.chars() {
+            match c {
+                '&' => out.push_str("&"),
+                '<' => out.push_str("<"),
+                '>' => out.push_str(">"),
+                '"' => out.push_str("""),
+                '\'' => out.push_str("'"),
+                _ => out.push(c),
+            }
+        }
+        out
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn plain_text_escapes() {
+        let h = highlight("README", "a < b\n1 & 2\n");
+        assert_eq!(h.lines.len(), 2);
+        // Syntect can also emit HTML for plain text; just check it's valid-ish.
+        assert!(h.lines[0].contains("a ") || h.lines[0].contains("<"));
+    }
+
+    #[test]
+    fn rust_keywords_get_spans() {
+        let src = "fn main() {}\n";
+        let h = highlight("main.rs", src);
+        assert!(h.lines[0].contains(",
+    /// Revisions that list this revision among their parents.
+    pub children: Vec,
+}
+
+/// The result of computing the whole-branch history graph.
+///
+/// `entries` is laid out in merge-sort order (tip first). `index` gives the
+/// position of each revid in `entries` for O(1) lookup.
+#[derive(Debug, Clone)]
+pub struct WholeHistory {
+    pub entries: Vec,
+    pub index: HashMap,
+}
+
+impl WholeHistory {
+    /// Compute the whole-history graph for a branch, mirroring
+    /// `wholehistory.compute_whole_history_data`.
+    pub fn compute(branch: &dyn Branch) -> Result {
+        let repo = branch.repository();
+        let last_revid = branch.last_revision();
+        let graph: Graph = repo.get_graph();
+
+        // Build parent map from iter_ancestry, dropping ghost entries.
+        let mut parent_map: HashMap> = HashMap::new();
+        for entry in graph.iter_ancestry(std::slice::from_ref(&last_revid))? {
+            let (node, parents) = entry?;
+            if let Some(ps) = parents {
+                parent_map.insert(node, ps);
+            }
+        }
+        // Drop NULL_REVISION and any references to nodes not present in the
+        // map (_strip_NULL_ghosts).
+        let null = RevisionId::null();
+        parent_map.remove(&null);
+        let present: std::collections::HashSet = parent_map.keys().cloned().collect();
+        for parents in parent_map.values_mut() {
+            parents.retain(|p| present.contains(p));
+        }
+
+        let sorted: Vec> = if last_revid.is_null() {
+            Vec::new()
+        } else {
+            merge_sort(&parent_map, &last_revid)?
+        };
+
+        let mut entries: Vec = Vec::with_capacity(sorted.len());
+        let mut index: HashMap = HashMap::with_capacity(sorted.len());
+        for e in sorted {
+            let parents = parent_map.get(&e.node).cloned().unwrap_or_default();
+            let pos = entries.len();
+            index.insert(e.node.clone(), pos);
+            let revno_str = e.revno_str();
+            entries.push(RevInfo {
+                sequence: e.sequence,
+                revid: e.node,
+                merge_depth: e.merge_depth,
+                revno: revno_str,
+                end_of_merge: e.end_of_merge,
+                parents,
+                children: Vec::new(),
+            });
+        }
+
+        // Second pass: compute children, skipping mainline entries (matches
+        // wholehistory.py's `merge_depth == 0` skip).
+        for i in 0..entries.len() {
+            if entries[i].merge_depth == 0 {
+                continue;
+            }
+            let revid = entries[i].revid.clone();
+            let parents = entries[i].parents.clone();
+            for parent in parents {
+                if let Some(&pi) = index.get(&parent) {
+                    if !entries[pi].children.contains(&revid) {
+                        entries[pi].children.push(revid.clone());
+                    }
+                }
+            }
+        }
+
+        Ok(WholeHistory { entries, index })
+    }
+
+    pub fn len(&self) -> usize {
+        self.entries.len()
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.entries.is_empty()
+    }
+
+    /// Look up the dotted revno for a revision id; returns `"unknown"` for
+    /// missing (ghost) revisions, matching `History.get_revno`.
+    pub fn get_revno(&self, revid: &RevisionId) -> String {
+        match self.index.get(revid) {
+            Some(&i) => self.entries[i].revno.clone(),
+            None => "unknown".to_string(),
+        }
+    }
+}
+
+/// One entry in a revision's file-change list.
+#[derive(Debug, Clone)]
+pub struct FileChange {
+    pub kind: FileChangeKind,
+    /// Canonical path for display: new path for add/modify/rename-target,
+    /// old path for remove.
+    pub path: String,
+    /// When the change is a rename (or copy), this is the prior path.
+    pub old_path: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FileChangeKind {
+    Added,
+    Removed,
+    Modified,
+    Renamed,
+    Copied,
+    KindChanged,
+}
+
+/// A revision plus the rendered fields needed by the changelog template.
+#[derive(Debug, Clone)]
+pub struct Change {
+    pub revid: RevisionId,
+    pub revno: String,
+    pub committer: String,
+    pub timestamp: f64,
+    pub timezone: i32,
+    pub message: String,
+    pub short_message: String,
+    pub parents: Vec<(RevisionId, String)>, // (revid, revno)
+    pub tags: Vec,
+}
+
+/// Branch-scoped history object, analogous to `loggerhead.history.History`.
+pub struct History {
+    pub last_revid: RevisionId,
+    pub nick: String,
+    pub whole: WholeHistory,
+    pub branch_tags: HashMap>,
+}
+
+impl History {
+    /// Resolve a URL identifier (either a dotted revno like `"3"` or
+    /// `"1.2.3"`, the literal `"head:"`, or a raw revid) to a concrete
+    /// `RevisionId`. Mirrors Python `History.fix_revid`.
+    pub fn fix_revid(&self, raw: &str) -> Option {
+        if raw == "head:" {
+            return Some(self.last_revid.clone());
+        }
+        // A dotted revno consists of digits and dots only.
+        if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_digit() || c == '.') {
+            // Find the revid with this revno by scanning the whole-history
+            // entries. For small-to-medium branches this is fine; for very
+            // large ones we could build a revno→revid index at graph time.
+            for e in &self.whole.entries {
+                if e.revno == raw {
+                    return Some(e.revid.clone());
+                }
+            }
+            return None;
+        }
+        // Otherwise treat as a raw revid.
+        Some(RevisionId::from(raw.as_bytes().to_vec()))
+    }
+
+    /// Build a History. Caller must hold a read lock on the branch.
+    pub fn new(branch: &dyn Branch) -> Result {
+        let whole = WholeHistory::compute(branch)?;
+        Self::from_whole(branch, whole)
+    }
+
+    /// Build a History reusing a pre-computed `WholeHistory`.
+    pub fn from_whole(branch: &dyn Branch, whole: WholeHistory) -> Result {
+        let last_revid = branch.last_revision();
+        let nick = branch
+            .get_config()
+            .get_nickname()
+            .unwrap_or_else(|_| "".to_string());
+        let reverse_tags = branch
+            .tags()
+            .ok()
+            .and_then(|t| t.get_reverse_tag_dict().ok())
+            .unwrap_or_default();
+        let branch_tags: HashMap> = reverse_tags
+            .into_iter()
+            .map(|(rid, tags)| {
+                let mut v: Vec = tags.into_iter().collect();
+                v.sort();
+                (rid, v)
+            })
+            .collect();
+        Ok(History {
+            last_revid,
+            nick,
+            whole,
+            branch_tags,
+        })
+    }
+
+    pub fn has_revisions(&self) -> bool {
+        !self.last_revid.is_null()
+    }
+
+    /// Yield revisions along the mainline starting at `start`, walking
+    /// first-parent pointers. Matches `get_revids_from(None, start)`.
+    pub fn mainline_from(&self, start: &RevisionId) -> Vec {
+        let mut out = Vec::new();
+        let mut cur = start.clone();
+        while !cur.is_null() {
+            out.push(cur.clone());
+            let idx = match self.whole.index.get(&cur) {
+                Some(&i) => i,
+                None => break,
+            };
+            let parents = &self.whole.entries[idx].parents;
+            if parents.is_empty() {
+                break;
+            }
+            cur = parents[0].clone();
+        }
+        out
+    }
+
+    /// Compute the per-file change list between `revid` and its first
+    /// parent, using breezy's `InterTree.compare()`. For root commits the
+    /// source tree is the null tree, so everything shows up as added.
+    pub fn get_file_changes(
+        &self,
+        branch: &dyn Branch,
+        revid: &RevisionId,
+    ) -> Result, AppError> {
+        use breezyshim::intertree;
+        let repo = branch.repository();
+        let new_tree = repo.revision_tree(revid)?;
+        let parents = self
+            .whole
+            .index
+            .get(revid)
+            .map(|&i| self.whole.entries[i].parents.clone())
+            .unwrap_or_default();
+        let old_tree = if let Some(p) = parents.first() {
+            repo.revision_tree(p)?
+        } else {
+            repo.revision_tree(&RevisionId::null())?
+        };
+        let inter = intertree::get(&old_tree, &new_tree);
+        let delta = inter.compare();
+
+        let mut out = Vec::new();
+        let path_of = |c: &breezyshim::tree::TreeChange, prefer_new: bool| -> String {
+            let (old, new) = (&c.path.0, &c.path.1);
+            let chosen = if prefer_new {
+                new.as_ref().or(old.as_ref())
+            } else {
+                old.as_ref().or(new.as_ref())
+            };
+            chosen
+                .map(|p| p.to_string_lossy().into_owned())
+                .unwrap_or_default()
+        };
+
+        for c in &delta.added {
+            out.push(FileChange {
+                kind: FileChangeKind::Added,
+                path: path_of(c, true),
+                old_path: None,
+            });
+        }
+        for c in &delta.removed {
+            out.push(FileChange {
+                kind: FileChangeKind::Removed,
+                path: path_of(c, false),
+                old_path: None,
+            });
+        }
+        for c in &delta.modified {
+            out.push(FileChange {
+                kind: FileChangeKind::Modified,
+                path: path_of(c, true),
+                old_path: None,
+            });
+        }
+        for c in &delta.renamed {
+            out.push(FileChange {
+                kind: FileChangeKind::Renamed,
+                path: path_of(c, true),
+                old_path: Some(path_of(c, false)),
+            });
+        }
+        for c in &delta.copied {
+            out.push(FileChange {
+                kind: FileChangeKind::Copied,
+                path: path_of(c, true),
+                old_path: Some(path_of(c, false)),
+            });
+        }
+        for c in &delta.kind_changed {
+            out.push(FileChange {
+                kind: FileChangeKind::KindChanged,
+                path: path_of(c, true),
+                old_path: None,
+            });
+        }
+        Ok(out)
+    }
+
+    /// Fetch revision objects for the given revids and produce display
+    /// `Change` records. Ghost / null entries are skipped.
+    pub fn get_changes(
+        &self,
+        branch: &dyn Branch,
+        revids: &[RevisionId],
+    ) -> Result, AppError> {
+        let repo = branch.repository();
+        let non_null: Vec = revids.iter().filter(|r| !r.is_null()).cloned().collect();
+        let mut out = Vec::with_capacity(non_null.len());
+        for revid in non_null {
+            let rev = repo.get_revision(&revid)?;
+            let (message, short_message) = clean_message(&rev.message);
+            let parents: Vec<(RevisionId, String)> = rev
+                .parent_ids
+                .iter()
+                .map(|p| (p.clone(), self.whole.get_revno(p)))
+                .collect();
+            let tags = self.branch_tags.get(&revid).cloned().unwrap_or_default();
+            out.push(Change {
+                revid: rev.revision_id,
+                revno: self.whole.get_revno(&revid),
+                committer: rev.committer,
+                timestamp: rev.timestamp,
+                timezone: rev.timezone,
+                message,
+                short_message,
+                parents,
+                tags,
+            });
+        }
+        Ok(out)
+    }
+}
+
+/// Lightly reflow / normalize a commit message and produce a short form,
+/// mirroring `history.clean_message`.
+fn clean_message(message: &str) -> (String, String) {
+    let trimmed = message.trim_start();
+    let lines: Vec<&str> = trimmed.lines().collect();
+    if lines.is_empty() {
+        return (String::new(), String::new());
+    }
+    let first = lines[0];
+    let short = if first.chars().count() > 60 {
+        let mut s: String = first.chars().take(60).collect();
+        s.push_str("...");
+        s
+    } else {
+        first.to_string()
+    };
+    (trimmed.to_string(), short)
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 00000000..256de05d
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,15 @@
+//! Loggerhead: a web viewer for Bazaar/Breezy branches.
+//!
+//! This crate provides the HTTP server, routing, templates, and VCS glue.
+//! VCS access is via [`breezyshim`], a PyO3 wrapper around the Python
+//! `breezy` library — all VCS calls are synchronous and must run on the
+//! blocking thread pool.
+
+pub mod app;
+pub mod breezy;
+pub mod cache;
+pub mod config;
+pub mod controllers;
+pub mod highlight;
+pub mod history;
+pub mod util;
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 00000000..bd96ce25
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,66 @@
+use std::net::SocketAddr;
+use std::sync::Arc;
+
+use clap::Parser;
+use tracing_subscriber::{EnvFilter, FmtSubscriber};
+
+use loggerhead::app::{build_router, AppState};
+use loggerhead::cache::RevInfoDiskCache;
+use loggerhead::config::Args;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    let args = Args::parse();
+
+    // Precedence: RUST_LOG env var → --log-level flag → info.
+    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
+        args.log_level
+            .as_deref()
+            .unwrap_or("info")
+            .parse::()
+            .unwrap_or_else(|_| EnvFilter::new("info"))
+    });
+    FmtSubscriber::builder().with_env_filter(filter).init();
+
+    if let Some(dir) = &args.log_folder {
+        tracing::info!(path = ?dir, "--log-folder accepted but file logging is not yet implemented; logging to stderr");
+    }
+
+    // Initialize breezy/Python once on the main thread.
+    breezyshim::init();
+
+    let disk_cache = match &args.cachepath {
+        Some(p) => match RevInfoDiskCache::open(p) {
+            Ok(c) => Some(Arc::new(c)),
+            Err(e) => {
+                tracing::warn!(path = ?p, error = %e, "disabling disk cache");
+                None
+            }
+        },
+        None => None,
+    };
+
+    let addr = SocketAddr::new(args.host, args.port);
+    let static_dir = args.static_dir.clone().unwrap_or_else(|| {
+        // Default to the sibling Python loggerhead static dir so a checkout
+        // "just works" without a separate install step.
+        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("loggerhead/static")
+    });
+    if !static_dir.is_dir() {
+        tracing::warn!(path = ?static_dir, "static asset directory not found; /static/* will 404");
+    }
+
+    let state = Arc::new(AppState::new(
+        args.root.clone(),
+        disk_cache,
+        args.export_tarballs,
+        static_dir,
+    ));
+
+    let router = build_router(state);
+
+    tracing::info!(%addr, root = %args.root, "loggerhead starting");
+    let listener = tokio::net::TcpListener::bind(addr).await?;
+    axum::serve(listener, router).await?;
+    Ok(())
+}
diff --git a/src/util/errors.rs b/src/util/errors.rs
new file mode 100644
index 00000000..c6cc1019
--- /dev/null
+++ b/src/util/errors.rs
@@ -0,0 +1,43 @@
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+
+/// Error type surfaced by controllers and returned to axum.
+#[derive(Debug, thiserror::Error)]
+pub enum AppError {
+    #[error("branch not found: {0}")]
+    NotFound(String),
+
+    #[error("breezy error: {0}")]
+    Breezy(Box),
+
+    #[error("template render: {0}")]
+    Template(#[from] askama::Error),
+
+    #[error("join error: {0}")]
+    Join(#[from] tokio::task::JoinError),
+
+    #[error("url parse: {0}")]
+    Url(#[from] url::ParseError),
+
+    #[error("{0}")]
+    Other(String),
+}
+
+impl IntoResponse for AppError {
+    fn into_response(self) -> Response {
+        let (status, msg) = match &self {
+            AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
+            _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
+        };
+        tracing::error!(error = %self, "request failed");
+        (status, msg).into_response()
+    }
+}
+
+pub type AppResult = Result;
+
+impl From for AppError {
+    fn from(e: breezyshim::error::Error) -> Self {
+        AppError::Breezy(Box::new(e))
+    }
+}
diff --git a/src/util/fmt.rs b/src/util/fmt.rs
new file mode 100644
index 00000000..046b99cb
--- /dev/null
+++ b/src/util/fmt.rs
@@ -0,0 +1,83 @@
+//! Small formatters used by templates: author-display scrubbing, relative
+//! dates, etc. Keep this dumb/stateless so templates can call through.
+
+use chrono::{DateTime, FixedOffset, TimeZone, Utc};
+
+/// Strip the `` part from a `"Name "` committer string,
+/// matching Python loggerhead's `util.hide_email`.
+pub fn hide_email(author: &str) -> String {
+    match author.find('<') {
+        Some(i) => author[..i].trim().to_string(),
+        None => author.trim().to_string(),
+    }
+}
+
+/// Render a Breezy timestamp+timezone as the "2026-04-21 17:09:44 UTC" form
+/// Python loggerhead uses as the `` tooltip.
+pub fn utc_iso(timestamp: f64, timezone: i32) -> String {
+    let tz = FixedOffset::east_opt(timezone).unwrap_or(FixedOffset::east_opt(0).unwrap());
+    match tz.timestamp_opt(timestamp as i64, 0).single() {
+        Some(dt) => dt
+            .with_timezone(&Utc)
+            .format("%Y-%m-%d %H:%M:%S UTC")
+            .to_string(),
+        None => String::new(),
+    }
+}
+
+/// Render a Breezy timestamp as a human-readable relative-time string,
+/// mirroring Python loggerhead's `util.approximate_date`:
+///   "just now", "N minutes ago", "N hours ago", "yesterday at ...",
+///   "N days ago", "YYYY-MM-DD".
+pub fn approximate_date(timestamp: f64) -> String {
+    let Some(dt) = DateTime::::from_timestamp(timestamp as i64, 0) else {
+        return String::new();
+    };
+    let now = Utc::now();
+    let delta = now - dt;
+    let secs = delta.num_seconds();
+    if secs < 60 {
+        "just now".to_string()
+    } else if secs < 3600 {
+        let m = secs / 60;
+        format!("{m} minute{} ago", if m == 1 { "" } else { "s" })
+    } else if secs < 86_400 {
+        let h = secs / 3600;
+        format!("{h} hour{} ago", if h == 1 { "" } else { "s" })
+    } else if secs < 7 * 86_400 {
+        let d = secs / 86_400;
+        format!("{d} day{} ago", if d == 1 { "" } else { "s" })
+    } else {
+        dt.format("%Y-%m-%d").to_string()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn hide_email_strips_angle_block() {
+        assert_eq!(
+            hide_email("Jelmer Vernooij "),
+            "Jelmer Vernooij"
+        );
+    }
+
+    #[test]
+    fn hide_email_passthrough_when_no_angle() {
+        assert_eq!(hide_email("Anonymous"), "Anonymous");
+    }
+
+    #[test]
+    fn approximate_date_just_now() {
+        let now = Utc::now().timestamp() as f64;
+        assert_eq!(approximate_date(now), "just now");
+    }
+
+    #[test]
+    fn approximate_date_hours_ago() {
+        let t = (Utc::now() - chrono::Duration::hours(3)).timestamp() as f64;
+        assert_eq!(approximate_date(t), "3 hours ago");
+    }
+}
diff --git a/src/util/mod.rs b/src/util/mod.rs
new file mode 100644
index 00000000..9bc50363
--- /dev/null
+++ b/src/util/mod.rs
@@ -0,0 +1,2 @@
+pub mod errors;
+pub mod fmt;
diff --git a/templates/annotate.html b/templates/annotate.html
new file mode 100644
index 00000000..164348dd
--- /dev/null
+++ b/templates/annotate.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+{% block title %}{{ nick }} : annotate {{ path }} at revision {{ revno }}{% endblock %}
+
+{% block head_extras %}
+
+
+{% endblock %}
+
+{% block heading %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+ +{% for l in lines %} + + + + + +{% endfor %} +
{{ l.revno }}
{{ l.n }}
{{ l.text }}
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 00000000..f40cb1d1 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,49 @@ + + + + + +{% block title %}{{ nick }}{% endblock %} + + + + + +{% block head_extras %}{% endblock %} + + + + +

+{{ nick }} +

+ + + +
+
+ +{% block heading %}{% endblock %} + +{% block content %}{% endblock %} + + +
+ + diff --git a/templates/changelog.html b/templates/changelog.html new file mode 100644 index 00000000..bf7755b7 --- /dev/null +++ b/templates/changelog.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : changes{% endblock %} + +{% block head_extras %} + + +{% endblock %} + +{% block heading %} +
+To get this branch, use:
+bzr branch {{ served_url }} +
+{% endblock %} + +{% block content %} + +

expand all expand all

+ + + + + + + + + + + + + +{% for c in changes %} + + + + + + + + + + +{% endfor %} +
Rev SummaryAuthorsDateDiffFiles
+
+
+ + +{{ c.author }}{{ c.relative_date }}DiffFiles
+{% endblock %} diff --git a/templates/filediff.html b/templates/filediff.html new file mode 100644 index 00000000..97c61f08 --- /dev/null +++ b/templates/filediff.html @@ -0,0 +1,43 @@ + + + + +diff — {{ path }} — loggerhead + + + +

{{ path }}

+

overview · changes

+

from {{ old_revid_hex }} to {{ new_revid_hex }}

+ +{% if chunks.is_empty() %} +

No differences.

+{% else %} +{% for chunk in chunks %} +
+
{{ chunk.header }}
+ + {% for line in chunk.lines %} + + + + + + {% endfor %} +
{% match line.old_lineno %}{% when Some with (n) %}{{ n }}{% when None %}{% endmatch %}{% match line.new_lineno %}{% when Some with (n) %}{{ n }}{% when None %}{% endmatch %}{{ line.text }}
+
+{% endfor %} +{% endif %} + + diff --git a/templates/inventory.html b/templates/inventory.html new file mode 100644 index 00000000..df9ca407 --- /dev/null +++ b/templates/inventory.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : files for revision {{ revno }}{% endblock %} + +{% block head_extras %} + +{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} +
+
+To get this branch, use:
+bzr branch {{ nick }} +
+ + + +{% match tip_change %}{% when Some with (c) %} +
+
+
    +
  • Committer: {{ c.committer }}
  • +
  • Date: {{ c.utc_iso }}
  • +
  • Revision ID: {{ revid_hex }}
  • +
+
+
{{ c.message }}
+
+
+
+{% when None %}{% endmatch %} + + + + + + + + + + + + + +{% match parent_path %}{% when Some with (p) %} + + + +{% when None %}{% endmatch %} + +{% for e in entries %} + + + + + + + + + + +{% endfor %} +
FilenameLatest RevLast ChangedCommitterCommentSize
..
+ + + + {{ e.name }}{% if e.is_dir %}/{% endif %} +{{ e.last_revno }}{{ e.last_relative }}{{ e.last_committer }}{{ e.last_message }}{% match e.size %}{% when Some with (s) %}{{ s }} bytes{% when None %}{% endmatch %} + {% if !e.is_dir %} + View + {% endif %} + + {% if !e.is_dir %} + Download File + {% endif %} +
+
+{% endblock %} diff --git a/templates/revision.html b/templates/revision.html new file mode 100644 index 00000000..a4202907 --- /dev/null +++ b/templates/revision.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : revision {{ revno }}{% endblock %} + +{% block head_extras %} + + +{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} +

Viewing all changes in revision {{ revno }}.

+ + + +
+
+
    +
  • Committer: {{ author }}
  • +
  • Date: {{ utc_iso }}
  • +{% if !parents.is_empty() %} +
  • Parent: + {% for p in parents %}{{ p.revno }}{% if !loop.last %}, {% endif %}{% endfor %} +
  • +{% endif %} +
  • Revision ID: {{ revid_hex }}
  • +
+ +
+ +
{{ message }}
+
+ +
    +{% if !added.is_empty() %} +
      +
    • added:
    • +{% for f in added %} +
    • {{ f.path }}
    • +{% endfor %} +
    +{% endif %} +{% if !removed.is_empty() %} +
      +
    • removed:
    • +{% for f in removed %} +
    • {{ f.path }}
    • +{% endfor %} +
    +{% endif %} +{% if !modified.is_empty() %} +
      +
    • files modified:
    • +{% for f in modified %} +
    • {{ f.path }}
    • +{% endfor %} +
    +{% endif %} +{% if !renamed.is_empty() %} +
      +
    • renamed:
    • +{% for f in renamed %} +
    • {% match f.old_path %}{% when Some with (o) %}{{ o }} → {{ f.path }}{% when None %}{{ f.path }}{% endmatch %}
    • +{% endfor %} +
    +{% endif %} +
+
+
+ +

expand all expand all

+ +

added added

+

removed removed

+
+ +
+{% for f in modified %} + +{% endfor %} +
+{% endblock %} diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 00000000..4a71a707 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,32 @@ + + + + +search — loggerhead + + + +

Search

+

overview · changes

+
+ + +
+{% if !available %} +

Search is not available in this loggerhead build. The breezy.plugins.search plugin must be installed and the branch indexed.

+{% else %} +{% if results.is_empty() %} +

No results.

+{% else %} +
    + {% for r in results %} +
  • {{ r.revno }} — {{ r.short_message }}
  • + {% endfor %} +
+{% endif %} +{% endif %} + + diff --git a/templates/view.html b/templates/view.html new file mode 100644 index 00000000..5f9991ec --- /dev/null +++ b/templates/view.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}{{ nick }} : contents of {{ path }} at revision {{ revno }}{% endblock %} + +{% block head_extras %} + + +{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} + + +
+{% if is_binary %} +

(binary file, not displayed)

+{% else %} + + + + + +
+
{% for l in lines %}{{ l.n }}
+{% endfor %}
+
+
{% for l in lines %}{{ l.html|safe }}
+{% endfor %}
+
+{% endif %} +
+{% endblock %} From 0fa3a4bae3c7a5a1face0b7be43f55626aec2a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Tue, 21 Apr 2026 18:51:21 +0100 Subject: [PATCH 03/19] Add parity features: /changes filter_path, /changes/, /revision//, directory listing --- src/app.rs | 52 ++++++++++++++- src/controllers/changelog.rs | 68 ++++++++++++++++++-- src/controllers/directory.rs | 120 +++++++++++++++++++++++++++++++++++ src/controllers/mod.rs | 1 + src/controllers/revision.rs | 16 +++++ templates/directory.html | 25 ++++++++ 6 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 src/controllers/directory.rs create mode 100644 templates/directory.html diff --git a/src/app.rs b/src/app.rs index 69183674..723259e5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,8 +16,11 @@ use crate::util::errors::AppError; /// Shared application state handed to every handler. pub struct AppState { - /// Location (filesystem path or URL) of the branch being served. + /// Location (filesystem path or URL) of the branch, or a directory + /// containing branches (see `serve_mode`). pub root: String, + /// Whether `root` is a single branch or a directory of branches. + pub serve_mode: ServeMode, /// Cached whole-branch graph, invalidated when the branch tip changes. pub whole_history_cache: Cache>, /// Optional SQLite-backed persistent cache. @@ -36,8 +39,10 @@ impl AppState { export_tarballs: bool, static_dir: std::path::PathBuf, ) -> Self { + let serve_mode = detect_serve_mode(&root); Self { root, + serve_mode, whole_history_cache: Cache::new(10), disk_cache, export_tarballs, @@ -80,14 +85,59 @@ async fn root_redirect() -> Redirect { Redirect::permanent("/changes") } +/// Serve mode determined at startup. +#[derive(Clone, Copy, Debug)] +pub enum ServeMode { + /// `root` points directly at a single branch. The usual + /// per-branch routes (`/changes`, `/revision/…`, `/files/…`, + /// etc.) are mounted at the URL root. + Branch, + /// `root` points at a directory containing zero or more + /// branches. `/` shows a DirectoryUI-style listing; + /// `/name/...` drills into a sub-branch. (Drill-down is not + /// yet wired — it's a TODO.) + Directory, +} + +/// Detect at startup whether `root` is itself a branch or a directory +/// containing branches. Errors fall back to Branch mode — the per-branch +/// handlers will produce a sensible error on the first request. +pub fn detect_serve_mode(root: &str) -> ServeMode { + if crate::breezy::open_branch(root).is_ok() { + ServeMode::Branch + } else if std::path::Path::new(root).is_dir() { + ServeMode::Directory + } else { + ServeMode::Branch + } +} + pub fn build_router(state: Arc) -> Router { + match state.serve_mode { + ServeMode::Directory => build_directory_router(state), + ServeMode::Branch => build_branch_router(state), + } +} + +fn build_directory_router(state: Arc) -> Router { + use crate::controllers::directory; + Router::new() + .route("/", get(directory::show)) + .nest_service("/static", ServeDir::new(&state.static_dir)) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +fn build_branch_router(state: Arc) -> Router { use crate::controllers::{ annotate, atom, diff, download, filediff, inventory, revlog, search, view, }; Router::new() .route("/", get(root_redirect)) .route("/changes", get(changelog::show)) + .route("/changes/:revno", get(changelog::show_from)) .route("/revision/:revid", get(revision::show)) + .route("/revision/:revid/*path", get(revision::show_with_path)) .route("/diff/:new_revid", get(diff::show_one)) .route("/diff/:new_revid/:old_revid", get(diff::show_two)) .route( diff --git a/src/controllers/changelog.rs b/src/controllers/changelog.rs index df61d088..e84f9cc2 100644 --- a/src/controllers/changelog.rs +++ b/src/controllers/changelog.rs @@ -1,9 +1,13 @@ +use std::path::PathBuf; use std::sync::Arc; use askama::Template; -use axum::extract::{Query, State}; +use axum::extract::{Path, Query, State}; use axum::response::Html; use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use breezyshim::revisionid::RevisionId; +use breezyshim::tree::Tree; use serde::Deserialize; use crate::app::AppState; @@ -16,6 +20,8 @@ use crate::util::fmt::{approximate_date, hide_email, utc_iso}; #[derive(Debug, Deserialize, Default)] pub struct ChangelogQuery { pub start_revid: Option, + /// Restrict the log to revisions that touched this path. + pub filter_path: Option, } const PAGE_SIZE: usize = 20; @@ -52,20 +58,72 @@ impl From for ChangeView { } } +/// `GET /changes` — full mainline from the branch tip. pub async fn show( State(state): State>, - Query(_q): Query, + Query(q): Query, ) -> AppResult> { + render(state, None, q).await +} + +/// `GET /changes/:revno` — log starting from `revno` (Python loggerhead's +/// "view history from revision N" link). +pub async fn show_from( + State(state): State>, + Path(revno): Path, + Query(q): Query, +) -> AppResult> { + render(state, Some(revno), q).await +} + +async fn render( + state: Arc, + start_ref: Option, + q: ChangelogQuery, +) -> AppResult> { + let filter_path = q.filter_path.clone(); let state2 = state.clone(); let (nick, last_revno, changes) = tokio::task::spawn_blocking(move || -> AppResult<_> { let branch = open_branch(&state2.root)?; let _lock = branch.lock_read()?; let whole = state2.load_whole_history(&branch)?; let history = History::from_whole(&branch, (*whole).clone())?; - let mainline = history.mainline_from(&history.last_revid); - let page: Vec<_> = mainline.into_iter().take(PAGE_SIZE).collect(); + // Resolve starting point: explicit URL segment, query param, then tip. + let start_revid = match start_ref.as_deref().or(q.start_revid.as_deref()) { + Some(r) => history + .fix_revid(r) + .ok_or_else(|| AppError::NotFound(format!("no revision {r}")))?, + None => history.last_revid.clone(), + }; + let mainline = history.mainline_from(&start_revid); + + // Optional file filter — keep only revisions that touched the + // path. A revision R touched `path` iff its tree's recorded + // "last revision for this file" (`get_file_revision`) points + // at R itself. + let filtered: Vec = if let Some(fp) = filter_path.as_deref() { + let repo = branch.repository(); + let path = PathBuf::from(fp); + let mut out = Vec::new(); + for rid in &mainline { + if rid.is_null() { + continue; + } + let tree = repo.revision_tree(rid)?; + if let Ok(file_rev) = tree.get_file_revision(&path) { + if file_rev == *rid { + out.push(rid.clone()); + } + } + } + out + } else { + mainline + }; + + let page: Vec<_> = filtered.into_iter().take(PAGE_SIZE).collect(); let changes = history.get_changes(&branch, &page)?; - let last_revno = history.whole.get_revno(&history.last_revid); + let last_revno = history.whole.get_revno(&start_revid); Ok::<_, AppError>((history.nick, last_revno, changes)) }) .await??; diff --git a/src/controllers/directory.rs b/src/controllers/directory.rs new file mode 100644 index 00000000..6dec2643 --- /dev/null +++ b/src/controllers/directory.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use askama::Template; +use axum::extract::State; +use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::repository::Repository; +use chrono::{DateTime, Utc}; + +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::util::errors::{AppError, AppResult}; +use crate::util::fmt::hide_email; + +#[derive(Template)] +#[template(path = "directory.html")] +struct DirectoryTemplate { + // base-template fields — `nick` is the root name here + nick: String, + fileview_active: bool, + entries: Vec, +} + +struct Entry { + name: String, + /// Href relative to the root — either a sub-branch view or a + /// drill-down into a deeper listing. + href: String, + /// True if this entry is itself a branch (so the row gets the + /// revision info). + is_branch: bool, + /// Last revision committer + ISO date, shown only for branches. + last_committer: String, + last_date: String, +} + +/// `GET /` when the configured root is a directory rather than a branch. +/// +/// Currently this only renders the listing; drill-down into sub-branches +/// (the old Python `BranchesFromTransportServer`) is not yet implemented +/// because axum's Router is built at startup and re-dispatching per +/// request requires either dynamic routing or per-controller support +/// for a branch sub-path. For single-branch installs, point +/// `loggerhead-serve` directly at the branch directory. +pub async fn show(State(state): State>) -> AppResult> { + let root_path = std::path::PathBuf::from(&state.root); + let root_name = root_path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| state.root.clone()); + + let state2 = state.clone(); + let entries = tokio::task::spawn_blocking(move || -> AppResult> { + let mut out: Vec = Vec::new(); + let read = std::fs::read_dir(&state2.root) + .map_err(|e| AppError::Other(format!("read_dir {}: {e}", state2.root)))?; + let mut names: Vec = read + .filter_map(|e| e.ok()) + .filter_map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + if n.starts_with('.') { + None + } else if e.file_type().ok().is_some_and(|t| t.is_dir()) { + Some(n) + } else { + None + } + }) + .collect(); + names.sort_by_key(|s| s.to_lowercase()); + + for name in names { + let child_path = format!("{}/{}", state2.root.trim_end_matches('/'), name); + let (is_branch, last_committer, last_date) = match open_branch(&child_path) { + Ok(branch) => { + let (committer, date) = branch + .lock_read() + .ok() + .map(|_| { + let rid = branch.last_revision(); + if rid.is_null() { + return (String::new(), String::new()); + } + let repo = branch.repository(); + match repo.get_revision(&rid) { + Ok(rev) => { + let committer = hide_email(&rev.committer); + let date = + DateTime::::from_timestamp(rev.timestamp as i64, 0) + .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_default(); + (committer, date) + } + Err(_) => (String::new(), String::new()), + } + }) + .unwrap_or_default(); + (true, committer, date) + } + Err(_) => (false, String::new(), String::new()), + }; + out.push(Entry { + href: format!("/{name}/"), + name, + is_branch, + last_committer, + last_date, + }); + } + Ok(out) + }) + .await??; + + let tmpl = DirectoryTemplate { + nick: root_name, + fileview_active: false, + entries, + }; + Ok(Html(tmpl.render()?)) +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 13dcf2ed..bd629a6c 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -2,6 +2,7 @@ pub mod annotate; pub mod atom; pub mod changelog; pub mod diff; +pub mod directory; pub mod download; pub mod filediff; pub mod inventory; diff --git a/src/controllers/revision.rs b/src/controllers/revision.rs index 69e9aeea..24e567f2 100644 --- a/src/controllers/revision.rs +++ b/src/controllers/revision.rs @@ -56,10 +56,26 @@ impl From for FileChangeView { } } +/// `GET /revision/:revid` — render the revision page. pub async fn show( State(state): State>, Path(idref): Path, ) -> AppResult> { + render(state, idref).await +} + +/// `GET /revision/:revid/*path` — same page; the path is used by the +/// anchor in the URL, which Python loggerhead links to from the file +/// list inside each revision view. Our template already assigns +/// `id=""` to each diff box. +pub async fn show_with_path( + State(state): State>, + Path((idref, _path)): Path<(String, String)>, +) -> AppResult> { + render(state, idref).await +} + +async fn render(state: Arc, idref: String) -> AppResult> { let idref = percent_decode_str(&idref).decode_utf8_lossy().into_owned(); let state2 = state.clone(); diff --git a/templates/directory.html b/templates/directory.html new file mode 100644 index 00000000..3d5764bd --- /dev/null +++ b/templates/directory.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}{{ nick }}{% endblock %} + +{% block heading %} + +{% endblock %} + +{% block content %} + + + + + + +{% for e in entries %} + + + + + +{% endfor %} +
NameLast committerLast commit
{{ e.name }}{% if !e.is_branch %}/{% endif %}{% if e.is_branch %}{{ e.last_committer }}{% endif %}{% if e.is_branch %}{{ e.last_date }}{% endif %}
+{% endblock %} From 733efe6cb5b64e9bdb5d6e8416fe2785c236d41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Tue, 21 Apr 2026 19:13:26 +0100 Subject: [PATCH 04/19] Directory drill-down, search plugin wiring, diff.js contract --- Cargo.lock | 1 + Cargo.toml | 1 + src/app.rs | 100 +++++++++++++++++++++++++++++++---- src/controllers/annotate.rs | 3 ++ src/controllers/changelog.rs | 2 + src/controllers/directory.rs | 3 ++ src/controllers/filediff.rs | 14 ++--- src/controllers/inventory.rs | 7 ++- src/controllers/revision.rs | 43 +++++++++++++++ src/controllers/search.rs | 87 +++++++++++++++++++++++++----- src/controllers/view.rs | 3 ++ templates/annotate.html | 12 ++--- templates/base.html | 12 +++-- templates/changelog.html | 14 ++--- templates/directory.html | 1 + templates/filediff.html | 63 ++++++++-------------- templates/inventory.html | 12 ++--- templates/revision.html | 25 +++++---- templates/search.html | 35 ++++++------ templates/view.html | 10 ++-- 20 files changed, 314 insertions(+), 134 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c4a75e6..7ea91429 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1098,6 +1098,7 @@ dependencies = [ "reqwest", "rusqlite", "serde", + "serde_json", "similar", "syntect", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 21181289..237dd879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ url = "2" thiserror = "2" anyhow = "1" serde = { version = "1", features = ["derive"] } +serde_json = "1" moka = { version = "0.12", features = ["sync"] } num_cpus = "1" chrono = "0.4" diff --git a/src/app.rs b/src/app.rs index 723259e5..87127ef4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,10 +16,20 @@ use crate::util::errors::AppError; /// Shared application state handed to every handler. pub struct AppState { - /// Location (filesystem path or URL) of the branch, or a directory - /// containing branches (see `serve_mode`). + /// Location (filesystem path or URL) of the branch served by this + /// router instance. In directory mode each sub-branch gets its own + /// nested `AppState`, so every handler can just read this without + /// knowing whether we're in single- or multi-branch mode. pub root: String, + /// URL prefix under which this branch is served. Empty (`""`) in + /// single-branch mode; `"/"` in directory mode. Every + /// template-facing link computed by a controller is prefixed with + /// this so that output works behind an additional mount point. + pub url_prefix: String, /// Whether `root` is a single branch or a directory of branches. + /// Only meaningful on the top-level AppState; the per-branch + /// AppState created for each nested directory mount is always + /// `ServeMode::Branch`. pub serve_mode: ServeMode, /// Cached whole-branch graph, invalidated when the branch tip changes. pub whole_history_cache: Cache>, @@ -42,6 +52,7 @@ impl AppState { let serve_mode = detect_serve_mode(&root); Self { root, + url_prefix: String::new(), serve_mode, whole_history_cache: Cache::new(10), disk_cache, @@ -50,6 +61,30 @@ impl AppState { } } + /// Build a per-branch `AppState` for a sub-branch inside a + /// directory-mode deployment. Shares the disk cache and static-dir + /// with the parent; gets its own in-memory history cache. + pub fn nested(parent: &AppState, root: String, name: &str) -> Self { + Self { + root, + url_prefix: format!("/{name}"), + serve_mode: ServeMode::Branch, + whole_history_cache: Cache::new(10), + disk_cache: parent.disk_cache.clone(), + export_tarballs: parent.export_tarballs, + static_dir: parent.static_dir.clone(), + } + } + + /// Produce an absolute URL for `path` under this branch's prefix. + pub fn url(&self, path: &str) -> String { + if self.url_prefix.is_empty() { + path.to_string() + } else { + format!("{}{}", self.url_prefix, path) + } + } + /// Fetch the whole-history for `branch`'s current tip, consulting the /// in-memory LRU and optional disk cache, computing + storing on miss. /// Must be called from a blocking context (holds the GIL). @@ -81,8 +116,10 @@ impl AppState { /// Permanent redirect to `/changes`, matching Python loggerhead's root /// behaviour (see `apps/branch.py::lookup_app`). -async fn root_redirect() -> Redirect { - Redirect::permanent("/changes") +async fn root_redirect( + axum::extract::State(state): axum::extract::State>, +) -> Redirect { + Redirect::permanent(&state.url("/changes")) } /// Serve mode determined at startup. @@ -121,14 +158,59 @@ pub fn build_router(state: Arc) -> Router { fn build_directory_router(state: Arc) -> Router { use crate::controllers::directory; - Router::new() + let static_dir = state.static_dir.clone(); + let subdirs = list_subdirs(&state.root); + let top = Router::new() .route("/", get(directory::show)) - .nest_service("/static", ServeDir::new(&state.static_dir)) - .layer(TraceLayer::new_for_http()) - .with_state(state) + .with_state(state.clone()); + let mut router: Router<()> = top.nest_service("/static", ServeDir::new(&static_dir)); + // Discover branches once at startup and mount each under /. + // New branches added to the directory after startup will 404 until + // the server restarts — acceptable for a loggerhead deployment. + for name in subdirs { + let child_root = format!("{}/{}", state.root.trim_end_matches('/'), name); + if crate::breezy::open_branch(&child_root).is_err() { + continue; + } + let child_state = Arc::new(AppState::nested(&state, child_root, &name)); + let branch_router = build_branch_router_inner(child_state); + router = router.nest(&format!("/{name}"), branch_router); + } + router.layer(TraceLayer::new_for_http()) +} + +fn list_subdirs(root: &str) -> Vec { + let Ok(read) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut names: Vec = read + .filter_map(|e| e.ok()) + .filter_map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + if n.starts_with('.') { + None + } else if e.file_type().ok().is_some_and(|t| t.is_dir()) { + Some(n) + } else { + None + } + }) + .collect(); + names.sort_by_key(|s| s.to_lowercase()); + names } fn build_branch_router(state: Arc) -> Router { + let static_dir = state.static_dir.clone(); + build_branch_router_inner(state) + .nest_service("/static", ServeDir::new(&static_dir)) + .layer(TraceLayer::new_for_http()) +} + +/// Per-branch routes, without the top-level `/static` mount or tracing +/// layer. Used both directly in single-branch mode and from +/// `build_directory_router` at each `/` nesting point. +fn build_branch_router_inner(state: Arc) -> Router { use crate::controllers::{ annotate, atom, diff, download, filediff, inventory, revlog, search, view, }; @@ -154,7 +236,5 @@ fn build_branch_router(state: Arc) -> Router { .route("/atom", get(atom::show)) .route("/+revlog/:revid", get(revlog::show)) .route("/search", get(search::show)) - .nest_service("/static", ServeDir::new(&state.static_dir)) - .layer(TraceLayer::new_for_http()) .with_state(state) } diff --git a/src/controllers/annotate.rs b/src/controllers/annotate.rs index 84f46e49..64ae1bb7 100644 --- a/src/controllers/annotate.rs +++ b/src/controllers/annotate.rs @@ -20,6 +20,7 @@ struct AnnotateTemplate { // base nick: String, fileview_active: bool, + url_prefix: String, // page revno: String, path: String, @@ -44,6 +45,7 @@ pub async fn show( return Err(AppError::Other("no filename provided".into())); } let path_for_task = path_norm.clone(); + let url_prefix = state.url_prefix.clone(); let (nick, annotated, revno) = tokio::task::spawn_blocking(move || -> AppResult<_> { let branch = open_branch(&state.root)?; @@ -96,6 +98,7 @@ pub async fn show( let tmpl = AnnotateTemplate { nick, fileview_active: true, + url_prefix, revno, path: path_norm, lines: annotated, diff --git a/src/controllers/changelog.rs b/src/controllers/changelog.rs index e84f9cc2..3c4c263a 100644 --- a/src/controllers/changelog.rs +++ b/src/controllers/changelog.rs @@ -32,6 +32,7 @@ struct ChangelogTemplate { // shared base-template fields nick: String, fileview_active: bool, + url_prefix: String, served_url: String, // page-specific last_revno: String, @@ -131,6 +132,7 @@ async fn render( let tmpl = ChangelogTemplate { nick, fileview_active: false, + url_prefix: state.url_prefix.clone(), served_url: state.root.clone(), last_revno, changes: changes.into_iter().map(Into::into).collect(), diff --git a/src/controllers/directory.rs b/src/controllers/directory.rs index 6dec2643..db41725f 100644 --- a/src/controllers/directory.rs +++ b/src/controllers/directory.rs @@ -17,7 +17,9 @@ use crate::util::fmt::hide_email; struct DirectoryTemplate { // base-template fields — `nick` is the root name here nick: String, + #[allow(dead_code)] fileview_active: bool, + url_prefix: String, entries: Vec, } @@ -114,6 +116,7 @@ pub async fn show(State(state): State>) -> AppResult> let tmpl = DirectoryTemplate { nick: root_name, fileview_active: false, + url_prefix: state.url_prefix.clone(), entries, }; Ok(Html(tmpl.render()?)) diff --git a/src/controllers/filediff.rs b/src/controllers/filediff.rs index 5acf35e0..88f7fad9 100644 --- a/src/controllers/filediff.rs +++ b/src/controllers/filediff.rs @@ -17,13 +17,11 @@ use crate::util::errors::{AppError, AppResult}; #[derive(Template)] #[template(path = "filediff.html")] struct FileDiffTemplate { - path: String, - new_revid_hex: String, - old_revid_hex: String, chunks: Vec, } struct Chunk { + #[allow(dead_code)] header: String, lines: Vec, } @@ -47,14 +45,13 @@ pub async fn show( .decode_utf8_lossy() .into_owned(); - let path_for_task = path.clone(); let (old_lines, new_lines) = tokio::task::spawn_blocking(move || -> AppResult<_> { let branch = open_branch(&state.root)?; let _lock = branch.lock_read()?; let repo = branch.repository(); let new_tree = repo.revision_tree(&new_revid)?; let old_tree = repo.revision_tree(&old_revid)?; - let p = std::path::Path::new(&path_for_task); + let p = std::path::Path::new(&path); let new_lines = read_file_lines(&new_tree, p); let old_lines = read_file_lines(&old_tree, p); Ok::<_, AppError>((old_lines, new_lines)) @@ -62,12 +59,7 @@ pub async fn show( .await??; let chunks = render_chunks(&old_lines, &new_lines); - let tmpl = FileDiffTemplate { - path, - new_revid_hex: String::from_utf8_lossy(new_revid_enc.as_bytes()).into_owned(), - old_revid_hex: String::from_utf8_lossy(old_revid_enc.as_bytes()).into_owned(), - chunks, - }; + let tmpl = FileDiffTemplate { chunks }; Ok(Html(tmpl.render()?)) } diff --git a/src/controllers/inventory.rs b/src/controllers/inventory.rs index 04b7db55..9ef477cb 100644 --- a/src/controllers/inventory.rs +++ b/src/controllers/inventory.rs @@ -21,6 +21,7 @@ struct InventoryTemplate { // base nick: String, fileview_active: bool, + url_prefix: String, // page revno: String, revid_hex: String, @@ -90,6 +91,7 @@ async fn render( revno_req: Option, path: String, ) -> AppResult> { + let state_for_tmpl = state.clone(); let (nick, revno, revid_hex, tip_change, entries, path_display, parent_path, normalized) = tokio::task::spawn_blocking(move || -> AppResult<_> { let branch = open_branch(&state.root)?; @@ -182,9 +184,9 @@ async fn render( format!("{normalized}/{name}") }; let href = if is_dir { - format!("/files/{}/{}", revno, full) + format!("{}/files/{}/{}", state.url_prefix, revno, full) } else { - format!("/view/{}/{}", revno, full) + format!("{}/view/{}/{}", state.url_prefix, revno, full) }; let ch = change_by_id.get(&child_revid); Entry { @@ -244,6 +246,7 @@ async fn render( let tmpl = InventoryTemplate { nick, fileview_active: true, + url_prefix: state_for_tmpl.url_prefix.clone(), revno, revid_hex, path: normalized, diff --git a/src/controllers/revision.rs b/src/controllers/revision.rs index 24e567f2..bb4261cc 100644 --- a/src/controllers/revision.rs +++ b/src/controllers/revision.rs @@ -19,6 +19,7 @@ struct RevisionTemplate { // shared base fields nick: String, fileview_active: bool, + url_prefix: String, // page-specific revno: String, revid_hex: String, @@ -34,6 +35,11 @@ struct RevisionTemplate { removed: Vec, modified: Vec, renamed: Vec, + /// JSON map `{ "diff-N": "//" }` consumed + /// by `static/javascript/diff.js` to build `/+filediff/...` URLs. + link_data: String, + /// JSON map `{ "": "diff-N" }` for anchor → diff-box lookup. + path_to_id: String, } struct ParentView { @@ -125,9 +131,44 @@ async fn render(state: Arc, idref: String) -> AppResult> } } + // Build the JSON maps consumed by static/javascript/diff.js. + // `link_data["diff-N"]` is the `//` fragment that + // diff.js uses to build /+filediff URLs; `path_to_id` is the + // inverse anchor lookup. Each element is percent-encoded the same + // way Python's util.dq wraps it. + let new_revid_enc = percent_encoding::utf8_percent_encode( + &String::from_utf8_lossy(change.revid.as_bytes()), + percent_encoding::NON_ALPHANUMERIC, + ) + .to_string(); + let old_revid_enc = change + .parents + .first() + .map(|(p, _)| { + percent_encoding::utf8_percent_encode( + &String::from_utf8_lossy(p.as_bytes()), + percent_encoding::NON_ALPHANUMERIC, + ) + .to_string() + }) + .unwrap_or_default(); + let mut link_obj = serde_json::Map::new(); + let mut path_obj = serde_json::Map::new(); + for (i, f) in modified.iter().enumerate() { + let id = format!("diff-{i}"); + link_obj.insert( + id.clone(), + serde_json::Value::String(format!("{}/{}/{}", new_revid_enc, old_revid_enc, f.path)), + ); + path_obj.insert(f.path.clone(), serde_json::Value::String(id)); + } + let link_data = serde_json::Value::Object(link_obj).to_string(); + let path_to_id = serde_json::Value::Object(path_obj).to_string(); + let tmpl = RevisionTemplate { nick, fileview_active: false, + url_prefix: state.url_prefix.clone(), revno: change.revno, revid_hex: String::from_utf8_lossy(change.revid.as_bytes()).into_owned(), author: hide_email(&change.committer), @@ -147,6 +188,8 @@ async fn render(state: Arc, idref: String) -> AppResult> removed, modified, renamed, + link_data, + path_to_id, }; Ok(Html(tmpl.render()?)) } diff --git a/src/controllers/search.rs b/src/controllers/search.rs index 6066b5ee..4e1c480b 100644 --- a/src/controllers/search.rs +++ b/src/controllers/search.rs @@ -1,9 +1,17 @@ +use std::sync::Arc; + use askama::Template; -use axum::extract::Query; +use axum::extract::{Query, State}; use axum::response::Html; +use breezyshim::branch::Branch; +use breezyshim::revisionid::RevisionId; +use breezyshim::search::{self, Hit, SearchError}; use serde::Deserialize; -use crate::util::errors::AppResult; +use crate::app::AppState; +use crate::breezy::open_branch; +use crate::history::History; +use crate::util::errors::{AppError, AppResult}; #[derive(Deserialize, Default)] pub struct SearchQuery { @@ -14,9 +22,14 @@ pub struct SearchQuery { #[derive(Template)] #[template(path = "search.html")] struct SearchTemplate { + // base + nick: String, + fileview_active: bool, + url_prefix: String, + // page query: String, - /// Whether search is available at all (the `breezy.plugins.search` plugin - /// is optional and we do not integrate with it yet). + /// True iff the `breezy.plugins.search` plugin is importable AND the + /// branch has been indexed. available: bool, results: Vec, } @@ -26,18 +39,66 @@ struct ResultRow { short_message: String, } -/// GET /search?q=... — search across commit messages / file contents. -/// -/// The Python loggerhead wires this up to the bzr-search plugin, which is -/// optional and rarely installed. For now the Rust port reports that search -/// is unavailable; returning an empty result set instead of 500-ing is the -/// same user-visible behaviour as a Python install without the plugin. -pub async fn show(Query(q): Query) -> AppResult> { +/// GET /search?q=… — search across commit messages / file contents +/// using the `bzr-search` plugin if it's installed and the branch is +/// indexed; otherwise render a "search unavailable" notice. +pub async fn show( + State(state): State>, + Query(q): Query, +) -> AppResult> { let query = q.q.unwrap_or_default(); + let query_for_task = query.clone(); + let state_for_task = state.clone(); + + let (nick, available, results) = tokio::task::spawn_blocking(move || -> AppResult<_> { + let branch = open_branch(&state_for_task.root)?; + let _lock = branch.lock_read()?; + let whole = state_for_task.load_whole_history(&branch)?; + let history = History::from_whole(&branch, (*whole).clone())?; + + if query_for_task.is_empty() { + // Just render the form. + return Ok::<_, AppError>((history.nick, search::is_available(), Vec::new())); + } + + let hits = match search::search(&branch, &query_for_task) { + Ok(h) => h, + Err(SearchError::Unavailable) | Err(SearchError::NoIndex) => { + return Ok((history.nick, false, Vec::new())); + } + Err(SearchError::Other(e)) => return Err(AppError::from(e)), + }; + + // De-duplicate to the set of revids; expand to rows with revno/msg. + let mut seen = std::collections::HashSet::new(); + let revids: Vec = hits + .into_iter() + .map(|h| match h { + Hit::Revision(r) => r, + Hit::FileText { revision, .. } => revision, + }) + .filter(|r| seen.insert(r.clone())) + .collect(); + + let changes = history.get_changes(&branch, &revids)?; + let results: Vec = changes + .into_iter() + .map(|c| ResultRow { + revno: c.revno, + short_message: c.short_message, + }) + .collect(); + Ok((history.nick, true, results)) + }) + .await??; + let tmpl = SearchTemplate { + nick, + fileview_active: false, + url_prefix: state.url_prefix.clone(), query, - available: false, - results: Vec::new(), + available, + results, }; Ok(Html(tmpl.render()?)) } diff --git a/src/controllers/view.rs b/src/controllers/view.rs index a5a6d051..999e7967 100644 --- a/src/controllers/view.rs +++ b/src/controllers/view.rs @@ -20,6 +20,7 @@ struct ViewTemplate { // shared base nick: String, fileview_active: bool, + url_prefix: String, // page-specific revno: String, path: String, @@ -53,6 +54,7 @@ async fn render( return Err(AppError::Other("no filename provided".into())); } let path_for_task = path_norm.clone(); + let url_prefix = state.url_prefix.clone(); let (nick, content, revno) = tokio::task::spawn_blocking(move || -> AppResult<_> { let branch = open_branch(&state.root)?; @@ -103,6 +105,7 @@ async fn render( let tmpl = ViewTemplate { nick, fileview_active: true, + url_prefix, revno, path: path_norm, lines, diff --git a/templates/annotate.html b/templates/annotate.html index 164348dd..8d6049a7 100644 --- a/templates/annotate.html +++ b/templates/annotate.html @@ -8,23 +8,23 @@ {% block heading %} {% endblock %} {% block content %}
{% for l in lines %} - + diff --git a/templates/base.html b/templates/base.html index f40cb1d1..904846b6 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,7 +7,7 @@ @@ -22,15 +22,17 @@

{{ nick }}

+{% block menu %} +{% endblock %}
diff --git a/templates/changelog.html b/templates/changelog.html index bf7755b7..46f43d68 100644 --- a/templates/changelog.html +++ b/templates/changelog.html @@ -2,7 +2,7 @@ {% block title %}{{ nick }} : changes{% endblock %} {% block head_extras %} - + {% endblock %} @@ -14,7 +14,7 @@ {% endblock %} {% block content %} - +

expand all expand all

@@ -32,23 +32,23 @@ {% for c in changes %}
- + - - + + {% endfor %}
{{ l.revno }}{{ l.revno }}
{{ l.n }}
{{ l.text }}
{{ c.author }} {{ c.relative_date }}DiffFilesDiffFiles
diff --git a/templates/directory.html b/templates/directory.html index 3d5764bd..06af5c30 100644 --- a/templates/directory.html +++ b/templates/directory.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% block title %}{{ nick }}{% endblock %} +{% block menu %}{% endblock %} {% block heading %}