Rust library to generate static websites

feat: smoother hot reload

+532 -25
+61 -10
Cargo.lock
··· 1662 1662 checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 1663 1663 1664 1664 [[package]] 1665 + name = "fixtures-prefetch-prerender" 1666 + version = "0.1.0" 1667 + dependencies = [ 1668 + "maud", 1669 + "maudit", 1670 + ] 1671 + 1672 + [[package]] 1665 1673 name = "flate2" 1666 1674 version = "1.1.8" 1667 1675 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2608 2616 "tar", 2609 2617 "tokio", 2610 2618 "tokio-util", 2619 + "toml", 2611 2620 "toml_edit 0.24.0+spec-1.1.0", 2612 2621 "tower-http", 2613 2622 "tracing", ··· 3847 3856 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 3848 3857 3849 3858 [[package]] 3850 - name = "prefetch-prerender" 3851 - version = "0.1.0" 3852 - dependencies = [ 3853 - "maud", 3854 - "maudit", 3855 - ] 3856 - 3857 - [[package]] 3858 3859 name = "proc-macro-crate" 3859 3860 version = "3.4.0" 3860 3861 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4514 4515 ] 4515 4516 4516 4517 [[package]] 4518 + name = "serde_spanned" 4519 + version = "0.6.9" 4520 + source = "registry+https://github.com/rust-lang/crates.io-index" 4521 + checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" 4522 + dependencies = [ 4523 + "serde", 4524 + ] 4525 + 4526 + [[package]] 4517 4527 name = "serde_urlencoded" 4518 4528 version = "0.7.1" 4519 4529 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5011 5021 ] 5012 5022 5013 5023 [[package]] 5024 + name = "toml" 5025 + version = "0.8.23" 5026 + source = "registry+https://github.com/rust-lang/crates.io-index" 5027 + checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" 5028 + dependencies = [ 5029 + "serde", 5030 + "serde_spanned", 5031 + "toml_datetime 0.6.11", 5032 + "toml_edit 0.22.27", 5033 + ] 5034 + 5035 + [[package]] 5036 + name = "toml_datetime" 5037 + version = "0.6.11" 5038 + source = "registry+https://github.com/rust-lang/crates.io-index" 5039 + checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" 5040 + dependencies = [ 5041 + "serde", 5042 + ] 5043 + 5044 + [[package]] 5014 5045 name = "toml_datetime" 5015 5046 version = "0.7.5+spec-1.1.0" 5016 5047 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5021 5052 5022 5053 [[package]] 5023 5054 name = "toml_edit" 5055 + version = "0.22.27" 5056 + source = "registry+https://github.com/rust-lang/crates.io-index" 5057 + checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" 5058 + dependencies = [ 5059 + "indexmap", 5060 + "serde", 5061 + "serde_spanned", 5062 + "toml_datetime 0.6.11", 5063 + "toml_write", 5064 + "winnow", 5065 + ] 5066 + 5067 + [[package]] 5068 + name = "toml_edit" 5024 5069 version = "0.23.10+spec-1.0.0" 5025 5070 source = "registry+https://github.com/rust-lang/crates.io-index" 5026 5071 checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" 5027 5072 dependencies = [ 5028 5073 "indexmap", 5029 - "toml_datetime", 5074 + "toml_datetime 0.7.5+spec-1.1.0", 5030 5075 "toml_parser", 5031 5076 "winnow", 5032 5077 ] ··· 5038 5083 checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e" 5039 5084 dependencies = [ 5040 5085 "indexmap", 5041 - "toml_datetime", 5086 + "toml_datetime 0.7.5+spec-1.1.0", 5042 5087 "toml_parser", 5043 5088 "toml_writer", 5044 5089 "winnow", ··· 5052 5097 dependencies = [ 5053 5098 "winnow", 5054 5099 ] 5100 + 5101 + [[package]] 5102 + name = "toml_write" 5103 + version = "0.1.2" 5104 + source = "registry+https://github.com/rust-lang/crates.io-index" 5105 + checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" 5055 5106 5056 5107 [[package]] 5057 5108 name = "toml_writer"
+1 -1
Cargo.toml
··· 1 1 [workspace] 2 - members = ["crates/*", "benchmarks/*", "examples/*", "website", "xtask", "e2e/fixtures/*"] 2 + members = ["crates/*", "benchmarks/*", "examples/*", "website", "xtask", "e2e/fixtures/prefetch-prerender"] 3 3 resolver = "3" 4 4 5 5 [workspace.dependencies]
+1
crates/maudit-cli/Cargo.toml
··· 28 28 ureq = "3.1.4" 29 29 tar = "0.4.44" 30 30 toml_edit = "0.24.0" 31 + toml = "0.8" 31 32 local-ip-address = "0.6.9" 32 33 flate2 = "1.1.8" 33 34 quanta = "0.12.6"
+36 -12
crates/maudit-cli/src/dev.rs
··· 1 1 pub(crate) mod server; 2 2 3 3 mod build; 4 + mod dep_tracker; 4 5 mod filterer; 5 6 6 7 use notify::{ ··· 10 11 use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer}; 11 12 use quanta::Instant; 12 13 use server::WebSocketMessage; 13 - use std::{fs, path::Path}; 14 + use std::{fs, path::{Path, PathBuf}}; 14 15 use tokio::{ 15 16 signal, 16 17 sync::{broadcast, mpsc::channel}, ··· 162 163 } 163 164 } 164 165 } else { 165 - // Normal rebuild - spawn in background so file watcher can continue 166 - info!(name: "watch", "Files changed, rebuilding..."); 167 - let build_manager_clone = build_manager_watcher.clone(); 168 - tokio::spawn(async move { 169 - match build_manager_clone.start_build().await { 170 - Ok(_) => { 171 - // Build completed (success or failure already logged) 166 + // Normal rebuild - check if we need full recompilation or just rerun 167 + let changed_paths: Vec<PathBuf> = events.iter() 168 + .flat_map(|e| e.paths.iter().cloned()) 169 + .collect(); 170 + 171 + let needs_recompile = build_manager_watcher.needs_recompile(&changed_paths).await; 172 + 173 + if needs_recompile { 174 + // Need to recompile - spawn in background so file watcher can continue 175 + info!(name: "watch", "Files changed, rebuilding..."); 176 + let build_manager_clone = build_manager_watcher.clone(); 177 + tokio::spawn(async move { 178 + match build_manager_clone.start_build().await { 179 + Ok(_) => { 180 + // Build completed (success or failure already logged) 181 + } 182 + Err(e) => { 183 + error!(name: "build", "Failed to start build: {}", e); 184 + } 172 185 } 173 - Err(e) => { 174 - error!(name: "build", "Failed to start build: {}", e); 186 + }); 187 + } else { 188 + // Just rerun the binary without recompiling 189 + info!(name: "watch", "Non-dependency files changed, rerunning binary..."); 190 + let build_manager_clone = build_manager_watcher.clone(); 191 + tokio::spawn(async move { 192 + match build_manager_clone.rerun_binary().await { 193 + Ok(_) => { 194 + // Rerun completed (success or failure already logged) 195 + } 196 + Err(e) => { 197 + error!(name: "build", "Failed to rerun binary: {}", e); 198 + } 175 199 } 176 - } 177 - }); 200 + }); 201 + } 178 202 } 179 203 } 180 204 }
+183 -2
crates/maudit-cli/src/dev/build.rs
··· 1 1 use cargo_metadata::Message; 2 2 use quanta::Instant; 3 3 use server::{StatusType, WebSocketMessage, update_status}; 4 + use std::path::PathBuf; 4 5 use std::sync::Arc; 5 6 use tokio::process::Command; 6 7 use tokio::sync::broadcast; 7 8 use tokio_util::sync::CancellationToken; 8 - use tracing::{debug, error, info}; 9 + use tracing::{debug, error, info, warn}; 9 10 10 11 use crate::{ 11 12 dev::server, 12 13 logging::{FormatElapsedTimeOptions, format_elapsed_time}, 13 14 }; 14 15 16 + use super::dep_tracker::{DependencyTracker, find_target_dir}; 17 + 15 18 #[derive(Clone)] 16 19 pub struct BuildManager { 17 20 current_cancel: Arc<tokio::sync::RwLock<Option<CancellationToken>>>, 18 21 build_semaphore: Arc<tokio::sync::Semaphore>, 19 22 websocket_tx: broadcast::Sender<WebSocketMessage>, 20 23 current_status: Arc<tokio::sync::RwLock<Option<server::PersistentStatus>>>, 24 + dep_tracker: Arc<tokio::sync::RwLock<Option<DependencyTracker>>>, 25 + binary_path: Arc<tokio::sync::RwLock<Option<PathBuf>>>, 21 26 } 22 27 23 28 impl BuildManager { ··· 27 32 build_semaphore: Arc::new(tokio::sync::Semaphore::new(1)), // Only one build at a time 28 33 websocket_tx, 29 34 current_status: Arc::new(tokio::sync::RwLock::new(None)), 35 + dep_tracker: Arc::new(tokio::sync::RwLock::new(None)), 36 + binary_path: Arc::new(tokio::sync::RwLock::new(None)), 30 37 } 31 38 } 32 39 ··· 35 42 self.current_status.clone() 36 43 } 37 44 45 + /// Check if the given paths require recompilation based on dependency tracking 46 + /// Returns true if recompilation is needed, false if we can just rerun the binary 47 + pub async fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool { 48 + let dep_tracker = self.dep_tracker.read().await; 49 + 50 + if let Some(tracker) = dep_tracker.as_ref() { 51 + if tracker.has_dependencies() { 52 + let needs_recompile = tracker.needs_recompile(changed_paths); 53 + if !needs_recompile { 54 + debug!(name: "build", "Changed files are not dependencies, rerun binary without recompile"); 55 + } 56 + return needs_recompile; 57 + } 58 + } 59 + 60 + // If we don't have a dependency tracker yet, always recompile 61 + true 62 + } 63 + 64 + /// Rerun the binary without recompiling 65 + pub async fn rerun_binary(&self) -> Result<bool, Box<dyn std::error::Error>> { 66 + let binary_path = self.binary_path.read().await; 67 + 68 + let Some(path) = binary_path.as_ref() else { 69 + warn!(name: "build", "No binary path available, falling back to full rebuild"); 70 + return self.start_build().await; 71 + }; 72 + 73 + if !path.exists() { 74 + warn!(name: "build", "Binary at {:?} no longer exists, falling back to full rebuild", path); 75 + return self.start_build().await; 76 + } 77 + 78 + info!(name: "build", "Rerunning binary without recompilation..."); 79 + 80 + // Notify that build is starting (even though we're just rerunning) 81 + update_status( 82 + &self.websocket_tx, 83 + self.current_status.clone(), 84 + StatusType::Info, 85 + "Rerunning...", 86 + ) 87 + .await; 88 + 89 + let build_start_time = Instant::now(); 90 + 91 + let child = Command::new(path) 92 + .envs([ 93 + ("MAUDIT_DEV", "true"), 94 + ("MAUDIT_QUIET", "true"), 95 + ]) 96 + .stdout(std::process::Stdio::piped()) 97 + .stderr(std::process::Stdio::piped()) 98 + .spawn()?; 99 + 100 + // Wait for the process to complete 101 + let output = child.wait_with_output().await?; 102 + 103 + let duration = build_start_time.elapsed(); 104 + let formatted_elapsed_time = format_elapsed_time( 105 + duration, 106 + &FormatElapsedTimeOptions::default_dev(), 107 + ); 108 + 109 + if output.status.success() { 110 + info!(name: "build", "Binary rerun finished {}", formatted_elapsed_time); 111 + update_status( 112 + &self.websocket_tx, 113 + self.current_status.clone(), 114 + StatusType::Success, 115 + "Binary rerun finished successfully", 116 + ) 117 + .await; 118 + Ok(true) 119 + } else { 120 + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); 121 + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); 122 + error!(name: "build", "Binary rerun failed {}\nstdout: {}\nstderr: {}", 123 + formatted_elapsed_time, stdout, stderr); 124 + update_status( 125 + &self.websocket_tx, 126 + self.current_status.clone(), 127 + StatusType::Error, 128 + &format!("Binary rerun failed:\n{}\n{}", stdout, stderr), 129 + ) 130 + .await; 131 + Ok(false) 132 + } 133 + } 134 + 38 135 /// Do initial build that can be cancelled (but isn't stored as current build) 39 136 pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error>> { 40 137 self.internal_build(true).await ··· 91 188 92 189 let websocket_tx = self.websocket_tx.clone(); 93 190 let current_status = self.current_status.clone(); 191 + let dep_tracker_clone = self.dep_tracker.clone(); 192 + let binary_path_clone = self.binary_path.clone(); 94 193 let build_start_time = Instant::now(); 95 194 96 195 // Create a channel to get the build result back ··· 182 281 if output.status.success() { 183 282 let build_type = if is_initial { "Initial build" } else { "Rebuild" }; 184 283 info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time); 185 - update_status(&websocket_tx, current_status, StatusType::Success, "Build finished successfully").await; 284 + update_status(&websocket_tx, current_status.clone(), StatusType::Success, "Build finished successfully").await; 285 + 286 + // Update dependency tracker after successful build 287 + Self::update_dependency_tracker_after_build(dep_tracker_clone.clone(), binary_path_clone.clone()).await; 288 + 186 289 true 187 290 } else { 188 291 let stderr = String::from_utf8_lossy(&output.stderr).to_string(); ··· 212 315 // Wait for the build result 213 316 let success = result_rx.recv().await.unwrap_or(false); 214 317 Ok(success) 318 + } 319 + 320 + /// Update the dependency tracker after a successful build 321 + async fn update_dependency_tracker_after_build( 322 + dep_tracker: Arc<tokio::sync::RwLock<Option<DependencyTracker>>>, 323 + binary_path: Arc<tokio::sync::RwLock<Option<PathBuf>>>, 324 + ) { 325 + // Try to get the binary name from Cargo.toml in the current directory 326 + let binary_name = match Self::get_binary_name_from_cargo_toml() { 327 + Ok(name) => name, 328 + Err(e) => { 329 + debug!(name: "build", "Could not determine binary name: {}", e); 330 + return; 331 + } 332 + }; 333 + 334 + debug!(name: "build", "Detected binary name: {}", binary_name); 335 + 336 + // Find the target directory 337 + let target_dir = match find_target_dir() { 338 + Ok(dir) => dir, 339 + Err(e) => { 340 + debug!(name: "build", "Could not find target directory: {}", e); 341 + return; 342 + } 343 + }; 344 + 345 + // Update binary path 346 + let bin_path = target_dir.join(&binary_name); 347 + if bin_path.exists() { 348 + *binary_path.write().await = Some(bin_path.clone()); 349 + debug!(name: "build", "Binary path set to: {:?}", bin_path); 350 + } else { 351 + debug!(name: "build", "Binary not found at expected path: {:?}", bin_path); 352 + } 353 + 354 + // Try to load the dependency tracker 355 + match DependencyTracker::load_from_binary_name(&binary_name) { 356 + Ok(tracker) => { 357 + debug!(name: "build", "Loaded {} dependencies for tracking", tracker.get_dependencies().len()); 358 + *dep_tracker.write().await = Some(tracker); 359 + } 360 + Err(e) => { 361 + debug!(name: "build", "Could not load dependency tracker: {}", e); 362 + } 363 + } 364 + } 365 + 366 + /// Get the binary name from Cargo.toml in the current directory 367 + fn get_binary_name_from_cargo_toml() -> Result<String, Box<dyn std::error::Error>> { 368 + let cargo_toml_path = PathBuf::from("Cargo.toml"); 369 + if !cargo_toml_path.exists() { 370 + return Err("Cargo.toml not found in current directory".into()); 371 + } 372 + 373 + let cargo_toml_content = std::fs::read_to_string(&cargo_toml_path)?; 374 + let cargo_toml: toml::Value = toml::from_str(&cargo_toml_content)?; 375 + 376 + // First, try to get the package name 377 + if let Some(package_name) = cargo_toml 378 + .get("package") 379 + .and_then(|p| p.get("name")) 380 + .and_then(|n| n.as_str()) 381 + { 382 + // Check if there's a [[bin]] section with a different name 383 + if let Some(bins) = cargo_toml.get("bin").and_then(|b| b.as_array()) { 384 + if let Some(first_bin) = bins.first() { 385 + if let Some(bin_name) = first_bin.get("name").and_then(|n| n.as_str()) { 386 + return Ok(bin_name.to_string()); 387 + } 388 + } 389 + } 390 + 391 + // No explicit bin name, use package name 392 + return Ok(package_name.to_string()); 393 + } 394 + 395 + Err("Could not find package name in Cargo.toml".into()) 215 396 } 216 397 }
+250
crates/maudit-cli/src/dev/dep_tracker.rs
··· 1 + use std::collections::HashMap; 2 + use std::fs; 3 + use std::path::{Path, PathBuf}; 4 + use std::time::SystemTime; 5 + use tracing::{debug, warn}; 6 + 7 + /// Tracks dependencies from .d files to determine if recompilation is needed 8 + #[derive(Debug, Clone)] 9 + pub struct DependencyTracker { 10 + /// Path to the .d file 11 + d_file_path: Option<PathBuf>, 12 + /// Map of dependency paths to their last modification times 13 + dependencies: HashMap<PathBuf, SystemTime>, 14 + } 15 + 16 + /// Find the target directory using multiple strategies 17 + /// 18 + /// This function tries multiple approaches to locate the target directory: 19 + /// 1. CARGO_TARGET_DIR / CARGO_BUILD_TARGET_DIR environment variables 20 + /// 2. Local ./target/debug directory 21 + /// 3. Workspace root target/debug directory (walking up to find [workspace]) 22 + /// 4. Fallback to relative "target/debug" path 23 + pub fn find_target_dir() -> Result<PathBuf, std::io::Error> { 24 + // 1. Check CARGO_TARGET_DIR and CARGO_BUILD_TARGET_DIR environment variables 25 + for env_var in ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"] { 26 + if let Ok(target_dir) = std::env::var(env_var) { 27 + // Try with /debug appended 28 + let path = PathBuf::from(&target_dir).join("debug"); 29 + if path.exists() { 30 + debug!("Using target directory from {}: {:?}", env_var, path); 31 + return Ok(path); 32 + } 33 + // If the env var points directly to debug or release 34 + let path_no_debug = PathBuf::from(&target_dir); 35 + if path_no_debug.exists() 36 + && (path_no_debug.ends_with("debug") || path_no_debug.ends_with("release")) 37 + { 38 + debug!( 39 + "Using target directory from {} (direct): {:?}", 40 + env_var, path_no_debug 41 + ); 42 + return Ok(path_no_debug); 43 + } 44 + } 45 + } 46 + 47 + // 2. Look for target directory in current directory 48 + let local_target = PathBuf::from("target/debug"); 49 + if local_target.exists() { 50 + debug!("Using local target directory: {:?}", local_target); 51 + return Ok(local_target); 52 + } 53 + 54 + // 3. Try to find workspace root by looking for Cargo.toml with [workspace] 55 + let mut current = std::env::current_dir()?; 56 + loop { 57 + let cargo_toml = current.join("Cargo.toml"); 58 + if cargo_toml.exists() { 59 + if let Ok(content) = fs::read_to_string(&cargo_toml) { 60 + if content.contains("[workspace]") { 61 + let workspace_target = current.join("target").join("debug"); 62 + if workspace_target.exists() { 63 + debug!("Using workspace target directory: {:?}", workspace_target); 64 + return Ok(workspace_target); 65 + } 66 + } 67 + } 68 + } 69 + 70 + // Move up to parent directory 71 + if !current.pop() { 72 + break; 73 + } 74 + } 75 + 76 + // 4. Final fallback to relative path 77 + debug!("Falling back to relative target/debug path"); 78 + Ok(PathBuf::from("target/debug")) 79 + } 80 + 81 + impl DependencyTracker { 82 + #[allow(dead_code)] 83 + pub fn new() -> Self { 84 + Self { 85 + d_file_path: None, 86 + dependencies: HashMap::new(), 87 + } 88 + } 89 + 90 + /// Locate and load the .d file for the current binary 91 + /// The .d file is typically at target/debug/<binary-name>.d 92 + pub fn load_from_binary_name(binary_name: &str) -> Result<Self, std::io::Error> { 93 + let target_dir = find_target_dir()?; 94 + let d_file_path = target_dir.join(format!("{}.d", binary_name)); 95 + 96 + if !d_file_path.exists() { 97 + return Err(std::io::Error::new( 98 + std::io::ErrorKind::NotFound, 99 + format!(".d file not found at {:?}", d_file_path), 100 + )); 101 + } 102 + 103 + let mut tracker = Self { 104 + d_file_path: Some(d_file_path.clone()), 105 + dependencies: HashMap::new(), 106 + }; 107 + 108 + tracker.reload_dependencies()?; 109 + Ok(tracker) 110 + } 111 + 112 + /// Reload dependencies from the .d file 113 + pub fn reload_dependencies(&mut self) -> Result<(), std::io::Error> { 114 + let Some(d_file_path) = &self.d_file_path else { 115 + return Err(std::io::Error::new( 116 + std::io::ErrorKind::NotFound, 117 + "No .d file path set", 118 + )); 119 + }; 120 + 121 + let content = fs::read_to_string(d_file_path)?; 122 + 123 + // Parse the .d file format: "target: dep1 dep2 dep3 ..." 124 + // The first line contains the target and dependencies, separated by ':' 125 + let deps = if let Some(colon_pos) = content.find(':') { 126 + // Everything after the colon is dependencies 127 + &content[colon_pos + 1..] 128 + } else { 129 + // Malformed .d file 130 + warn!("Malformed .d file at {:?}", d_file_path); 131 + return Ok(()); 132 + }; 133 + 134 + // Dependencies are space-separated and may span multiple lines (with line continuations) 135 + let dep_paths: Vec<PathBuf> = deps 136 + .split_whitespace() 137 + .filter(|s| !s.is_empty() && *s != "\\") // Filter out line continuation characters 138 + .map(PathBuf::from) 139 + .collect(); 140 + 141 + // Clear old dependencies and load new ones with their modification times 142 + self.dependencies.clear(); 143 + 144 + for dep_path in dep_paths { 145 + match fs::metadata(&dep_path) { 146 + Ok(metadata) => { 147 + if let Ok(modified) = metadata.modified() { 148 + self.dependencies.insert(dep_path.clone(), modified); 149 + debug!("Tracking dependency: {:?}", dep_path); 150 + } 151 + } 152 + Err(e) => { 153 + // Dependency file doesn't exist or can't be read - this is okay, 154 + // it might have been deleted or moved 155 + debug!("Could not read dependency {:?}: {}", dep_path, e); 156 + } 157 + } 158 + } 159 + 160 + debug!( 161 + "Loaded {} dependencies from {:?}", 162 + self.dependencies.len(), 163 + d_file_path 164 + ); 165 + Ok(()) 166 + } 167 + 168 + /// Check if any of the given paths require recompilation 169 + /// Returns true if any path is a tracked dependency that has been modified 170 + pub fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool { 171 + for changed_path in changed_paths { 172 + // Normalize the changed path to handle relative vs absolute paths 173 + let changed_path_canonical = changed_path.canonicalize().ok(); 174 + 175 + for (dep_path, last_modified) in &self.dependencies { 176 + // Try to match both exact path and canonical path 177 + let matches = changed_path == dep_path 178 + || changed_path_canonical.as_ref() == Some(dep_path) 179 + || dep_path.canonicalize().ok().as_ref() == changed_path_canonical.as_ref(); 180 + 181 + if matches { 182 + // Check if the file was modified after we last tracked it 183 + if let Ok(metadata) = fs::metadata(changed_path) { 184 + if let Ok(current_modified) = metadata.modified() { 185 + if current_modified > *last_modified { 186 + debug!( 187 + "Dependency {:?} was modified, recompile needed", 188 + changed_path 189 + ); 190 + return true; 191 + } 192 + } 193 + } else { 194 + // File was deleted or can't be read, assume recompile is needed 195 + debug!( 196 + "Dependency {:?} no longer exists, recompile needed", 197 + changed_path 198 + ); 199 + return true; 200 + } 201 + } 202 + } 203 + } 204 + 205 + false 206 + } 207 + 208 + /// Get the list of tracked dependency paths 209 + pub fn get_dependencies(&self) -> Vec<&Path> { 210 + self.dependencies.keys().map(|p| p.as_path()).collect() 211 + } 212 + 213 + /// Check if we have any dependencies loaded 214 + pub fn has_dependencies(&self) -> bool { 215 + !self.dependencies.is_empty() 216 + } 217 + } 218 + 219 + #[cfg(test)] 220 + mod tests { 221 + use super::*; 222 + use std::fs; 223 + use std::io::Write; 224 + use tempfile::TempDir; 225 + 226 + #[test] 227 + fn test_parse_d_file() { 228 + let temp_dir = TempDir::new().unwrap(); 229 + let d_file_path = temp_dir.path().join("test.d"); 230 + 231 + // Create a mock .d file 232 + let mut d_file = fs::File::create(&d_file_path).unwrap(); 233 + writeln!( 234 + d_file, 235 + "/path/to/target: /path/to/dep1.rs /path/to/dep2.rs \\" 236 + ) 237 + .unwrap(); 238 + writeln!(d_file, " /path/to/dep3.rs").unwrap(); 239 + 240 + // Create a tracker and point it to our test file 241 + let mut tracker = DependencyTracker::new(); 242 + tracker.d_file_path = Some(d_file_path); 243 + 244 + // This will fail to load the actual files, but we can check the parsing logic 245 + let _ = tracker.reload_dependencies(); 246 + 247 + // We won't have any dependencies because the files don't exist, 248 + // but we've verified the parsing doesn't crash 249 + } 250 + }