char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
8f07a0e
main
1use std:: collections:: BTreeMap ; 2use std:: fs; 3use std:: path::{ Path , PathBuf }; 4 5use anyhow::{ Context , Result , bail}; 6 7# [ derive ( Clone , Debug , Eq , PartialEq )] 8pub struct Repo { 9pub user : String , 10pub name : String , 11pub path : PathBuf , 12pub description : Option < String >, 13pub head : Option < String >, 14} 15 16impl Repo { 17pub fn id ( & self ) ->String { 18format! ( "{}/{}" , self . user , self . name ) 19} 20} 21 22pub fn discover ( root : & Path ) ->Result < Vec < Repo >> { 23let root = root 24. canonicalize () 25. with_context ( ||format! ( "canonicalizing repository root {}" , root. display ())) ?; 26let mut found =BTreeMap :: new (); 27 28for userin sorted_dirs ( & root) ?{ 29let Some ( user_name) = user. file_name (). and_then ( |name| name. to_str ()) else { 30continue ; 31}; 32if user_name. starts_with ( '.' ) { 33continue ; 34} 35for pathin sorted_dirs ( & user) ?{ 36let Some ( file_name) = path 37. file_name () 38. and_then ( |name| name. to_str ()) 39. map ( str:: to_owned) 40else { 41continue ; 42}; 43if file_name. starts_with ( '.' ) || !path. join ( "HEAD" ). is_file () { 44continue ; 45} 46let name = file_name 47. strip_suffix ( ".git" ) 48. unwrap_or ( & file_name) 49. to_owned (); 50if name. is_empty () { 51continue ; 52} 53 found 54. entry (( user_name. to_owned (), name. clone ())) 55. or_insert_with ( ||Repo { 56user : user_name. to_owned (), 57 name, 58description : description ( & path), 59head : head ( & path), 60 path, 61}); 62} 63} 64 65Ok ( found. into_values (). collect ()) 66} 67 68pub fn find ( root : & Path , id : & str ) ->Result < Repo > { 69let ( user, name) =parse_id ( id) ?; 70discover ( root) ? 71. into_iter () 72. find ( |repo| repo. user == user && repo. name == name) 73. with_context ( ||format! ( "repository {user}/{name} not found" )) 74} 75 76pub fn set_description ( repo : & Repo , text : & str ) ->Result <()> { 77if text. contains ([ '\r' , '\n' ]) { 78bail! ( "descriptions must fit on one line" ); 79} 80let text = text. trim (); 81let path = repo. path . join ( "description" ); 82if text. is_empty () { 83match fs:: remove_file ( & path) { 84Ok (()) =>{} 85Err ( error) if error. kind () == std:: io:: ErrorKind :: NotFound =>{} 86Err ( error) =>{ 87return Err ( error). with_context ( ||format! ( "removing {}" , path. display ())); 88} 89} 90} else { 91 fs:: write ( & path, format! ( "{text}\n" )) 92. with_context ( ||format! ( "writing {}" , path. display ())) ?; 93} 94Ok (()) 95} 96 97fn parse_id ( id : & str ) ->Result <( & str , & str )> { 98let ( user, name) = id 99. split_once ( '/' ) 100. with_context ( ||format! ( "repository must be written as user/repo, got {id:?}" )) ?; 101let name = name. strip_suffix ( ".git" ). unwrap_or ( name); 102if user. is_empty () 103 || name. is_empty () 104 || user. starts_with ( '.' ) 105 || name. starts_with ( '.' ) 106 || name. contains ( '/' ) 107{ 108bail! ( "repository must be written as user/repo, got {id:?}" ); 109} 110Ok (( user, name)) 111} 112 113fn description ( path : & Path ) ->Option < String > { 114 fs:: read_to_string ( path. join ( "description" )) 115. ok () 116. map ( |description| description. trim (). to_owned ()) 117. filter ( |description|{ 118 !description. is_empty () && !description. starts_with ( "Unnamed repository" ) 119}) 120} 121 122fn head ( path : & Path ) ->Option < String > { 123let head = fs:: read_to_string ( path. join ( "HEAD" )). ok () ?; 124let head = head. trim (); 125Some ( 126 head. strip_prefix ( "ref: refs/heads/" ) 127. unwrap_or ( head) 128. to_owned (), 129) 130. filter ( |head| !head. is_empty ()) 131} 132 133fn sorted_dirs ( path : & Path ) ->Result < Vec < PathBuf >> { 134let mut dirs = fs:: read_dir ( path) 135. with_context ( ||format! ( "reading {}" , path. display ())) ? 136. filter_map ( |entry| entry. ok ()) 137. filter_map ( |entry| entry. file_type (). ok () ?. is_dir (). then_some ( entry. path ())) 138. collect ::< Vec < _ >>(); 139 dirs. sort (); 140Ok ( dirs) 141} 142 143# [ cfg ( test )] 144mod tests{ 145use std:: fs; 146 147use tempfile:: TempDir ; 148 149use super :: * ; 150 151fn bare_repo ( root : & Path , user : & str , name : & str ) ->PathBuf { 152let path = root. join ( user). join ( name); 153 fs:: create_dir_all ( & path). unwrap (); 154 fs:: write ( path. join ( "HEAD" ), "ref: refs/heads/main\n" ). unwrap (); 155 path 156} 157 158# [ test ] 159fn discovers_bare_repositories_and_descriptions () { 160let temp =TempDir :: new (). unwrap (); 161let first =bare_repo ( temp. path (), "alice" , "first.git" ); 162 fs:: write ( first. join ( "description" ), "first repo\n" ). unwrap (); 163bare_repo ( temp. path (), "alice" , ".hidden" ); 164bare_repo ( temp. path (), ".hidden" , "repo" ); 165 fs:: create_dir_all ( temp. path (). join ( "alice/not-a-repo" )). unwrap (); 166 167let repos =discover ( temp. path ()). unwrap (); 168assert_eq! ( repos. len (), 1 ); 169assert_eq! ( repos[ 0 ]. id (), "alice/first" ); 170assert_eq! ( repos[ 0 ]. description . as_deref (), Some ( "first repo" )); 171assert_eq! ( repos[ 0 ]. head . as_deref (), Some ( "main" )); 172} 173 174# [ test ] 175fn description_round_trip_and_removal () { 176let temp =TempDir :: new (). unwrap (); 177bare_repo ( temp. path (), "alice" , "first" ); 178let repo =find ( temp. path (), "alice/first.git" ). unwrap (); 179 180set_description ( & repo, " hello " ). unwrap (); 181assert_eq! ( 182find ( temp. path (), "alice/first" ) 183. unwrap () 184. description 185. as_deref (), 186Some ( "hello" ) 187); 188 189set_description ( & repo, "" ). unwrap (); 190assert_eq! ( find ( temp. path (), "alice/first" ). unwrap (). description , None ); 191} 192 193# [ test ] 194fn rejects_paths_and_multiline_descriptions () { 195let temp =TempDir :: new (). unwrap (); 196bare_repo ( temp. path (), "alice" , "first" ); 197let repo =find ( temp. path (), "alice/first" ). unwrap (); 198 199assert! ( find ( temp. path (), "../alice/first" ). is_err ()); 200assert! ( find ( temp. path (), "alice/first/other" ). is_err ()); 201assert! ( set_description ( & repo, "one\ntwo" ). is_err ()); 202} 203}