char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
a29d09f
main
1use std:: collections:: BTreeSet ; 2use std:: path::{ Component , Path , PathBuf }; 3 4use glob:: Pattern ; 5use sha2::{ Digest , Sha256 }; 6 7use crate ::{ Error , Graph , Result , collect_paths, compile_pattern, covers}; 8 9# [ derive ( Debug , Clone , Default , PartialEq )] 10pub struct WatchSet { 11pub patterns : Vec < Pattern >, 12/// Physically resolved env-file targets, for watching and classifying 13/// edits to the file that is actually loaded. 14pub env_files : Vec < PathBuf >, 15/// The env-file paths as configured (joined to the package root, not 16/// resolved), so replacing a symlink along the way is also seen. 17pub env_aliases : Vec < PathBuf >, 18/// Symlinks discovered while resolving an env file, whose 19/// retargeting changes which file is loaded. 20pub env_links : Vec < PathBuf >, 21/// Missing directory components traversed while resolving, kept 22/// even when a later `..` removes them from the final path: creating 23/// one can make the configured spelling openable. 24pub env_deps : Vec < PathBuf >, 25} 26 27impl Graph { 28/// The inputs a watcher should care about for `target`: the union of 29/// `files` patterns over its transitive dependencies, plus env files. 30pub fn watch_set ( & self , target : & str , root : & Path ) ->Result < WatchSet > { 31let mut set =WatchSet :: default (); 32let mut seen =BTreeSet :: new (); 33let mut ancestors: Vec < String > =Vec :: new (); 34fn visit ( 35graph : & Graph , 36name : & str , 37root : & Path , 38ancestors : & mut Vec < String >, 39seen : & mut BTreeSet < String >, 40set : & mut WatchSet , 41) ->Result <()> { 42if let Some ( start) = ancestors. iter (). position ( |a| a == name) { 43let mut cycle = ancestors[ start..]. to_vec (); 44 cycle. push ( name. to_owned ()); 45return Err ( Error :: Cycle ( cycle. join ( " -> " ))); 46} 47if !seen. insert ( name. to_owned ()) { 48return Ok (()); 49} 50let task = graph. get ( name) ?; 51 ancestors. push ( name. to_owned ()); 52for dependencyin & task. dependencies { 53visit ( graph, dependency, root, ancestors, seen, set) ?; 54} 55 ancestors. pop (); 56if let Some ( patterns) =& task. files { 57for patternin patterns{ 58let normalized =crate :: glob:: normalize_pattern ( pattern); 59if !set 60. patterns 61. iter () 62. any ( |known| known. as_str () == normalized) 63{ 64 set. patterns . push ( compile_pattern ( name, pattern) ?); 65} 66} 67} 68if let Some ( file) =& task. env_file { 69let alias = root. join ( file); 70if !set. env_aliases . contains ( & alias) { 71 set. env_aliases . push ( alias. clone ()); 72} 73let resolution =resolve_watch_target ( & alias); 74for depin resolution. dependencies { 75if !set. env_deps . contains ( & dep) { 76 set. env_deps . push ( dep); 77} 78} 79for linkin resolution. links { 80if !set. env_links . contains ( & link) { 81 set. env_links . push ( link); 82} 83} 84if let Some ( target) = resolution. target . or ( resolution. candidate ) 85 && !set. env_files . contains ( & target) 86{ 87 set. env_files . push ( target); 88} 89} 90Ok (()) 91} 92visit ( self , target, root, & mut ancestors, & mut seen, & mut set) ?; 93Ok ( set) 94} 95} 96 97pub fn relative_to ( root : & Path , path : & Path ) ->Option < String > { 98Some ( 99 path. strip_prefix ( root) 100. ok () ? 101. components () 102. map ( |part| part. as_os_str (). to_string_lossy ()) 103. collect ::< Vec < _ >>() 104. join ( "/" ), 105) 106} 107 108pub fn remember_directories ( root : & Path , known_dirs : & mut BTreeSet < String >, paths : & [ PathBuf ]) { 109// Fold established types into the directory history 110// before classifying: a path now known to be a file 111// must not borrow its former directory identity. 112for pathin paths{ 113let path =normalize_path ( path); 114if let Some ( relative) =relative_to ( root, & path) { 115if path. is_dir () { 116 known_dirs. insert ( relative); 117} else if path. symlink_metadata (). is_ok () { 118 known_dirs. remove ( & relative); 119} 120} 121} 122} 123 124pub fn forget_directories ( root : & Path , known_dirs : & mut BTreeSet < String >, gone : & [ PathBuf ]) { 125// Sources of removals/renames keep their historical 126// type for this event only. 127for pathin gone{ 128if let Some ( relative) =relative_to ( root, & normalize_path ( path)) { 129 known_dirs. remove ( & relative); 130} 131} 132} 133 134/// Resolves `.` and `..` lexically, so config-relative spellings of the 135/// same file compare equal however they were written. 136pub fn normalize_path ( path : & Path ) ->PathBuf { 137let mut normalized =PathBuf :: new (); 138for componentin path. components () { 139match component{ 140Component :: CurDir =>{} 141Component :: ParentDir =>{ 142if matches! ( 143 normalized. components (). next_back (), 144Some ( Component :: Normal ( _)) 145) { 146 normalized. pop (); 147} else if !normalized. has_root () { 148 normalized. push ( ".." ); 149} 150} 151 _ => normalized. push ( component. as_os_str ()), 152} 153} 154 normalized 155} 156 157/// Resolves an env-file path to the file a watch should follow, the way 158/// opening it would: each existing component is canonicalized as it is 159/// reached, so a `..` after a symlink applies to the symlink's target. 160/// Traversal that opening would reject (through a missing or regular-file 161/// component) resolves to None instead of being silently rerouted; the 162/// configured alias is still watched, and the Runner still opens the 163/// original spelling. 164pub struct EnvResolution { 165pub target : Option < PathBuf >, 166/// Where the target would be if the missing directories along the 167/// way existed; watched so their creation is noticed. 168pub candidate : Option < PathBuf >, 169pub links : Vec < PathBuf >, 170pub dependencies : Vec < PathBuf >, 171} 172 173pub fn resolve_watch_target ( path : & Path ) ->EnvResolution { 174resolve_watch_target_inner ( path, 0 ) 175} 176 177fn resolve_watch_target_inner ( path : & Path , depth : usize ) ->EnvResolution { 178let none = |links : Vec < PathBuf >, dependencies : Vec < PathBuf > |EnvResolution { 179target : None , 180candidate : None , 181 links, 182 dependencies, 183}; 184if depth >40 { 185return none ( Vec :: new (), Vec :: new ()); 186} 187let components: Vec < Component > = path. components (). collect (); 188let mut current =PathBuf :: new (); 189let mut links: Vec < PathBuf > =Vec :: new (); 190let mut dependencies: Vec < PathBuf > =Vec :: new (); 191for ( index, component) in components. iter (). enumerate () { 192let last = index +1 == components. len (); 193match component{ 194Component :: CurDir =>{} 195Component :: ParentDir =>{ 196// The directory being traversed out of decides where 197// the rest of the path lands: replacing it with a 198// symlink redirects the whole spelling, so it is a 199// recovery dependency like the missing/blocking cases. 200if current. parent (). is_some () 201 && current. is_dir () 202 && !dependencies. contains ( & current) 203{ 204 dependencies. push ( current. clone ()); 205} 206if !current. pop () && !current. has_root () { 207return none ( links, dependencies); 208} 209} 210Component :: Normal ( name) =>{ 211let candidate = current. join ( name); 212match candidate. symlink_metadata () { 213Ok ( metadata) if metadata. file_type (). is_symlink () =>{ 214 links. push ( candidate. clone ()); 215let Ok ( target) = std:: fs:: read_link ( & candidate) else { 216return none ( links, dependencies); 217}; 218let target =if target. is_absolute () { 219 target 220} else { 221 current. join ( & target) 222}; 223let resolved =resolve_watch_target_inner ( & target, depth +1 ); 224 links. extend ( resolved. links ); 225 dependencies. extend ( resolved. dependencies ); 226let Some ( reached) = resolved. target . or ( resolved. candidate ) else { 227return none ( links, dependencies); 228}; 229if !last && reached. symlink_metadata (). is_err () { 230 dependencies. push ( reached. clone ()); 231} 232if !last && reached. symlink_metadata (). is_ok () && !reached. is_dir () { 233// Traversal is blocked by an existing 234// non-directory; replacing it can unblock 235// the spelling, so keep it watched. 236if !dependencies. contains ( & reached) { 237 dependencies. push ( reached); 238} 239return none ( links, dependencies); 240} 241// A not-yet-existing reached path is a candidate 242// prefix: keep walking so the remaining suffix 243// becomes part of the watched candidate. 244 current = reached; 245} 246Ok ( _) =>{ 247let Ok ( canonical) = std:: fs:: canonicalize ( & candidate) else { 248return none ( links, dependencies); 249}; 250if !last && !canonical. is_dir () { 251if !dependencies. contains ( & canonical) { 252 dependencies. push ( canonical); 253} 254return none ( links, dependencies); 255} 256 current = canonical; 257} 258Err ( error) if error. kind () == std:: io:: ErrorKind :: NotFound =>{ 259if last{ 260return EnvResolution { 261target : Some ( candidate), 262candidate : None , 263 links, 264 dependencies, 265}; 266} 267 dependencies. push ( candidate. clone ()); 268let mut attempted = candidate. clone (); 269for restin & components[ index +1 ..] { 270match rest{ 271Component :: ParentDir =>{ 272 attempted. pop (); 273} 274Component :: CurDir =>{} 275 _ => attempted. push ( rest. as_os_str ()), 276} 277} 278return EnvResolution { 279target : None , 280candidate : Some ( attempted), 281 links, 282 dependencies, 283}; 284} 285Err ( _) =>return none ( links, dependencies), 286} 287} 288 _ => current. push ( component. as_os_str ()), 289} 290} 291EnvResolution { 292target : Some ( current), 293candidate : None , 294 links, 295 dependencies, 296} 297} 298 299# [ derive ( Debug , PartialEq )] 300pub struct WatchState { 301fingerprint : String , 302config : String , 303env : Vec < String >, 304} 305 306impl WatchState { 307/// Config or env-file contents moved, as opposed to input files: such a 308/// change is outside some tasks' cache keys, so a rerun must bypass 309/// the cache rather than trust it. 310pub fn metadata_changed ( & self , baseline : & Self ) ->bool { 311self . config != baseline. config ||self . env != baseline. env 312} 313 314pub fn capture ( 315graph : & Graph , 316root : & Path , 317config_source : & str , 318name : & str , 319extra_args : & [ String ], 320watch_set : & WatchSet , 321) ->Self { 322let fingerprint =match crate :: graph_fingerprint ( graph, root, name, extra_args) { 323Ok ( fingerprint) =>format! ( "ok:{fingerprint}" ), 324Err ( error) =>format! ( "error:{error}" ), 325}; 326let config =format! ( "{:x}" , Sha256 :: digest ( config_source. as_bytes ())); 327let env = watch_set 328. env_aliases 329. iter () 330. map ( |path|{ 331let content =match std:: fs:: read ( path) { 332Ok ( bytes) =>format! ( "{:x}" , Sha256 :: digest ( & bytes)), 333Err ( error) =>format! ( "unreadable:{error}" ), 334}; 335format! ( "{}={content}" , path. display ()) 336}) 337. collect (); 338WatchState { fingerprint, config, env} 339} 340} 341 342/// The nearest existing ancestor of each env file outside the package, 343/// watched non-recursively. This may be above the file's parent when the 344/// parent does not exist yet, so coverage can be discovered (and, when it 345/// changes, reconciled by the watch loop) as directories appear. 346pub fn external_roots ( root : & Path , watch_set : & WatchSet , extra : & [ PathBuf ]) ->Vec < PathBuf > { 347// Every existing ancestor of an external path, not just its parent: 348// replacing a directory higher up (secrets/ in ../secrets/v/x.env) 349// is otherwise invisible to the watch on the nearest parent, which 350// stays attached to the moved inode. Identities are canonical, so a 351// path that reaches the package through a symlink does not register 352// a second, conflicting watch over the package directory itself. 353let canonical_root = std:: fs:: canonicalize ( root). unwrap_or_else ( |_| root. to_path_buf ()); 354let mut roots =Vec :: new (); 355let aliases: Vec < PathBuf > = watch_set 356. env_aliases 357. iter () 358. map ( |alias|normalize_path ( alias)) 359. collect (); 360for pathin watch_set 361. env_files 362. iter () 363. chain ( & aliases) 364. chain ( & watch_set. env_links ) 365. chain ( & watch_set. env_deps ) 366. chain ( extra. iter ()) 367{ 368if path. starts_with ( root) { 369continue ; 370} 371for ancestorin path. ancestors (). skip ( 1 ) { 372if ancestor == root{ 373continue ; 374} 375let Ok ( canonical) = std:: fs:: canonicalize ( ancestor) else { 376continue ; 377}; 378if canonical == canonical_root || canonical. starts_with ( & canonical_root) { 379continue ; 380} 381if !roots. contains ( & canonical) { 382 roots. push ( canonical); 383} 384} 385} 386 roots 387} 388 389/// Directories currently under the package root, remembered so a 390/// directory that later vanishes (moves out, is removed) is still 391/// recognized as a directory event for the patterns it could feed. 392pub fn known_directories ( root : & Path ) ->BTreeSet < String > { 393fn walk ( directory : & Path , prefix : & str , dirs : & mut BTreeSet < String >) { 394let Ok ( entries) = std:: fs:: read_dir ( directory) else { 395return ; 396}; 397for entryin entries. filter_map ( std:: result:: Result :: ok) { 398if !entry. file_type (). is_ok_and ( |kind| kind. is_dir ()) { 399continue ; 400} 401let name = entry. file_name (). to_string_lossy (). into_owned (); 402let relative =if prefix. is_empty () { 403 name 404} else { 405format! ( "{prefix}/{name}" ) 406}; 407 dirs. insert ( relative. clone ()); 408walk ( & entry. path (), & relative, dirs); 409} 410} 411let mut dirs =BTreeSet :: new (); 412walk ( root, "" , & mut dirs); 413 dirs 414} 415 416/// Resolved targets and intermediate links of matched input files that 417/// are themselves symlinks out of (or within) the package. 418pub fn resolved_input_paths ( root : & Path , watch_set : & WatchSet ) ->Vec < PathBuf > { 419let mut paths =Vec :: new (); 420for relativein collect_paths ( root, false ) { 421let matched = watch_set 422. patterns 423. iter () 424. any ( |pattern|covers ( pattern, & relative)); 425if !matched{ 426continue ; 427} 428let absolute = root. join ( & relative); 429if absolute 430. symlink_metadata () 431. is_ok_and ( |metadata| metadata. file_type (). is_symlink ()) 432{ 433for pathin resolution_paths ( & absolute) { 434if !paths. contains ( & path) { 435 paths. push ( path); 436} 437} 438} 439} 440 paths 441} 442 443/// The paths a symlinked file resolves through and to, watched so the 444/// target's edits are seen even when it lives outside the package. 445pub fn resolution_paths ( path : & Path ) ->Vec < PathBuf > { 446let resolution =resolve_watch_target ( path); 447let mut paths = resolution. links ; 448for depin resolution. dependencies { 449if !paths. contains ( & dep) { 450 paths. push ( dep); 451} 452} 453if let Some ( target) = resolution. target . or ( resolution. candidate ) 454 && !paths. contains ( & target) 455{ 456 paths. push ( target); 457} 458 paths 459} 460 461/// What an event asks of the watch loop: re-evaluate content, and/or 462/// repair the watcher installation (topology changed under it). These 463/// are independent: an input ancestor being replaced needs both. 464# [ derive ( Debug , Clone , Copy , Default )] 465pub struct Relevance { 466pub evaluate : bool , 467pub rewatch : bool , 468} 469 470/// Coverage identities used by static-config watch classification. 471pub struct WatchContext < ' a > { 472pub root : &' a Path , 473pub config_path : &' a Path , 474pub config_paths : &' a [ PathBuf ], 475pub input_paths : &' a [ PathBuf ], 476pub known_dirs : &' a BTreeSet < String >, 477} 478 479/// Filesystem event facts, independent of a particular watcher backend. 480pub struct WatchEvent < ' a > { 481pub paths : &' a [ PathBuf ], 482pub structural : bool , 483pub has_directory : bool , 484pub is_folder_event : bool , 485} 486 487impl WatchSet { 488pub fn classify ( & self , context : WatchContext < ' _ >, event : WatchEvent < ' _ >) ->Option < Relevance > { 489let WatchContext { 490 root, 491 config_path, 492 config_paths, 493 input_paths, 494 known_dirs, 495} = context; 496let structural = event. structural ; 497// Prefix matching below is deliberately conservative and is reserved 498// for structural changes: an ordinary write to an unrelated file 499// under a pattern's tree must not postpone the debounce forever. 500let mut relevance: Option < Relevance > =None ; 501let merge = |relevance : & mut Option < Relevance >, evaluate : bool , rewatch : bool |{ 502let entry = relevance. get_or_insert_default (); 503 entry. evaluate |= evaluate; 504 entry. rewatch |= rewatch; 505}; 506for pathin event. paths { 507let path =normalize_path ( path); 508if path == config_path 509 ||self . env_files . contains ( & path) 510 ||self . env_links . contains ( & path) 511 ||self . env_deps . contains ( & path) 512 ||self 513. env_aliases 514. iter () 515. any ( |alias|normalize_path ( alias) == path) 516{ 517merge ( & mut relevance, true , true ); 518} 519if config_paths. contains ( & path) 520 || config_paths. iter (). any ( |config| config. starts_with ( & path)) 521{ 522merge ( & mut relevance, true , true ); 523} 524if input_paths. contains ( & path) 525 || input_paths. iter (). any ( |input| input. starts_with ( & path)) 526{ 527// A structural change above a resolved input needs its 528// coverage rebuilt as well as its content re-read. 529merge ( & mut relevance, true , structural); 530} 531if config_path. starts_with ( & path) 532 ||self 533. env_files 534. iter () 535. chain ( & self . env_links ) 536. chain ( & self . env_deps ) 537. any ( |env| env. starts_with ( & path)) 538 ||self . env_aliases . iter (). any ( |alias|{ 539 alias. starts_with ( & path) ||normalize_path ( alias). starts_with ( & path) 540}) 541{ 542merge ( & mut relevance, true , true ); 543} 544let Some ( relative) =relative_to ( root, & path) else { 545continue ; 546}; 547if relative. is_empty () { 548merge ( & mut relevance, true , false ); 549continue ; 550} 551if self 552. patterns 553. iter () 554. any ( |pattern|covers ( pattern, & relative)) 555 ||(( path. is_dir () 556 || event. has_directory 557 || known_dirs. contains ( & relative) 558 || event. is_folder_event 559 ||( structural 560 &&self . patterns . iter (). any ( |pattern|{ 561relative_is_above_literal ( pattern. as_str (), & relative) 562}))) 563 &&self 564. patterns 565. iter () 566. any ( |pattern|pattern_could_match ( pattern, & relative))) 567{ 568merge ( & mut relevance, true , false ); 569} 570} 571 relevance 572} 573} 574 575fn literal_prefix ( pattern : & str ) ->impl Iterator < Item =& str > { 576 pattern 577. split ( '/' ) 578. take_while ( |segment| !segment. contains ([ '*' , '?' , '[' ])) 579} 580 581fn pattern_could_match ( pattern : & Pattern , relative : & str ) ->bool { 582if covers ( pattern, relative) { 583return true ; 584} 585let literal: Vec < & str > =literal_prefix ( pattern. as_str ()). collect (); 586let relative_parts: Vec < & str > = relative. split ( '/' ). collect (); 587let is_prefix = |parts : & [ & str ], prefix : & [ & str ] |{ 588 prefix. len () <= parts. len () && parts[ ..prefix. len ()] ==* prefix 589}; 590is_prefix ( & literal, & relative_parts) ||is_prefix ( & relative_parts, & literal) 591} 592 593/// Whether `relative` is a pattern's literal prefix or a directory above 594/// it: moving or removing that directory carries the pattern's tree. 595fn relative_is_above_literal ( normalized : & str , relative : & str ) ->bool { 596let literal: Vec < & str > =literal_prefix ( normalized). collect (); 597let relative_parts: Vec < & str > = relative. split ( '/' ). collect (); 598 relative_parts. len () <= literal. len () && literal[ ..relative_parts. len ()] == relative_parts[ ..] 599} 600 601impl WatchSet { 602/// Callback graphs may discover inputs not named by the current graph. 603pub fn is_relevant_to_callback ( & self , root : & Path , path : & Path , structural : bool ) ->bool { 604let path =normalize_path ( path); 605if self 606. env_files 607. iter () 608. chain ( & self . env_links ) 609. chain ( & self . env_deps ) 610. any ( |env|* env == path ||( structural && env. starts_with ( & path))) 611 ||self . env_aliases . iter (). any ( |alias|{ 612let alias =normalize_path ( alias); 613 alias == path ||( structural && alias. starts_with ( & path)) 614}) 615{ 616return true ; 617} 618let Some ( relative) =relative_to ( root, & path) else { 619return false ; 620}; 621if self 622. patterns 623. iter () 624. any ( |pattern|covers ( pattern, & relative)) 625{ 626return true ; 627} 628// A created, removed, or renamed entry cannot match the current 629// graph's patterns if the graph does not know about it yet, but it 630// can change what a re-evaluated graph enumerates. Treat structural 631// events in or above a directory that holds inputs as relevant. 632if structural{ 633let relative_dir =match relative. rsplit_once ( '/' ) { 634Some (( dir, _)) => dir. to_owned (), 635None =>String :: new (), 636}; 637return self 638. patterns 639. iter () 640. map ( |pattern|literal_dir ( pattern. as_str ())) 641. any ( |dir|{ 642 dir. is_empty () 643 || relative. is_empty () 644 || relative == dir 645 || dir. starts_with ( & format! ( "{relative}/" )) 646 || relative_dir == dir 647 || relative_dir. starts_with ( & format! ( "{dir}/" )) 648 || dir. starts_with ( & format! ( "{relative_dir}/" )) 649}); 650} 651false 652} 653} 654 655/// The directory a pattern's literal prefix names: everything before the 656/// first glob segment, minus a trailing literal file segment. 657fn literal_dir ( pattern : & str ) ->String { 658let mut literal: Vec < _ > =literal_prefix ( pattern). collect (); 659if literal. len () == pattern. split ( '/' ). count () && literal. len () >1 { 660 literal. pop (); 661} 662 literal. join ( "/" ) 663}