+1
-1
.github/workflows/benchmark.yaml
+1
-1
.github/workflows/benchmark.yaml
+4
-4
.github/workflows/ci.yaml
+4
-4
.github/workflows/ci.yaml
···
38
uses: actions/setup-node@v4
39
with:
40
node-version: latest
41
-
cache: 'pnpm'
42
43
- name: Install dependencies
44
run: pnpm install
···
66
uses: actions/setup-node@v4
67
with:
68
node-version: latest
69
-
cache: 'pnpm'
70
71
- name: Install dependencies
72
run: pnpm install
···
94
uses: actions/setup-node@v4
95
with:
96
node-version: latest
97
-
cache: 'pnpm'
98
99
- name: Install dependencies
100
run: pnpm install
···
126
uses: actions/setup-node@v4
127
with:
128
node-version: latest
129
-
cache: 'pnpm'
130
131
- name: Install dependencies
132
run: pnpm install
···
38
uses: actions/setup-node@v4
39
with:
40
node-version: latest
41
+
cache: "pnpm"
42
43
- name: Install dependencies
44
run: pnpm install
···
66
uses: actions/setup-node@v4
67
with:
68
node-version: latest
69
+
cache: "pnpm"
70
71
- name: Install dependencies
72
run: pnpm install
···
94
uses: actions/setup-node@v4
95
with:
96
node-version: latest
97
+
cache: "pnpm"
98
99
- name: Install dependencies
100
run: pnpm install
···
126
uses: actions/setup-node@v4
127
with:
128
node-version: latest
129
+
cache: "pnpm"
130
131
- name: Install dependencies
132
run: pnpm install
+1
-1
.github/workflows/release.yml
+1
-1
.github/workflows/release.yml
+2
-6
.vscode/extensions.json
+2
-6
.vscode/extensions.json
+14
-14
.vscode/settings.json
+14
-14
.vscode/settings.json
···
1
{
2
-
"typescript.experimental.useTsgo": true,
3
-
"editor.defaultFormatter": "oxc.oxc-vscode",
4
-
"oxc.typeAware": true,
5
-
"oxc.fixKind": "safe_fix",
6
-
"oxc.unusedDisableDirectives": "deny",
7
-
"[rust]": {
8
-
"editor.defaultFormatter": "rust-lang.rust-analyzer"
9
-
},
10
-
"editor.codeActionsOnSave": {
11
-
"source.fixAll.oxc": "explicit"
12
-
},
13
-
"biome.enabled": false,
14
-
"css.lint.unknownAtRules": "ignore",
15
-
}
···
1
{
2
+
"typescript.experimental.useTsgo": true,
3
+
"editor.defaultFormatter": "oxc.oxc-vscode",
4
+
"oxc.typeAware": true,
5
+
"oxc.fixKind": "safe_fix",
6
+
"oxc.unusedDisableDirectives": "deny",
7
+
"[rust]": {
8
+
"editor.defaultFormatter": "rust-lang.rust-analyzer"
9
+
},
10
+
"editor.codeActionsOnSave": {
11
+
"source.fixAll.oxc": "explicit"
12
+
},
13
+
"biome.enabled": false,
14
+
"css.lint.unknownAtRules": "ignore"
15
+
}
+64
-2
Cargo.lock
+64
-2
Cargo.lock
···
1670
]
1671
1672
[[package]]
1673
name = "fixtures-prefetch-prerender"
1674
version = "0.1.0"
1675
dependencies = [
···
2574
dependencies = [
2575
"base64",
2576
"brk_rolldown",
2577
"brk_rolldown_plugin_replace",
2578
"chrono",
2579
"colored 3.1.1",
···
2592
"rayon",
2593
"rustc-hash",
2594
"serde",
2595
"serde_yaml",
2596
"slug",
2597
"syntect",
···
2622
"serde_json",
2623
"spinach",
2624
"tar",
2625
"tokio",
2626
"tokio-util",
2627
"toml_edit 0.24.0+spec-1.1.0",
2628
"tower-http",
2629
"tracing",
···
4522
]
4523
4524
[[package]]
4525
name = "serde_urlencoded"
4526
version = "0.7.1"
4527
source = "registry+https://github.com/rust-lang/crates.io-index"
···
5019
]
5020
5021
[[package]]
5022
name = "toml_datetime"
5023
version = "0.7.5+spec-1.1.0"
5024
source = "registry+https://github.com/rust-lang/crates.io-index"
···
5029
5030
[[package]]
5031
name = "toml_edit"
5032
version = "0.23.10+spec-1.0.0"
5033
source = "registry+https://github.com/rust-lang/crates.io-index"
5034
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
5035
dependencies = [
5036
"indexmap",
5037
-
"toml_datetime",
5038
"toml_parser",
5039
"winnow",
5040
]
···
5046
checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e"
5047
dependencies = [
5048
"indexmap",
5049
-
"toml_datetime",
5050
"toml_parser",
5051
"toml_writer",
5052
"winnow",
···
5060
dependencies = [
5061
"winnow",
5062
]
5063
5064
[[package]]
5065
name = "toml_writer"
···
1670
]
1671
1672
[[package]]
1673
+
name = "fixtures-incremental-build"
1674
+
version = "0.1.0"
1675
+
dependencies = [
1676
+
"maud",
1677
+
"maudit",
1678
+
]
1679
+
1680
+
[[package]]
1681
name = "fixtures-prefetch-prerender"
1682
version = "0.1.0"
1683
dependencies = [
···
2582
dependencies = [
2583
"base64",
2584
"brk_rolldown",
2585
+
"brk_rolldown_common",
2586
"brk_rolldown_plugin_replace",
2587
"chrono",
2588
"colored 3.1.1",
···
2601
"rayon",
2602
"rustc-hash",
2603
"serde",
2604
+
"serde_json",
2605
"serde_yaml",
2606
"slug",
2607
"syntect",
···
2632
"serde_json",
2633
"spinach",
2634
"tar",
2635
+
"tempfile",
2636
"tokio",
2637
"tokio-util",
2638
+
"toml",
2639
"toml_edit 0.24.0+spec-1.1.0",
2640
"tower-http",
2641
"tracing",
···
4534
]
4535
4536
[[package]]
4537
+
name = "serde_spanned"
4538
+
version = "0.6.9"
4539
+
source = "registry+https://github.com/rust-lang/crates.io-index"
4540
+
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
4541
+
dependencies = [
4542
+
"serde",
4543
+
]
4544
+
4545
+
[[package]]
4546
name = "serde_urlencoded"
4547
version = "0.7.1"
4548
source = "registry+https://github.com/rust-lang/crates.io-index"
···
5040
]
5041
5042
[[package]]
5043
+
name = "toml"
5044
+
version = "0.8.23"
5045
+
source = "registry+https://github.com/rust-lang/crates.io-index"
5046
+
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
5047
+
dependencies = [
5048
+
"serde",
5049
+
"serde_spanned",
5050
+
"toml_datetime 0.6.11",
5051
+
"toml_edit 0.22.27",
5052
+
]
5053
+
5054
+
[[package]]
5055
+
name = "toml_datetime"
5056
+
version = "0.6.11"
5057
+
source = "registry+https://github.com/rust-lang/crates.io-index"
5058
+
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
5059
+
dependencies = [
5060
+
"serde",
5061
+
]
5062
+
5063
+
[[package]]
5064
name = "toml_datetime"
5065
version = "0.7.5+spec-1.1.0"
5066
source = "registry+https://github.com/rust-lang/crates.io-index"
···
5071
5072
[[package]]
5073
name = "toml_edit"
5074
+
version = "0.22.27"
5075
+
source = "registry+https://github.com/rust-lang/crates.io-index"
5076
+
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
5077
+
dependencies = [
5078
+
"indexmap",
5079
+
"serde",
5080
+
"serde_spanned",
5081
+
"toml_datetime 0.6.11",
5082
+
"toml_write",
5083
+
"winnow",
5084
+
]
5085
+
5086
+
[[package]]
5087
+
name = "toml_edit"
5088
version = "0.23.10+spec-1.0.0"
5089
source = "registry+https://github.com/rust-lang/crates.io-index"
5090
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
5091
dependencies = [
5092
"indexmap",
5093
+
"toml_datetime 0.7.5+spec-1.1.0",
5094
"toml_parser",
5095
"winnow",
5096
]
···
5102
checksum = "8c740b185920170a6d9191122cafef7010bd6270a3824594bff6784c04d7f09e"
5103
dependencies = [
5104
"indexmap",
5105
+
"toml_datetime 0.7.5+spec-1.1.0",
5106
"toml_parser",
5107
"toml_writer",
5108
"winnow",
···
5116
dependencies = [
5117
"winnow",
5118
]
5119
+
5120
+
[[package]]
5121
+
name = "toml_write"
5122
+
version = "0.1.2"
5123
+
source = "registry+https://github.com/rust-lang/crates.io-index"
5124
+
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
5125
5126
[[package]]
5127
name = "toml_writer"
+3
-1
crates/maudit/Cargo.toml
+3
-1
crates/maudit/Cargo.toml
···
23
24
# TODO: Allow making those optional
25
rolldown = { package = "brk_rolldown", version = "0.8.0" }
26
serde = { workspace = true }
27
serde_yaml = "0.9.34"
28
pulldown-cmark = "0.13.0"
29
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
···
48
rayon = "1.11.0"
49
rapidhash = "4.2.1"
50
pathdiff = "0.2.3"
51
-
rolldown_plugin_replace = {package = "brk_rolldown_plugin_replace", version = "0.8.0"}
52
53
[dev-dependencies]
54
tempfile = "3.24.0"
···
23
24
# TODO: Allow making those optional
25
rolldown = { package = "brk_rolldown", version = "0.8.0" }
26
+
rolldown_common = { package = "brk_rolldown_common", version = "0.8.0" }
27
serde = { workspace = true }
28
+
serde_json = "1.0"
29
serde_yaml = "0.9.34"
30
pulldown-cmark = "0.13.0"
31
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
···
50
rayon = "1.11.0"
51
rapidhash = "4.2.1"
52
pathdiff = "0.2.3"
53
+
rolldown_plugin_replace = { package = "brk_rolldown_plugin_replace", version = "0.8.0" }
54
55
[dev-dependencies]
56
tempfile = "3.24.0"
+4
-8
crates/maudit/src/assets/image_cache.rs
+4
-8
crates/maudit/src/assets/image_cache.rs
···
338
339
#[test]
340
fn test_build_options_integration() {
341
-
use crate::build::options::{AssetsOptions, BuildOptions};
342
343
// Test that BuildOptions can configure the cache directory
344
let custom_cache = PathBuf::from("/tmp/custom_maudit_cache");
345
let build_options = BuildOptions {
346
-
assets: AssetsOptions {
347
-
image_cache_dir: custom_cache.clone(),
348
-
..Default::default()
349
-
},
350
..Default::default()
351
};
352
353
-
// Create cache with build options
354
-
let cache = ImageCache::with_cache_dir(&build_options.assets.image_cache_dir);
355
356
// Verify it uses the configured directory
357
-
assert_eq!(cache.get_cache_dir(), custom_cache);
358
}
359
360
#[test]
···
338
339
#[test]
340
fn test_build_options_integration() {
341
+
use crate::build::options::BuildOptions;
342
343
// Test that BuildOptions can configure the cache directory
344
let custom_cache = PathBuf::from("/tmp/custom_maudit_cache");
345
let build_options = BuildOptions {
346
+
cache_dir: custom_cache.clone(),
347
..Default::default()
348
};
349
350
+
let cache = ImageCache::with_cache_dir(build_options.assets_cache_dir());
351
352
// Verify it uses the configured directory
353
+
assert_eq!(cache.get_cache_dir(), custom_cache.join("assets"));
354
}
355
356
#[test]
+104
-13
crates/maudit/src/build/options.rs
+104
-13
crates/maudit/src/build/options.rs
···
1
-
use std::{env, path::PathBuf};
2
3
use crate::{assets::RouteAssetsOptions, is_dev, sitemap::SitemapOptions};
4
···
36
/// assets: AssetsOptions {
37
/// assets_dir: "_assets".into(),
38
/// tailwind_binary_path: "./node_modules/.bin/tailwindcss".into(),
39
-
/// image_cache_dir: ".cache/maudit/images".into(),
40
/// ..Default::default()
41
/// },
42
/// prefetch: PrefetchOptions {
···
61
/// At the speed Maudit operates at, not cleaning the output directory may offer a significant performance improvement at the cost of potentially serving stale content.
62
pub clean_output_dir: bool,
63
64
pub assets: AssetsOptions,
65
66
pub prefetch: PrefetchOptions,
···
124
hashing_strategy: self.assets.hashing_strategy,
125
}
126
}
127
}
128
129
#[derive(Clone)]
···
139
/// Note that this value is not automatically joined with the `output_dir` in `BuildOptions`. Use [`BuildOptions::route_assets_options()`] to get a `RouteAssetsOptions` with the correct final path.
140
pub assets_dir: PathBuf,
141
142
-
/// Directory to use for image cache storage.
143
-
/// Defaults to `target/maudit_cache/images`.
144
-
///
145
-
/// This cache is used to store processed images and their placeholders to speed up subsequent builds.
146
-
pub image_cache_dir: PathBuf,
147
-
148
/// Strategy to use when hashing assets for fingerprinting.
149
///
150
/// Defaults to [`AssetHashingStrategy::Precise`] in production builds, and [`AssetHashingStrategy::FastImprecise`] in development builds. Note that this means that the cache isn't shared between dev and prod builds by default, if you have a lot of assets you may want to set this to the same value in both environments.
···
164
Self {
165
tailwind_binary_path: "tailwindcss".into(),
166
assets_dir: "_maudit".into(),
167
-
image_cache_dir: {
168
-
let target_dir =
169
-
env::var("CARGO_TARGET_DIR").unwrap_or_else(|_| "target".to_string());
170
-
PathBuf::from(target_dir).join("maudit_cache/images")
171
-
},
172
hashing_strategy: if is_dev() {
173
AssetHashingStrategy::FastImprecise
174
} else {
···
196
/// ```
197
impl Default for BuildOptions {
198
fn default() -> Self {
199
Self {
200
base_url: None,
201
output_dir: "dist".into(),
202
static_dir: "static".into(),
203
clean_output_dir: true,
204
prefetch: PrefetchOptions::default(),
205
assets: AssetsOptions::default(),
206
sitemap: SitemapOptions::default(),
207
}
208
}
209
}
···
1
+
use std::{fs, path::PathBuf};
2
3
use crate::{assets::RouteAssetsOptions, is_dev, sitemap::SitemapOptions};
4
···
36
/// assets: AssetsOptions {
37
/// assets_dir: "_assets".into(),
38
/// tailwind_binary_path: "./node_modules/.bin/tailwindcss".into(),
39
/// ..Default::default()
40
/// },
41
/// prefetch: PrefetchOptions {
···
60
/// At the speed Maudit operates at, not cleaning the output directory may offer a significant performance improvement at the cost of potentially serving stale content.
61
pub clean_output_dir: bool,
62
63
+
/// Whether to enable incremental builds.
64
+
///
65
+
/// When enabled, Maudit tracks which assets are used by which routes and only rebuilds
66
+
/// routes affected by changed files. This can significantly speed up rebuilds when only
67
+
/// a few files have changed.
68
+
///
69
+
/// Defaults to `true` in dev mode (`maudit dev`) and `false` in production builds.
70
+
pub incremental: bool,
71
+
72
+
/// Directory for build cache storage (incremental build state, etc.).
73
+
///
74
+
/// Defaults to `target/maudit_cache/{package_name}` where `{package_name}` is derived
75
+
/// from the current directory name.
76
+
pub cache_dir: PathBuf,
77
+
78
+
/// Directory for caching processed assets (images, etc.).
79
+
///
80
+
/// If `None`, defaults to `{cache_dir}/assets`.
81
+
pub assets_cache_dir: Option<PathBuf>,
82
+
83
pub assets: AssetsOptions,
84
85
pub prefetch: PrefetchOptions,
···
143
hashing_strategy: self.assets.hashing_strategy,
144
}
145
}
146
+
147
+
/// Returns the directory for caching processed assets (images, etc.).
148
+
/// Uses `assets_cache_dir` if set, otherwise defaults to `{cache_dir}/assets`.
149
+
pub fn assets_cache_dir(&self) -> PathBuf {
150
+
self.assets_cache_dir
151
+
.clone()
152
+
.unwrap_or_else(|| self.cache_dir.join("assets"))
153
+
}
154
}
155
156
#[derive(Clone)]
···
166
/// Note that this value is not automatically joined with the `output_dir` in `BuildOptions`. Use [`BuildOptions::route_assets_options()`] to get a `RouteAssetsOptions` with the correct final path.
167
pub assets_dir: PathBuf,
168
169
/// Strategy to use when hashing assets for fingerprinting.
170
///
171
/// Defaults to [`AssetHashingStrategy::Precise`] in production builds, and [`AssetHashingStrategy::FastImprecise`] in development builds. Note that this means that the cache isn't shared between dev and prod builds by default, if you have a lot of assets you may want to set this to the same value in both environments.
···
185
Self {
186
tailwind_binary_path: "tailwindcss".into(),
187
assets_dir: "_maudit".into(),
188
hashing_strategy: if is_dev() {
189
AssetHashingStrategy::FastImprecise
190
} else {
···
212
/// ```
213
impl Default for BuildOptions {
214
fn default() -> Self {
215
+
let site_name = get_site_name();
216
+
let cache_dir = find_target_dir()
217
+
.unwrap_or_else(|_| PathBuf::from("target"))
218
+
.join("maudit_cache")
219
+
.join(&site_name);
220
+
221
Self {
222
base_url: None,
223
output_dir: "dist".into(),
224
static_dir: "static".into(),
225
clean_output_dir: true,
226
+
incremental: is_dev(),
227
+
cache_dir,
228
+
assets_cache_dir: None,
229
prefetch: PrefetchOptions::default(),
230
assets: AssetsOptions::default(),
231
sitemap: SitemapOptions::default(),
232
}
233
}
234
}
235
+
236
+
/// Get the site name for cache directory purposes.
237
+
///
238
+
/// Uses the current executable's name (which matches the package/binary name),
239
+
/// falling back to the current directory name.
240
+
fn get_site_name() -> String {
241
+
// Get the binary name from the current executable
242
+
std::env::current_exe()
243
+
.ok()
244
+
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
245
+
.unwrap_or_else(|| {
246
+
// Fallback to current directory name
247
+
std::env::current_dir()
248
+
.ok()
249
+
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
250
+
.unwrap_or_else(|| "default".to_string())
251
+
})
252
+
}
253
+
254
+
/// Find the target directory using multiple strategies
255
+
///
256
+
/// This function tries multiple approaches to locate the target directory:
257
+
/// 1. CARGO_TARGET_DIR / CARGO_BUILD_TARGET_DIR environment variables
258
+
/// 2. Local ./target directory
259
+
/// 3. Workspace root target directory (walking up to find [workspace])
260
+
/// 4. Fallback to relative "target" path
261
+
fn find_target_dir() -> Result<PathBuf, std::io::Error> {
262
+
// 1. Check CARGO_TARGET_DIR and CARGO_BUILD_TARGET_DIR environment variables
263
+
for env_var in ["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR"] {
264
+
if let Ok(target_dir) = std::env::var(env_var) {
265
+
let path = PathBuf::from(&target_dir);
266
+
if path.exists() {
267
+
return Ok(path);
268
+
}
269
+
}
270
+
}
271
+
272
+
// 2. Look for target directory in current directory
273
+
let local_target = PathBuf::from("target");
274
+
if local_target.exists() {
275
+
return Ok(local_target);
276
+
}
277
+
278
+
// 3. Try to find workspace root by looking for Cargo.toml with [workspace]
279
+
let mut current = std::env::current_dir()?;
280
+
loop {
281
+
let cargo_toml = current.join("Cargo.toml");
282
+
if cargo_toml.exists()
283
+
&& let Ok(content) = fs::read_to_string(&cargo_toml)
284
+
&& content.contains("[workspace]")
285
+
{
286
+
let workspace_target = current.join("target");
287
+
if workspace_target.exists() {
288
+
return Ok(workspace_target);
289
+
}
290
+
}
291
+
292
+
// Move up to parent directory
293
+
if !current.pop() {
294
+
break;
295
+
}
296
+
}
297
+
298
+
// 4. Final fallback to relative path
299
+
Ok(PathBuf::from("target"))
300
+
}
+233
crates/maudit/src/build/state.rs
+233
crates/maudit/src/build/state.rs
···
···
1
+
use rustc_hash::{FxHashMap, FxHashSet};
2
+
use serde::{Deserialize, Serialize};
3
+
use std::fs;
4
+
use std::path::{Path, PathBuf};
5
+
6
+
/// Identifies a specific route or variant for incremental rebuilds
7
+
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
8
+
pub enum RouteIdentifier {
9
+
/// A base route with optional page parameters
10
+
/// Params are stored as a sorted Vec for hashing purposes
11
+
Base {
12
+
route_path: String,
13
+
params: Option<Vec<(String, Option<String>)>>,
14
+
},
15
+
/// A variant route with optional page parameters
16
+
/// Params are stored as a sorted Vec for hashing purposes
17
+
Variant {
18
+
variant_id: String,
19
+
variant_path: String,
20
+
params: Option<Vec<(String, Option<String>)>>,
21
+
},
22
+
}
23
+
24
+
impl RouteIdentifier {
25
+
pub fn base(route_path: String, params: Option<FxHashMap<String, Option<String>>>) -> Self {
26
+
Self::Base {
27
+
route_path,
28
+
params: params.map(|p| {
29
+
let mut sorted: Vec<_> = p.into_iter().collect();
30
+
sorted.sort_by(|a, b| a.0.cmp(&b.0));
31
+
sorted
32
+
}),
33
+
}
34
+
}
35
+
36
+
pub fn variant(
37
+
variant_id: String,
38
+
variant_path: String,
39
+
params: Option<FxHashMap<String, Option<String>>>,
40
+
) -> Self {
41
+
Self::Variant {
42
+
variant_id,
43
+
variant_path,
44
+
params: params.map(|p| {
45
+
let mut sorted: Vec<_> = p.into_iter().collect();
46
+
sorted.sort_by(|a, b| a.0.cmp(&b.0));
47
+
sorted
48
+
}),
49
+
}
50
+
}
51
+
}
52
+
53
+
/// Tracks build state for incremental builds
54
+
#[derive(Debug, Default, Serialize, Deserialize)]
55
+
pub struct BuildState {
56
+
/// Maps asset paths to routes that use them
57
+
/// Key: canonicalized asset path
58
+
/// Value: set of routes using this asset
59
+
pub asset_to_routes: FxHashMap<PathBuf, FxHashSet<RouteIdentifier>>,
60
+
61
+
/// Stores all bundler input paths from the last build
62
+
/// This needs to be preserved to ensure consistent bundling
63
+
pub bundler_inputs: Vec<String>,
64
+
}
65
+
66
+
impl BuildState {
67
+
pub fn new() -> Self {
68
+
Self::default()
69
+
}
70
+
71
+
/// Load build state from disk cache
72
+
pub fn load(cache_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
73
+
let state_path = cache_dir.join("build_state.json");
74
+
75
+
if !state_path.exists() {
76
+
return Ok(Self::new());
77
+
}
78
+
79
+
let content = fs::read_to_string(&state_path)?;
80
+
let state: BuildState = serde_json::from_str(&content)?;
81
+
Ok(state)
82
+
}
83
+
84
+
/// Save build state to disk cache
85
+
pub fn save(&self, cache_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
86
+
fs::create_dir_all(cache_dir)?;
87
+
let state_path = cache_dir.join("build_state.json");
88
+
let content = serde_json::to_string_pretty(self)?;
89
+
fs::write(state_path, content)?;
90
+
Ok(())
91
+
}
92
+
93
+
/// Add an asset->route mapping
94
+
pub fn track_asset(&mut self, asset_path: PathBuf, route_id: RouteIdentifier) {
95
+
self.asset_to_routes
96
+
.entry(asset_path)
97
+
.or_default()
98
+
.insert(route_id);
99
+
}
100
+
101
+
/// Get all routes affected by changes to specific files
102
+
pub fn get_affected_routes(&self, changed_files: &[PathBuf]) -> FxHashSet<RouteIdentifier> {
103
+
let mut affected_routes = FxHashSet::default();
104
+
105
+
for changed_file in changed_files {
106
+
// Canonicalize the changed file path for consistent comparison
107
+
// All asset paths in asset_to_routes are stored as canonical paths
108
+
let canonical_changed = changed_file.canonicalize().ok();
109
+
110
+
// Try exact match with canonical path
111
+
if let Some(canonical) = &canonical_changed
112
+
&& let Some(routes) = self.asset_to_routes.get(canonical)
113
+
{
114
+
affected_routes.extend(routes.iter().cloned());
115
+
continue; // Found exact match, no need for directory check
116
+
}
117
+
118
+
// Fallback: try exact match with original path (shouldn't normally match)
119
+
if let Some(routes) = self.asset_to_routes.get(changed_file) {
120
+
affected_routes.extend(routes.iter().cloned());
121
+
continue;
122
+
}
123
+
124
+
// Directory prefix check: find all routes using assets within this directory.
125
+
// This handles two cases:
126
+
// 1. A directory was modified - rebuild all routes using assets in that dir
127
+
// 2. A directory was renamed/deleted - the old path no longer exists but we
128
+
// still need to rebuild routes that used assets under that path
129
+
//
130
+
// We do this check if:
131
+
// - The path currently exists as a directory, OR
132
+
// - The path doesn't exist (could be a deleted/renamed directory)
133
+
let should_check_prefix = changed_file.is_dir() || !changed_file.exists();
134
+
135
+
if should_check_prefix {
136
+
// Use original path for prefix matching (canonical won't exist for deleted dirs)
137
+
for (asset_path, routes) in &self.asset_to_routes {
138
+
if asset_path.starts_with(changed_file) {
139
+
affected_routes.extend(routes.iter().cloned());
140
+
}
141
+
}
142
+
}
143
+
}
144
+
145
+
affected_routes
146
+
}
147
+
148
+
/// Clear all tracked data (for full rebuild)
149
+
pub fn clear(&mut self) {
150
+
self.asset_to_routes.clear();
151
+
self.bundler_inputs.clear();
152
+
}
153
+
}
154
+
155
+
#[cfg(test)]
156
+
mod tests {
157
+
use super::*;
158
+
159
+
fn make_route(path: &str) -> RouteIdentifier {
160
+
RouteIdentifier::base(path.to_string(), None)
161
+
}
162
+
163
+
#[test]
164
+
fn test_get_affected_routes_exact_match() {
165
+
let mut state = BuildState::new();
166
+
let asset_path = PathBuf::from("/project/src/assets/logo.png");
167
+
let route = make_route("/");
168
+
169
+
state.track_asset(asset_path.clone(), route.clone());
170
+
171
+
// Exact match should work
172
+
let affected = state.get_affected_routes(&[asset_path]);
173
+
assert_eq!(affected.len(), 1);
174
+
assert!(affected.contains(&route));
175
+
}
176
+
177
+
#[test]
178
+
fn test_get_affected_routes_no_match() {
179
+
let mut state = BuildState::new();
180
+
let asset_path = PathBuf::from("/project/src/assets/logo.png");
181
+
let route = make_route("/");
182
+
183
+
state.track_asset(asset_path, route);
184
+
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());
189
+
}
190
+
191
+
#[test]
192
+
fn test_get_affected_routes_deleted_directory() {
193
+
let mut state = BuildState::new();
194
+
195
+
// Track assets under a directory path
196
+
let asset1 = PathBuf::from("/project/src/assets/icons/logo.png");
197
+
let asset2 = PathBuf::from("/project/src/assets/icons/favicon.ico");
198
+
let asset3 = PathBuf::from("/project/src/assets/styles.css");
199
+
let route1 = make_route("/");
200
+
let route2 = make_route("/about");
201
+
202
+
state.track_asset(asset1, route1.clone());
203
+
state.track_asset(asset2, route1.clone());
204
+
state.track_asset(asset3, route2.clone());
205
+
206
+
// Simulate a deleted/renamed directory (path doesn't exist)
207
+
// The "icons" directory was renamed, so the old path doesn't exist
208
+
let deleted_dir = PathBuf::from("/project/src/assets/icons");
209
+
210
+
// Since the path doesn't exist, it should check prefix matching
211
+
let affected = state.get_affected_routes(&[deleted_dir]);
212
+
213
+
// Should find route1 (uses assets under /icons/) but not route2
214
+
assert_eq!(affected.len(), 1);
215
+
assert!(affected.contains(&route1));
216
+
}
217
+
218
+
#[test]
219
+
fn test_get_affected_routes_multiple_routes_same_asset() {
220
+
let mut state = BuildState::new();
221
+
let asset_path = PathBuf::from("/project/src/assets/shared.css");
222
+
let route1 = make_route("/");
223
+
let route2 = make_route("/about");
224
+
225
+
state.track_asset(asset_path.clone(), route1.clone());
226
+
state.track_asset(asset_path.clone(), route2.clone());
227
+
228
+
let affected = state.get_affected_routes(&[asset_path]);
229
+
assert_eq!(affected.len(), 2);
230
+
assert!(affected.contains(&route1));
231
+
assert!(affected.contains(&route2));
232
+
}
233
+
}
+457
-137
crates/maudit/src/build.rs
+457
-137
crates/maudit/src/build.rs
···
14
self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin,
15
calculate_hash, image_cache::ImageCache, prefetch,
16
},
17
-
build::{images::process_image, options::PrefetchStrategy},
18
content::ContentSources,
19
is_dev,
20
logging::print_title,
···
26
use log::{debug, info, trace, warn};
27
use pathdiff::diff_paths;
28
use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType};
29
use rolldown_plugin_replace::ReplacePlugin;
30
use rustc_hash::{FxHashMap, FxHashSet};
31
···
36
pub mod images;
37
pub mod metadata;
38
pub mod options;
39
40
pub fn execute_build(
41
routes: &[&dyn FullRoute],
42
content_sources: &mut ContentSources,
43
options: &BuildOptions,
44
async_runtime: &tokio::runtime::Runtime,
45
) -> Result<BuildOutput, Box<dyn std::error::Error>> {
46
-
async_runtime.block_on(async { build(routes, content_sources, options).await })
47
}
48
49
pub async fn build(
50
routes: &[&dyn FullRoute],
51
content_sources: &mut ContentSources,
52
options: &BuildOptions,
53
) -> Result<BuildOutput, Box<dyn std::error::Error>> {
54
let build_start = Instant::now();
55
let mut build_metadata = BuildOutput::new(build_start);
···
57
// Create a directory for the output
58
trace!(target: "build", "Setting up required directories...");
59
60
-
let clean_up_handle = if options.clean_output_dir {
61
let old_dist_tmp_dir = {
62
let duration = SystemTime::now().duration_since(UNIX_EPOCH)?;
63
let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000;
···
74
};
75
76
// Create the image cache early so it can be shared across routes
77
-
let image_cache = ImageCache::with_cache_dir(&options.assets.image_cache_dir);
78
let _ = fs::create_dir_all(image_cache.get_cache_dir());
79
80
// Create route_assets_options with the image cache
···
183
184
// Static base route
185
if base_params.is_empty() {
186
-
let mut route_assets = RouteAssets::with_default_assets(
187
-
&route_assets_options,
188
-
Some(image_cache.clone()),
189
-
default_scripts.clone(),
190
-
vec![],
191
-
);
192
193
-
let params = PageParams::default();
194
-
let url = cached_route.url(¶ms);
195
196
-
let result = route.build(&mut PageContext::from_static_route(
197
-
content_sources,
198
-
&mut route_assets,
199
-
&url,
200
-
&options.base_url,
201
-
None,
202
-
))?;
203
204
-
let file_path = cached_route.file_path(¶ms, &options.output_dir);
205
206
-
write_route_file(&result, &file_path)?;
207
208
-
info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options));
209
210
-
build_pages_images.extend(route_assets.images);
211
-
build_pages_scripts.extend(route_assets.scripts);
212
-
build_pages_styles.extend(route_assets.styles);
213
214
-
build_metadata.add_page(
215
-
base_path.clone(),
216
-
file_path.to_string_lossy().to_string(),
217
-
None,
218
-
);
219
220
-
add_sitemap_entry(
221
-
&mut sitemap_entries,
222
-
normalized_base_url,
223
-
&url,
224
-
base_path,
225
-
&route.sitemap_metadata(),
226
-
&options.sitemap,
227
-
);
228
229
-
page_count += 1;
230
} else {
231
// Dynamic base route
232
let mut route_assets = RouteAssets::with_default_assets(
···
250
251
// Build all pages for this route
252
for page in pages {
253
-
let page_start = Instant::now();
254
-
let url = cached_route.url(&page.0);
255
-
let file_path = cached_route.file_path(&page.0, &options.output_dir);
256
257
-
let content = route.build(&mut PageContext::from_dynamic_route(
258
-
&page,
259
-
content_sources,
260
-
&mut route_assets,
261
-
&url,
262
-
&options.base_url,
263
-
None,
264
-
))?;
265
266
-
write_route_file(&content, &file_path)?;
267
268
-
info!(target: "pages", "โโ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options));
269
270
-
build_metadata.add_page(
271
-
base_path.clone(),
272
-
file_path.to_string_lossy().to_string(),
273
-
Some(page.0.0.clone()),
274
-
);
275
276
-
add_sitemap_entry(
277
-
&mut sitemap_entries,
278
-
normalized_base_url,
279
-
&url,
280
-
base_path,
281
-
&route.sitemap_metadata(),
282
-
&options.sitemap,
283
-
);
284
285
-
page_count += 1;
286
}
287
}
288
···
299
300
if variant_params.is_empty() {
301
// Static variant
302
-
let mut route_assets = RouteAssets::with_default_assets(
303
-
&route_assets_options,
304
-
Some(image_cache.clone()),
305
-
default_scripts.clone(),
306
-
vec![],
307
-
);
308
309
-
let params = PageParams::default();
310
-
let url = cached_route.variant_url(¶ms, &variant_id)?;
311
-
let file_path =
312
-
cached_route.variant_file_path(¶ms, &options.output_dir, &variant_id)?;
313
314
-
let result = route.build(&mut PageContext::from_static_route(
315
-
content_sources,
316
-
&mut route_assets,
317
-
&url,
318
-
&options.base_url,
319
-
Some(variant_id.clone()),
320
-
))?;
321
322
-
write_route_file(&result, &file_path)?;
323
324
-
info!(target: "pages", "โโ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options));
325
326
-
build_pages_images.extend(route_assets.images);
327
-
build_pages_scripts.extend(route_assets.scripts);
328
-
build_pages_styles.extend(route_assets.styles);
329
330
-
build_metadata.add_page(
331
-
variant_path.clone(),
332
-
file_path.to_string_lossy().to_string(),
333
-
None,
334
-
);
335
336
-
add_sitemap_entry(
337
-
&mut sitemap_entries,
338
-
normalized_base_url,
339
-
&url,
340
-
&variant_path,
341
-
&route.sitemap_metadata(),
342
-
&options.sitemap,
343
-
);
344
345
-
page_count += 1;
346
} else {
347
// Dynamic variant
348
let mut route_assets = RouteAssets::with_default_assets(
···
365
366
// Build all pages for this variant group
367
for page in pages {
368
-
let variant_page_start = Instant::now();
369
-
let url = cached_route.variant_url(&page.0, &variant_id)?;
370
-
let file_path = cached_route.variant_file_path(
371
-
&page.0,
372
-
&options.output_dir,
373
-
&variant_id,
374
-
)?;
375
376
-
let content = route.build(&mut PageContext::from_dynamic_route(
377
-
&page,
378
-
content_sources,
379
-
&mut route_assets,
380
-
&url,
381
-
&options.base_url,
382
-
Some(variant_id.clone()),
383
-
))?;
384
385
-
write_route_file(&content, &file_path)?;
386
387
-
info!(target: "pages", "โ โโ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options));
388
389
-
build_metadata.add_page(
390
-
variant_path.clone(),
391
-
file_path.to_string_lossy().to_string(),
392
-
Some(page.0.0.clone()),
393
-
);
394
395
-
add_sitemap_entry(
396
-
&mut sitemap_entries,
397
-
normalized_base_url,
398
-
&url,
399
-
&variant_path,
400
-
&route.sitemap_metadata(),
401
-
&options.sitemap,
402
-
);
403
404
-
page_count += 1;
405
}
406
}
407
···
421
fs::create_dir_all(&route_assets_options.output_assets_dir)?;
422
}
423
424
-
if !build_pages_styles.is_empty() || !build_pages_scripts.is_empty() {
425
let assets_start = Instant::now();
426
print_title("generating assets");
427
···
439
})
440
.collect::<Vec<InputItem>>();
441
442
-
let bundler_inputs = build_pages_scripts
443
.iter()
444
.map(|script| InputItem {
445
import: script.path().to_string_lossy().to_string(),
···
454
.chain(css_inputs.into_iter())
455
.collect::<Vec<InputItem>>();
456
457
debug!(
458
target: "bundling",
459
"Bundler inputs: {:?}",
···
463
.collect::<Vec<String>>()
464
);
465
466
if !bundler_inputs.is_empty() {
467
let mut module_types_hashmap = FxHashMap::default();
468
module_types_hashmap.insert("woff".to_string(), ModuleType::Asset);
469
module_types_hashmap.insert("woff2".to_string(), ModuleType::Asset);
470
471
let mut bundler = Bundler::with_plugins(
472
BundlerOptions {
···
500
],
501
)?;
502
503
-
let _result = bundler.write().await?;
504
505
-
// TODO: Add outputted chunks to build_metadata
506
}
507
508
info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), §ion_format_options)).bold());
···
598
info!(target: "SKIP_FORMAT", "{}", "");
599
info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), §ion_format_options)).bold());
600
601
if let Some(clean_up_handle) = clean_up_handle {
602
clean_up_handle.await?;
603
}
···
680
fs::create_dir_all(parent_dir)?
681
}
682
683
fs::write(file_path, content)?;
684
685
Ok(())
···
14
self, HashAssetType, HashConfig, PrefetchPlugin, RouteAssets, Script, TailwindPlugin,
15
calculate_hash, image_cache::ImageCache, prefetch,
16
},
17
+
build::{
18
+
images::process_image,
19
+
options::PrefetchStrategy,
20
+
state::{BuildState, RouteIdentifier},
21
+
},
22
content::ContentSources,
23
is_dev,
24
logging::print_title,
···
30
use log::{debug, info, trace, warn};
31
use pathdiff::diff_paths;
32
use rolldown::{Bundler, BundlerOptions, InputItem, ModuleType};
33
+
use rolldown_common::Output;
34
use rolldown_plugin_replace::ReplacePlugin;
35
use rustc_hash::{FxHashMap, FxHashSet};
36
···
41
pub mod images;
42
pub mod metadata;
43
pub mod options;
44
+
pub mod state;
45
+
46
+
/// Helper to check if a route should be rebuilt during incremental builds
47
+
fn should_rebuild_route(
48
+
route_id: &RouteIdentifier,
49
+
routes_to_rebuild: &Option<FxHashSet<RouteIdentifier>>,
50
+
) -> bool {
51
+
let result = match routes_to_rebuild {
52
+
Some(set) => set.contains(route_id),
53
+
None => true, // Full build
54
+
};
55
+
56
+
if !result {
57
+
trace!(target: "build", "Skipping route {:?} (not in rebuild set)", route_id);
58
+
}
59
+
60
+
result
61
+
}
62
+
63
+
/// Helper to track all assets used by a route
64
+
fn track_route_assets(
65
+
build_state: &mut BuildState,
66
+
route_id: &RouteIdentifier,
67
+
route_assets: &RouteAssets,
68
+
) {
69
+
// Track images
70
+
for image in &route_assets.images {
71
+
if let Ok(canonical) = image.path().canonicalize() {
72
+
build_state.track_asset(canonical, route_id.clone());
73
+
}
74
+
}
75
+
76
+
// Track scripts
77
+
for script in &route_assets.scripts {
78
+
if let Ok(canonical) = script.path().canonicalize() {
79
+
build_state.track_asset(canonical, route_id.clone());
80
+
}
81
+
}
82
+
83
+
// Track styles
84
+
for style in &route_assets.styles {
85
+
if let Ok(canonical) = style.path().canonicalize() {
86
+
build_state.track_asset(canonical, route_id.clone());
87
+
}
88
+
}
89
+
}
90
91
pub fn execute_build(
92
routes: &[&dyn FullRoute],
93
content_sources: &mut ContentSources,
94
options: &BuildOptions,
95
+
changed_files: Option<&[PathBuf]>,
96
async_runtime: &tokio::runtime::Runtime,
97
) -> Result<BuildOutput, Box<dyn std::error::Error>> {
98
+
async_runtime.block_on(async { build(routes, content_sources, options, changed_files).await })
99
}
100
101
pub async fn build(
102
routes: &[&dyn FullRoute],
103
content_sources: &mut ContentSources,
104
options: &BuildOptions,
105
+
changed_files: Option<&[PathBuf]>,
106
) -> Result<BuildOutput, Box<dyn std::error::Error>> {
107
let build_start = Instant::now();
108
let mut build_metadata = BuildOutput::new(build_start);
···
110
// Create a directory for the output
111
trace!(target: "build", "Setting up required directories...");
112
113
+
// Use cache directory from options
114
+
let build_cache_dir = &options.cache_dir;
115
+
116
+
// Load build state for incremental builds (only if incremental is enabled)
117
+
let mut build_state = if options.incremental {
118
+
BuildState::load(build_cache_dir).unwrap_or_else(|e| {
119
+
debug!(target: "build", "Failed to load build state: {}", e);
120
+
BuildState::new()
121
+
})
122
+
} else {
123
+
BuildState::new()
124
+
};
125
+
126
+
debug!(target: "build", "Loaded build state with {} asset mappings", build_state.asset_to_routes.len());
127
+
debug!(target: "build", "options.incremental: {}, changed_files.is_some(): {}", options.incremental, changed_files.is_some());
128
+
129
+
// Determine if this is an incremental build
130
+
let is_incremental =
131
+
options.incremental && changed_files.is_some() && !build_state.asset_to_routes.is_empty();
132
+
133
+
let routes_to_rebuild = if is_incremental {
134
+
let changed = changed_files.unwrap();
135
+
info!(target: "build", "Incremental build: {} files changed", changed.len());
136
+
info!(target: "build", "Changed files: {:?}", changed);
137
+
138
+
info!(target: "build", "Build state has {} asset mappings", build_state.asset_to_routes.len());
139
+
140
+
let affected = build_state.get_affected_routes(changed);
141
+
info!(target: "build", "Rebuilding {} affected routes", affected.len());
142
+
info!(target: "build", "Affected routes: {:?}", affected);
143
+
144
+
Some(affected)
145
+
} else {
146
+
if changed_files.is_some() {
147
+
info!(target: "build", "Full build (first run after recompilation)");
148
+
}
149
+
// Full build - clear old state
150
+
build_state.clear();
151
+
None
152
+
};
153
+
154
+
// Check if we should rebundle during incremental builds
155
+
// Rebundle if a changed file is either:
156
+
// 1. A direct bundler input (entry point)
157
+
// 2. A transitive dependency tracked in asset_to_routes (any file the bundler processed)
158
+
let should_rebundle = if is_incremental && !build_state.bundler_inputs.is_empty() {
159
+
let changed = changed_files.unwrap();
160
+
let should = changed.iter().any(|changed_file| {
161
+
// Check if it's a direct bundler input
162
+
let is_bundler_input = build_state.bundler_inputs.iter().any(|bundler_input| {
163
+
if let (Ok(changed_canonical), Ok(bundler_canonical)) = (
164
+
changed_file.canonicalize(),
165
+
PathBuf::from(bundler_input).canonicalize(),
166
+
) {
167
+
changed_canonical == bundler_canonical
168
+
} else {
169
+
false
170
+
}
171
+
});
172
+
173
+
if is_bundler_input {
174
+
return true;
175
+
}
176
+
177
+
// Check if it's a transitive dependency tracked by the bundler
178
+
// (JS/TS modules, CSS files, or assets like images/fonts referenced via url())
179
+
if let Ok(canonical) = changed_file.canonicalize() {
180
+
return build_state.asset_to_routes.contains_key(&canonical);
181
+
}
182
+
183
+
false
184
+
});
185
+
186
+
if should {
187
+
info!(target: "build", "Rebundling needed: changed file affects bundled assets");
188
+
} else {
189
+
info!(target: "build", "Skipping bundler: no changed files affect bundled assets");
190
+
}
191
+
192
+
should
193
+
} else {
194
+
// Not incremental or no previous bundler inputs
195
+
false
196
+
};
197
+
198
+
let clean_up_handle = if options.clean_output_dir && !is_incremental {
199
let old_dist_tmp_dir = {
200
let duration = SystemTime::now().duration_since(UNIX_EPOCH)?;
201
let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000;
···
212
};
213
214
// Create the image cache early so it can be shared across routes
215
+
let image_cache = ImageCache::with_cache_dir(options.assets_cache_dir());
216
let _ = fs::create_dir_all(image_cache.get_cache_dir());
217
218
// Create route_assets_options with the image cache
···
321
322
// Static base route
323
if base_params.is_empty() {
324
+
let route_id = RouteIdentifier::base(base_path.clone(), None);
325
+
326
+
// Check if we need to rebuild this route
327
+
if should_rebuild_route(&route_id, &routes_to_rebuild) {
328
+
let mut route_assets = RouteAssets::with_default_assets(
329
+
&route_assets_options,
330
+
Some(image_cache.clone()),
331
+
default_scripts.clone(),
332
+
vec![],
333
+
);
334
335
+
let params = PageParams::default();
336
+
let url = cached_route.url(¶ms);
337
338
+
let result = route.build(&mut PageContext::from_static_route(
339
+
content_sources,
340
+
&mut route_assets,
341
+
&url,
342
+
&options.base_url,
343
+
None,
344
+
))?;
345
346
+
let file_path = cached_route.file_path(¶ms, &options.output_dir);
347
348
+
write_route_file(&result, &file_path)?;
349
350
+
info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options));
351
352
+
// Track assets for this route
353
+
track_route_assets(&mut build_state, &route_id, &route_assets);
354
355
+
build_pages_images.extend(route_assets.images);
356
+
build_pages_scripts.extend(route_assets.scripts);
357
+
build_pages_styles.extend(route_assets.styles);
358
+
359
+
build_metadata.add_page(
360
+
base_path.clone(),
361
+
file_path.to_string_lossy().to_string(),
362
+
None,
363
+
);
364
365
+
add_sitemap_entry(
366
+
&mut sitemap_entries,
367
+
normalized_base_url,
368
+
&url,
369
+
base_path,
370
+
&route.sitemap_metadata(),
371
+
&options.sitemap,
372
+
);
373
374
+
page_count += 1;
375
+
} else {
376
+
trace!(target: "build", "Skipping unchanged route: {}", base_path);
377
+
}
378
} else {
379
// Dynamic base route
380
let mut route_assets = RouteAssets::with_default_assets(
···
398
399
// Build all pages for this route
400
for page in pages {
401
+
let route_id =
402
+
RouteIdentifier::base(base_path.clone(), Some(page.0.0.clone()));
403
404
+
// Check if we need to rebuild this specific page
405
+
if should_rebuild_route(&route_id, &routes_to_rebuild) {
406
+
let page_start = Instant::now();
407
+
let url = cached_route.url(&page.0);
408
+
let file_path = cached_route.file_path(&page.0, &options.output_dir);
409
410
+
let content = route.build(&mut PageContext::from_dynamic_route(
411
+
&page,
412
+
content_sources,
413
+
&mut route_assets,
414
+
&url,
415
+
&options.base_url,
416
+
None,
417
+
))?;
418
419
+
write_route_file(&content, &file_path)?;
420
421
+
info!(target: "pages", "โโ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(page_start.elapsed(), &route_format_options));
422
+
423
+
// Track assets for this page
424
+
track_route_assets(&mut build_state, &route_id, &route_assets);
425
+
426
+
build_metadata.add_page(
427
+
base_path.clone(),
428
+
file_path.to_string_lossy().to_string(),
429
+
Some(page.0.0.clone()),
430
+
);
431
432
+
add_sitemap_entry(
433
+
&mut sitemap_entries,
434
+
normalized_base_url,
435
+
&url,
436
+
base_path,
437
+
&route.sitemap_metadata(),
438
+
&options.sitemap,
439
+
);
440
441
+
page_count += 1;
442
+
} else {
443
+
trace!(target: "build", "Skipping unchanged page: {} with params {:?}", base_path, page.0.0);
444
+
}
445
}
446
}
447
···
458
459
if variant_params.is_empty() {
460
// Static variant
461
+
let route_id =
462
+
RouteIdentifier::variant(variant_id.clone(), variant_path.clone(), None);
463
464
+
// Check if we need to rebuild this variant
465
+
if should_rebuild_route(&route_id, &routes_to_rebuild) {
466
+
let mut route_assets = RouteAssets::with_default_assets(
467
+
&route_assets_options,
468
+
Some(image_cache.clone()),
469
+
default_scripts.clone(),
470
+
vec![],
471
+
);
472
473
+
let params = PageParams::default();
474
+
let url = cached_route.variant_url(¶ms, &variant_id)?;
475
+
let file_path = cached_route.variant_file_path(
476
+
¶ms,
477
+
&options.output_dir,
478
+
&variant_id,
479
+
)?;
480
+
481
+
let result = route.build(&mut PageContext::from_static_route(
482
+
content_sources,
483
+
&mut route_assets,
484
+
&url,
485
+
&options.base_url,
486
+
Some(variant_id.clone()),
487
+
))?;
488
+
489
+
write_route_file(&result, &file_path)?;
490
491
+
info!(target: "pages", "โโ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_start.elapsed(), &route_format_options));
492
493
+
// Track assets for this variant
494
+
track_route_assets(&mut build_state, &route_id, &route_assets);
495
496
+
build_pages_images.extend(route_assets.images);
497
+
build_pages_scripts.extend(route_assets.scripts);
498
+
build_pages_styles.extend(route_assets.styles);
499
500
+
build_metadata.add_page(
501
+
variant_path.clone(),
502
+
file_path.to_string_lossy().to_string(),
503
+
None,
504
+
);
505
506
+
add_sitemap_entry(
507
+
&mut sitemap_entries,
508
+
normalized_base_url,
509
+
&url,
510
+
&variant_path,
511
+
&route.sitemap_metadata(),
512
+
&options.sitemap,
513
+
);
514
515
+
page_count += 1;
516
+
} else {
517
+
trace!(target: "build", "Skipping unchanged variant: {}", variant_path);
518
+
}
519
} else {
520
// Dynamic variant
521
let mut route_assets = RouteAssets::with_default_assets(
···
538
539
// Build all pages for this variant group
540
for page in pages {
541
+
let route_id = RouteIdentifier::variant(
542
+
variant_id.clone(),
543
+
variant_path.clone(),
544
+
Some(page.0.0.clone()),
545
+
);
546
547
+
// Check if we need to rebuild this specific variant page
548
+
if should_rebuild_route(&route_id, &routes_to_rebuild) {
549
+
let variant_page_start = Instant::now();
550
+
let url = cached_route.variant_url(&page.0, &variant_id)?;
551
+
let file_path = cached_route.variant_file_path(
552
+
&page.0,
553
+
&options.output_dir,
554
+
&variant_id,
555
+
)?;
556
557
+
let content = route.build(&mut PageContext::from_dynamic_route(
558
+
&page,
559
+
content_sources,
560
+
&mut route_assets,
561
+
&url,
562
+
&options.base_url,
563
+
Some(variant_id.clone()),
564
+
))?;
565
+
566
+
write_route_file(&content, &file_path)?;
567
+
568
+
info!(target: "pages", "โ โโ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(variant_page_start.elapsed(), &route_format_options));
569
570
+
// Track assets for this variant page
571
+
track_route_assets(&mut build_state, &route_id, &route_assets);
572
573
+
build_metadata.add_page(
574
+
variant_path.clone(),
575
+
file_path.to_string_lossy().to_string(),
576
+
Some(page.0.0.clone()),
577
+
);
578
579
+
add_sitemap_entry(
580
+
&mut sitemap_entries,
581
+
normalized_base_url,
582
+
&url,
583
+
&variant_path,
584
+
&route.sitemap_metadata(),
585
+
&options.sitemap,
586
+
);
587
588
+
page_count += 1;
589
+
} else {
590
+
trace!(target: "build", "Skipping unchanged variant page: {} with params {:?}", variant_path, page.0.0);
591
+
}
592
}
593
}
594
···
608
fs::create_dir_all(&route_assets_options.output_assets_dir)?;
609
}
610
611
+
if !build_pages_styles.is_empty()
612
+
|| !build_pages_scripts.is_empty()
613
+
|| (is_incremental && should_rebundle)
614
+
{
615
let assets_start = Instant::now();
616
print_title("generating assets");
617
···
629
})
630
.collect::<Vec<InputItem>>();
631
632
+
let mut bundler_inputs = build_pages_scripts
633
.iter()
634
.map(|script| InputItem {
635
import: script.path().to_string_lossy().to_string(),
···
644
.chain(css_inputs.into_iter())
645
.collect::<Vec<InputItem>>();
646
647
+
// During incremental builds, merge with previous bundler inputs
648
+
// to ensure we bundle all assets, not just from rebuilt routes
649
+
if is_incremental && !build_state.bundler_inputs.is_empty() {
650
+
debug!(target: "bundling", "Merging with {} previous bundler inputs", build_state.bundler_inputs.len());
651
+
652
+
let current_imports: FxHashSet<String> = bundler_inputs
653
+
.iter()
654
+
.map(|input| input.import.clone())
655
+
.collect();
656
+
657
+
// Add previous inputs that aren't in the current set
658
+
for prev_input in &build_state.bundler_inputs {
659
+
if !current_imports.contains(prev_input) {
660
+
bundler_inputs.push(InputItem {
661
+
import: prev_input.clone(),
662
+
name: Some(
663
+
PathBuf::from(prev_input)
664
+
.file_stem()
665
+
.unwrap_or_default()
666
+
.to_string_lossy()
667
+
.to_string(),
668
+
),
669
+
});
670
+
}
671
+
}
672
+
}
673
+
674
debug!(
675
target: "bundling",
676
"Bundler inputs: {:?}",
···
680
.collect::<Vec<String>>()
681
);
682
683
+
// Store bundler inputs in build state for next incremental build
684
+
if options.incremental {
685
+
build_state.bundler_inputs = bundler_inputs
686
+
.iter()
687
+
.map(|input| input.import.clone())
688
+
.collect();
689
+
}
690
+
691
if !bundler_inputs.is_empty() {
692
let mut module_types_hashmap = FxHashMap::default();
693
+
// Fonts
694
module_types_hashmap.insert("woff".to_string(), ModuleType::Asset);
695
module_types_hashmap.insert("woff2".to_string(), ModuleType::Asset);
696
+
module_types_hashmap.insert("ttf".to_string(), ModuleType::Asset);
697
+
module_types_hashmap.insert("otf".to_string(), ModuleType::Asset);
698
+
module_types_hashmap.insert("eot".to_string(), ModuleType::Asset);
699
+
// Images
700
+
module_types_hashmap.insert("png".to_string(), ModuleType::Asset);
701
+
module_types_hashmap.insert("jpg".to_string(), ModuleType::Asset);
702
+
module_types_hashmap.insert("jpeg".to_string(), ModuleType::Asset);
703
+
module_types_hashmap.insert("gif".to_string(), ModuleType::Asset);
704
+
module_types_hashmap.insert("svg".to_string(), ModuleType::Asset);
705
+
module_types_hashmap.insert("webp".to_string(), ModuleType::Asset);
706
+
module_types_hashmap.insert("avif".to_string(), ModuleType::Asset);
707
+
module_types_hashmap.insert("ico".to_string(), ModuleType::Asset);
708
709
let mut bundler = Bundler::with_plugins(
710
BundlerOptions {
···
738
],
739
)?;
740
741
+
let result = bundler.write().await?;
742
743
+
// Track transitive dependencies from bundler output
744
+
// For each chunk, map all its modules to the routes that use the entry point
745
+
// For assets (images, fonts via CSS url()), map them to all routes using any entry point
746
+
if options.incremental {
747
+
// First, collect all routes that use any bundler entry point
748
+
let mut all_bundler_routes: FxHashSet<RouteIdentifier> = FxHashSet::default();
749
+
750
+
for output in &result.assets {
751
+
if let Output::Chunk(chunk) = output {
752
+
// Get the entry point for this chunk
753
+
if let Some(facade_module_id) = &chunk.facade_module_id {
754
+
// Try to find routes using this entry point
755
+
let entry_path = PathBuf::from(facade_module_id.as_str());
756
+
let canonical_entry = entry_path.canonicalize().ok();
757
+
758
+
// Look up routes for this entry point
759
+
let routes = canonical_entry
760
+
.as_ref()
761
+
.and_then(|p| build_state.asset_to_routes.get(p))
762
+
.cloned();
763
+
764
+
if let Some(routes) = routes {
765
+
// Collect routes for asset tracking later
766
+
all_bundler_routes.extend(routes.iter().cloned());
767
+
768
+
// Register all modules in this chunk as dependencies for those routes
769
+
let mut transitive_count = 0;
770
+
for module_id in &chunk.module_ids {
771
+
let module_path = PathBuf::from(module_id.as_str());
772
+
if let Ok(canonical_module) = module_path.canonicalize() {
773
+
// Skip the entry point itself (already tracked)
774
+
if Some(&canonical_module) != canonical_entry.as_ref() {
775
+
for route in &routes {
776
+
build_state.track_asset(
777
+
canonical_module.clone(),
778
+
route.clone(),
779
+
);
780
+
}
781
+
transitive_count += 1;
782
+
}
783
+
}
784
+
}
785
+
if transitive_count > 0 {
786
+
debug!(target: "build", "Tracked {} transitive dependencies for {}", transitive_count, facade_module_id);
787
+
}
788
+
}
789
+
}
790
+
}
791
+
}
792
+
793
+
// Now track Output::Asset items (images, fonts, etc. referenced via CSS url() or JS imports)
794
+
// These are mapped to all routes that use any bundler entry point
795
+
if !all_bundler_routes.is_empty() {
796
+
let mut asset_count = 0;
797
+
for output in &result.assets {
798
+
if let Output::Asset(asset) = output {
799
+
for original_file in &asset.original_file_names {
800
+
let asset_path = PathBuf::from(original_file);
801
+
if let Ok(canonical_asset) = asset_path.canonicalize() {
802
+
for route in &all_bundler_routes {
803
+
build_state
804
+
.track_asset(canonical_asset.clone(), route.clone());
805
+
}
806
+
asset_count += 1;
807
+
}
808
+
}
809
+
}
810
+
}
811
+
if asset_count > 0 {
812
+
debug!(target: "build", "Tracked {} bundler assets for {} routes", asset_count, all_bundler_routes.len());
813
+
}
814
+
}
815
+
}
816
}
817
818
info!(target: "build", "{}", format!("Assets generated in {}", format_elapsed_time(assets_start.elapsed(), §ion_format_options)).bold());
···
908
info!(target: "SKIP_FORMAT", "{}", "");
909
info!(target: "build", "{}", format!("Build completed in {}", format_elapsed_time(build_start.elapsed(), §ion_format_options)).bold());
910
911
+
// Save build state for next incremental build (only if incremental is enabled)
912
+
if options.incremental {
913
+
if let Err(e) = build_state.save(build_cache_dir) {
914
+
warn!(target: "build", "Failed to save build state: {}", e);
915
+
} else {
916
+
debug!(target: "build", "Build state saved to {}", build_cache_dir.join("build_state.json").display());
917
+
}
918
+
}
919
+
920
if let Some(clean_up_handle) = clean_up_handle {
921
clean_up_handle.await?;
922
}
···
999
fs::create_dir_all(parent_dir)?
1000
}
1001
1002
+
trace!(target: "build", "Writing HTML file: {}", file_path.display());
1003
fs::write(file_path, content)?;
1004
1005
Ok(())
+22
-3
crates/maudit/src/lib.rs
+22
-3
crates/maudit/src/lib.rs
···
54
// Internal modules
55
mod logging;
56
57
-
use std::env;
58
59
use build::execute_build;
60
use content::ContentSources;
61
use logging::init_logging;
62
use route::FullRoute;
63
64
/// Returns whether Maudit is running in development mode (through `maudit dev`).
65
///
66
/// This can be useful to conditionally enable features or logging that should only be active during development.
67
/// Oftentimes, this is used to disable some expensive operations that would slow down build times during development.
68
pub fn is_dev() -> bool {
69
-
env::var("MAUDIT_DEV").map(|v| v == "true").unwrap_or(false)
70
}
71
72
#[macro_export]
···
212
.enable_all()
213
.build()?;
214
215
-
execute_build(routes, &mut content_sources, &options, &async_runtime)
216
}
···
54
// Internal modules
55
mod logging;
56
57
+
use std::sync::LazyLock;
58
+
use std::{env, path::PathBuf};
59
60
use build::execute_build;
61
use content::ContentSources;
62
use logging::init_logging;
63
use route::FullRoute;
64
65
+
static IS_DEV: LazyLock<bool> = LazyLock::new(|| {
66
+
std::env::var("MAUDIT_DEV")
67
+
.map(|v| v == "true")
68
+
.unwrap_or(false)
69
+
});
70
+
71
/// Returns whether Maudit is running in development mode (through `maudit dev`).
72
///
73
/// This can be useful to conditionally enable features or logging that should only be active during development.
74
/// Oftentimes, this is used to disable some expensive operations that would slow down build times during development.
75
pub fn is_dev() -> bool {
76
+
*IS_DEV
77
}
78
79
#[macro_export]
···
219
.enable_all()
220
.build()?;
221
222
+
// Check for changed files from environment variable (set by CLI in dev mode)
223
+
let changed_files = env::var("MAUDIT_CHANGED_FILES")
224
+
.ok()
225
+
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
226
+
.map(|paths| paths.into_iter().map(PathBuf::from).collect::<Vec<_>>());
227
+
228
+
execute_build(
229
+
routes,
230
+
&mut content_sources,
231
+
&options,
232
+
changed_files.as_deref(),
233
+
&async_runtime,
234
+
)
235
}
+6
-2
crates/maudit/src/logging.rs
+6
-2
crates/maudit/src/logging.rs
···
29
30
let _ = Builder::from_env(logging_env)
31
.format(|buf, record| {
32
+
if std::env::args().any(|arg| arg == "--quiet") {
33
+
return Ok(());
34
+
}
35
+
36
+
// In quiet mode, only show build target logs (for debugging incremental builds)
37
+
if std::env::var("MAUDIT_QUIET").is_ok() && record.target() != "build" {
38
return Ok(());
39
}
40
+5
crates/maudit-cli/Cargo.toml
+5
crates/maudit-cli/Cargo.toml
···
28
ureq = "3.1.4"
29
tar = "0.4.44"
30
toml_edit = "0.24.0"
31
+
toml = "0.8"
32
local-ip-address = "0.6.9"
33
flate2 = "1.1.8"
34
quanta = "0.12.6"
35
serde_json = "1.0"
36
tokio-util = "0.7"
37
cargo_metadata = "0.23.1"
38
+
39
+
[dev-dependencies]
40
+
tempfile = "3.24.0"
41
+
tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] }
+499
-142
crates/maudit-cli/src/dev/build.rs
+499
-142
crates/maudit-cli/src/dev/build.rs
···
1
use cargo_metadata::Message;
2
use quanta::Instant;
3
-
use server::{StatusType, WebSocketMessage, update_status};
4
use std::sync::Arc;
5
use tokio::process::Command;
6
-
use tokio::sync::broadcast;
7
use tokio_util::sync::CancellationToken;
8
-
use tracing::{debug, error, info};
9
10
use crate::{
11
-
dev::server,
12
logging::{FormatElapsedTimeOptions, format_elapsed_time},
13
};
14
15
#[derive(Clone)]
16
pub struct BuildManager {
17
-
current_cancel: Arc<tokio::sync::RwLock<Option<CancellationToken>>>,
18
-
build_semaphore: Arc<tokio::sync::Semaphore>,
19
-
websocket_tx: broadcast::Sender<WebSocketMessage>,
20
-
current_status: Arc<tokio::sync::RwLock<Option<server::PersistentStatus>>>,
21
}
22
23
impl BuildManager {
24
-
pub fn new(websocket_tx: broadcast::Sender<WebSocketMessage>) -> Self {
25
Self {
26
-
current_cancel: Arc::new(tokio::sync::RwLock::new(None)),
27
-
build_semaphore: Arc::new(tokio::sync::Semaphore::new(1)), // Only one build at a time
28
-
websocket_tx,
29
-
current_status: Arc::new(tokio::sync::RwLock::new(None)),
30
}
31
}
32
33
-
/// Get a reference to the current status for use with the web server
34
-
pub fn current_status(&self) -> Arc<tokio::sync::RwLock<Option<server::PersistentStatus>>> {
35
-
self.current_status.clone()
36
}
37
38
-
/// Do initial build that can be cancelled (but isn't stored as current build)
39
-
pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error>> {
40
self.internal_build(true).await
41
}
42
43
-
/// Start a new build, cancelling any previous one
44
-
pub async fn start_build(&self) -> Result<bool, Box<dyn std::error::Error>> {
45
self.internal_build(false).await
46
}
47
48
-
/// Internal build method that handles both initial and regular builds
49
-
async fn internal_build(&self, is_initial: bool) -> Result<bool, Box<dyn std::error::Error>> {
50
// Cancel any existing build immediately
51
let cancel = CancellationToken::new();
52
{
53
-
let mut current_cancel = self.current_cancel.write().await;
54
if let Some(old_cancel) = current_cancel.replace(cancel.clone()) {
55
old_cancel.cancel();
56
}
57
}
58
59
// Acquire semaphore to ensure only one build runs at a time
60
-
// This prevents resource conflicts if cancellation fails
61
-
let _ = self.build_semaphore.acquire().await?;
62
63
-
// Notify that build is starting
64
-
update_status(
65
-
&self.websocket_tx,
66
-
self.current_status.clone(),
67
-
StatusType::Info,
68
-
"Building...",
69
-
)
70
-
.await;
71
72
let mut child = Command::new("cargo")
73
.args([
···
85
.stderr(std::process::Stdio::piped())
86
.spawn()?;
87
88
-
// Take the stderr stream for manual handling
89
-
let mut stdout = child.stdout.take().unwrap();
90
-
let mut stderr = child.stderr.take().unwrap();
91
92
-
let websocket_tx = self.websocket_tx.clone();
93
-
let current_status = self.current_status.clone();
94
let build_start_time = Instant::now();
95
96
-
// Create a channel to get the build result back
97
-
let (result_tx, mut result_rx) = tokio::sync::mpsc::channel::<bool>(1);
98
99
-
// Spawn watcher task to monitor the child process
100
-
tokio::spawn(async move {
101
-
let output_future = async {
102
-
// Read stdout concurrently with waiting for process to finish
103
-
let stdout_task = tokio::spawn(async move {
104
-
let mut out = Vec::new();
105
-
tokio::io::copy(&mut stdout, &mut out).await.unwrap_or(0);
106
107
-
let mut rendered_messages: Vec<String> = Vec::new();
108
109
-
// Ideally we'd stream things as they come, but I can't figure it out
110
-
for message in cargo_metadata::Message::parse_stream(
111
-
String::from_utf8_lossy(&out).to_string().as_bytes(),
112
-
) {
113
-
match message {
114
-
Err(e) => {
115
-
error!(name: "build", "Failed to parse cargo message: {}", e);
116
-
continue;
117
-
}
118
-
Ok(message) => {
119
-
match message {
120
-
// Compiler wants to tell us something
121
-
Message::CompilerMessage(msg) => {
122
-
// TODO: For now, just send through the rendered messages, but in the future let's send
123
-
// structured messages to the frontend so we can do better formatting
124
-
if let Some(rendered) = &msg.message.rendered {
125
-
info!("{}", rendered);
126
-
rendered_messages.push(rendered.to_string());
127
-
}
128
-
}
129
-
// Random text came in, just log it
130
-
Message::TextLine(msg) => {
131
-
info!("{}", msg);
132
-
}
133
-
_ => {}
134
-
}
135
-
}
136
}
137
}
138
139
-
(out, rendered_messages)
140
-
});
141
142
-
let stderr_task = tokio::spawn(async move {
143
-
let mut err = Vec::new();
144
-
tokio::io::copy(&mut stderr, &mut err).await.unwrap_or(0);
145
146
-
err
147
-
});
148
149
-
let status = child.wait().await?;
150
-
let stdout_data = stdout_task.await.unwrap_or_default();
151
-
let stderr_data = stderr_task.await.unwrap_or_default();
152
153
-
Ok::<(std::process::Output, Vec<String>), Box<dyn std::error::Error + Send + Sync>>(
154
-
(
155
-
std::process::Output {
156
-
status,
157
-
stdout: stdout_data.0,
158
-
stderr: stderr_data,
159
-
},
160
-
stdout_data.1,
161
-
),
162
-
)
163
};
164
165
-
tokio::select! {
166
-
_ = cancel.cancelled() => {
167
-
debug!(name: "build", "Build cancelled");
168
-
let _ = child.kill().await;
169
-
update_status(&websocket_tx, current_status, StatusType::Info, "Build cancelled").await;
170
-
let _ = result_tx.send(false).await; // Build failed due to cancellation
171
-
}
172
-
res = output_future => {
173
-
let duration = build_start_time.elapsed();
174
-
let formatted_elapsed_time = format_elapsed_time(
175
-
duration,
176
-
&FormatElapsedTimeOptions::default_dev(),
177
-
);
178
179
-
let success = match res {
180
-
Ok(output) => {
181
-
let (output, rendered_messages) = output;
182
-
if output.status.success() {
183
-
let build_type = if is_initial { "Initial build" } else { "Rebuild" };
184
-
info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time);
185
-
update_status(&websocket_tx, current_status, StatusType::Success, "Build finished successfully").await;
186
-
true
187
-
} else {
188
-
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
189
-
println!("{}", stderr); // Raw stderr sometimes has something to say whenever cargo fails, even if the errors messages are actually in stdout
190
-
let build_type = if is_initial { "Initial build" } else { "Rebuild" };
191
-
error!(name: "build", "{} failed with errors {}", build_type, formatted_elapsed_time);
192
-
if is_initial {
193
-
error!(name: "build", "Initial build needs to succeed before we can start the dev server");
194
-
update_status(&websocket_tx, current_status, StatusType::Error, "Initial build failed - fix errors and save to retry").await;
195
-
} else {
196
-
update_status(&websocket_tx, current_status, StatusType::Error, &rendered_messages.join("\n")).await;
197
-
}
198
-
false
199
-
}
200
-
}
201
-
Err(e) => {
202
-
error!(name: "build", "Failed to wait for build: {}", e);
203
-
update_status(&websocket_tx, current_status, StatusType::Error, &format!("Failed to wait for build: {}", e)).await;
204
-
false
205
-
}
206
-
};
207
-
let _ = result_tx.send(success).await;
208
-
}
209
}
210
-
});
211
212
-
// Wait for the build result
213
-
let success = result_rx.recv().await.unwrap_or(false);
214
-
Ok(success)
215
}
216
}
···
1
use cargo_metadata::Message;
2
use quanta::Instant;
3
+
use std::path::PathBuf;
4
use std::sync::Arc;
5
use tokio::process::Command;
6
+
use tokio::sync::RwLock;
7
use tokio_util::sync::CancellationToken;
8
+
use tracing::{debug, error, info, warn};
9
10
use crate::{
11
+
dev::server::{StatusManager, StatusType},
12
logging::{FormatElapsedTimeOptions, format_elapsed_time},
13
};
14
15
+
use super::dep_tracker::{DependencyTracker, find_target_dir};
16
+
17
+
/// Internal state shared across all BuildManager handles.
18
+
struct BuildManagerState {
19
+
current_cancel: RwLock<Option<CancellationToken>>,
20
+
build_semaphore: tokio::sync::Semaphore,
21
+
status_manager: StatusManager,
22
+
dep_tracker: RwLock<Option<DependencyTracker>>,
23
+
binary_path: RwLock<Option<PathBuf>>,
24
+
// Cached values computed once at startup
25
+
target_dir: Option<PathBuf>,
26
+
binary_name: Option<String>,
27
+
}
28
+
29
+
/// Manages cargo build processes with cancellation support.
30
+
/// Cheap to clone - all clones share the same underlying state.
31
#[derive(Clone)]
32
pub struct BuildManager {
33
+
state: Arc<BuildManagerState>,
34
}
35
36
impl BuildManager {
37
+
pub fn new(status_manager: StatusManager) -> Self {
38
+
// Try to determine target directory and binary name at startup
39
+
let target_dir = find_target_dir().ok();
40
+
let binary_name = Self::get_binary_name_from_cargo_toml().ok();
41
+
42
+
if let Some(ref name) = binary_name {
43
+
debug!(name: "build", "Detected binary name at startup: {}", name);
44
+
}
45
+
if let Some(ref dir) = target_dir {
46
+
debug!(name: "build", "Using target directory: {:?}", dir);
47
+
}
48
+
49
Self {
50
+
state: Arc::new(BuildManagerState {
51
+
current_cancel: RwLock::new(None),
52
+
build_semaphore: tokio::sync::Semaphore::new(1),
53
+
status_manager,
54
+
dep_tracker: RwLock::new(None),
55
+
binary_path: RwLock::new(None),
56
+
target_dir,
57
+
binary_name,
58
+
}),
59
}
60
}
61
62
+
/// Check if the given paths require recompilation based on dependency tracking.
63
+
/// Returns true if recompilation is needed, false if we can just rerun the binary.
64
+
pub async fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool {
65
+
let dep_tracker = self.state.dep_tracker.read().await;
66
+
67
+
if let Some(tracker) = dep_tracker.as_ref()
68
+
&& tracker.has_dependencies()
69
+
{
70
+
let needs_recompile = tracker.needs_recompile(changed_paths);
71
+
if !needs_recompile {
72
+
debug!(name: "build", "Changed files are not dependencies, rerun binary without recompile");
73
+
}
74
+
return needs_recompile;
75
+
}
76
+
77
+
// If we don't have a dependency tracker yet, always recompile
78
+
true
79
+
}
80
+
81
+
/// Rerun the binary without recompiling.
82
+
pub async fn rerun_binary(
83
+
&self,
84
+
changed_paths: &[PathBuf],
85
+
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
86
+
// Get binary path with limited lock scope
87
+
let path = {
88
+
let guard = self.state.binary_path.read().await;
89
+
match guard.as_ref() {
90
+
Some(p) if p.exists() => p.clone(),
91
+
Some(p) => {
92
+
warn!(name: "build", "Binary at {:?} no longer exists, falling back to full rebuild", p);
93
+
return self.start_build().await;
94
+
}
95
+
None => {
96
+
warn!(name: "build", "No binary path available, falling back to full rebuild");
97
+
return self.start_build().await;
98
+
}
99
+
}
100
+
};
101
+
102
+
// Log that we're doing an incremental build
103
+
debug!(name: "build", "Incremental build: {} files changed", changed_paths.len());
104
+
debug!(name: "build", "Changed files: {:?}", changed_paths);
105
+
debug!(name: "build", "Rerunning binary without recompilation...");
106
+
107
+
self.state
108
+
.status_manager
109
+
.update(StatusType::Info, "Rerunning...")
110
+
.await;
111
+
112
+
let build_start_time = Instant::now();
113
+
114
+
// Serialize changed paths to JSON for the binary
115
+
let changed_files_json = serde_json::to_string(changed_paths)?;
116
+
117
+
let child = Command::new(&path)
118
+
.envs([
119
+
("MAUDIT_DEV", "true"),
120
+
("MAUDIT_QUIET", "true"),
121
+
("MAUDIT_CHANGED_FILES", changed_files_json.as_str()),
122
+
])
123
+
.stdout(std::process::Stdio::piped())
124
+
.stderr(std::process::Stdio::piped())
125
+
.spawn()?;
126
+
127
+
let output = child.wait_with_output().await?;
128
+
129
+
let duration = build_start_time.elapsed();
130
+
let formatted_elapsed_time =
131
+
format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev());
132
+
133
+
if output.status.success() {
134
+
if std::env::var("MAUDIT_SHOW_BINARY_OUTPUT").is_ok() {
135
+
let stdout = String::from_utf8_lossy(&output.stdout);
136
+
let stderr = String::from_utf8_lossy(&output.stderr);
137
+
for line in stdout.lines().chain(stderr.lines()) {
138
+
if !line.trim().is_empty() {
139
+
info!(name: "build", "{}", line);
140
+
}
141
+
}
142
+
}
143
+
info!(name: "build", "Binary rerun finished {}", formatted_elapsed_time);
144
+
self.state
145
+
.status_manager
146
+
.update(StatusType::Success, "Binary rerun finished successfully")
147
+
.await;
148
+
Ok(true)
149
+
} else {
150
+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
151
+
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
152
+
error!(name: "build", "Binary rerun failed {}\nstdout: {}\nstderr: {}",
153
+
formatted_elapsed_time, stdout, stderr);
154
+
self.state
155
+
.status_manager
156
+
.update(
157
+
StatusType::Error,
158
+
&format!("Binary rerun failed:\n{}\n{}", stdout, stderr),
159
+
)
160
+
.await;
161
+
Ok(false)
162
+
}
163
}
164
165
+
/// Do initial build that can be cancelled.
166
+
pub async fn do_initial_build(&self) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
167
self.internal_build(true).await
168
}
169
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
173
}
174
175
+
async fn internal_build(
176
+
&self,
177
+
is_initial: bool,
178
+
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
179
// Cancel any existing build immediately
180
let cancel = CancellationToken::new();
181
{
182
+
let mut current_cancel = self.state.current_cancel.write().await;
183
if let Some(old_cancel) = current_cancel.replace(cancel.clone()) {
184
old_cancel.cancel();
185
}
186
}
187
188
// Acquire semaphore to ensure only one build runs at a time
189
+
let _permit = self.state.build_semaphore.acquire().await?;
190
191
+
self.state
192
+
.status_manager
193
+
.update(StatusType::Info, "Building...")
194
+
.await;
195
196
let mut child = Command::new("cargo")
197
.args([
···
209
.stderr(std::process::Stdio::piped())
210
.spawn()?;
211
212
+
// Take stdout/stderr before select! so we can use them in the completion branch
213
+
// while still being able to kill the child in the cancellation branch
214
+
let stdout = child.stdout.take().unwrap();
215
+
let stderr = child.stderr.take().unwrap();
216
217
let build_start_time = Instant::now();
218
219
+
tokio::select! {
220
+
_ = cancel.cancelled() => {
221
+
debug!(name: "build", "Build cancelled");
222
+
let _ = child.kill().await;
223
+
self.state.status_manager.update(StatusType::Info, "Build cancelled").await;
224
+
Ok(false)
225
+
}
226
+
result = self.run_build_to_completion(&mut child, stdout, stderr, is_initial, build_start_time) => {
227
+
result
228
+
}
229
+
}
230
+
}
231
232
+
/// Run the cargo build process to completion and handle the output.
233
+
async fn run_build_to_completion(
234
+
&self,
235
+
child: &mut tokio::process::Child,
236
+
mut stdout: tokio::process::ChildStdout,
237
+
mut stderr: tokio::process::ChildStderr,
238
+
is_initial: bool,
239
+
build_start_time: Instant,
240
+
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
241
+
// Read stdout and stderr concurrently
242
+
let stdout_task = tokio::spawn(async move {
243
+
let mut out = Vec::new();
244
+
tokio::io::copy(&mut stdout, &mut out).await.unwrap_or(0);
245
246
+
let mut rendered_messages: Vec<String> = Vec::new();
247
248
+
for message in cargo_metadata::Message::parse_stream(
249
+
String::from_utf8_lossy(&out).to_string().as_bytes(),
250
+
) {
251
+
match message {
252
+
Err(e) => {
253
+
error!(name: "build", "Failed to parse cargo message: {}", e);
254
+
}
255
+
Ok(Message::CompilerMessage(msg)) => {
256
+
if let Some(rendered) = &msg.message.rendered {
257
+
info!("{}", rendered);
258
+
rendered_messages.push(rendered.to_string());
259
}
260
}
261
+
Ok(Message::TextLine(msg)) => {
262
+
info!("{}", msg);
263
+
}
264
+
_ => {}
265
+
}
266
+
}
267
268
+
(out, rendered_messages)
269
+
});
270
271
+
let stderr_task = tokio::spawn(async move {
272
+
let mut err = Vec::new();
273
+
tokio::io::copy(&mut stderr, &mut err).await.unwrap_or(0);
274
+
err
275
+
});
276
277
+
let status = child.wait().await?;
278
+
let (_stdout_bytes, rendered_messages) = stdout_task.await.unwrap_or_default();
279
+
let stderr_bytes = stderr_task.await.unwrap_or_default();
280
281
+
let duration = build_start_time.elapsed();
282
+
let formatted_elapsed_time =
283
+
format_elapsed_time(duration, &FormatElapsedTimeOptions::default_dev());
284
+
285
+
if status.success() {
286
+
let build_type = if is_initial {
287
+
"Initial build"
288
+
} else {
289
+
"Rebuild"
290
+
};
291
+
info!(name: "build", "{} finished {}", build_type, formatted_elapsed_time);
292
+
self.state
293
+
.status_manager
294
+
.update(StatusType::Success, "Build finished successfully")
295
+
.await;
296
+
297
+
self.update_dependency_tracker().await;
298
+
299
+
Ok(true)
300
+
} else {
301
+
let stderr_str = String::from_utf8_lossy(&stderr_bytes).to_string();
302
+
// Raw stderr sometimes has something to say whenever cargo fails
303
+
println!("{}", stderr_str);
304
305
+
let build_type = if is_initial {
306
+
"Initial build"
307
+
} else {
308
+
"Rebuild"
309
};
310
+
error!(name: "build", "{} failed with errors {}", build_type, formatted_elapsed_time);
311
312
+
if is_initial {
313
+
error!(name: "build", "Initial build needs to succeed before we can start the dev server");
314
+
self.state
315
+
.status_manager
316
+
.update(
317
+
StatusType::Error,
318
+
"Initial build failed - fix errors and save to retry",
319
+
)
320
+
.await;
321
+
} else {
322
+
self.state
323
+
.status_manager
324
+
.update(StatusType::Error, &rendered_messages.join("\n"))
325
+
.await;
326
+
}
327
328
+
Ok(false)
329
+
}
330
+
}
331
+
332
+
/// Update the dependency tracker after a successful build.
333
+
async fn update_dependency_tracker(&self) {
334
+
let Some(ref name) = self.state.binary_name else {
335
+
debug!(name: "build", "No binary name available, skipping dependency tracker update");
336
+
return;
337
+
};
338
+
339
+
let Some(ref target) = self.state.target_dir else {
340
+
debug!(name: "build", "No target directory available, skipping dependency tracker update");
341
+
return;
342
+
};
343
+
344
+
// Update binary path
345
+
let bin_path = target.join(name);
346
+
if bin_path.exists() {
347
+
*self.state.binary_path.write().await = Some(bin_path.clone());
348
+
debug!(name: "build", "Binary path set to: {:?}", bin_path);
349
+
} else {
350
+
debug!(name: "build", "Binary not found at expected path: {:?}", bin_path);
351
+
}
352
+
353
+
// Reload the dependency tracker from the .d file
354
+
match DependencyTracker::load_from_binary_name(name) {
355
+
Ok(tracker) => {
356
+
debug!(name: "build", "Loaded {} dependencies for tracking", tracker.get_dependencies().len());
357
+
*self.state.dep_tracker.write().await = Some(tracker);
358
}
359
+
Err(e) => {
360
+
debug!(name: "build", "Could not load dependency tracker: {}", e);
361
+
}
362
+
}
363
+
}
364
365
+
fn get_binary_name_from_cargo_toml() -> Result<String, Box<dyn std::error::Error + Send + Sync>>
366
+
{
367
+
let cargo_toml_path = PathBuf::from("Cargo.toml");
368
+
if !cargo_toml_path.exists() {
369
+
return Err("Cargo.toml not found in current directory".into());
370
+
}
371
+
372
+
let cargo_toml_content = std::fs::read_to_string(&cargo_toml_path)?;
373
+
let cargo_toml: toml::Value = toml::from_str(&cargo_toml_content)?;
374
+
375
+
if let Some(package_name) = cargo_toml
376
+
.get("package")
377
+
.and_then(|p| p.get("name"))
378
+
.and_then(|n| n.as_str())
379
+
{
380
+
// Check if there's a [[bin]] section with a different name
381
+
if let Some(bins) = cargo_toml.get("bin").and_then(|b| b.as_array())
382
+
&& let Some(first_bin) = bins.first()
383
+
&& let Some(bin_name) = first_bin.get("name").and_then(|n| n.as_str())
384
+
{
385
+
return Ok(bin_name.to_string());
386
+
}
387
+
388
+
return Ok(package_name.to_string());
389
+
}
390
+
391
+
Err("Could not find package name in Cargo.toml".into())
392
+
}
393
+
394
+
/// Set the dependency tracker directly (for testing).
395
+
#[cfg(test)]
396
+
pub(crate) async fn set_dep_tracker(&self, tracker: Option<DependencyTracker>) {
397
+
*self.state.dep_tracker.write().await = tracker;
398
+
}
399
+
400
+
/// Set the binary path directly (for testing).
401
+
#[cfg(test)]
402
+
pub(crate) async fn set_binary_path(&self, path: Option<PathBuf>) {
403
+
*self.state.binary_path.write().await = path;
404
+
}
405
+
406
+
/// Get the current binary path (for testing).
407
+
#[cfg(test)]
408
+
pub(crate) async fn get_binary_path(&self) -> Option<PathBuf> {
409
+
self.state.binary_path.read().await.clone()
410
+
}
411
+
412
+
/// Create a BuildManager with custom target_dir and binary_name (for testing).
413
+
#[cfg(test)]
414
+
pub(crate) fn new_with_config(
415
+
status_manager: StatusManager,
416
+
target_dir: Option<PathBuf>,
417
+
binary_name: Option<String>,
418
+
) -> Self {
419
+
Self {
420
+
state: Arc::new(BuildManagerState {
421
+
current_cancel: RwLock::new(None),
422
+
build_semaphore: tokio::sync::Semaphore::new(1),
423
+
status_manager,
424
+
dep_tracker: RwLock::new(None),
425
+
binary_path: RwLock::new(None),
426
+
target_dir,
427
+
binary_name,
428
+
}),
429
+
}
430
+
}
431
+
}
432
+
433
+
#[cfg(test)]
434
+
mod tests {
435
+
use super::*;
436
+
use std::collections::HashMap;
437
+
use std::time::SystemTime;
438
+
use tempfile::TempDir;
439
+
440
+
fn create_test_manager() -> BuildManager {
441
+
let status_manager = StatusManager::new();
442
+
BuildManager::new_with_config(status_manager, None, None)
443
+
}
444
+
445
+
fn create_test_manager_with_config(
446
+
target_dir: Option<PathBuf>,
447
+
binary_name: Option<String>,
448
+
) -> BuildManager {
449
+
let status_manager = StatusManager::new();
450
+
BuildManager::new_with_config(status_manager, target_dir, binary_name)
451
+
}
452
+
453
+
#[tokio::test]
454
+
async fn test_build_manager_clone_shares_state() {
455
+
let manager1 = create_test_manager();
456
+
let manager2 = manager1.clone();
457
+
458
+
// Set binary path via one clone
459
+
let test_path = PathBuf::from("/test/path");
460
+
manager1.set_binary_path(Some(test_path.clone())).await;
461
+
462
+
// Should be visible via the other clone
463
+
assert_eq!(manager2.get_binary_path().await, Some(test_path));
464
+
}
465
+
466
+
#[tokio::test]
467
+
async fn test_needs_recompile_without_tracker() {
468
+
let manager = create_test_manager();
469
+
470
+
// Without a dependency tracker, should always return true
471
+
let changed = vec![PathBuf::from("src/main.rs")];
472
+
assert!(manager.needs_recompile(&changed).await);
473
+
}
474
+
475
+
#[tokio::test]
476
+
async fn test_needs_recompile_with_empty_tracker() {
477
+
let manager = create_test_manager();
478
+
479
+
// Set an empty tracker (no dependencies)
480
+
let tracker = DependencyTracker::new();
481
+
manager.set_dep_tracker(Some(tracker)).await;
482
+
483
+
// Empty tracker has no dependencies, so has_dependencies() returns false
484
+
// This means we should still return true (recompile needed)
485
+
let changed = vec![PathBuf::from("src/main.rs")];
486
+
assert!(manager.needs_recompile(&changed).await);
487
+
}
488
+
489
+
#[tokio::test]
490
+
async fn test_needs_recompile_with_matching_dependency() {
491
+
let manager = create_test_manager();
492
+
493
+
// Create a tracker with some dependencies
494
+
let temp_dir = TempDir::new().unwrap();
495
+
let dep_file = temp_dir.path().join("src/lib.rs");
496
+
std::fs::create_dir_all(dep_file.parent().unwrap()).unwrap();
497
+
std::fs::write(&dep_file, "// test").unwrap();
498
+
499
+
// Get canonical path and current mod time
500
+
let canonical_path = dep_file.canonicalize().unwrap();
501
+
let old_time = SystemTime::UNIX_EPOCH; // Very old time
502
+
503
+
let mut tracker = DependencyTracker::new();
504
+
tracker.dependencies = HashMap::from([(canonical_path, old_time)]);
505
+
506
+
manager.set_dep_tracker(Some(tracker)).await;
507
+
508
+
// Changed file IS a dependency and is newer - should need recompile
509
+
let changed = vec![dep_file];
510
+
assert!(manager.needs_recompile(&changed).await);
511
+
}
512
+
513
+
#[tokio::test]
514
+
async fn test_needs_recompile_with_non_matching_file() {
515
+
let manager = create_test_manager();
516
+
517
+
// Create a tracker with some dependencies
518
+
let temp_dir = TempDir::new().unwrap();
519
+
let dep_file = temp_dir.path().join("src/lib.rs");
520
+
std::fs::create_dir_all(dep_file.parent().unwrap()).unwrap();
521
+
std::fs::write(&dep_file, "// test").unwrap();
522
+
523
+
let canonical_path = dep_file.canonicalize().unwrap();
524
+
let mod_time = std::fs::metadata(&dep_file).unwrap().modified().unwrap();
525
+
526
+
let mut tracker = DependencyTracker::new();
527
+
tracker.dependencies = HashMap::from([(canonical_path, mod_time)]);
528
+
529
+
manager.set_dep_tracker(Some(tracker)).await;
530
+
531
+
// Changed file is NOT a dependency (different file)
532
+
let other_file = temp_dir.path().join("assets/style.css");
533
+
std::fs::create_dir_all(other_file.parent().unwrap()).unwrap();
534
+
std::fs::write(&other_file, "/* css */").unwrap();
535
+
536
+
let changed = vec![other_file];
537
+
assert!(!manager.needs_recompile(&changed).await);
538
+
}
539
+
540
+
#[tokio::test]
541
+
async fn test_update_dependency_tracker_with_config_missing_binary() {
542
+
let temp_dir = TempDir::new().unwrap();
543
+
let manager = create_test_manager_with_config(
544
+
Some(temp_dir.path().to_path_buf()),
545
+
Some("nonexistent-binary".to_string()),
546
+
);
547
+
548
+
// Binary doesn't exist, so binary_path should not be set
549
+
manager.update_dependency_tracker().await;
550
+
551
+
assert!(manager.get_binary_path().await.is_none());
552
+
}
553
+
554
+
#[tokio::test]
555
+
async fn test_update_dependency_tracker_with_existing_binary() {
556
+
let temp_dir = TempDir::new().unwrap();
557
+
let binary_name = "test-binary";
558
+
let binary_path = temp_dir.path().join(binary_name);
559
+
560
+
// Create a fake binary file
561
+
std::fs::write(&binary_path, "fake binary").unwrap();
562
+
563
+
let manager = create_test_manager_with_config(
564
+
Some(temp_dir.path().to_path_buf()),
565
+
Some(binary_name.to_string()),
566
+
);
567
+
568
+
manager.update_dependency_tracker().await;
569
+
570
+
// Binary path should be set
571
+
assert_eq!(manager.get_binary_path().await, Some(binary_path));
572
}
573
}
+377
crates/maudit-cli/src/dev/dep_tracker.rs
+377
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
+
pub(crate) d_file_path: Option<PathBuf>,
12
+
/// Map of dependency paths to their last modification times
13
+
pub(crate) 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
+
&& let Ok(content) = fs::read_to_string(&cargo_toml)
60
+
&& content.contains("[workspace]")
61
+
{
62
+
let workspace_target = current.join("target").join("debug");
63
+
if workspace_target.exists() {
64
+
debug!("Using workspace target directory: {:?}", workspace_target);
65
+
return Ok(workspace_target);
66
+
}
67
+
}
68
+
69
+
// Move up to parent directory
70
+
if !current.pop() {
71
+
break;
72
+
}
73
+
}
74
+
75
+
// 4. Final fallback to relative path
76
+
debug!("Falling back to relative target/debug path");
77
+
Ok(PathBuf::from("target/debug"))
78
+
}
79
+
80
+
impl DependencyTracker {
81
+
#[allow(dead_code)]
82
+
pub fn new() -> Self {
83
+
Self {
84
+
d_file_path: None,
85
+
dependencies: HashMap::new(),
86
+
}
87
+
}
88
+
89
+
/// Locate and load the .d file for the current binary
90
+
/// The .d file is typically at target/debug/<binary-name>.d
91
+
pub fn load_from_binary_name(binary_name: &str) -> Result<Self, std::io::Error> {
92
+
let target_dir = find_target_dir()?;
93
+
let d_file_path = target_dir.join(format!("{}.d", binary_name));
94
+
95
+
if !d_file_path.exists() {
96
+
return Err(std::io::Error::new(
97
+
std::io::ErrorKind::NotFound,
98
+
format!(".d file not found at {:?}", d_file_path),
99
+
));
100
+
}
101
+
102
+
let mut tracker = Self {
103
+
d_file_path: Some(d_file_path.clone()),
104
+
dependencies: HashMap::new(),
105
+
};
106
+
107
+
tracker.reload_dependencies()?;
108
+
Ok(tracker)
109
+
}
110
+
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
167
+
pub fn reload_dependencies(&mut self) -> Result<(), std::io::Error> {
168
+
let Some(d_file_path) = &self.d_file_path else {
169
+
return Err(std::io::Error::new(
170
+
std::io::ErrorKind::NotFound,
171
+
"No .d file path set",
172
+
));
173
+
};
174
+
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);
191
+
192
+
// Clear old dependencies and load new ones with their modification times
193
+
self.dependencies.clear();
194
+
195
+
for dep_path in dep_paths {
196
+
match fs::metadata(&dep_path) {
197
+
Ok(metadata) => {
198
+
if let Ok(modified) = metadata.modified() {
199
+
self.dependencies.insert(dep_path.clone(), modified);
200
+
debug!("Tracking dependency: {:?}", dep_path);
201
+
}
202
+
}
203
+
Err(e) => {
204
+
// Dependency file doesn't exist or can't be read - this is okay,
205
+
// it might have been deleted or moved
206
+
debug!("Could not read dependency {:?}: {}", dep_path, e);
207
+
}
208
+
}
209
+
}
210
+
211
+
debug!(
212
+
"Loaded {} dependencies from {:?}",
213
+
self.dependencies.len(),
214
+
d_file_path
215
+
);
216
+
Ok(())
217
+
}
218
+
219
+
/// Check if any of the given paths require recompilation
220
+
/// Returns true if any path is a tracked dependency that has been modified
221
+
pub fn needs_recompile(&self, changed_paths: &[PathBuf]) -> bool {
222
+
for changed_path in changed_paths {
223
+
// Normalize the changed path to handle relative vs absolute paths
224
+
let changed_path_canonical = changed_path.canonicalize().ok();
225
+
226
+
for (dep_path, last_modified) in &self.dependencies {
227
+
// Try to match both exact path and canonical path
228
+
let matches = changed_path == dep_path
229
+
|| changed_path_canonical.as_ref() == Some(dep_path)
230
+
|| dep_path.canonicalize().ok().as_ref() == changed_path_canonical.as_ref();
231
+
232
+
if matches {
233
+
// Check if the file was modified after we last tracked it
234
+
if let Ok(metadata) = fs::metadata(changed_path) {
235
+
if let Ok(current_modified) = metadata.modified()
236
+
&& current_modified > *last_modified
237
+
{
238
+
debug!(
239
+
"Dependency {:?} was modified, recompile needed",
240
+
changed_path
241
+
);
242
+
return true;
243
+
}
244
+
} else {
245
+
// File was deleted or can't be read, assume recompile is needed
246
+
debug!(
247
+
"Dependency {:?} no longer exists, recompile needed",
248
+
changed_path
249
+
);
250
+
return true;
251
+
}
252
+
}
253
+
}
254
+
}
255
+
256
+
false
257
+
}
258
+
259
+
/// Get the list of tracked dependency paths
260
+
pub fn get_dependencies(&self) -> Vec<&Path> {
261
+
self.dependencies.keys().map(|p| p.as_path()).collect()
262
+
}
263
+
264
+
/// Check if we have any dependencies loaded
265
+
pub fn has_dependencies(&self) -> bool {
266
+
!self.dependencies.is_empty()
267
+
}
268
+
}
269
+
270
+
#[cfg(test)]
271
+
mod tests {
272
+
use super::*;
273
+
use std::fs;
274
+
use std::io::Write;
275
+
use tempfile::TempDir;
276
+
277
+
#[test]
278
+
fn test_parse_d_file() {
279
+
let temp_dir = TempDir::new().unwrap();
280
+
let d_file_path = temp_dir.path().join("test.d");
281
+
282
+
// Create a mock .d file
283
+
let mut d_file = fs::File::create(&d_file_path).unwrap();
284
+
writeln!(
285
+
d_file,
286
+
"/path/to/target: /path/to/dep1.rs /path/to/dep2.rs \\"
287
+
)
288
+
.unwrap();
289
+
writeln!(d_file, " /path/to/dep3.rs").unwrap();
290
+
291
+
// Create a tracker and point it to our test file
292
+
let mut tracker = DependencyTracker::new();
293
+
tracker.d_file_path = Some(d_file_path);
294
+
295
+
// This will fail to load the actual files, but we can check the parsing logic
296
+
let _ = tracker.reload_dependencies();
297
+
298
+
// We won't have any dependencies because the files don't exist,
299
+
// but we've verified the parsing doesn't crash
300
+
}
301
+
302
+
#[test]
303
+
fn test_parse_d_file_with_spaces() {
304
+
let temp_dir = TempDir::new().unwrap();
305
+
let d_file_path = temp_dir.path().join("test_spaces.d");
306
+
307
+
// Create actual test files with spaces in names
308
+
let dep_with_space = temp_dir.path().join("my file.rs");
309
+
fs::write(&dep_with_space, "// test").unwrap();
310
+
311
+
let normal_dep = temp_dir.path().join("normal.rs");
312
+
fs::write(&normal_dep, "// test").unwrap();
313
+
314
+
// Create a mock .d file with escaped spaces (Make format)
315
+
let mut d_file = fs::File::create(&d_file_path).unwrap();
316
+
writeln!(
317
+
d_file,
318
+
"/path/to/target: {} {}",
319
+
dep_with_space.to_str().unwrap().replace(' ', "\\ "),
320
+
normal_dep.to_str().unwrap()
321
+
)
322
+
.unwrap();
323
+
324
+
let mut tracker = DependencyTracker::new();
325
+
tracker.d_file_path = Some(d_file_path);
326
+
327
+
// Load dependencies
328
+
tracker.reload_dependencies().unwrap();
329
+
330
+
// Should have successfully parsed both files
331
+
assert!(tracker.has_dependencies());
332
+
let deps = tracker.get_dependencies();
333
+
assert_eq!(deps.len(), 2);
334
+
assert!(
335
+
deps.iter()
336
+
.any(|p| p.to_str().unwrap().contains("my file.rs")),
337
+
"Should contain file with space"
338
+
);
339
+
assert!(
340
+
deps.iter()
341
+
.any(|p| p.to_str().unwrap().contains("normal.rs")),
342
+
"Should contain normal file"
343
+
);
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
+
}
377
+
}
+223
-64
crates/maudit-cli/src/dev/server.rs
+223
-64
crates/maudit-cli/src/dev/server.rs
···
64
pub message: String,
65
}
66
67
#[derive(Clone)]
68
-
struct AppState {
69
tx: broadcast::Sender<WebSocketMessage>,
70
current_status: Arc<RwLock<Option<PersistentStatus>>>,
71
}
72
73
fn inject_live_reload_script(html_content: &str, socket_addr: SocketAddr, host: bool) -> String {
···
93
94
pub async fn start_dev_web_server(
95
start_time: Instant,
96
-
tx: broadcast::Sender<WebSocketMessage>,
97
host: bool,
98
port: Option<u16>,
99
initial_error: Option<String>,
100
-
current_status: Arc<RwLock<Option<PersistentStatus>>>,
101
) {
102
// TODO: The dist dir should be configurable
103
let dist_dir = "dist";
104
105
// Send initial error if present
106
if let Some(error) = initial_error {
107
-
let _ = tx.send(WebSocketMessage {
108
data: json!({
109
"type": StatusType::Error.to_string(),
110
"message": error
···
172
.on_response(CustomOnResponse),
173
)
174
.with_state(AppState {
175
-
tx: tx.clone(),
176
-
current_status: current_status.clone(),
177
});
178
179
log_server_start(
···
192
.unwrap();
193
}
194
195
-
pub async fn update_status(
196
-
tx: &broadcast::Sender<WebSocketMessage>,
197
-
current_status: Arc<RwLock<Option<PersistentStatus>>>,
198
-
status_type: StatusType,
199
-
message: &str,
200
-
) {
201
-
// Only store persistent states (Success clears errors, Error stores the error)
202
-
let persistent_status = match status_type {
203
-
StatusType::Success => None, // Clear any error state
204
-
StatusType::Error => Some(PersistentStatus {
205
-
status_type: StatusType::Error,
206
-
message: message.to_string(),
207
-
}),
208
-
// Everything else just keeps the current state
209
-
_ => {
210
-
let status = current_status.read().await;
211
-
status.clone() // Keep existing persistent state
212
-
}
213
-
};
214
-
215
-
// Update the stored status
216
-
{
217
-
let mut status = current_status.write().await;
218
-
*status = persistent_status;
219
-
}
220
-
221
-
// Send the message to all connected clients
222
-
let _ = tx.send(WebSocketMessage {
223
-
data: json!({
224
-
"type": status_type.to_string(),
225
-
"message": message
226
-
})
227
-
.to_string(),
228
-
});
229
-
}
230
-
231
async fn add_dev_client_script(
232
req: Request,
233
next: Next,
···
311
debug!("`{addr} connected.");
312
// finalize the upgrade process by returning upgrade callback.
313
// we can customize the callback by sending additional info such as address.
314
-
ws.on_upgrade(move |socket| handle_socket(socket, addr, state.tx, state.current_status))
315
}
316
317
-
async fn handle_socket(
318
-
socket: WebSocket,
319
-
who: SocketAddr,
320
-
tx: broadcast::Sender<WebSocketMessage>,
321
-
current_status: Arc<RwLock<Option<PersistentStatus>>>,
322
-
) {
323
let (mut sender, mut receiver) = socket.split();
324
325
// Send current persistent status to new connection if there is one
326
-
{
327
-
let status = current_status.read().await;
328
-
if let Some(persistent_status) = status.as_ref() {
329
-
let _ = sender
330
-
.send(Message::Text(
331
-
json!({
332
-
"type": persistent_status.status_type.to_string(),
333
-
"message": persistent_status.message
334
-
})
335
-
.to_string()
336
-
.into(),
337
-
))
338
-
.await;
339
-
}
340
}
341
342
-
let mut rx = tx.subscribe();
343
344
tokio::select! {
345
_ = async {
···
387
_ = terminate => {},
388
}
389
}
···
64
pub message: String,
65
}
66
67
+
/// Manages status updates and WebSocket broadcasting.
68
+
/// Cheap to clone - all clones share the same underlying state.
69
#[derive(Clone)]
70
+
pub struct StatusManager {
71
tx: broadcast::Sender<WebSocketMessage>,
72
current_status: Arc<RwLock<Option<PersistentStatus>>>,
73
+
}
74
+
75
+
impl StatusManager {
76
+
pub fn new() -> Self {
77
+
let (tx, _) = broadcast::channel::<WebSocketMessage>(100);
78
+
Self {
79
+
tx,
80
+
current_status: Arc::new(RwLock::new(None)),
81
+
}
82
+
}
83
+
84
+
/// Update the status and broadcast to all connected WebSocket clients.
85
+
pub async fn update(&self, status_type: StatusType, message: &str) {
86
+
// Only store persistent states (Success clears errors, Error stores the error)
87
+
let persistent_status = match status_type {
88
+
StatusType::Success => None, // Clear any error state
89
+
StatusType::Error => Some(PersistentStatus {
90
+
status_type: StatusType::Error,
91
+
message: message.to_string(),
92
+
}),
93
+
// Everything else just keeps the current state
94
+
_ => {
95
+
let status = self.current_status.read().await;
96
+
status.clone() // Keep existing persistent state
97
+
}
98
+
};
99
+
100
+
// Update the stored status
101
+
{
102
+
let mut status = self.current_status.write().await;
103
+
*status = persistent_status;
104
+
}
105
+
106
+
// Send the message to all connected clients
107
+
let _ = self.tx.send(WebSocketMessage {
108
+
data: json!({
109
+
"type": status_type.to_string(),
110
+
"message": message
111
+
})
112
+
.to_string(),
113
+
});
114
+
}
115
+
116
+
/// Subscribe to WebSocket messages (for new connections).
117
+
pub fn subscribe(&self) -> broadcast::Receiver<WebSocketMessage> {
118
+
self.tx.subscribe()
119
+
}
120
+
121
+
/// Get the current persistent status (for new connections).
122
+
pub async fn get_current(&self) -> Option<PersistentStatus> {
123
+
self.current_status.read().await.clone()
124
+
}
125
+
126
+
/// Send a raw WebSocket message (for initial errors, etc.).
127
+
pub fn send_raw(&self, message: WebSocketMessage) {
128
+
let _ = self.tx.send(message);
129
+
}
130
+
}
131
+
132
+
impl Default for StatusManager {
133
+
fn default() -> Self {
134
+
Self::new()
135
+
}
136
+
}
137
+
138
+
#[derive(Clone)]
139
+
struct AppState {
140
+
status_manager: StatusManager,
141
}
142
143
fn inject_live_reload_script(html_content: &str, socket_addr: SocketAddr, host: bool) -> String {
···
163
164
pub async fn start_dev_web_server(
165
start_time: Instant,
166
+
status_manager: StatusManager,
167
host: bool,
168
port: Option<u16>,
169
initial_error: Option<String>,
170
) {
171
// TODO: The dist dir should be configurable
172
let dist_dir = "dist";
173
174
// Send initial error if present
175
if let Some(error) = initial_error {
176
+
status_manager.send_raw(WebSocketMessage {
177
data: json!({
178
"type": StatusType::Error.to_string(),
179
"message": error
···
241
.on_response(CustomOnResponse),
242
)
243
.with_state(AppState {
244
+
status_manager: status_manager.clone(),
245
});
246
247
log_server_start(
···
260
.unwrap();
261
}
262
263
async fn add_dev_client_script(
264
req: Request,
265
next: Next,
···
343
debug!("`{addr} connected.");
344
// finalize the upgrade process by returning upgrade callback.
345
// we can customize the callback by sending additional info such as address.
346
+
ws.on_upgrade(move |socket| handle_socket(socket, addr, state.status_manager))
347
}
348
349
+
async fn handle_socket(socket: WebSocket, who: SocketAddr, status_manager: StatusManager) {
350
let (mut sender, mut receiver) = socket.split();
351
352
// Send current persistent status to new connection if there is one
353
+
if let Some(persistent_status) = status_manager.get_current().await {
354
+
let _ = sender
355
+
.send(Message::Text(
356
+
json!({
357
+
"type": persistent_status.status_type.to_string(),
358
+
"message": persistent_status.message
359
+
})
360
+
.to_string()
361
+
.into(),
362
+
))
363
+
.await;
364
}
365
366
+
let mut rx = status_manager.subscribe();
367
368
tokio::select! {
369
_ = async {
···
411
_ = terminate => {},
412
}
413
}
414
+
415
+
#[cfg(test)]
416
+
mod tests {
417
+
use super::*;
418
+
419
+
#[tokio::test]
420
+
async fn test_status_manager_update_error_persists() {
421
+
let manager = StatusManager::new();
422
+
423
+
manager
424
+
.update(StatusType::Error, "Something went wrong")
425
+
.await;
426
+
427
+
let status = manager.get_current().await;
428
+
assert!(status.is_some());
429
+
let status = status.unwrap();
430
+
assert!(matches!(status.status_type, StatusType::Error));
431
+
assert_eq!(status.message, "Something went wrong");
432
+
}
433
+
434
+
#[tokio::test]
435
+
async fn test_status_manager_update_success_clears_error() {
436
+
let manager = StatusManager::new();
437
+
438
+
// First set an error
439
+
manager.update(StatusType::Error, "Build failed").await;
440
+
assert!(manager.get_current().await.is_some());
441
+
442
+
// Then send success - should clear the error
443
+
manager.update(StatusType::Success, "Build succeeded").await;
444
+
assert!(manager.get_current().await.is_none());
445
+
}
446
+
447
+
#[tokio::test]
448
+
async fn test_status_manager_update_info_preserves_state() {
449
+
let manager = StatusManager::new();
450
+
451
+
// Set an error
452
+
manager.update(StatusType::Error, "Build failed").await;
453
+
let original_status = manager.get_current().await;
454
+
assert!(original_status.is_some());
455
+
456
+
// Send info - should preserve the error state
457
+
manager.update(StatusType::Info, "Building...").await;
458
+
let status = manager.get_current().await;
459
+
assert!(status.is_some());
460
+
assert_eq!(status.unwrap().message, "Build failed");
461
+
}
462
+
463
+
#[tokio::test]
464
+
async fn test_status_manager_update_info_when_no_error() {
465
+
let manager = StatusManager::new();
466
+
467
+
// No prior state
468
+
assert!(manager.get_current().await.is_none());
469
+
470
+
// Send info - should remain None
471
+
manager.update(StatusType::Info, "Building...").await;
472
+
assert!(manager.get_current().await.is_none());
473
+
}
474
+
475
+
#[tokio::test]
476
+
async fn test_status_manager_subscribe_receives_messages() {
477
+
let manager = StatusManager::new();
478
+
let mut rx = manager.subscribe();
479
+
480
+
manager.update(StatusType::Info, "Hello").await;
481
+
482
+
let msg = rx.try_recv();
483
+
assert!(msg.is_ok());
484
+
let msg = msg.unwrap();
485
+
assert!(msg.data.contains("Hello"));
486
+
assert!(msg.data.contains("info"));
487
+
}
488
+
489
+
#[tokio::test]
490
+
async fn test_status_manager_multiple_subscribers() {
491
+
let manager = StatusManager::new();
492
+
let mut rx1 = manager.subscribe();
493
+
let mut rx2 = manager.subscribe();
494
+
495
+
manager.update(StatusType::Success, "Done").await;
496
+
497
+
// Both subscribers should receive the message
498
+
assert!(rx1.try_recv().is_ok());
499
+
assert!(rx2.try_recv().is_ok());
500
+
}
501
+
502
+
#[tokio::test]
503
+
async fn test_status_manager_send_raw() {
504
+
let manager = StatusManager::new();
505
+
let mut rx = manager.subscribe();
506
+
507
+
manager.send_raw(WebSocketMessage {
508
+
data: r#"{"custom": "message"}"#.to_string(),
509
+
});
510
+
511
+
let msg = rx.try_recv();
512
+
assert!(msg.is_ok());
513
+
assert_eq!(msg.unwrap().data, r#"{"custom": "message"}"#);
514
+
}
515
+
516
+
#[tokio::test]
517
+
async fn test_status_manager_clone_shares_state() {
518
+
let manager1 = StatusManager::new();
519
+
let manager2 = manager1.clone();
520
+
521
+
// Update via one clone
522
+
manager1
523
+
.update(StatusType::Error, "Error from clone 1")
524
+
.await;
525
+
526
+
// Should be visible via the other clone
527
+
let status = manager2.get_current().await;
528
+
assert!(status.is_some());
529
+
assert_eq!(status.unwrap().message, "Error from clone 1");
530
+
}
531
+
532
+
#[tokio::test]
533
+
async fn test_status_manager_clone_shares_broadcast() {
534
+
let manager1 = StatusManager::new();
535
+
let manager2 = manager1.clone();
536
+
537
+
// Subscribe via one clone
538
+
let mut rx = manager2.subscribe();
539
+
540
+
// Send via the other clone
541
+
manager1.update(StatusType::Info, "From clone 1").await;
542
+
543
+
// Should receive the message
544
+
let msg = rx.try_recv();
545
+
assert!(msg.is_ok());
546
+
assert!(msg.unwrap().data.contains("From clone 1"));
547
+
}
548
+
}
+65
-27
crates/maudit-cli/src/dev.rs
+65
-27
crates/maudit-cli/src/dev.rs
···
1
pub(crate) mod server;
2
3
mod build;
4
mod filterer;
5
6
use notify::{
···
9
};
10
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer};
11
use quanta::Instant;
12
-
use server::WebSocketMessage;
13
-
use std::{fs, path::Path};
14
-
use tokio::{
15
-
signal,
16
-
sync::{broadcast, mpsc::channel},
17
-
task::JoinHandle,
18
};
19
use tracing::{error, info};
20
21
use crate::dev::build::BuildManager;
22
23
-
pub async fn start_dev_env(cwd: &str, host: bool, port: Option<u16>) -> Result<(), Box<dyn std::error::Error>> {
24
let start_time = Instant::now();
25
info!(name: "dev", "Preparing dev environmentโฆ");
26
27
-
let (sender_websocket, _) = broadcast::channel::<WebSocketMessage>(100);
28
29
-
// Create build manager (it will create its own status state internally)
30
-
let build_manager = BuildManager::new(sender_websocket.clone());
31
32
// Do initial build
33
info!(name: "build", "Doing initial buildโฆ");
···
48
.collect::<Vec<_>>();
49
50
let mut debouncer = new_debouncer(
51
-
std::time::Duration::from_millis(100),
52
None,
53
move |result: DebounceEventResult| {
54
tx.blocking_send(result).unwrap_or(());
···
73
info!(name: "dev", "Starting web server...");
74
web_server_thread = Some(tokio::spawn(server::start_dev_web_server(
75
start_time,
76
-
sender_websocket.clone(),
77
host,
78
port,
79
None,
80
-
build_manager.current_status(),
81
)));
82
}
83
84
// Clone build manager for the file watcher task
85
let build_manager_watcher = build_manager.clone();
86
-
let sender_websocket_watcher = sender_websocket.clone();
87
88
let file_watcher_task = tokio::spawn(async move {
89
let mut dev_server_started = initial_build_success;
···
147
dev_server_handle =
148
Some(tokio::spawn(server::start_dev_web_server(
149
start_time,
150
-
sender_websocket_watcher.clone(),
151
host,
152
port,
153
None,
154
-
build_manager_watcher.current_status(),
155
)));
156
}
157
Ok(false) => {
···
162
}
163
}
164
} 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)
172
}
173
-
Err(e) => {
174
-
error!(name: "build", "Failed to start build: {}", e);
175
}
176
-
}
177
-
});
178
}
179
}
180
}
···
1
pub(crate) mod server;
2
3
mod build;
4
+
mod dep_tracker;
5
mod filterer;
6
7
use notify::{
···
10
};
11
use notify_debouncer_full::{DebounceEventResult, DebouncedEvent, new_debouncer};
12
use quanta::Instant;
13
+
use server::StatusManager;
14
+
use std::{
15
+
fs,
16
+
path::{Path, PathBuf},
17
};
18
+
use tokio::{signal, sync::mpsc::channel, task::JoinHandle};
19
use tracing::{error, info};
20
21
use crate::dev::build::BuildManager;
22
23
+
pub async fn start_dev_env(
24
+
cwd: &str,
25
+
host: bool,
26
+
port: Option<u16>,
27
+
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
28
let start_time = Instant::now();
29
info!(name: "dev", "Preparing dev environmentโฆ");
30
31
+
// Create status manager (handles WebSocket communication)
32
+
let status_manager = StatusManager::new();
33
34
+
// Create build manager
35
+
let build_manager = BuildManager::new(status_manager.clone());
36
37
// Do initial build
38
info!(name: "build", "Doing initial buildโฆ");
···
53
.collect::<Vec<_>>();
54
55
let mut debouncer = new_debouncer(
56
+
std::time::Duration::from_millis(200), // Longer debounce to better batch rapid file changes
57
None,
58
move |result: DebounceEventResult| {
59
tx.blocking_send(result).unwrap_or(());
···
78
info!(name: "dev", "Starting web server...");
79
web_server_thread = Some(tokio::spawn(server::start_dev_web_server(
80
start_time,
81
+
status_manager.clone(),
82
host,
83
port,
84
None,
85
)));
86
}
87
88
// Clone build manager for the file watcher task
89
let build_manager_watcher = build_manager.clone();
90
+
let status_manager_watcher = status_manager.clone();
91
92
let file_watcher_task = tokio::spawn(async move {
93
let mut dev_server_started = initial_build_success;
···
151
dev_server_handle =
152
Some(tokio::spawn(server::start_dev_web_server(
153
start_time,
154
+
status_manager_watcher.clone(),
155
host,
156
port,
157
None,
158
)));
159
}
160
Ok(false) => {
···
165
}
166
}
167
} else {
168
+
// Normal rebuild - check if we need full recompilation or just rerun
169
+
// Only collect paths from events that actually trigger a rebuild
170
+
let mut changed_paths: Vec<PathBuf> = events.iter()
171
+
.filter(|e| should_rebuild_for_event(e))
172
+
.flat_map(|e| e.paths.iter().cloned())
173
+
.collect();
174
+
175
+
// Deduplicate paths
176
+
changed_paths.sort();
177
+
changed_paths.dedup();
178
+
179
+
if changed_paths.is_empty() {
180
+
// No file changes, only directory changes - skip rebuild
181
+
continue;
182
+
}
183
+
184
+
let needs_recompile = build_manager_watcher.needs_recompile(&changed_paths).await;
185
+
186
+
if needs_recompile {
187
+
// Need to recompile - spawn in background so file watcher can continue
188
+
info!(name: "watch", "Files changed, rebuilding...");
189
+
let build_manager_clone = build_manager_watcher.clone();
190
+
tokio::spawn(async move {
191
+
match build_manager_clone.start_build().await {
192
+
Ok(_) => {
193
+
// Build completed (success or failure already logged)
194
+
}
195
+
Err(e) => {
196
+
error!(name: "build", "Failed to start build: {}", e);
197
+
}
198
}
199
+
});
200
+
} else {
201
+
// Just rerun the binary without recompiling
202
+
info!(name: "watch", "Non-dependency files changed, rerunning binary...");
203
+
let build_manager_clone = build_manager_watcher.clone();
204
+
let changed_paths_clone = changed_paths.clone();
205
+
tokio::spawn(async move {
206
+
match build_manager_clone.rerun_binary(&changed_paths_clone).await {
207
+
Ok(_) => {
208
+
// Rerun completed (success or failure already logged)
209
+
}
210
+
Err(e) => {
211
+
error!(name: "build", "Failed to rerun binary: {}", e);
212
+
}
213
}
214
+
});
215
+
}
216
}
217
}
218
}
+3
e2e/README.md
+3
e2e/README.md
···
13
## Running Tests
14
15
The tests will automatically:
16
1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`)
17
2. Start the Maudit dev server on the test fixture site
18
3. Run the tests
···
46
## Features Tested
47
48
### Basic Prefetch
49
- Creating link elements with `rel="prefetch"`
50
- Preventing duplicate prefetches
51
- Skipping current page prefetch
52
- Blocking cross-origin prefetches
53
54
### Prerendering (Chromium only)
55
- Creating `<script type="speculationrules">` elements
56
- Different eagerness levels (immediate, eager, moderate, conservative)
57
- Fallback to link prefetch on non-Chromium browsers
···
13
## Running Tests
14
15
The tests will automatically:
16
+
17
1. Build the prefetch.js bundle (via `cargo xtask build-maudit-js`)
18
2. Start the Maudit dev server on the test fixture site
19
3. Run the tests
···
47
## Features Tested
48
49
### Basic Prefetch
50
+
51
- Creating link elements with `rel="prefetch"`
52
- Preventing duplicate prefetches
53
- Skipping current page prefetch
54
- Blocking cross-origin prefetches
55
56
### Prerendering (Chromium only)
57
+
58
- Creating `<script type="speculationrules">` elements
59
- Different eagerness levels (immediate, eager, moderate, conservative)
60
- Fallback to link prefetch on non-Chromium browsers
+1
e2e/fixtures/hot-reload/data.txt
+1
e2e/fixtures/hot-reload/data.txt
···
···
1
+
Test data
+1
-1
e2e/fixtures/hot-reload/src/main.rs
+1
-1
e2e/fixtures/hot-reload/src/main.rs
+9
e2e/fixtures/incremental-build/Cargo.toml
+9
e2e/fixtures/incremental-build/Cargo.toml
+2
e2e/fixtures/incremental-build/src/assets/about.js
+2
e2e/fixtures/incremental-build/src/assets/about.js
e2e/fixtures/incremental-build/src/assets/bg.png
e2e/fixtures/incremental-build/src/assets/bg.png
This is a binary file and will not be displayed.
+10
e2e/fixtures/incremental-build/src/assets/blog.css
+10
e2e/fixtures/incremental-build/src/assets/blog.css
+8
e2e/fixtures/incremental-build/src/assets/icons/blog-icon.css
+8
e2e/fixtures/incremental-build/src/assets/icons/blog-icon.css
e2e/fixtures/incremental-build/src/assets/logo.png
e2e/fixtures/incremental-build/src/assets/logo.png
This is a binary file and will not be displayed.
+5
e2e/fixtures/incremental-build/src/assets/main.js
+5
e2e/fixtures/incremental-build/src/assets/main.js
+13
e2e/fixtures/incremental-build/src/assets/styles.css
+13
e2e/fixtures/incremental-build/src/assets/styles.css
e2e/fixtures/incremental-build/src/assets/team.png
e2e/fixtures/incremental-build/src/assets/team.png
This is a binary file and will not be displayed.
+4
e2e/fixtures/incremental-build/src/assets/utils.js
+4
e2e/fixtures/incremental-build/src/assets/utils.js
+11
e2e/fixtures/incremental-build/src/main.rs
+11
e2e/fixtures/incremental-build/src/main.rs
···
···
1
+
use maudit::{BuildOptions, BuildOutput, content_sources, coronate, routes};
2
+
3
+
mod pages;
4
+
5
+
fn main() -> Result<BuildOutput, Box<dyn std::error::Error>> {
6
+
coronate(
7
+
routes![pages::index::Index, pages::about::About, pages::blog::Blog],
8
+
content_sources![],
9
+
BuildOptions::default(),
10
+
)
11
+
}
+33
e2e/fixtures/incremental-build/src/pages/about.rs
+33
e2e/fixtures/incremental-build/src/pages/about.rs
···
···
1
+
use maud::html;
2
+
use maudit::route::prelude::*;
3
+
use std::time::{SystemTime, UNIX_EPOCH};
4
+
5
+
#[route("/about")]
6
+
pub struct About;
7
+
8
+
impl Route for About {
9
+
fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
10
+
let _image = ctx.assets.add_image("src/assets/team.png");
11
+
let _script = ctx.assets.add_script("src/assets/about.js");
12
+
// Shared style with index page (for testing shared assets)
13
+
let _style = ctx.assets.add_style("src/assets/styles.css");
14
+
15
+
// Generate a unique build ID - uses nanoseconds for uniqueness
16
+
let build_id = SystemTime::now()
17
+
.duration_since(UNIX_EPOCH)
18
+
.map(|d| d.as_nanos().to_string())
19
+
.unwrap_or_else(|_| "0".to_string());
20
+
21
+
html! {
22
+
html {
23
+
head {
24
+
title { "About Page" }
25
+
}
26
+
body data-build-id=(build_id) {
27
+
h1 id="title" { "About Us" }
28
+
p id="content" { "Learn more about us" }
29
+
}
30
+
}
31
+
}
32
+
}
33
+
}
+31
e2e/fixtures/incremental-build/src/pages/blog.rs
+31
e2e/fixtures/incremental-build/src/pages/blog.rs
···
···
1
+
use maud::html;
2
+
use maudit::route::prelude::*;
3
+
use std::time::{SystemTime, UNIX_EPOCH};
4
+
5
+
#[route("/blog")]
6
+
pub struct Blog;
7
+
8
+
impl Route for Blog {
9
+
fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
10
+
let _style = ctx.assets.add_style("src/assets/blog.css");
11
+
let _icon_style = ctx.assets.add_style("src/assets/icons/blog-icon.css");
12
+
13
+
// Generate a unique build ID - uses nanoseconds for uniqueness
14
+
let build_id = SystemTime::now()
15
+
.duration_since(UNIX_EPOCH)
16
+
.map(|d| d.as_nanos().to_string())
17
+
.unwrap_or_else(|_| "0".to_string());
18
+
19
+
html! {
20
+
html {
21
+
head {
22
+
title { "Blog Page" }
23
+
}
24
+
body data-build-id=(build_id) {
25
+
h1 id="title" { "Blog" }
26
+
p id="content" { "Read our latest posts" }
27
+
}
28
+
}
29
+
}
30
+
}
31
+
}
+32
e2e/fixtures/incremental-build/src/pages/index.rs
+32
e2e/fixtures/incremental-build/src/pages/index.rs
···
···
1
+
use maud::html;
2
+
use maudit::route::prelude::*;
3
+
use std::time::{SystemTime, UNIX_EPOCH};
4
+
5
+
#[route("/")]
6
+
pub struct Index;
7
+
8
+
impl Route for Index {
9
+
fn render(&self, ctx: &mut PageContext) -> impl Into<RenderResult> {
10
+
let _image = ctx.assets.add_image("src/assets/logo.png");
11
+
let _script = ctx.assets.add_script("src/assets/main.js");
12
+
let _style = ctx.assets.add_style("src/assets/styles.css");
13
+
14
+
// Generate a unique build ID - uses nanoseconds for uniqueness
15
+
let build_id = SystemTime::now()
16
+
.duration_since(UNIX_EPOCH)
17
+
.map(|d| d.as_nanos().to_string())
18
+
.unwrap_or_else(|_| "0".to_string());
19
+
20
+
html! {
21
+
html {
22
+
head {
23
+
title { "Home Page" }
24
+
}
25
+
body data-build-id=(build_id) {
26
+
h1 id="title" { "Home Page" }
27
+
p id="content" { "Welcome to the home page" }
28
+
}
29
+
}
30
+
}
31
+
}
32
+
}
+3
e2e/fixtures/incremental-build/src/pages/mod.rs
+3
e2e/fixtures/incremental-build/src/pages/mod.rs
+1
-1
e2e/fixtures/prefetch-prerender/src/main.rs
+1
-1
e2e/fixtures/prefetch-prerender/src/main.rs
+116
-8
e2e/tests/hot-reload.spec.ts
+116
-8
e2e/tests/hot-reload.spec.ts
···
12
13
test.describe.configure({ mode: "serial" });
14
15
test.describe("Hot Reload", () => {
16
const fixturePath = resolve(__dirname, "..", "fixtures", "hot-reload");
17
const indexPath = resolve(fixturePath, "src", "pages", "index.rs");
18
-
let originalContent: string;
19
20
test.beforeAll(async () => {
21
// Save original content
22
-
originalContent = readFileSync(indexPath, "utf-8");
23
});
24
25
-
test.afterEach(async () => {
26
// Restore original content after each test
27
-
writeFileSync(indexPath, originalContent, "utf-8");
28
-
// Wait a bit for the rebuild
29
-
await new Promise((resolve) => setTimeout(resolve, 2000));
30
});
31
32
test.afterAll(async () => {
33
// Restore original content
34
-
writeFileSync(indexPath, originalContent, "utf-8");
35
});
36
37
test("should show updated content after file changes", async ({ page, devServer }) => {
···
44
const currentUrl = page.url();
45
46
// Modify the file
47
-
const modifiedContent = originalContent.replace(
48
'h1 id="title" { "Original Title" }',
49
'h1 id="title" { "Another Update" }',
50
);
···
12
13
test.describe.configure({ mode: "serial" });
14
15
+
/**
16
+
* Wait for dev server to complete a build/rerun by polling logs
17
+
*/
18
+
async function waitForBuildComplete(devServer: any, timeoutMs = 20000): Promise<string[]> {
19
+
const startTime = Date.now();
20
+
21
+
while (Date.now() - startTime < timeoutMs) {
22
+
const logs = devServer.getLogs(100);
23
+
const logsText = logs.join("\n").toLowerCase();
24
+
25
+
// Look for completion messages
26
+
if (
27
+
logsText.includes("finished") ||
28
+
logsText.includes("rerun finished") ||
29
+
logsText.includes("build finished")
30
+
) {
31
+
return logs;
32
+
}
33
+
34
+
// Wait 100ms before checking again
35
+
await new Promise((resolve) => setTimeout(resolve, 100));
36
+
}
37
+
38
+
throw new Error(`Build did not complete within ${timeoutMs}ms`);
39
+
}
40
+
41
test.describe("Hot Reload", () => {
42
+
// Increase timeout for these tests since they involve compilation
43
+
test.setTimeout(60000);
44
+
45
const fixturePath = resolve(__dirname, "..", "fixtures", "hot-reload");
46
const indexPath = resolve(fixturePath, "src", "pages", "index.rs");
47
+
const mainPath = resolve(fixturePath, "src", "main.rs");
48
+
const dataPath = resolve(fixturePath, "data.txt");
49
+
let originalIndexContent: string;
50
+
let originalMainContent: string;
51
+
let originalDataContent: string;
52
53
test.beforeAll(async () => {
54
// Save original content
55
+
originalIndexContent = readFileSync(indexPath, "utf-8");
56
+
originalMainContent = readFileSync(mainPath, "utf-8");
57
+
originalDataContent = readFileSync(dataPath, "utf-8");
58
+
59
+
// Ensure files are in original state
60
+
writeFileSync(indexPath, originalIndexContent, "utf-8");
61
+
writeFileSync(mainPath, originalMainContent, "utf-8");
62
+
writeFileSync(dataPath, originalDataContent, "utf-8");
63
});
64
65
+
test.afterEach(async ({ devServer }) => {
66
// Restore original content after each test
67
+
writeFileSync(indexPath, originalIndexContent, "utf-8");
68
+
writeFileSync(mainPath, originalMainContent, "utf-8");
69
+
writeFileSync(dataPath, originalDataContent, "utf-8");
70
+
71
+
// Only wait for build if devServer is available (startup might have failed)
72
+
if (devServer) {
73
+
try {
74
+
devServer.clearLogs();
75
+
await waitForBuildComplete(devServer);
76
+
} catch (error) {
77
+
console.warn("Failed to wait for build completion in afterEach:", error);
78
+
}
79
+
}
80
});
81
82
test.afterAll(async () => {
83
// Restore original content
84
+
writeFileSync(indexPath, originalIndexContent, "utf-8");
85
+
writeFileSync(mainPath, originalMainContent, "utf-8");
86
+
writeFileSync(dataPath, originalDataContent, "utf-8");
87
+
});
88
+
89
+
test("should recompile when Rust code changes (dependencies)", async ({ page, devServer }) => {
90
+
await page.goto(devServer.url);
91
+
92
+
// Verify initial content
93
+
await expect(page.locator("#title")).toHaveText("Original Title");
94
+
95
+
// Clear logs to track what happens after this point
96
+
devServer.clearLogs();
97
+
98
+
// Modify main.rs - this is a tracked dependency, should trigger recompile
99
+
const modifiedMain = originalMainContent.replace(
100
+
"BuildOptions::default()",
101
+
"BuildOptions::default() // Modified comment",
102
+
);
103
+
writeFileSync(mainPath, modifiedMain, "utf-8");
104
+
105
+
// Wait for rebuild to complete
106
+
const logs = await waitForBuildComplete(devServer, 20000);
107
+
const logsText = logs.join("\n");
108
+
109
+
// Check logs to verify it actually recompiled (ran cargo)
110
+
expect(logsText).toContain("rebuilding");
111
+
// Make sure it didn't just rerun the binary
112
+
expect(logsText.toLowerCase()).not.toContain("rerunning binary");
113
+
});
114
+
115
+
test("should rerun without recompile when non-dependency files change", async ({
116
+
page,
117
+
devServer,
118
+
}) => {
119
+
await page.goto(devServer.url);
120
+
121
+
// Verify initial content
122
+
await expect(page.locator("#title")).toHaveText("Original Title");
123
+
124
+
// Clear logs to track what happens after this point
125
+
devServer.clearLogs();
126
+
127
+
// Modify data.txt - this file is NOT in the .d dependencies
128
+
// So it should trigger a rerun without recompilation
129
+
writeFileSync(dataPath, "Modified data", "utf-8");
130
+
131
+
// Wait for build/rerun to complete
132
+
const logs = await waitForBuildComplete(devServer, 20000);
133
+
const logsText = logs.join("\n");
134
+
135
+
// Should see "rerunning binary" message (case insensitive)
136
+
const hasRerunMessage = logsText.toLowerCase().includes("rerunning binary");
137
+
expect(hasRerunMessage).toBe(true);
138
+
139
+
// Should NOT see cargo-related rebuild messages (compiling, building crate)
140
+
// Note: "Rebuilding N affected routes" is fine - that's the incremental build system
141
+
expect(logsText.toLowerCase()).not.toContain("compiling");
142
+
expect(logsText.toLowerCase()).not.toContain("cargo build");
143
});
144
145
test("should show updated content after file changes", async ({ page, devServer }) => {
···
152
const currentUrl = page.url();
153
154
// Modify the file
155
+
const modifiedContent = originalIndexContent.replace(
156
'h1 id="title" { "Original Title" }',
157
'h1 id="title" { "Another Update" }',
158
);
+521
e2e/tests/incremental-build.spec.ts
+521
e2e/tests/incremental-build.spec.ts
···
···
1
+
import { expect } from "@playwright/test";
2
+
import { createTestWithFixture } from "./test-utils";
3
+
import { readFileSync, writeFileSync, renameSync, rmSync, existsSync } from "node:fs";
4
+
import { resolve, dirname } from "node:path";
5
+
import { fileURLToPath } from "node:url";
6
+
7
+
const __filename = fileURLToPath(import.meta.url);
8
+
const __dirname = dirname(__filename);
9
+
10
+
// Create test instance with incremental-build fixture
11
+
const test = createTestWithFixture("incremental-build");
12
+
13
+
// Allow retries for timing-sensitive tests
14
+
test.describe.configure({ mode: "serial", retries: 2 });
15
+
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.
19
+
*/
20
+
async function waitForBuildComplete(devServer: any, timeoutMs = 30000): Promise<string[]> {
21
+
const startTime = Date.now();
22
+
23
+
// Phase 1: Wait for build to start
24
+
while (Date.now() - startTime < timeoutMs) {
25
+
const logs = devServer.getLogs(200);
26
+
const logsText = logs.join("\n").toLowerCase();
27
+
28
+
if (
29
+
logsText.includes("rerunning") ||
30
+
logsText.includes("rebuilding") ||
31
+
logsText.includes("files changed")
32
+
) {
33
+
break;
34
+
}
35
+
36
+
await new Promise((resolve) => setTimeout(resolve, 50));
37
+
}
38
+
39
+
// Phase 2: Wait for build to finish
40
+
while (Date.now() - startTime < timeoutMs) {
41
+
const logs = devServer.getLogs(200);
42
+
const logsText = logs.join("\n").toLowerCase();
43
+
44
+
if (
45
+
logsText.includes("finished") ||
46
+
logsText.includes("rerun finished") ||
47
+
logsText.includes("build finished")
48
+
) {
49
+
// Wait for filesystem to fully sync
50
+
await new Promise((resolve) => setTimeout(resolve, 500));
51
+
return devServer.getLogs(200);
52
+
}
53
+
54
+
await new Promise((resolve) => setTimeout(resolve, 100));
55
+
}
56
+
57
+
// On timeout, log what we DID see for debugging
58
+
console.log("TIMEOUT - logs seen:", devServer.getLogs(50));
59
+
throw new Error(`Build did not complete within ${timeoutMs}ms`);
60
+
}
61
+
62
+
/**
63
+
* Extract the build ID from an HTML file.
64
+
*/
65
+
function getBuildId(htmlPath: string): string | null {
66
+
try {
67
+
const content = readFileSync(htmlPath, "utf-8");
68
+
const match = content.match(/data-build-id="(\d+)"/);
69
+
return match ? match[1] : null;
70
+
} catch {
71
+
return null;
72
+
}
73
+
}
74
+
75
+
/**
76
+
* Check if logs indicate incremental build was used
77
+
*/
78
+
function isIncrementalBuild(logs: string[]): boolean {
79
+
return logs.join("\n").toLowerCase().includes("incremental build");
80
+
}
81
+
82
+
/**
83
+
* Get the number of affected routes from logs
84
+
*/
85
+
function getAffectedRouteCount(logs: string[]): number {
86
+
const logsText = logs.join("\n");
87
+
const match = logsText.match(/Rebuilding (\d+) affected routes/i);
88
+
return match ? parseInt(match[1], 10) : -1;
89
+
}
90
+
91
+
/**
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
+
* Record build IDs for all pages
110
+
*/
111
+
function recordBuildIds(htmlPaths: Record<string, string>): Record<string, string | null> {
112
+
const ids: Record<string, string | null> = {};
113
+
for (const [name, path] of Object.entries(htmlPaths)) {
114
+
ids[name] = getBuildId(path);
115
+
}
116
+
return ids;
117
+
}
118
+
119
+
test.describe("Incremental Build", () => {
120
+
test.setTimeout(180000);
121
+
122
+
const fixturePath = resolve(__dirname, "..", "fixtures", "incremental-build");
123
+
124
+
// Asset paths
125
+
const assets = {
126
+
blogCss: resolve(fixturePath, "src", "assets", "blog.css"),
127
+
utilsJs: resolve(fixturePath, "src", "assets", "utils.js"),
128
+
mainJs: resolve(fixturePath, "src", "assets", "main.js"),
129
+
aboutJs: resolve(fixturePath, "src", "assets", "about.js"),
130
+
stylesCss: resolve(fixturePath, "src", "assets", "styles.css"),
131
+
logoPng: resolve(fixturePath, "src", "assets", "logo.png"),
132
+
teamPng: resolve(fixturePath, "src", "assets", "team.png"),
133
+
bgPng: resolve(fixturePath, "src", "assets", "bg.png"),
134
+
};
135
+
136
+
// Output HTML paths
137
+
const htmlPaths = {
138
+
index: resolve(fixturePath, "dist", "index.html"),
139
+
about: resolve(fixturePath, "dist", "about", "index.html"),
140
+
blog: resolve(fixturePath, "dist", "blog", "index.html"),
141
+
};
142
+
143
+
// Original content storage
144
+
const originals: Record<string, string | Buffer> = {};
145
+
146
+
test.beforeAll(async () => {
147
+
// Store original content for all assets we might modify
148
+
originals.blogCss = readFileSync(assets.blogCss, "utf-8");
149
+
originals.utilsJs = readFileSync(assets.utilsJs, "utf-8");
150
+
originals.mainJs = readFileSync(assets.mainJs, "utf-8");
151
+
originals.aboutJs = readFileSync(assets.aboutJs, "utf-8");
152
+
originals.stylesCss = readFileSync(assets.stylesCss, "utf-8");
153
+
originals.logoPng = readFileSync(assets.logoPng); // binary
154
+
originals.teamPng = readFileSync(assets.teamPng); // binary
155
+
originals.bgPng = readFileSync(assets.bgPng); // binary
156
+
});
157
+
158
+
test.afterAll(async () => {
159
+
// Restore all original content
160
+
writeFileSync(assets.blogCss, originals.blogCss);
161
+
writeFileSync(assets.utilsJs, originals.utilsJs);
162
+
writeFileSync(assets.mainJs, originals.mainJs);
163
+
writeFileSync(assets.aboutJs, originals.aboutJs);
164
+
writeFileSync(assets.stylesCss, originals.stylesCss);
165
+
writeFileSync(assets.logoPng, originals.logoPng);
166
+
writeFileSync(assets.teamPng, originals.teamPng);
167
+
writeFileSync(assets.bgPng, originals.bgPng);
168
+
});
169
+
170
+
// ============================================================
171
+
// TEST 1: Direct CSS dependency (blog.css โ /blog only)
172
+
// ============================================================
173
+
test("CSS file change rebuilds only routes using it", async ({ devServer }) => {
174
+
let testCounter = 0;
175
+
176
+
async function triggerChange(suffix: string) {
177
+
testCounter++;
178
+
devServer.clearLogs();
179
+
writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`);
180
+
return await waitForBuildComplete(devServer, 30000);
181
+
}
182
+
183
+
await setupIncrementalState(devServer, triggerChange);
184
+
185
+
// Record build IDs before
186
+
const before = recordBuildIds(htmlPaths);
187
+
expect(before.index).not.toBeNull();
188
+
expect(before.about).not.toBeNull();
189
+
expect(before.blog).not.toBeNull();
190
+
191
+
await new Promise((resolve) => setTimeout(resolve, 500));
192
+
193
+
// Trigger the change
194
+
const logs = await triggerChange("final");
195
+
196
+
// Verify incremental build with 1 route
197
+
expect(isIncrementalBuild(logs)).toBe(true);
198
+
expect(getAffectedRouteCount(logs)).toBe(1);
199
+
200
+
// Verify only blog was rebuilt
201
+
const after = recordBuildIds(htmlPaths);
202
+
expect(after.index).toBe(before.index);
203
+
expect(after.about).toBe(before.about);
204
+
expect(after.blog).not.toBe(before.blog);
205
+
});
206
+
207
+
// ============================================================
208
+
// TEST 2: Transitive JS dependency (utils.js โ main.js โ /)
209
+
// ============================================================
210
+
test("transitive JS dependency change rebuilds affected routes", async ({ devServer }) => {
211
+
let testCounter = 0;
212
+
213
+
async function triggerChange(suffix: string) {
214
+
testCounter++;
215
+
devServer.clearLogs();
216
+
writeFileSync(assets.utilsJs, originals.utilsJs + `\n// test-${testCounter}-${suffix}`);
217
+
return await waitForBuildComplete(devServer, 30000);
218
+
}
219
+
220
+
await setupIncrementalState(devServer, triggerChange);
221
+
222
+
const before = recordBuildIds(htmlPaths);
223
+
expect(before.index).not.toBeNull();
224
+
225
+
await new Promise((resolve) => setTimeout(resolve, 500));
226
+
227
+
const logs = await triggerChange("final");
228
+
229
+
// Verify incremental build with 1 route
230
+
expect(isIncrementalBuild(logs)).toBe(true);
231
+
expect(getAffectedRouteCount(logs)).toBe(1);
232
+
233
+
// Only index should be rebuilt (uses main.js which imports utils.js)
234
+
const after = recordBuildIds(htmlPaths);
235
+
expect(after.about).toBe(before.about);
236
+
expect(after.blog).toBe(before.blog);
237
+
expect(after.index).not.toBe(before.index);
238
+
});
239
+
240
+
// ============================================================
241
+
// TEST 3: Direct JS entry point change (about.js โ /about)
242
+
// ============================================================
243
+
test("direct JS entry point change rebuilds only routes using it", async ({ devServer }) => {
244
+
let testCounter = 0;
245
+
246
+
async function triggerChange(suffix: string) {
247
+
testCounter++;
248
+
devServer.clearLogs();
249
+
writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`);
250
+
return await waitForBuildComplete(devServer, 30000);
251
+
}
252
+
253
+
await setupIncrementalState(devServer, triggerChange);
254
+
255
+
const before = recordBuildIds(htmlPaths);
256
+
expect(before.about).not.toBeNull();
257
+
258
+
await new Promise((resolve) => setTimeout(resolve, 500));
259
+
260
+
const logs = await triggerChange("final");
261
+
262
+
// Verify incremental build with 1 route
263
+
expect(isIncrementalBuild(logs)).toBe(true);
264
+
expect(getAffectedRouteCount(logs)).toBe(1);
265
+
266
+
// Only about should be rebuilt
267
+
const after = recordBuildIds(htmlPaths);
268
+
expect(after.index).toBe(before.index);
269
+
expect(after.blog).toBe(before.blog);
270
+
expect(after.about).not.toBe(before.about);
271
+
});
272
+
273
+
// ============================================================
274
+
// TEST 4: Shared asset change (styles.css โ / AND /about)
275
+
// ============================================================
276
+
test("shared asset change rebuilds all routes using it", async ({ devServer }) => {
277
+
let testCounter = 0;
278
+
279
+
async function triggerChange(suffix: string) {
280
+
testCounter++;
281
+
devServer.clearLogs();
282
+
writeFileSync(
283
+
assets.stylesCss,
284
+
originals.stylesCss + `\n/* test-${testCounter}-${suffix} */`,
285
+
);
286
+
return await waitForBuildComplete(devServer, 30000);
287
+
}
288
+
289
+
await setupIncrementalState(devServer, triggerChange);
290
+
291
+
const before = recordBuildIds(htmlPaths);
292
+
expect(before.index).not.toBeNull();
293
+
expect(before.about).not.toBeNull();
294
+
295
+
await new Promise((resolve) => setTimeout(resolve, 500));
296
+
297
+
const logs = await triggerChange("final");
298
+
299
+
// Verify incremental build with 2 routes (/ and /about both use styles.css)
300
+
expect(isIncrementalBuild(logs)).toBe(true);
301
+
expect(getAffectedRouteCount(logs)).toBe(2);
302
+
303
+
// Index and about should be rebuilt, blog should not
304
+
const after = recordBuildIds(htmlPaths);
305
+
expect(after.blog).toBe(before.blog);
306
+
expect(after.index).not.toBe(before.index);
307
+
expect(after.about).not.toBe(before.about);
308
+
});
309
+
310
+
// ============================================================
311
+
// TEST 5: Image change (logo.png โ /)
312
+
// ============================================================
313
+
test("image change rebuilds only routes using it", async ({ devServer }) => {
314
+
let testCounter = 0;
315
+
316
+
async function triggerChange(suffix: string) {
317
+
testCounter++;
318
+
devServer.clearLogs();
319
+
// For images, we append bytes to change the file
320
+
// This simulates modifying an image file
321
+
const modified = Buffer.concat([
322
+
originals.logoPng as Buffer,
323
+
Buffer.from(`<!-- test-${testCounter}-${suffix} -->`),
324
+
]);
325
+
writeFileSync(assets.logoPng, modified);
326
+
return await waitForBuildComplete(devServer, 30000);
327
+
}
328
+
329
+
await setupIncrementalState(devServer, triggerChange);
330
+
331
+
const before = recordBuildIds(htmlPaths);
332
+
expect(before.index).not.toBeNull();
333
+
334
+
await new Promise((resolve) => setTimeout(resolve, 500));
335
+
336
+
const logs = await triggerChange("final");
337
+
338
+
// Verify incremental build with 1 route
339
+
expect(isIncrementalBuild(logs)).toBe(true);
340
+
expect(getAffectedRouteCount(logs)).toBe(1);
341
+
342
+
// Only index should be rebuilt (uses logo.png)
343
+
const after = recordBuildIds(htmlPaths);
344
+
expect(after.about).toBe(before.about);
345
+
expect(after.blog).toBe(before.blog);
346
+
expect(after.index).not.toBe(before.index);
347
+
});
348
+
349
+
// ============================================================
350
+
// TEST 6: Multiple files changed simultaneously
351
+
// ============================================================
352
+
test("multiple file changes rebuild union of affected routes", async ({ devServer }) => {
353
+
let testCounter = 0;
354
+
355
+
async function triggerChange(suffix: string) {
356
+
testCounter++;
357
+
devServer.clearLogs();
358
+
// Change both blog.css (affects /blog) and about.js (affects /about)
359
+
writeFileSync(assets.blogCss, originals.blogCss + `\n/* test-${testCounter}-${suffix} */`);
360
+
writeFileSync(assets.aboutJs, originals.aboutJs + `\n// test-${testCounter}-${suffix}`);
361
+
return await waitForBuildComplete(devServer, 30000);
362
+
}
363
+
364
+
await setupIncrementalState(devServer, triggerChange);
365
+
366
+
const before = recordBuildIds(htmlPaths);
367
+
expect(before.about).not.toBeNull();
368
+
expect(before.blog).not.toBeNull();
369
+
370
+
await new Promise((resolve) => setTimeout(resolve, 500));
371
+
372
+
const logs = await triggerChange("final");
373
+
374
+
// Verify incremental build with 2 routes (/about and /blog)
375
+
expect(isIncrementalBuild(logs)).toBe(true);
376
+
expect(getAffectedRouteCount(logs)).toBe(2);
377
+
378
+
// About and blog should be rebuilt, index should not
379
+
const after = recordBuildIds(htmlPaths);
380
+
expect(after.index).toBe(before.index);
381
+
expect(after.about).not.toBe(before.about);
382
+
expect(after.blog).not.toBe(before.blog);
383
+
});
384
+
385
+
// ============================================================
386
+
// TEST 7: CSS url() asset dependency (bg.png via blog.css โ /blog)
387
+
// ============================================================
388
+
test("CSS url() asset change triggers rebundling and rebuilds affected routes", async ({
389
+
devServer,
390
+
}) => {
391
+
let testCounter = 0;
392
+
393
+
async function triggerChange(suffix: string) {
394
+
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
+
const modified = Buffer.concat([
399
+
originals.bgPng as Buffer,
400
+
Buffer.from(`<!-- test-${testCounter}-${suffix} -->`),
401
+
]);
402
+
writeFileSync(assets.bgPng, modified);
403
+
return await waitForBuildComplete(devServer, 30000);
404
+
}
405
+
406
+
await setupIncrementalState(devServer, triggerChange);
407
+
408
+
const before = recordBuildIds(htmlPaths);
409
+
expect(before.blog).not.toBeNull();
410
+
411
+
await new Promise((resolve) => setTimeout(resolve, 500));
412
+
413
+
const logs = await triggerChange("final");
414
+
415
+
// Verify incremental build triggered
416
+
expect(isIncrementalBuild(logs)).toBe(true);
417
+
418
+
// 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
+
const after = recordBuildIds(htmlPaths);
421
+
expect(after.blog).not.toBe(before.blog);
422
+
});
423
+
424
+
// ============================================================
425
+
// TEST 8: Folder rename detection
426
+
// ============================================================
427
+
test("folder rename is detected and affects routes using assets in that folder", async ({ devServer }) => {
428
+
// This test verifies that renaming a folder containing tracked assets
429
+
// is detected by the file watcher and affects the correct routes.
430
+
//
431
+
// Setup: The blog page uses src/assets/icons/blog-icon.css
432
+
// Test: Rename icons -> icons-renamed, verify the blog route is identified as affected
433
+
//
434
+
// Note: The actual build will fail because the asset path becomes invalid,
435
+
// but this test verifies the DETECTION and ROUTE MATCHING works correctly.
436
+
437
+
const iconsFolder = resolve(fixturePath, "src", "assets", "icons");
438
+
const renamedFolder = resolve(fixturePath, "src", "assets", "icons-renamed");
439
+
const iconFile = resolve(iconsFolder, "blog-icon.css");
440
+
441
+
// Ensure we start with the correct state
442
+
if (existsSync(renamedFolder)) {
443
+
// Restore from previous failed run
444
+
renameSync(renamedFolder, iconsFolder);
445
+
await new Promise((resolve) => setTimeout(resolve, 1000));
446
+
}
447
+
448
+
// Make sure the icons folder exists with the file
449
+
expect(existsSync(iconsFolder)).toBe(true);
450
+
expect(existsSync(iconFile)).toBe(true);
451
+
452
+
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));
462
+
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));
468
+
469
+
// Clear for the actual test
470
+
devServer.clearLogs();
471
+
472
+
// Rename icons -> icons-renamed
473
+
renameSync(iconsFolder, renamedFolder);
474
+
475
+
// Wait for the build to be attempted (it will fail because path is now invalid)
476
+
const startTime = Date.now();
477
+
const timeoutMs = 15000;
478
+
let logs: string[] = [];
479
+
480
+
while (Date.now() - startTime < timeoutMs) {
481
+
logs = devServer.getLogs(100);
482
+
const logsText = logs.join("\n");
483
+
484
+
// Wait for either success or failure
485
+
if (logsText.includes("finished") || logsText.includes("failed")) {
486
+
break;
487
+
}
488
+
489
+
await new Promise((resolve) => setTimeout(resolve, 100));
490
+
}
491
+
492
+
console.log("Logs after folder rename:", logs.slice(-15));
493
+
494
+
const logsText = logs.join("\n");
495
+
496
+
// Key assertions: verify the detection and route matching worked
497
+
// 1. The folder paths should be in changed files
498
+
expect(logsText).toContain("icons");
499
+
500
+
// 2. The blog route should be identified as affected
501
+
expect(logsText).toContain("Rebuilding 1 affected routes");
502
+
expect(logsText).toContain("/blog");
503
+
504
+
// 3. Other routes should NOT be affected (index and about don't use icons/)
505
+
expect(logsText).not.toContain("/about");
506
+
507
+
} finally {
508
+
// Restore: rename icons-renamed back to icons
509
+
if (existsSync(renamedFolder) && !existsSync(iconsFolder)) {
510
+
renameSync(renamedFolder, iconsFolder);
511
+
}
512
+
// Restore original content
513
+
if (existsSync(iconFile)) {
514
+
const content = readFileSync(iconFile, "utf-8");
515
+
writeFileSync(iconFile, content.replace(/\n\/\* setup[12] \*\//g, ""));
516
+
}
517
+
// Wait for restoration to be processed
518
+
await new Promise((resolve) => setTimeout(resolve, 1000));
519
+
}
520
+
});
521
+
});
+76
-6
e2e/tests/test-utils.ts
+76
-6
e2e/tests/test-utils.ts
···
23
port: number;
24
/** Stop the dev server */
25
stop: () => Promise<void>;
26
}
27
28
/**
···
52
const childProcess = spawn(command, args, {
53
cwd: fixturePath,
54
stdio: ["ignore", "pipe", "pipe"],
55
});
56
57
// Capture output to detect when server is ready
58
let serverReady = false;
59
60
const outputPromise = new Promise<number>((resolve, reject) => {
61
const timeout = setTimeout(() => {
62
-
reject(new Error("Dev server did not start within 30 seconds"));
63
-
}, 30000);
64
65
childProcess.stdout?.on("data", (data: Buffer) => {
66
const output = data.toString();
67
68
// Look for "waiting for requests" to know server is ready
69
if (output.includes("waiting for requests")) {
···
75
});
76
77
childProcess.stderr?.on("data", (data: Buffer) => {
78
-
// Only log errors, not all stderr output
79
const output = data.toString();
80
if (output.toLowerCase().includes("error")) {
81
console.error(`[maudit dev] ${output}`);
82
}
···
113
}, 5000);
114
});
115
},
116
};
117
}
118
···
138
// Worker-scoped server pool - one server per worker, shared across all tests in that worker
139
// Key format: "workerIndex-fixtureName"
140
const workerServers = new Map<string, DevServer>();
141
142
/**
143
* Create a test instance with a devServer fixture for a specific fixture.
144
* This allows each test file to use a different fixture while sharing the same pattern.
145
*
146
* @param fixtureName - Name of the fixture directory under e2e/fixtures/
147
-
* @param basePort - Starting port number (default: 1864). Each worker gets basePort + workerIndex
148
*
149
* @example
150
* ```ts
···
167
let server = workerServers.get(serverKey);
168
169
if (!server) {
170
-
// Assign unique port based on worker index
171
-
const port = basePort + workerIndex;
172
173
server = await startDevServer({
174
fixture: fixtureName,
···
23
port: number;
24
/** Stop the dev server */
25
stop: () => Promise<void>;
26
+
/** Get recent log output (last N lines) */
27
+
getLogs: (lines?: number) => string[];
28
+
/** Clear captured logs */
29
+
clearLogs: () => void;
30
}
31
32
/**
···
56
const childProcess = spawn(command, args, {
57
cwd: fixturePath,
58
stdio: ["ignore", "pipe", "pipe"],
59
+
env: {
60
+
...process.env,
61
+
// Show binary output for tests so we can verify incremental build logs
62
+
MAUDIT_SHOW_BINARY_OUTPUT: "1",
63
+
},
64
});
65
66
// Capture output to detect when server is ready
67
let serverReady = false;
68
+
const capturedLogs: string[] = [];
69
70
const outputPromise = new Promise<number>((resolve, reject) => {
71
const timeout = setTimeout(() => {
72
+
console.error("[test-utils] Dev server startup timeout. Recent logs:");
73
+
console.error(capturedLogs.slice(-20).join("\n"));
74
+
reject(new Error("Dev server did not start within 120 seconds"));
75
+
}, 120000); // Increased to 120 seconds for CI
76
77
childProcess.stdout?.on("data", (data: Buffer) => {
78
const output = data.toString();
79
+
// Capture all stdout logs
80
+
output
81
+
.split("\n")
82
+
.filter((line) => line.trim())
83
+
.forEach((line) => {
84
+
capturedLogs.push(line);
85
+
});
86
87
// Look for "waiting for requests" to know server is ready
88
if (output.includes("waiting for requests")) {
···
94
});
95
96
childProcess.stderr?.on("data", (data: Buffer) => {
97
const output = data.toString();
98
+
// Capture all stderr logs
99
+
output
100
+
.split("\n")
101
+
.filter((line) => line.trim())
102
+
.forEach((line) => {
103
+
capturedLogs.push(line);
104
+
});
105
+
106
+
// Only log errors to console, not all stderr output
107
if (output.toLowerCase().includes("error")) {
108
console.error(`[maudit dev] ${output}`);
109
}
···
140
}, 5000);
141
});
142
},
143
+
getLogs: (lines?: number) => {
144
+
if (lines) {
145
+
return capturedLogs.slice(-lines);
146
+
}
147
+
return [...capturedLogs];
148
+
},
149
+
clearLogs: () => {
150
+
capturedLogs.length = 0;
151
+
},
152
};
153
}
154
···
174
// Worker-scoped server pool - one server per worker, shared across all tests in that worker
175
// Key format: "workerIndex-fixtureName"
176
const workerServers = new Map<string, DevServer>();
177
+
178
+
// Track used ports to avoid collisions
179
+
const usedPorts = new Set<number>();
180
+
181
+
/**
182
+
* Generate a deterministic port offset based on fixture name.
183
+
* This ensures each fixture gets a unique port range, avoiding collisions
184
+
* when multiple fixtures run on the same worker.
185
+
*/
186
+
function getFixturePortOffset(fixtureName: string): number {
187
+
// Simple hash function to get a number from the fixture name
188
+
let hash = 0;
189
+
for (let i = 0; i < fixtureName.length; i++) {
190
+
const char = fixtureName.charCodeAt(i);
191
+
hash = (hash << 5) - hash + char;
192
+
hash = hash & hash; // Convert to 32bit integer
193
+
}
194
+
// Use modulo to keep the offset reasonable (0-99)
195
+
return Math.abs(hash) % 100;
196
+
}
197
+
198
+
/**
199
+
* Find an available port starting from the preferred port.
200
+
*/
201
+
function findAvailablePort(preferredPort: number): number {
202
+
let port = preferredPort;
203
+
while (usedPorts.has(port)) {
204
+
port++;
205
+
}
206
+
usedPorts.add(port);
207
+
return port;
208
+
}
209
210
/**
211
* Create a test instance with a devServer fixture for a specific fixture.
212
* This allows each test file to use a different fixture while sharing the same pattern.
213
*
214
* @param fixtureName - Name of the fixture directory under e2e/fixtures/
215
+
* @param basePort - Starting port number (default: 1864). Each fixture gets a unique port based on its name.
216
*
217
* @example
218
* ```ts
···
235
let server = workerServers.get(serverKey);
236
237
if (!server) {
238
+
// Calculate port based on fixture name hash + worker index to avoid collisions
239
+
const fixtureOffset = getFixturePortOffset(fixtureName);
240
+
const preferredPort = basePort + workerIndex * 100 + fixtureOffset;
241
+
const port = findAvailablePort(preferredPort);
242
243
server = await startDevServer({
244
fixture: fixtureName,
+1
-1
website/content/docs/prefetching.md
+1
-1
website/content/docs/prefetching.md
···
49
50
Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views.
51
52
-
## Possible risks
53
54
Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening.
55
···
49
50
Note that prerendering, unlike prefetching, may require rethinking how the JavaScript on your pages works, as it'll run JavaScript from pages that the user hasn't visited yet. For example, this might result in analytics reporting incorrect page views.
51
52
+
## Possible risks
53
54
Prefetching pages in static websites is typically always safe. In more traditional apps, an issue can arise if your pages cause side effects to happen on the server. For instance, if you were to prefetch `/logout`, your user might get disconnected on hover, or worse as soon as the log out link appear in the viewport. In modern times, it is typically not recommended to have links cause such side effects anyway, reducing the risk of this happening.
55
+1
-1
website/content/news/2026-in-the-cursed-lands.md
+1
-1
website/content/news/2026-in-the-cursed-lands.md
History
1 round
0 comments
erika.florist
submitted
#0
20 commits
expand
collapse
feat: smoother hot reload
fix: fix
fix: e2e tests
fix: longer timeout perhaps
fix: make it more reliable
fix: don't recompute dirs
fix: use same target finding function in assets
feat: some sort of incremental builds
fix: some things
fix: lint
fix: remove unrelated changes
fix: update hot reload tests
fix: just some clean up
fix: transitive dependencies
fix: bunch of fixes
fix: allow showing the binary output in dev if needs be
perf: cache is_dev()
fix: no idea what im doing
fix: folders
fix: folders
merge conflicts detected
expand
collapse
expand
collapse
- Cargo.lock:1661
- Cargo.lock:2622
- Cargo.lock:1661
- crates/maudit-cli/src/dev.rs:21
- e2e/fixtures/hot-reload/src/main.rs:1
- e2e/fixtures/prefetch-prerender/src/main.rs:1
- crates/maudit-cli/src/dev.rs:10
- .github/workflows/benchmark.yaml:41
- .github/workflows/ci.yaml:38
- .github/workflows/release.yml:30
- .vscode/extensions.json:1
- .vscode/settings.json:1
- crates/maudit-cli/src/dev.rs:15
- crates/maudit/Cargo.toml:50
- e2e/README.md:13
- website/content/docs/content.md:214
- website/content/docs/prefetching.md:49
- website/content/news/2026-in-the-cursed-lands.md:70
- crates/maudit-cli/src/dev.rs:172
- website/content/news/2026-in-the-cursed-lands.md:70