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