Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: track rust files

+864 -284
+10
Cargo.lock
··· 1330 1330 checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" 1331 1331 1332 1332 [[package]] 1333 + name = "depinfo" 1334 + version = "0.7.3" 1335 + source = "registry+https://github.com/rust-lang/crates.io-index" 1336 + checksum = "ef6dbc1a9be8240ab2bf1f337cd232ca39f361f698227fae35eff7b11690278f" 1337 + dependencies = [ 1338 + "thiserror 2.0.18", 1339 + ] 1340 + 1341 + [[package]] 1333 1342 name = "deranged" 1334 1343 version = "0.5.5" 1335 1344 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2621 2630 "chrono", 2622 2631 "clap", 2623 2632 "colored 3.1.1", 2633 + "depinfo", 2624 2634 "flate2", 2625 2635 "futures", 2626 2636 "inquire",
+1
crates/maudit-cli/Cargo.toml
··· 35 35 serde_json = "1.0" 36 36 tokio-util = "0.7" 37 37 cargo_metadata = "0.23.1" 38 + depinfo = "0.7.3" 38 39 39 40 [dev-dependencies] 40 41 tempfile = "3.24.0"
+2 -1
crates/maudit-cli/src/dev.rs
··· 187 187 // Need to recompile - spawn in background so file watcher can continue 188 188 info!(name: "watch", "Files changed, rebuilding..."); 189 189 let build_manager_clone = build_manager_watcher.clone(); 190 + let changed_paths_clone = changed_paths.clone(); 190 191 tokio::spawn(async move { 191 - match build_manager_clone.start_build().await { 192 + match build_manager_clone.start_build(Some(&changed_paths_clone)).await { 192 193 Ok(_) => { 193 194 // Build completed (success or failure already logged) 194 195 }
+25 -10
crates/maudit-cli/src/dev/build.rs
··· 90 90 Some(p) if p.exists() => p.clone(), 91 91 Some(p) => { 92 92 warn!(name: "build", "Binary at {:?} no longer exists, falling back to full rebuild", p); 93 - return self.start_build().await; 93 + return self.start_build(Some(changed_paths)).await; 94 94 } 95 95 None => { 96 96 warn!(name: "build", "No binary path available, falling back to full rebuild"); 97 - return self.start_build().await; 97 + return self.start_build(Some(changed_paths)).await; 98 98 } 99 99 } 100 100 }; ··· 164 164 165 165 /// Do initial build that can be cancelled. 166 166 pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 167 - self.internal_build(true).await 167 + self.internal_build(true, None).await 168 168 } 169 169 170 170 /// Start a new build, cancelling any previous one. 171 - pub async fn start_build(&self) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 172 - self.internal_build(false).await 171 + /// If changed_paths is provided, they will be passed to the binary for incremental builds. 172 + pub async fn start_build( 173 + &self, 174 + changed_paths: Option<&[PathBuf]>, 175 + ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 176 + self.internal_build(false, changed_paths).await 173 177 } 174 178 175 179 async fn internal_build( 176 180 &self, 177 181 is_initial: bool, 182 + changed_paths: Option<&[PathBuf]>, 178 183 ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> { 179 184 // Cancel any existing build immediately 180 185 let cancel = CancellationToken::new(); ··· 193 198 .update(StatusType::Info, "Building...") 194 199 .await; 195 200 201 + // Build environment variables 202 + let mut envs: Vec<(&str, String)> = vec![ 203 + ("MAUDIT_DEV", "true".to_string()), 204 + ("MAUDIT_QUIET", "true".to_string()), 205 + ("CARGO_TERM_COLOR", "always".to_string()), 206 + ]; 207 + 208 + // Add changed files if provided (for incremental builds after recompilation) 209 + if let Some(paths) = changed_paths 210 + && let Ok(json) = serde_json::to_string(paths) { 211 + debug!(name: "build", "Passing MAUDIT_CHANGED_FILES to cargo: {}", json); 212 + envs.push(("MAUDIT_CHANGED_FILES", json)); 213 + } 214 + 196 215 let mut child = Command::new("cargo") 197 216 .args([ 198 217 "run", ··· 200 219 "--message-format", 201 220 "json-diagnostic-rendered-ansi", 202 221 ]) 203 - .envs([ 204 - ("MAUDIT_DEV", "true"), 205 - ("MAUDIT_QUIET", "true"), 206 - ("CARGO_TERM_COLOR", "always"), 207 - ]) 222 + .envs(envs.iter().map(|(k, v)| (*k, v.as_str()))) 208 223 .stdout(std::process::Stdio::piped()) 209 224 .stderr(std::process::Stdio::piped()) 210 225 .spawn()?;
+7 -105
crates/maudit-cli/src/dev/dep_tracker.rs
··· 1 + use depinfo::RustcDepInfo; 1 2 use std::collections::HashMap; 2 3 use std::fs; 3 4 use std::path::{Path, PathBuf}; ··· 108 109 Ok(tracker) 109 110 } 110 111 111 - /// Parse space-separated paths from a string, handling escaped spaces 112 - /// In Make-style .d files, spaces in filenames are escaped with backslashes 113 - fn parse_paths(input: &str) -> Vec<PathBuf> { 114 - let mut paths = Vec::new(); 115 - let mut current_path = String::new(); 116 - let mut chars = input.chars().peekable(); 117 - 118 - while let Some(ch) = chars.next() { 119 - match ch { 120 - '\\' => { 121 - // Check if this is escaping a space or newline 122 - if let Some(&next_ch) = chars.peek() { 123 - if next_ch == ' ' { 124 - // Escaped space - add it to the current path 125 - current_path.push(' '); 126 - chars.next(); // consume the space 127 - } else if next_ch == '\n' || next_ch == '\r' { 128 - // Line continuation - skip the backslash and newline 129 - chars.next(); 130 - if next_ch == '\r' { 131 - // Handle \r\n 132 - if chars.peek() == Some(&'\n') { 133 - chars.next(); 134 - } 135 - } 136 - } else { 137 - // Not escaping space or newline, keep the backslash 138 - current_path.push('\\'); 139 - } 140 - } else { 141 - // Backslash at end of string 142 - current_path.push('\\'); 143 - } 144 - } 145 - ' ' | '\t' | '\n' | '\r' => { 146 - // Unescaped whitespace - end current path 147 - if !current_path.is_empty() { 148 - paths.push(PathBuf::from(current_path.clone())); 149 - current_path.clear(); 150 - } 151 - } 152 - _ => { 153 - current_path.push(ch); 154 - } 155 - } 156 - } 157 - 158 - // Don't forget the last path 159 - if !current_path.is_empty() { 160 - paths.push(PathBuf::from(current_path)); 161 - } 162 - 163 - paths 164 - } 165 - 166 - /// Reload dependencies from the .d file 112 + /// Reload dependencies from the .d file using the depinfo crate 167 113 pub fn reload_dependencies(&mut self) -> Result<(), std::io::Error> { 168 114 let Some(d_file_path) = &self.d_file_path else { 169 115 return Err(std::io::Error::new( ··· 172 118 )); 173 119 }; 174 120 175 - let content = fs::read_to_string(d_file_path)?; 176 - 177 - // Parse the .d file format: "target: dep1 dep2 dep3 ..." 178 - // The first line contains the target and dependencies, separated by ':' 179 - let deps = if let Some(colon_pos) = content.find(':') { 180 - // Everything after the colon is dependencies 181 - &content[colon_pos + 1..] 182 - } else { 183 - // Malformed .d file 184 - warn!("Malformed .d file at {:?}", d_file_path); 185 - return Ok(()); 186 - }; 187 - 188 - // Dependencies are space-separated and may span multiple lines (with line continuations) 189 - // Spaces in filenames are escaped with backslashes 190 - let dep_paths = Self::parse_paths(deps); 121 + let dep_info = RustcDepInfo::from_file(d_file_path).map_err(|e| { 122 + warn!("Failed to parse .d file at {:?}: {}", d_file_path, e); 123 + std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) 124 + })?; 191 125 192 126 // Clear old dependencies and load new ones with their modification times 193 127 self.dependencies.clear(); 194 128 195 - for dep_path in dep_paths { 129 + for dep_path in dep_info.files { 196 130 match fs::metadata(&dep_path) { 197 131 Ok(metadata) => { 198 132 if let Ok(modified) = metadata.modified() { ··· 341 275 .any(|p| p.to_str().unwrap().contains("normal.rs")), 342 276 "Should contain normal file" 343 277 ); 344 - } 345 - 346 - #[test] 347 - fn test_parse_escaped_paths() { 348 - // Test basic space-separated paths 349 - let paths = DependencyTracker::parse_paths("a.rs b.rs c.rs"); 350 - assert_eq!(paths.len(), 3); 351 - assert_eq!(paths[0], PathBuf::from("a.rs")); 352 - assert_eq!(paths[1], PathBuf::from("b.rs")); 353 - assert_eq!(paths[2], PathBuf::from("c.rs")); 354 - 355 - // Test escaped spaces 356 - let paths = DependencyTracker::parse_paths("my\\ file.rs another.rs"); 357 - assert_eq!(paths.len(), 2); 358 - assert_eq!(paths[0], PathBuf::from("my file.rs")); 359 - assert_eq!(paths[1], PathBuf::from("another.rs")); 360 - 361 - // Test line continuation 362 - let paths = DependencyTracker::parse_paths("a.rs b.rs \\\nc.rs"); 363 - assert_eq!(paths.len(), 3); 364 - assert_eq!(paths[0], PathBuf::from("a.rs")); 365 - assert_eq!(paths[1], PathBuf::from("b.rs")); 366 - assert_eq!(paths[2], PathBuf::from("c.rs")); 367 - 368 - // Test multiple escaped spaces 369 - let paths = DependencyTracker::parse_paths("path/to/my\\ file\\ name.rs"); 370 - assert_eq!(paths.len(), 1); 371 - assert_eq!(paths[0], PathBuf::from("path/to/my file name.rs")); 372 - 373 - // Test mixed whitespace 374 - let paths = DependencyTracker::parse_paths("a.rs\tb.rs\nc.rs"); 375 - assert_eq!(paths.len(), 3); 376 278 } 377 279 }
+4
crates/maudit-macros/src/lib.rs
··· 330 330 impl maudit::route::InternalRoute for #struct_name { 331 331 #route_raw_impl 332 332 333 + fn source_file(&self) -> &'static str { 334 + file!() 335 + } 336 + 333 337 #variant_method 334 338 335 339 #sitemap_method
+74 -14
crates/maudit/src/build.rs
··· 63 63 } 64 64 } 65 65 66 - /// Helper to track all assets used by a route. 66 + /// Helper to track all assets and source files used by a route. 67 67 /// Only performs work when incremental builds are enabled and route_id is provided. 68 68 fn track_route_assets( 69 69 build_state: &mut BuildState, ··· 97 97 } 98 98 } 99 99 100 + /// Helper to track the source file where a route is defined. 101 + /// Only performs work when incremental builds are enabled and route_id is provided. 102 + fn track_route_source_file( 103 + build_state: &mut BuildState, 104 + route_id: Option<&RouteIdentifier>, 105 + source_file: &str, 106 + ) { 107 + // Skip tracking entirely when route_id is not provided (incremental disabled) 108 + let Some(route_id) = route_id else { 109 + return; 110 + }; 111 + 112 + // The file!() macro returns a path relative to the cargo workspace root. 113 + // We need to canonicalize it to match against changed file paths (which are absolute). 114 + let source_path = PathBuf::from(source_file); 115 + 116 + // Try direct canonicalization first (works if CWD is workspace root) 117 + if let Ok(canonical) = source_path.canonicalize() { 118 + build_state.track_source_file(canonical, route_id.clone()); 119 + return; 120 + } 121 + 122 + // The file!() macro path is relative to the workspace root at compile time. 123 + // At runtime, we're typically running from the package directory. 124 + // Try to find the file by walking up from CWD until we find it. 125 + if let Ok(cwd) = std::env::current_dir() { 126 + let mut current = cwd.as_path(); 127 + loop { 128 + let candidate = current.join(&source_path); 129 + if let Ok(canonical) = candidate.canonicalize() { 130 + build_state.track_source_file(canonical, route_id.clone()); 131 + return; 132 + } 133 + match current.parent() { 134 + Some(parent) => current = parent, 135 + None => break, 136 + } 137 + } 138 + } 139 + 140 + // Last resort: store the relative path (won't match absolute changed files) 141 + debug!(target: "build", "Could not canonicalize source file path: {}", source_file); 142 + build_state.track_source_file(source_path, route_id.clone()); 143 + } 144 + 100 145 pub fn execute_build( 101 146 routes: &[&dyn FullRoute], 102 147 content_sources: &mut ContentSources, ··· 132 177 BuildState::new() 133 178 }; 134 179 135 - debug!(target: "build", "Loaded build state with {} asset mappings", build_state.asset_to_routes.len()); 180 + debug!(target: "build", "Loaded build state with {} asset mappings, {} source mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len()); 136 181 debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some()); 137 182 138 183 // Determine if this is an incremental build 139 - let is_incremental = 140 - options.incremental && changed_files.is_some() && !build_state.asset_to_routes.is_empty(); 184 + // We need either asset mappings OR source file mappings to do incremental builds 185 + let has_build_state = 186 + !build_state.asset_to_routes.is_empty() || !build_state.source_to_routes.is_empty(); 187 + let is_incremental = options.incremental && changed_files.is_some() && has_build_state; 141 188 142 189 let routes_to_rebuild = if is_incremental { 143 190 let changed = changed_files.unwrap(); 144 191 info!(target: "build", "Incremental build: {} files changed", changed.len()); 145 192 info!(target: "build", "Changed files: {:?}", changed); 146 193 147 - info!(target: "build", "Build state has {} asset mappings", build_state.asset_to_routes.len()); 194 + info!(target: "build", "Build state has {} asset mappings, {} source mappings", build_state.asset_to_routes.len(), build_state.source_to_routes.len()); 148 195 149 - let affected = build_state.get_affected_routes(changed); 150 - info!(target: "build", "Rebuilding {} affected routes", affected.len()); 151 - info!(target: "build", "Affected routes: {:?}", affected); 152 - 153 - Some(affected) 196 + match build_state.get_affected_routes(changed) { 197 + Some(affected) => { 198 + info!(target: "build", "Rebuilding {} affected routes", affected.len()); 199 + info!(target: "build", "Affected routes: {:?}", affected); 200 + Some(affected) 201 + } 202 + None => { 203 + // Some changed files weren't tracked (e.g., include_str! dependencies) 204 + // Fall back to full rebuild to ensure correctness 205 + info!(target: "build", "Untracked files changed, falling back to full rebuild"); 206 + build_state.clear(); 207 + None 208 + } 209 + } 154 210 } else { 155 211 if changed_files.is_some() { 156 212 info!(target: "build", "Full build (first run after recompilation)"); ··· 363 419 364 420 info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 365 421 366 - // Track assets for this route 422 + // Track assets and source file for this route 367 423 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 424 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 368 425 369 426 build_pages_images.extend(route_assets.images); 370 427 build_pages_scripts.extend(route_assets.scripts); ··· 438 495 439 496 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options)); 440 497 441 - // Track assets for this page 498 + // Track assets and source file for this page 442 499 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 500 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 443 501 444 502 build_metadata.add_page( 445 503 base_path.clone(), ··· 512 570 513 571 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options)); 514 572 515 - // Track assets for this variant 573 + // Track assets and source file for this variant 516 574 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 575 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 517 576 518 577 build_pages_images.extend(route_assets.images); 519 578 build_pages_scripts.extend(route_assets.scripts); ··· 594 653 595 654 info!(target: "pages", "│ ├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options)); 596 655 597 - // Track assets for this variant page 656 + // Track assets and source file for this variant page 598 657 track_route_assets(&mut build_state, route_id.as_ref(), &route_assets); 658 + track_route_source_file(&mut build_state, route_id.as_ref(), route.source_file()); 599 659 600 660 build_metadata.add_page( 601 661 variant_path.clone(),
+354 -19
crates/maudit/src/build/state.rs
··· 58 58 /// Value: set of routes using this asset 59 59 pub asset_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>, 60 60 61 + /// Maps source file paths to routes defined in them 62 + /// Key: canonicalized source file path (e.g., src/pages/index.rs) 63 + /// Value: set of routes defined in this source file 64 + pub source_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>, 65 + 61 66 /// Stores all bundler input paths from the last build 62 67 /// This needs to be preserved to ensure consistent bundling 63 68 pub bundler_inputs: Vec<String>, ··· 98 103 .insert(route_id); 99 104 } 100 105 101 - /// Get all routes affected by changes to specific files 102 - pub fn get_affected_routes(&self, changed_files: &[PathBuf]) -> FxHashSet<RouteIdentifier> { 106 + /// Add a source file->route mapping 107 + /// This tracks which .rs file defines which routes for incremental rebuilds 108 + pub fn track_source_file(&mut self, source_path: PathBuf, route_id: RouteIdentifier) { 109 + self.source_to_routes 110 + .entry(source_path) 111 + .or_default() 112 + .insert(route_id); 113 + } 114 + 115 + /// Get all routes affected by changes to specific files. 116 + /// 117 + /// Returns `Some(routes)` if all changed files were found in the mappings, 118 + /// or `None` if any changed file is untracked (meaning we need a full rebuild). 119 + /// 120 + /// This handles the case where files like those referenced by `include_str!()` 121 + /// are not tracked at the route level - when these change, we fall back to 122 + /// rebuilding all routes to ensure correctness. 123 + /// 124 + /// Note: Existing directories are not considered "untracked" - they are checked 125 + /// via prefix matching, but a new/unknown directory won't trigger a full rebuild. 126 + pub fn get_affected_routes( 127 + &self, 128 + changed_files: &[PathBuf], 129 + ) -> Option<FxHashSet<RouteIdentifier>> { 103 130 let mut affected_routes = FxHashSet::default(); 131 + let mut has_untracked_file = false; 104 132 105 133 for changed_file in changed_files { 134 + let mut file_was_tracked = false; 135 + 106 136 // Canonicalize the changed file path for consistent comparison 107 137 // All asset paths in asset_to_routes are stored as canonical paths 108 138 let canonical_changed = changed_file.canonicalize().ok(); 109 139 110 - // Try exact match with canonical path 140 + // Check source file mappings first (for .rs files) 141 + if let Some(canonical) = &canonical_changed 142 + && let Some(routes) = self.source_to_routes.get(canonical) 143 + { 144 + affected_routes.extend(routes.iter().cloned()); 145 + file_was_tracked = true; 146 + // Continue to also check asset mappings (a file could be both) 147 + } 148 + 149 + // Also check with original path for source files 150 + if let Some(routes) = self.source_to_routes.get(changed_file) { 151 + affected_routes.extend(routes.iter().cloned()); 152 + file_was_tracked = true; 153 + } 154 + 155 + // Try exact match with canonical path for assets 111 156 if let Some(canonical) = &canonical_changed 112 157 && let Some(routes) = self.asset_to_routes.get(canonical) 113 158 { 114 159 affected_routes.extend(routes.iter().cloned()); 115 - continue; // Found exact match, no need for directory check 160 + file_was_tracked = true; 116 161 } 117 162 118 163 // Fallback: try exact match with original path (shouldn't normally match) 119 164 if let Some(routes) = self.asset_to_routes.get(changed_file) { 120 165 affected_routes.extend(routes.iter().cloned()); 121 - continue; 166 + file_was_tracked = true; 122 167 } 123 168 124 169 // Directory prefix check: find all routes using assets within this directory. ··· 130 175 // We do this check if: 131 176 // - The path currently exists as a directory, OR 132 177 // - The path doesn't exist (could be a deleted/renamed directory) 133 - let should_check_prefix = changed_file.is_dir() || !changed_file.exists(); 178 + let is_existing_directory = changed_file.is_dir(); 179 + let path_does_not_exist = !changed_file.exists(); 134 180 135 - if should_check_prefix { 181 + if is_existing_directory || path_does_not_exist { 136 182 // Use original path for prefix matching (canonical won't exist for deleted dirs) 137 183 for (asset_path, routes) in &self.asset_to_routes { 138 184 if asset_path.starts_with(changed_file) { 139 185 affected_routes.extend(routes.iter().cloned()); 186 + file_was_tracked = true; 187 + } 188 + } 189 + // Also check source files for directory prefix 190 + for (source_path, routes) in &self.source_to_routes { 191 + if source_path.starts_with(changed_file) { 192 + affected_routes.extend(routes.iter().cloned()); 193 + file_was_tracked = true; 140 194 } 141 195 } 142 196 } 197 + 198 + // Flag as untracked (triggering full rebuild) if: 199 + // 1. The file wasn't found in any mapping, AND 200 + // 2. It's not a currently-existing directory (new directories are OK to ignore) 201 + // 202 + // For non-existent paths that weren't matched: 203 + // - If the path has a file extension, treat it as a deleted file → full rebuild 204 + // - If the path has no extension, it might be a deleted directory → allow 205 + // (we already checked prefix matching above) 206 + // 207 + // This is conservative: we'd rather rebuild too much than too little. 208 + if !file_was_tracked && !is_existing_directory { 209 + if path_does_not_exist { 210 + // For deleted paths, check if it looks like a file (has extension) 211 + // If it has an extension, it was probably a file → trigger full rebuild 212 + // If no extension, it might have been a directory → don't trigger 213 + let has_extension = changed_file 214 + .extension() 215 + .map(|ext| !ext.is_empty()) 216 + .unwrap_or(false); 217 + 218 + if has_extension { 219 + has_untracked_file = true; 220 + } 221 + } else { 222 + // Path exists but wasn't tracked → definitely untracked file 223 + has_untracked_file = true; 224 + } 225 + } 143 226 } 144 227 145 - affected_routes 228 + if has_untracked_file { 229 + // Some files weren't tracked - caller should do a full rebuild 230 + None 231 + } else { 232 + Some(affected_routes) 233 + } 146 234 } 147 235 148 236 /// Clear all tracked data (for full rebuild) 149 237 pub fn clear(&mut self) { 150 238 self.asset_to_routes.clear(); 239 + self.source_to_routes.clear(); 151 240 self.bundler_inputs.clear(); 152 241 } 153 242 } ··· 168 257 169 258 state.track_asset(asset_path.clone(), route.clone()); 170 259 171 - // Exact match should work 172 - let affected = state.get_affected_routes(&[asset_path]); 260 + // Exact match should work and return Some 261 + let affected = state.get_affected_routes(&[asset_path]).unwrap(); 173 262 assert_eq!(affected.len(), 1); 174 263 assert!(affected.contains(&route)); 175 264 } 176 265 177 266 #[test] 178 - fn test_get_affected_routes_no_match() { 267 + fn test_get_affected_routes_untracked_file() { 268 + use std::fs; 269 + use tempfile::TempDir; 270 + 179 271 let mut state = BuildState::new(); 180 - let asset_path = PathBuf::from("/project/src/assets/logo.png"); 272 + 273 + // Create temp files 274 + let temp_dir = TempDir::new().unwrap(); 275 + let tracked_file = temp_dir.path().join("logo.png"); 276 + let untracked_file = temp_dir.path().join("other.png"); 277 + fs::write(&tracked_file, "tracked").unwrap(); 278 + fs::write(&untracked_file, "untracked").unwrap(); 279 + 181 280 let route = make_route("/"); 281 + state.track_asset(tracked_file.clone(), route); 182 282 183 - state.track_asset(asset_path, route); 283 + // Untracked file that EXISTS should return None (triggers full rebuild) 284 + let affected = state.get_affected_routes(&[untracked_file]); 285 + assert!(affected.is_none()); 286 + } 287 + 288 + #[test] 289 + fn test_get_affected_routes_mixed_tracked_untracked() { 290 + use std::fs; 291 + use tempfile::TempDir; 292 + 293 + let mut state = BuildState::new(); 294 + 295 + // Create temp files 296 + let temp_dir = TempDir::new().unwrap(); 297 + let tracked_file = temp_dir.path().join("logo.png"); 298 + let untracked_file = temp_dir.path().join("other.png"); 299 + fs::write(&tracked_file, "tracked").unwrap(); 300 + fs::write(&untracked_file, "untracked").unwrap(); 184 301 185 - // Different file should not match 186 - let other_path = PathBuf::from("/project/src/assets/other.png"); 187 - let affected = state.get_affected_routes(&[other_path]); 188 - assert!(affected.is_empty()); 302 + let route = make_route("/"); 303 + state.track_asset(tracked_file.canonicalize().unwrap(), route); 304 + 305 + // If any file is untracked, return None (even if some are tracked) 306 + let affected = state.get_affected_routes(&[tracked_file, untracked_file]); 307 + assert!(affected.is_none()); 189 308 } 190 309 191 310 #[test] ··· 208 327 let deleted_dir = PathBuf::from("/project/src/assets/icons"); 209 328 210 329 // Since the path doesn't exist, it should check prefix matching 211 - let affected = state.get_affected_routes(&[deleted_dir]); 330 + let affected = state.get_affected_routes(&[deleted_dir]).unwrap(); 212 331 213 332 // Should find route1 (uses assets under /icons/) but not route2 214 333 assert_eq!(affected.len(), 1); ··· 225 344 state.track_asset(asset_path.clone(), route1.clone()); 226 345 state.track_asset(asset_path.clone(), route2.clone()); 227 346 228 - let affected = state.get_affected_routes(&[asset_path]); 347 + let affected = state.get_affected_routes(&[asset_path]).unwrap(); 229 348 assert_eq!(affected.len(), 2); 230 349 assert!(affected.contains(&route1)); 231 350 assert!(affected.contains(&route2)); 351 + } 352 + 353 + #[test] 354 + fn test_get_affected_routes_source_file() { 355 + let mut state = BuildState::new(); 356 + let source_path = PathBuf::from("/project/src/pages/index.rs"); 357 + let route1 = make_route("/"); 358 + let route2 = make_route("/about"); 359 + 360 + // Track routes to their source files 361 + state.track_source_file(source_path.clone(), route1.clone()); 362 + state.track_source_file(source_path.clone(), route2.clone()); 363 + 364 + // When the source file changes, both routes should be affected 365 + let affected = state.get_affected_routes(&[source_path]).unwrap(); 366 + assert_eq!(affected.len(), 2); 367 + assert!(affected.contains(&route1)); 368 + assert!(affected.contains(&route2)); 369 + } 370 + 371 + #[test] 372 + fn test_get_affected_routes_source_file_only_matching() { 373 + let mut state = BuildState::new(); 374 + let source_index = PathBuf::from("/project/src/pages/index.rs"); 375 + let source_about = PathBuf::from("/project/src/pages/about.rs"); 376 + let route_index = make_route("/"); 377 + let route_about = make_route("/about"); 378 + 379 + state.track_source_file(source_index.clone(), route_index.clone()); 380 + state.track_source_file(source_about.clone(), route_about.clone()); 381 + 382 + // Changing only index.rs should only affect the index route 383 + let affected = state.get_affected_routes(&[source_index]).unwrap(); 384 + assert_eq!(affected.len(), 1); 385 + assert!(affected.contains(&route_index)); 386 + assert!(!affected.contains(&route_about)); 387 + } 388 + 389 + #[test] 390 + fn test_clear_also_clears_source_files() { 391 + let mut state = BuildState::new(); 392 + let source_path = PathBuf::from("/project/src/pages/index.rs"); 393 + let asset_path = PathBuf::from("/project/src/assets/logo.png"); 394 + let route = make_route("/"); 395 + 396 + state.track_source_file(source_path.clone(), route.clone()); 397 + state.track_asset(asset_path.clone(), route.clone()); 398 + 399 + assert!(!state.source_to_routes.is_empty()); 400 + assert!(!state.asset_to_routes.is_empty()); 401 + 402 + state.clear(); 403 + 404 + assert!(state.source_to_routes.is_empty()); 405 + assert!(state.asset_to_routes.is_empty()); 406 + } 407 + 408 + #[test] 409 + fn test_get_affected_routes_new_directory_not_untracked() { 410 + use std::fs; 411 + use tempfile::TempDir; 412 + 413 + let mut state = BuildState::new(); 414 + 415 + // Create a temporary directory to simulate the "new directory" scenario 416 + let temp_dir = TempDir::new().unwrap(); 417 + let new_dir = temp_dir.path().join("new-folder"); 418 + fs::create_dir(&new_dir).unwrap(); 419 + 420 + // Track some asset under a different path 421 + let asset_path = PathBuf::from("/project/src/assets/logo.png"); 422 + let route = make_route("/"); 423 + state.track_asset(asset_path.clone(), route.clone()); 424 + 425 + // When a new directory appears (e.g., from renaming another folder), 426 + // it should NOT trigger a full rebuild (return None), even though 427 + // we don't have any assets tracked under it. 428 + let affected = state.get_affected_routes(&[new_dir]); 429 + 430 + // Should return Some (not None), meaning we don't trigger full rebuild 431 + // The set should be empty since no assets are under this new directory 432 + assert!( 433 + affected.is_some(), 434 + "New directory should not trigger full rebuild" 435 + ); 436 + assert!(affected.unwrap().is_empty()); 437 + } 438 + 439 + #[test] 440 + fn test_get_affected_routes_folder_rename_scenario() { 441 + use std::fs; 442 + use tempfile::TempDir; 443 + 444 + let mut state = BuildState::new(); 445 + 446 + // Create temp directories to simulate folder rename 447 + let temp_dir = TempDir::new().unwrap(); 448 + let new_dir = temp_dir.path().join("icons-renamed"); 449 + fs::create_dir(&new_dir).unwrap(); 450 + 451 + // Track assets under the OLD folder path (which no longer exists) 452 + let old_dir = PathBuf::from("/project/src/assets/icons"); 453 + let asset1 = PathBuf::from("/project/src/assets/icons/logo.png"); 454 + let route = make_route("/blog"); 455 + state.track_asset(asset1, route.clone()); 456 + 457 + // Simulate folder rename: old path doesn't exist, new path is a directory 458 + // Both paths are passed as "changed" 459 + let affected = state.get_affected_routes(&[old_dir, new_dir]); 460 + 461 + // Should return Some (not None) - we found the affected route via prefix matching 462 + // and the new directory doesn't trigger "untracked file" behavior 463 + assert!( 464 + affected.is_some(), 465 + "Folder rename should not trigger full rebuild" 466 + ); 467 + let routes = affected.unwrap(); 468 + assert_eq!(routes.len(), 1); 469 + assert!(routes.contains(&route)); 470 + } 471 + 472 + #[test] 473 + fn test_get_affected_routes_deleted_untracked_file() { 474 + let mut state = BuildState::new(); 475 + 476 + // Track some assets 477 + let tracked_asset = PathBuf::from("/project/src/assets/logo.png"); 478 + let route = make_route("/"); 479 + state.track_asset(tracked_asset, route); 480 + 481 + // Simulate a deleted file that was NEVER tracked 482 + // (e.g., a file used via include_str! that we don't know about) 483 + // This path doesn't exist and isn't in any mapping 484 + let deleted_untracked_file = PathBuf::from("/project/src/content/data.txt"); 485 + 486 + let affected = state.get_affected_routes(&[deleted_untracked_file]); 487 + 488 + // Since the deleted path has a file extension (.txt), we treat it as 489 + // a deleted file that might have been a dependency we don't track. 490 + // We should trigger a full rebuild (return None) to be safe. 491 + assert!( 492 + affected.is_none(), 493 + "Deleted untracked file with extension should trigger full rebuild" 494 + ); 495 + } 496 + 497 + #[test] 498 + fn test_get_affected_routes_deleted_untracked_directory() { 499 + let mut state = BuildState::new(); 500 + 501 + // Track some assets 502 + let tracked_asset = PathBuf::from("/project/src/assets/logo.png"); 503 + let route = make_route("/"); 504 + state.track_asset(tracked_asset, route); 505 + 506 + // Simulate a deleted directory that was NEVER tracked 507 + // This path doesn't exist, isn't in any mapping, and has no extension 508 + let deleted_untracked_dir = PathBuf::from("/project/src/content"); 509 + 510 + let affected = state.get_affected_routes(&[deleted_untracked_dir]); 511 + 512 + // Since the path has no extension, it might have been a directory. 513 + // We already did prefix matching (found nothing), so we allow this 514 + // without triggering a full rebuild. 515 + assert!( 516 + affected.is_some(), 517 + "Deleted path without extension (possible directory) should not trigger full rebuild" 518 + ); 519 + assert!(affected.unwrap().is_empty()); 520 + } 521 + 522 + #[test] 523 + fn test_get_affected_routes_deleted_tracked_file() { 524 + use std::fs; 525 + use tempfile::TempDir; 526 + 527 + let mut state = BuildState::new(); 528 + 529 + // Create a temp file, track it, then delete it 530 + let temp_dir = TempDir::new().unwrap(); 531 + let tracked_file = temp_dir.path().join("logo.png"); 532 + fs::write(&tracked_file, "content").unwrap(); 533 + 534 + let canonical_path = tracked_file.canonicalize().unwrap(); 535 + let route = make_route("/"); 536 + state.track_asset(canonical_path.clone(), route.clone()); 537 + 538 + // Now delete the file 539 + fs::remove_file(&tracked_file).unwrap(); 540 + 541 + // The file no longer exists, but its canonical path is still in our mapping 542 + // When we get the change event, notify gives us the original path 543 + let affected = state.get_affected_routes(std::slice::from_ref(&tracked_file)); 544 + 545 + // This SHOULD find the route because we track by canonical path 546 + // and the original path should match via the mapping lookup 547 + println!("Result for deleted tracked file: {:?}", affected); 548 + 549 + // The path doesn't exist anymore, so canonicalize() fails. 550 + // We fall back to prefix matching, but exact path matching on 551 + // the non-canonical path should still work if stored that way. 552 + // Let's check what actually happens... 553 + match affected { 554 + Some(routes) => { 555 + // If we found routes, great - the system works 556 + assert!( 557 + routes.contains(&route), 558 + "Should find the route for deleted tracked file" 559 + ); 560 + } 561 + None => { 562 + // If None, that means we triggered a full rebuild, which is also safe 563 + // This happens because the file doesn't exist and wasn't found in mappings 564 + println!("Deleted tracked file triggered full rebuild (safe behavior)"); 565 + } 566 + } 232 567 } 233 568 }
+17 -4
crates/maudit/src/route.rs
··· 9 9 use std::any::Any; 10 10 use std::path::{Path, PathBuf}; 11 11 12 - use lol_html::{RewriteStrSettings, element, rewrite_str}; 12 + use lol_html::{element, rewrite_str, RewriteStrSettings}; 13 13 14 14 /// The result of a page render, can be either text, raw bytes, or an error. 15 15 /// ··· 504 504 pub trait InternalRoute { 505 505 fn route_raw(&self) -> Option<String>; 506 506 507 + /// Returns the source file path where this route is defined. 508 + /// This is used for incremental builds to track which routes are affected 509 + /// when a source file changes. 510 + fn source_file(&self) -> &'static str; 511 + 507 512 fn variants(&self) -> Vec<(String, String)> { 508 513 vec![] 509 514 } ··· 796 801 self.inner.route_raw() 797 802 } 798 803 804 + fn source_file(&self) -> &'static str { 805 + self.inner.source_file() 806 + } 807 + 799 808 fn variants(&self) -> Vec<(String, String)> { 800 809 self.inner.variants() 801 810 } ··· 957 966 //! use maudit::route::prelude::*; 958 967 //! ``` 959 968 pub use super::{ 960 - CachedRoute, DynamicRouteContext, FullRoute, Page, PageContext, PageParams, Pages, 961 - PaginatedContentPage, PaginationPage, RenderResult, Route, RouteExt, paginate, redirect, 969 + paginate, redirect, CachedRoute, DynamicRouteContext, FullRoute, Page, PageContext, 970 + PageParams, Pages, PaginatedContentPage, PaginationPage, RenderResult, Route, RouteExt, 962 971 }; 963 972 pub use crate::assets::{ 964 973 Asset, Image, ImageFormat, ImageOptions, ImagePlaceholder, RenderWithAlt, Script, Style, 965 974 StyleOptions, 966 975 }; 967 976 pub use crate::content::{ContentContext, ContentEntry, Entry, EntryInner, MarkdownContent}; 968 - pub use maudit_macros::{Params, route}; 977 + pub use maudit_macros::{route, Params}; 969 978 } 970 979 971 980 #[cfg(test)] ··· 982 991 impl InternalRoute for TestPage { 983 992 fn route_raw(&self) -> Option<String> { 984 993 Some(self.route.clone()) 994 + } 995 + 996 + fn source_file(&self) -> &'static str { 997 + file!() 985 998 } 986 999 } 987 1000
+3
e2e/fixtures/incremental-build/src/assets/about-content.txt
··· 1 + Learn more about us 2 + 3 + <!-- test-1-init -->
+1 -1
e2e/fixtures/incremental-build/src/main.rs
··· 1 - use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes}; 1 + use maudit::{content_sources, coronate, routes, BuildOptions, BuildOutput}; 2 2 3 3 mod pages; 4 4
+10 -1
e2e/fixtures/incremental-build/src/pages/about.rs
··· 2 2 use maudit::route::prelude::*; 3 3 use std::time::{SystemTime, UNIX_EPOCH}; 4 4 5 + use super::helpers; 6 + 7 + // Include content from external file - this creates a compile-time dependency 8 + const ABOUT_CONTENT: &str = include_str!("../assets/about-content.txt"); 9 + 5 10 #[route("/about")] 6 11 pub struct About; 7 12 ··· 12 17 // Shared style with index page (for testing shared assets) 13 18 let _style = ctx.assets.add_style("src/assets/styles.css"); 14 19 20 + // Use shared helper function 21 + let greeting = helpers::get_greeting(); 22 + 15 23 // Generate a unique build ID - uses nanoseconds for uniqueness 16 24 let build_id = SystemTime::now() 17 25 .duration_since(UNIX_EPOCH) ··· 25 33 } 26 34 body data-build-id=(build_id) { 27 35 h1 id="title" { "About Us" } 28 - p id="content" { "Learn more about us" } 36 + p id="greeting" { (greeting) } 37 + p id="content" { (ABOUT_CONTENT.trim()) } 29 38 } 30 39 } 31 40 }
+3
e2e/fixtures/incremental-build/src/pages/helpers.rs
··· 1 + pub fn get_greeting() -> &'static str { 2 + "Welcome to our site!" 3 + }
+1
e2e/fixtures/incremental-build/src/pages/mod.rs
··· 1 1 pub mod about; 2 2 pub mod blog; 3 + pub mod helpers; 3 4 pub mod index;
+352 -129
e2e/tests/incremental-build.spec.ts
··· 1 1 import { expect } from "@playwright/test"; 2 2 import { createTestWithFixture } from "./test-utils"; 3 - import { readFileSync, writeFileSync, renameSync, rmSync, existsSync } from "node:fs"; 3 + import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs"; 4 4 import { resolve, dirname } from "node:path"; 5 5 import { fileURLToPath } from "node:url"; 6 6 ··· 10 10 // Create test instance with incremental-build fixture 11 11 const test = createTestWithFixture("incremental-build"); 12 12 13 - // Allow retries for timing-sensitive tests 13 + // Run tests serially since they share state; allow retries for timing-sensitive tests 14 14 test.describe.configure({ mode: "serial", retries: 2 }); 15 15 16 16 /** 17 - * Wait for dev server to complete a build by looking for specific patterns. 18 - * Waits for the build to START, then waits for it to FINISH. 17 + * Wait for dev server to complete a build by polling logs. 18 + * Returns logs once build is finished. 19 19 */ 20 20 async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> { 21 21 const startTime = Date.now(); 22 + const pollInterval = 50; 22 23 23 24 // Phase 1: Wait for build to start 24 25 while (Date.now() - startTime < timeoutMs) { ··· 33 34 break; 34 35 } 35 36 36 - await new Promise((resolve) => setTimeout(resolve, 50)); 37 + await new Promise((r) => setTimeout(r, pollInterval)); 37 38 } 38 39 39 40 // Phase 2: Wait for build to finish ··· 46 47 logsText.includes("rerun finished") || 47 48 logsText.includes("build finished") 48 49 ) { 49 - // Wait for filesystem to fully sync 50 - await new Promise((resolve) => setTimeout(resolve, 500)); 51 - return devServer.getLogs(200); 50 + return logs; 52 51 } 53 52 54 - await new Promise((resolve) => setTimeout(resolve, 100)); 53 + await new Promise((r) => setTimeout(r, pollInterval)); 55 54 } 56 55 57 - // On timeout, log what we DID see for debugging 58 56 console.log("TIMEOUT - logs seen:", devServer.getLogs(50)); 59 57 throw new Error(`Build did not complete within ${timeoutMs}ms`); 60 58 } 61 59 62 60 /** 61 + * Wait for the dev server to become idle (no builds in progress). 62 + * This polls build IDs until they stop changing. 63 + */ 64 + async function waitForIdle(htmlPaths: Record<string, string>, stableMs = 200): Promise<void> { 65 + let lastIds = recordBuildIds(htmlPaths); 66 + let stableTime = 0; 67 + 68 + while (stableTime < stableMs) { 69 + await new Promise((r) => setTimeout(r, 50)); 70 + const currentIds = recordBuildIds(htmlPaths); 71 + 72 + const allSame = Object.keys(lastIds).every( 73 + (key) => lastIds[key] === currentIds[key] 74 + ); 75 + 76 + if (allSame) { 77 + stableTime += 50; 78 + } else { 79 + stableTime = 0; 80 + lastIds = currentIds; 81 + } 82 + } 83 + } 84 + 85 + /** 86 + * Wait for a specific HTML file's build ID to change from a known value. 87 + * This is more reliable than arbitrary sleeps. 88 + */ 89 + async function waitForBuildIdChange( 90 + htmlPath: string, 91 + previousId: string | null, 92 + timeoutMs = 30000, 93 + ): Promise<string> { 94 + const startTime = Date.now(); 95 + const pollInterval = 50; 96 + 97 + while (Date.now() - startTime < timeoutMs) { 98 + const currentId = getBuildId(htmlPath); 99 + if (currentId !== null && currentId !== previousId) { 100 + // Small delay to let any concurrent writes settle 101 + await new Promise((r) => setTimeout(r, 100)); 102 + return currentId; 103 + } 104 + await new Promise((r) => setTimeout(r, pollInterval)); 105 + } 106 + 107 + throw new Error(`Build ID did not change within ${timeoutMs}ms`); 108 + } 109 + 110 + /** 63 111 * Extract the build ID from an HTML file. 64 112 */ 65 113 function getBuildId(htmlPath: string): string | null { ··· 89 137 } 90 138 91 139 /** 92 - * Helper to set up incremental build state 93 - */ 94 - async function setupIncrementalState( 95 - devServer: any, 96 - triggerChange: (suffix: string) => Promise<string[]>, 97 - ): Promise<void> { 98 - // First change triggers a full build (no previous state) 99 - await triggerChange("init"); 100 - await new Promise((resolve) => setTimeout(resolve, 500)); 101 - 102 - // Second change should be incremental (state now exists) 103 - const logs = await triggerChange("setup"); 104 - expect(isIncrementalBuild(logs)).toBe(true); 105 - await new Promise((resolve) => setTimeout(resolve, 500)); 106 - } 107 - 108 - /** 109 140 * Record build IDs for all pages 110 141 */ 111 142 function recordBuildIds(htmlPaths: Record<string, string>): Record<string, string | null> { ··· 116 147 return ids; 117 148 } 118 149 150 + /** 151 + * Trigger a change and wait for build to complete. 152 + * Returns logs from the build. 153 + */ 154 + async function triggerAndWaitForBuild( 155 + devServer: any, 156 + modifyFn: () => void, 157 + timeoutMs = 30000, 158 + ): Promise<string[]> { 159 + devServer.clearLogs(); 160 + modifyFn(); 161 + return await waitForBuildComplete(devServer, timeoutMs); 162 + } 163 + 164 + /** 165 + * Set up incremental build state by triggering two builds. 166 + * First build establishes state, second ensures state is populated. 167 + * Returns build IDs recorded after the second build completes and server is idle. 168 + * 169 + * Note: We don't assert incremental here - the actual test will verify that. 170 + * This is because on first test run the server might still be initializing. 171 + */ 172 + async function setupIncrementalState( 173 + devServer: any, 174 + modifyFn: (suffix: string) => void, 175 + htmlPaths: Record<string, string>, 176 + expectedChangedRoute: string, // Which route we expect to change 177 + ): Promise<Record<string, string | null>> { 178 + // First change: triggers build (establishes state) 179 + const beforeInit = getBuildId(htmlPaths[expectedChangedRoute]); 180 + await triggerAndWaitForBuild(devServer, () => modifyFn("init")); 181 + await waitForBuildIdChange(htmlPaths[expectedChangedRoute], beforeInit); 182 + 183 + // Second change: state should now exist for incremental builds 184 + const beforeSetup = getBuildId(htmlPaths[expectedChangedRoute]); 185 + await triggerAndWaitForBuild(devServer, () => modifyFn("setup")); 186 + await waitForBuildIdChange(htmlPaths[expectedChangedRoute], beforeSetup); 187 + 188 + // Wait for server to become completely idle before recording baseline 189 + await waitForIdle(htmlPaths); 190 + 191 + return recordBuildIds(htmlPaths); 192 + } 193 + 119 194 test.describe("Incremental Build", () => { 120 195 test.setTimeout(180000); 121 196 ··· 173 248 test("CSS file change rebuilds only routes using it", async ({ devServer }) => { 174 249 let testCounter = 0; 175 250 176 - async function triggerChange(suffix: string) { 251 + function modifyFile(suffix: string) { 177 252 testCounter++; 178 - devServer.clearLogs(); 179 253 writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 180 - return await waitForBuildComplete(devServer, 30000); 181 254 } 182 255 183 - await setupIncrementalState(devServer, triggerChange); 184 - 185 - // Record build IDs before 186 - const before = recordBuildIds(htmlPaths); 256 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 187 257 expect(before.index).not.toBeNull(); 188 258 expect(before.about).not.toBeNull(); 189 259 expect(before.blog).not.toBeNull(); 190 260 191 - await new Promise((resolve) => setTimeout(resolve, 500)); 192 - 193 - // Trigger the change 194 - const logs = await triggerChange("final"); 261 + // Trigger the final change and wait for build 262 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 263 + await waitForBuildIdChange(htmlPaths.blog, before.blog); 195 264 196 265 // Verify incremental build with 1 route 197 266 expect(isIncrementalBuild(logs)).toBe(true); ··· 210 279 test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => { 211 280 let testCounter = 0; 212 281 213 - async function triggerChange(suffix: string) { 282 + function modifyFile(suffix: string) { 214 283 testCounter++; 215 - devServer.clearLogs(); 216 284 writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`); 217 - return await waitForBuildComplete(devServer, 30000); 218 285 } 219 286 220 - await setupIncrementalState(devServer, triggerChange); 221 - 222 - const before = recordBuildIds(htmlPaths); 287 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index"); 223 288 expect(before.index).not.toBeNull(); 224 289 225 - await new Promise((resolve) => setTimeout(resolve, 500)); 226 - 227 - const logs = await triggerChange("final"); 290 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 291 + await waitForBuildIdChange(htmlPaths.index, before.index); 228 292 229 293 // Verify incremental build with 1 route 230 294 expect(isIncrementalBuild(logs)).toBe(true); ··· 243 307 test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => { 244 308 let testCounter = 0; 245 309 246 - async function triggerChange(suffix: string) { 310 + function modifyFile(suffix: string) { 247 311 testCounter++; 248 - devServer.clearLogs(); 249 312 writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 250 - return await waitForBuildComplete(devServer, 30000); 251 313 } 252 314 253 - await setupIncrementalState(devServer, triggerChange); 254 - 255 - const before = recordBuildIds(htmlPaths); 315 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "about"); 256 316 expect(before.about).not.toBeNull(); 257 317 258 - await new Promise((resolve) => setTimeout(resolve, 500)); 259 - 260 - const logs = await triggerChange("final"); 318 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 319 + await waitForBuildIdChange(htmlPaths.about, before.about); 261 320 262 321 // Verify incremental build with 1 route 263 322 expect(isIncrementalBuild(logs)).toBe(true); ··· 276 335 test("shared asset change rebuilds all routes using it", async ({ devServer }) => { 277 336 let testCounter = 0; 278 337 279 - async function triggerChange(suffix: string) { 338 + function modifyFile(suffix: string) { 280 339 testCounter++; 281 - devServer.clearLogs(); 282 - writeFileSync( 283 - assets.stylesCss, 284 - originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`, 285 - ); 286 - return await waitForBuildComplete(devServer, 30000); 340 + writeFileSync(assets.stylesCss, originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`); 287 341 } 288 342 289 - await setupIncrementalState(devServer, triggerChange); 290 - 291 - const before = recordBuildIds(htmlPaths); 343 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index"); 292 344 expect(before.index).not.toBeNull(); 293 345 expect(before.about).not.toBeNull(); 294 346 295 - await new Promise((resolve) => setTimeout(resolve, 500)); 296 - 297 - const logs = await triggerChange("final"); 347 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 348 + await waitForBuildIdChange(htmlPaths.index, before.index); 298 349 299 350 // Verify incremental build with 2 routes (/ and /about both use styles.css) 300 351 expect(isIncrementalBuild(logs)).toBe(true); ··· 313 364 test("image change rebuilds only routes using it", async ({ devServer }) => { 314 365 let testCounter = 0; 315 366 316 - async function triggerChange(suffix: string) { 367 + function modifyFile(suffix: string) { 317 368 testCounter++; 318 - devServer.clearLogs(); 319 - // For images, we append bytes to change the file 320 - // This simulates modifying an image file 321 369 const modified = Buffer.concat([ 322 370 originals.logoPng as Buffer, 323 371 Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 324 372 ]); 325 373 writeFileSync(assets.logoPng, modified); 326 - return await waitForBuildComplete(devServer, 30000); 327 374 } 328 375 329 - await setupIncrementalState(devServer, triggerChange); 330 - 331 - const before = recordBuildIds(htmlPaths); 376 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "index"); 332 377 expect(before.index).not.toBeNull(); 333 378 334 - await new Promise((resolve) => setTimeout(resolve, 500)); 335 - 336 - const logs = await triggerChange("final"); 379 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 380 + await waitForBuildIdChange(htmlPaths.index, before.index); 337 381 338 382 // Verify incremental build with 1 route 339 383 expect(isIncrementalBuild(logs)).toBe(true); ··· 352 396 test("multiple file changes rebuild union of affected routes", async ({ devServer }) => { 353 397 let testCounter = 0; 354 398 355 - async function triggerChange(suffix: string) { 399 + function modifyFile(suffix: string) { 356 400 testCounter++; 357 - devServer.clearLogs(); 358 401 // Change both blog.css (affects /blog) and about.js (affects /about) 359 402 writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`); 360 403 writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`); 361 - return await waitForBuildComplete(devServer, 30000); 362 404 } 363 405 364 - await setupIncrementalState(devServer, triggerChange); 365 - 366 - const before = recordBuildIds(htmlPaths); 406 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 367 407 expect(before.about).not.toBeNull(); 368 408 expect(before.blog).not.toBeNull(); 369 409 370 - await new Promise((resolve) => setTimeout(resolve, 500)); 371 - 372 - const logs = await triggerChange("final"); 410 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 411 + await waitForBuildIdChange(htmlPaths.blog, before.blog); 373 412 374 413 // Verify incremental build with 2 routes (/about and /blog) 375 414 expect(isIncrementalBuild(logs)).toBe(true); ··· 390 429 }) => { 391 430 let testCounter = 0; 392 431 393 - async function triggerChange(suffix: string) { 432 + function modifyFile(suffix: string) { 394 433 testCounter++; 395 - devServer.clearLogs(); 396 - // Modify bg.png - this is referenced via url() in blog.css 397 - // Changing it should trigger rebundling and rebuild /blog 398 434 const modified = Buffer.concat([ 399 435 originals.bgPng as Buffer, 400 436 Buffer.from(`<!-- test-${testCounter}-${suffix} -->`), 401 437 ]); 402 438 writeFileSync(assets.bgPng, modified); 403 - return await waitForBuildComplete(devServer, 30000); 404 439 } 405 440 406 - await setupIncrementalState(devServer, triggerChange); 407 - 408 - const before = recordBuildIds(htmlPaths); 441 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 409 442 expect(before.blog).not.toBeNull(); 410 443 411 - await new Promise((resolve) => setTimeout(resolve, 500)); 412 - 413 - const logs = await triggerChange("final"); 444 + const logs = await triggerAndWaitForBuild(devServer, () => modifyFile("final")); 445 + await waitForBuildIdChange(htmlPaths.blog, before.blog); 414 446 415 447 // Verify incremental build triggered 416 448 expect(isIncrementalBuild(logs)).toBe(true); 417 449 418 450 // Blog should be rebuilt (uses blog.css which references bg.png via url()) 419 - // The bundler should have been re-run to update the hashed asset reference 420 451 const after = recordBuildIds(htmlPaths); 421 452 expect(after.blog).not.toBe(before.blog); 422 453 }); 423 454 424 455 // ============================================================ 425 - // TEST 8: Folder rename detection 456 + // TEST 8: Source file change rebuilds only routes defined in that file 457 + // ============================================================ 458 + test("source file change rebuilds only routes defined in that file", async ({ devServer }) => { 459 + // This test verifies that when a .rs source file changes, only routes 460 + // defined in that file are rebuilt (via source_to_routes tracking). 461 + // 462 + // Flow: 463 + // 1. Dev server starts → initial build → creates build_state.json with source file mappings 464 + // 2. Modify about.rs → cargo recompiles → binary reruns with MAUDIT_CHANGED_FILES 465 + // 3. New binary loads build_state.json and finds /about is affected by about.rs 466 + // 4. Only /about route is rebuilt 467 + // 468 + // Note: Unlike asset changes, .rs changes require cargo recompilation. 469 + // The binary's logs (showing "Incremental build") aren't captured by the 470 + // dev server's log collection, so we verify behavior through build IDs. 471 + 472 + const aboutRs = resolve(fixturePath, "src", "pages", "about.rs"); 473 + const originalAboutRs = readFileSync(aboutRs, "utf-8"); 474 + 475 + try { 476 + let testCounter = 0; 477 + 478 + function modifyFile(suffix: string) { 479 + testCounter++; 480 + writeFileSync(aboutRs, originalAboutRs + `\n// test-${testCounter}-${suffix}`); 481 + } 482 + 483 + const rsTimeout = 60000; 484 + 485 + // First change: triggers recompile + build (establishes build state with source_to_routes) 486 + const beforeInit = getBuildId(htmlPaths.about); 487 + await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout); 488 + await waitForBuildIdChange(htmlPaths.about, beforeInit, rsTimeout); 489 + 490 + // Record build IDs - state now exists with source_to_routes mappings 491 + const before = recordBuildIds(htmlPaths); 492 + expect(before.index).not.toBeNull(); 493 + expect(before.about).not.toBeNull(); 494 + expect(before.blog).not.toBeNull(); 495 + 496 + // Second change: should do incremental build (only about.rs route) 497 + await triggerAndWaitForBuild(devServer, () => modifyFile("final"), rsTimeout); 498 + await waitForBuildIdChange(htmlPaths.about, before.about, rsTimeout); 499 + 500 + // Verify only /about was rebuilt (it's defined in about.rs) 501 + const after = recordBuildIds(htmlPaths); 502 + expect(after.index).toBe(before.index); 503 + expect(after.blog).toBe(before.blog); 504 + expect(after.about).not.toBe(before.about); 505 + 506 + } finally { 507 + // Restore original content and wait for build to complete 508 + const beforeRestore = getBuildId(htmlPaths.about); 509 + writeFileSync(aboutRs, originalAboutRs); 510 + try { 511 + await waitForBuildIdChange(htmlPaths.about, beforeRestore, 60000); 512 + } catch { 513 + // Restoration build may not always complete, that's ok 514 + } 515 + } 516 + }); 517 + 518 + // ============================================================ 519 + // TEST 9: include_str! file change triggers full rebuild (untracked file) 520 + // ============================================================ 521 + test("include_str file change triggers full rebuild", async ({ devServer }) => { 522 + // This test verifies that changing a file referenced by include_str!() 523 + // triggers cargo recompilation and a FULL rebuild (all routes). 524 + // 525 + // Setup: about.rs uses include_str!("../assets/about-content.txt") 526 + // The .d file from cargo includes this dependency, so the dependency tracker 527 + // knows that changing about-content.txt requires recompilation. 528 + // 529 + // Flow: 530 + // 1. Dev server starts → initial build 531 + // 2. Modify about-content.txt → cargo recompiles (because .d file tracks it) 532 + // 3. Binary runs with MAUDIT_CHANGED_FILES pointing to about-content.txt 533 + // 4. Since about-content.txt is NOT in source_to_routes or asset_to_routes, 534 + // it's an "untracked file" and triggers a full rebuild of all routes 535 + // 536 + // This is the correct safe behavior - we don't know which route uses the 537 + // include_str! file, so we rebuild everything to ensure correctness. 538 + 539 + const contentFile = resolve(fixturePath, "src", "assets", "about-content.txt"); 540 + const originalContent = readFileSync(contentFile, "utf-8"); 541 + const rsTimeout = 60000; 542 + 543 + try { 544 + let testCounter = 0; 545 + 546 + function modifyFile(suffix: string) { 547 + testCounter++; 548 + writeFileSync(contentFile, originalContent + `\n<!-- test-${testCounter}-${suffix} -->`); 549 + } 550 + 551 + // First change: triggers recompile + full build (establishes build state) 552 + const beforeInit = getBuildId(htmlPaths.about); 553 + await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout); 554 + await waitForBuildIdChange(htmlPaths.about, beforeInit, rsTimeout); 555 + 556 + // Record build IDs before the final change 557 + const before = recordBuildIds(htmlPaths); 558 + expect(before.index).not.toBeNull(); 559 + expect(before.about).not.toBeNull(); 560 + expect(before.blog).not.toBeNull(); 561 + 562 + // Trigger the content file change with unique content to verify 563 + devServer.clearLogs(); 564 + writeFileSync(contentFile, originalContent + "\nUpdated content!"); 565 + await waitForBuildComplete(devServer, rsTimeout); 566 + await waitForBuildIdChange(htmlPaths.about, before.about, rsTimeout); 567 + 568 + // All routes should be rebuilt (full rebuild due to untracked file) 569 + const after = recordBuildIds(htmlPaths); 570 + expect(after.index).not.toBe(before.index); 571 + expect(after.about).not.toBe(before.about); 572 + expect(after.blog).not.toBe(before.blog); 573 + 574 + // Verify the content was actually updated in the output 575 + const aboutHtml = readFileSync(htmlPaths.about, "utf-8"); 576 + expect(aboutHtml).toContain("Updated content!"); 577 + 578 + } finally { 579 + // Restore original content and wait for build to complete 580 + const beforeRestore = getBuildId(htmlPaths.about); 581 + writeFileSync(contentFile, originalContent); 582 + try { 583 + await waitForBuildIdChange(htmlPaths.about, beforeRestore, 60000); 584 + } catch { 585 + // Restoration build may not always complete, that's ok 586 + } 587 + } 588 + }); 589 + 590 + // ============================================================ 591 + // TEST 10: Folder rename detection 426 592 // ============================================================ 427 593 test("folder rename is detected and affects routes using assets in that folder", async ({ devServer }) => { 428 594 // This test verifies that renaming a folder containing tracked assets ··· 440 606 441 607 // Ensure we start with the correct state 442 608 if (existsSync(renamedFolder)) { 443 - // Restore from previous failed run 444 609 renameSync(renamedFolder, iconsFolder); 445 - await new Promise((resolve) => setTimeout(resolve, 1000)); 610 + // Wait briefly for any triggered build to start 611 + await new Promise((resolve) => setTimeout(resolve, 500)); 446 612 } 447 613 448 - // Make sure the icons folder exists with the file 449 614 expect(existsSync(iconsFolder)).toBe(true); 450 615 expect(existsSync(iconFile)).toBe(true); 451 616 617 + const originalContent = readFileSync(iconFile, "utf-8"); 618 + 452 619 try { 453 - // First, trigger TWO builds to establish the asset tracking 454 - // The first build creates the state, the second ensures the icon is tracked 455 - const originalContent = readFileSync(iconFile, "utf-8"); 456 - 457 - // Build 1: Ensure blog-icon.css is used and tracked 458 - devServer.clearLogs(); 459 - writeFileSync(iconFile, originalContent + "\n/* setup1 */"); 460 - await waitForBuildComplete(devServer, 30000); 461 - await new Promise((resolve) => setTimeout(resolve, 500)); 620 + let testCounter = 0; 462 621 463 - // Build 2: Now the asset should definitely be in the state 464 - devServer.clearLogs(); 465 - writeFileSync(iconFile, originalContent + "\n/* setup2 */"); 466 - await waitForBuildComplete(devServer, 30000); 467 - await new Promise((resolve) => setTimeout(resolve, 500)); 622 + function modifyFile(suffix: string) { 623 + testCounter++; 624 + writeFileSync(iconFile, originalContent + `\n/* test-${testCounter}-${suffix} */`); 625 + } 626 + 627 + // Use setupIncrementalState to establish tracking 628 + const before = await setupIncrementalState(devServer, modifyFile, htmlPaths, "blog"); 629 + expect(before.blog).not.toBeNull(); 468 630 469 - // Clear for the actual test 631 + // Clear logs for the actual test 470 632 devServer.clearLogs(); 471 633 472 634 // Rename icons -> icons-renamed ··· 481 643 logs = devServer.getLogs(100); 482 644 const logsText = logs.join("\n"); 483 645 484 - // Wait for either success or failure 485 - if (logsText.includes("finished") || logsText.includes("failed")) { 646 + // Wait for either success or failure indication 647 + if (logsText.includes("finished") || logsText.includes("failed") || logsText.includes("error")) { 486 648 break; 487 649 } 488 650 489 651 await new Promise((resolve) => setTimeout(resolve, 100)); 490 652 } 491 653 492 - console.log("Logs after folder rename:", logs.slice(-15)); 493 - 654 + logs = devServer.getLogs(100); 494 655 const logsText = logs.join("\n"); 495 656 496 657 // Key assertions: verify the detection and route matching worked ··· 509 670 if (existsSync(renamedFolder) && !existsSync(iconsFolder)) { 510 671 renameSync(renamedFolder, iconsFolder); 511 672 } 512 - // Restore original content 673 + // Restore original content and wait for build 513 674 if (existsSync(iconFile)) { 514 - const content = readFileSync(iconFile, "utf-8"); 515 - writeFileSync(iconFile, content.replace(/\n\/\* setup[12] \*\//g, "")); 675 + const beforeRestore = getBuildId(htmlPaths.blog); 676 + writeFileSync(iconFile, originalContent); 677 + try { 678 + await waitForBuildIdChange(htmlPaths.blog, beforeRestore, 30000); 679 + } catch { 680 + // Restoration build may not always complete, that's ok 681 + } 516 682 } 517 - // Wait for restoration to be processed 518 - await new Promise((resolve) => setTimeout(resolve, 1000)); 683 + } 684 + }); 685 + 686 + // ============================================================ 687 + // TEST 11: Shared Rust module change triggers full rebuild 688 + // ============================================================ 689 + test("shared Rust module change triggers full rebuild", async ({ devServer }) => { 690 + // This test verifies that changing a shared Rust module (not a route file) 691 + // triggers a full rebuild of all routes. 692 + // 693 + // Setup: helpers.rs contains shared functions used by about.rs 694 + // The helpers.rs file is not tracked in source_to_routes (only route files are) 695 + // so it's treated as an "untracked file" which triggers a full rebuild. 696 + // 697 + // This is the correct safe behavior - we can't determine which routes 698 + // depend on the shared module, so we rebuild everything. 699 + 700 + const helpersRs = resolve(fixturePath, "src", "pages", "helpers.rs"); 701 + const originalContent = readFileSync(helpersRs, "utf-8"); 702 + const rsTimeout = 60000; 703 + 704 + try { 705 + let testCounter = 0; 706 + 707 + function modifyFile(suffix: string) { 708 + testCounter++; 709 + writeFileSync(helpersRs, originalContent + `\n// test-${testCounter}-${suffix}`); 710 + } 711 + 712 + // First change: triggers recompile + full build (establishes build state) 713 + const beforeInit = getBuildId(htmlPaths.index); 714 + await triggerAndWaitForBuild(devServer, () => modifyFile("init"), rsTimeout); 715 + await waitForBuildIdChange(htmlPaths.index, beforeInit, rsTimeout); 716 + 717 + // Record build IDs before the final change 718 + const before = recordBuildIds(htmlPaths); 719 + expect(before.index).not.toBeNull(); 720 + expect(before.about).not.toBeNull(); 721 + expect(before.blog).not.toBeNull(); 722 + 723 + // Trigger the shared module change 724 + await triggerAndWaitForBuild(devServer, () => modifyFile("final"), rsTimeout); 725 + await waitForBuildIdChange(htmlPaths.index, before.index, rsTimeout); 726 + 727 + // All routes should be rebuilt (full rebuild due to untracked shared module) 728 + const after = recordBuildIds(htmlPaths); 729 + expect(after.index).not.toBe(before.index); 730 + expect(after.about).not.toBe(before.about); 731 + expect(after.blog).not.toBe(before.blog); 732 + 733 + } finally { 734 + // Restore original content and wait for build to complete 735 + const beforeRestore = getBuildId(htmlPaths.index); 736 + writeFileSync(helpersRs, originalContent); 737 + try { 738 + await waitForBuildIdChange(htmlPaths.index, beforeRestore, 60000); 739 + } catch { 740 + // Restoration build may not always complete, that's ok 741 + } 519 742 } 520 743 }); 521 744 });