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