1use anyhow::{Context, Result};
4use log::{debug, info, trace};
5use rayon::prelude::*;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use crate::assets::copy_static_files;
11use crate::config::{Config, PageDef};
12use crate::markdown::{Pipeline, TransformContext};
13use crate::templates::Templates;
14use crate::tracker::{BuildTracker, CachedDeps, SharedTracker};
15
16const CACHE_FILE: &str = ".rs-web-cache/deps.bin";
18
19pub struct Builder {
21 config: Config,
22 output_dir: PathBuf,
23 project_dir: PathBuf,
24 tracker: SharedTracker,
26 cached_deps: Option<CachedDeps>,
28 cached_global_data: Option<serde_json::Value>,
30 cached_pages: Option<Vec<PageDef>>,
32}
33
34impl Builder {
35 pub fn new(config: Config, output_dir: PathBuf, project_dir: PathBuf) -> Self {
36 let cache_path = project_dir.join(CACHE_FILE);
38 let cached_deps = CachedDeps::load(&cache_path);
39 if cached_deps.is_some() {
40 debug!("Loaded cached dependency info from {:?}", cache_path);
41 }
42
43 let tracker = config.tracker().clone();
45
46 Self {
47 config,
48 output_dir,
49 project_dir,
50 tracker,
51 cached_deps,
52 cached_global_data: None,
53 cached_pages: None,
54 }
55 }
56
57 pub fn new_with_tracker(project_dir: PathBuf, output_dir: PathBuf) -> Result<Self> {
59 let tracker = Arc::new(BuildTracker::new());
60 let config = Config::load_with_tracker(&project_dir, tracker.clone())?;
61
62 let cache_path = project_dir.join(CACHE_FILE);
64 let cached_deps = CachedDeps::load(&cache_path);
65 if cached_deps.is_some() {
66 debug!("Loaded cached dependency info from {:?}", cache_path);
67 }
68
69 Ok(Self {
70 config,
71 output_dir,
72 project_dir,
73 tracker,
74 cached_deps,
75 cached_global_data: None,
76 cached_pages: None,
77 })
78 }
79
80 fn resolve_path(&self, path: &str) -> PathBuf {
82 let p = Path::new(path);
83 if p.is_absolute() {
84 p.to_path_buf()
85 } else {
86 self.project_dir.join(path)
87 }
88 }
89
90 pub fn build(&mut self) -> Result<()> {
91 info!("Starting build");
92 debug!("Output directory: {:?}", self.output_dir);
93 debug!("Project directory: {:?}", self.project_dir);
94
95 trace!("Stage 1: Cleaning output directory");
97 self.clean()?;
98
99 trace!("Running before_build hook");
101 self.config.call_before_build()?;
102
103 trace!("Stage 2: Calling data() function");
105 let global_data = self.config.call_data()?;
106 debug!("Global data loaded");
107
108 trace!("Stage 3: Calling pages() function");
110 let pages = self.config.call_pages(&global_data)?;
111 debug!("Found {} pages to generate", pages.len());
112
113 self.cached_global_data = Some(global_data.clone());
115 self.cached_pages = Some(pages.clone());
116
117 trace!("Stage 4: Processing assets");
119 self.process_assets()?;
120
121 trace!("Stage 5: Loading templates");
123 let templates = Templates::new(
124 &self.resolve_path(&self.config.paths.templates),
125 Some(self.tracker.clone()),
126 )?;
127
128 trace!("Stage 6: Rendering {} pages", pages.len());
130 let pipeline = Pipeline::from_config(&self.config);
131 self.render_pages(&pages, &global_data, &templates, &pipeline)?;
132
133 info!("Build complete: {} pages generated", pages.len());
134 println!("Generated {} pages", pages.len());
135
136 trace!("Running after_build hook");
138 self.config.call_after_build()?;
139
140 self.tracker.merge_all_threads();
142 self.save_cached_deps()?;
143
144 Ok(())
145 }
146
147 fn save_cached_deps(&self) -> Result<()> {
149 let cache_path = self.project_dir.join(CACHE_FILE);
150 let deps = CachedDeps::from_tracker(&self.tracker);
151 deps.save(&cache_path)
152 .with_context(|| format!("Failed to save dependency cache to {:?}", cache_path))?;
153 debug!(
154 "Saved dependency cache: {} reads, {} writes",
155 deps.reads.len(),
156 deps.writes.len()
157 );
158 Ok(())
159 }
160
161 pub fn get_changed_files(&self) -> Vec<PathBuf> {
163 match &self.cached_deps {
164 Some(cached) => self.tracker.get_changed_files(cached),
165 None => Vec::new(), }
167 }
168
169 pub fn needs_full_rebuild(&self) -> bool {
171 self.cached_deps.is_none()
172 }
173
174 pub fn is_tracked_file(&self, path: &Path) -> bool {
176 if let Some(ref cached) = self.cached_deps {
177 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
179 cached.reads.contains_key(&path)
180 } else {
181 true
183 }
184 }
185
186 pub fn has_tracked_changes(&self) -> bool {
188 if let Some(ref cached) = self.cached_deps {
189 !self.tracker.get_changed_files(cached).is_empty()
190 } else {
191 true }
193 }
194
195 fn clean(&self) -> Result<()> {
196 if self.output_dir.exists() {
197 debug!("Removing existing output directory: {:?}", self.output_dir);
198 fs::remove_dir_all(&self.output_dir).with_context(|| {
199 format!("Failed to clean output directory: {:?}", self.output_dir)
200 })?;
201 }
202 trace!("Creating output directories");
203 fs::create_dir_all(&self.output_dir)?;
204 fs::create_dir_all(self.output_dir.join("static"))?;
205 Ok(())
206 }
207
208 fn remove_stale_pages(&self, old_pages: &[PageDef], new_pages: &[PageDef]) -> Result<()> {
210 use std::collections::HashSet;
211
212 let new_paths: HashSet<&str> = new_pages.iter().map(|p| p.path.as_str()).collect();
214
215 for old_page in old_pages {
217 if !new_paths.contains(old_page.path.as_str()) {
218 let relative_path = old_page.path.trim_matches('/');
219
220 let has_extension = relative_path.contains('.')
222 && !relative_path.ends_with('/')
223 && relative_path
224 .rsplit('/')
225 .next()
226 .map(|s| s.contains('.'))
227 .unwrap_or(false);
228
229 let file_path = if has_extension {
230 self.output_dir.join(relative_path)
231 } else if relative_path.is_empty() {
232 self.output_dir.join("index.html")
233 } else {
234 self.output_dir.join(relative_path).join("index.html")
235 };
236
237 if file_path.exists() {
238 println!(" Removed: {}", old_page.path);
239 fs::remove_file(&file_path)?;
240
241 if let Some(parent) = file_path.parent()
243 && parent != self.output_dir
244 && parent.read_dir()?.next().is_none()
245 {
246 let _ = fs::remove_dir(parent);
247 }
248 }
249 }
250 }
251
252 Ok(())
253 }
254
255 fn process_assets(&self) -> Result<()> {
256 let static_dir = self.output_dir.join("static");
257 let paths = &self.config.paths;
258
259 debug!(
261 "Copying static files from {:?}",
262 self.resolve_path(&paths.static_files)
263 );
264 copy_static_files(&self.resolve_path(&paths.static_files), &static_dir)?;
265
266 Ok(())
267 }
268
269 fn render_pages(
270 &self,
271 pages: &[PageDef],
272 global_data: &serde_json::Value,
273 templates: &Templates,
274 pipeline: &Pipeline,
275 ) -> Result<()> {
276 pages
278 .par_iter()
279 .try_for_each(|page| self.render_single_page(page, global_data, templates, pipeline))?;
280
281 Ok(())
282 }
283
284 fn render_single_page(
285 &self,
286 page: &PageDef,
287 global_data: &serde_json::Value,
288 templates: &Templates,
289 pipeline: &Pipeline,
290 ) -> Result<()> {
291 trace!("Rendering page: {}", page.path);
292
293 let html_content = if let Some(ref markdown) = page.content {
295 let ctx = TransformContext {
296 config: &self.config,
297 current_path: &self.project_dir,
298 base_url: &self.config.site.base_url,
299 };
300 Some(pipeline.process(markdown, &ctx))
301 } else {
302 page.html.clone()
303 };
304
305 let html = if page.template.is_none() {
307 html_content.unwrap_or_default()
308 } else {
309 templates.render_page(&self.config, page, global_data, html_content.as_deref())?
310 };
311
312 let relative_path = page.path.trim_matches('/');
314
315 let has_extension = relative_path.contains('.')
317 && !relative_path.ends_with('/')
318 && relative_path
319 .rsplit('/')
320 .next()
321 .map(|s| s.contains('.'))
322 .unwrap_or(false);
323
324 if has_extension {
325 let file_path = self.output_dir.join(relative_path);
327 if let Some(parent) = file_path.parent() {
328 fs::create_dir_all(parent)?;
329 }
330 fs::write(file_path, html)?;
331 } else {
332 let page_dir = if relative_path.is_empty() {
334 self.output_dir.clone()
335 } else {
336 self.output_dir.join(relative_path)
337 };
338 fs::create_dir_all(&page_dir)?;
339 fs::write(page_dir.join("index.html"), html)?;
340 }
341
342 Ok(())
343 }
344
345 pub fn incremental_build(&mut self, changes: &crate::watch::ChangeSet) -> Result<()> {
348 debug!("Starting incremental build");
349 trace!("Change set: {:?}", changes);
350
351 if changes.full_rebuild {
353 return self.build();
354 }
355
356 let relevant_changes: Vec<PathBuf> = changes
358 .content_files
359 .iter()
360 .filter(|p| {
361 let full_path = self.project_dir.join(p);
362 let is_tracked = self.is_tracked_file(&full_path);
363 if !is_tracked {
364 trace!("Skipping untracked file: {:?}", p);
365 }
366 is_tracked
367 })
368 .map(|p| self.project_dir.join(p))
369 .collect();
370
371 if !relevant_changes.is_empty() {
373 debug!(
374 "{} tracked content files changed (out of {} total)",
375 relevant_changes.len(),
376 changes.content_files.len()
377 );
378 return self.rebuild_content_only(&relevant_changes);
379 } else if !changes.content_files.is_empty() {
380 debug!(
381 "All {} changed files were untracked, skipping rebuild",
382 changes.content_files.len()
383 );
384 }
385
386 if changes.has_template_changes() {
388 for path in &changes.template_files {
389 println!(" Changed: {}", path.display());
390 }
391 return self.rebuild_templates_only(&changes.template_files);
392 }
393
394 if changes.rebuild_css {
396 self.rebuild_css_only()?;
397 }
398
399 self.process_static_changes(changes)?;
401
402 Ok(())
403 }
404
405 fn rebuild_content_only(&mut self, changed_paths: &[PathBuf]) -> Result<()> {
407 debug!(
408 "Content-only rebuild for {} changed files",
409 changed_paths.len()
410 );
411
412 for path in changed_paths {
414 if let Ok(rel) = path.strip_prefix(&self.project_dir) {
415 println!(" Changed: {}", rel.display());
416 } else {
417 println!(" Changed: {}", path.display());
418 }
419 }
420
421 let global_data = if self.config.has_update_data() && self.cached_global_data.is_some() {
423 debug!("Using incremental update_data()");
424 let cached = self.cached_global_data.as_ref().unwrap();
425 let relative_paths: Vec<PathBuf> = changed_paths
427 .iter()
428 .filter_map(|p| {
429 p.strip_prefix(&self.project_dir)
430 .ok()
431 .map(|r| r.to_path_buf())
432 })
433 .collect();
434 self.config.call_update_data(cached, &relative_paths)?
435 } else {
436 debug!("Using full data() reload");
437 self.config.call_data()?
438 };
439
440 let pages = self.config.call_pages(&global_data)?;
441
442 if let Some(ref old_pages) = self.cached_pages {
444 self.remove_stale_pages(old_pages, &pages)?;
445 }
446
447 self.cached_global_data = Some(global_data.clone());
449 self.cached_pages = Some(pages.clone());
450
451 let templates = Templates::new(
453 &self.resolve_path(&self.config.paths.templates),
454 Some(self.tracker.clone()),
455 )?;
456 let pipeline = Pipeline::from_config(&self.config);
457 self.render_pages(&pages, &global_data, &templates, &pipeline)?;
458
459 self.tracker.merge_all_threads();
461 self.save_cached_deps()?;
462
463 println!("Re-rendered {} pages (content changed)", pages.len());
464 Ok(())
465 }
466
467 fn rebuild_templates_only(
469 &mut self,
470 changed_template_files: &std::collections::HashSet<PathBuf>,
471 ) -> Result<()> {
472 let (global_data, all_pages) = match (&self.cached_global_data, &self.cached_pages) {
473 (Some(data), Some(pages)) => (data.clone(), pages.clone()),
474 _ => {
475 log::info!("No cached data available, performing full build");
477 return self.build();
478 }
479 };
480
481 let template_dir = self.resolve_path(&self.config.paths.templates);
483 let templates = Templates::new(&template_dir, Some(self.tracker.clone()))?;
484 let deps = templates.deps();
485
486 let mut affected_templates = std::collections::HashSet::new();
488 for changed_path in changed_template_files {
489 if let Some(template_name) = deps.find_template_by_path(changed_path) {
491 let transitive = deps.get_affected_templates(template_name);
492 affected_templates.extend(transitive);
493 } else if let Ok(rel_path) = changed_path.strip_prefix(&template_dir) {
494 let template_name = rel_path.to_string_lossy().to_string();
496 let transitive = deps.get_affected_templates(&template_name);
497 affected_templates.extend(transitive);
498 }
499 }
500
501 debug!("Affected templates: {:?}", affected_templates);
502
503 let pages_to_rebuild: Vec<_> = all_pages
505 .iter()
506 .filter(|page| {
507 if let Some(ref template) = page.template {
508 affected_templates.contains(template)
509 } else {
510 false
511 }
512 })
513 .cloned()
514 .collect();
515
516 if pages_to_rebuild.is_empty() {
517 println!("No pages affected by template changes");
518 return Ok(());
519 }
520
521 debug!(
522 "Template rebuild: {} of {} pages affected",
523 pages_to_rebuild.len(),
524 all_pages.len()
525 );
526
527 let pipeline = Pipeline::from_config(&self.config);
528
529 self.render_pages(&pages_to_rebuild, &global_data, &templates, &pipeline)?;
531
532 println!(
533 "Re-rendered {} of {} pages (templates changed)",
534 pages_to_rebuild.len(),
535 all_pages.len()
536 );
537 Ok(())
538 }
539
540 fn rebuild_css_only(&self) -> Result<()> {
542 println!(" Changed: styles");
543 self.config.call_before_build()?;
544 println!("Rebuilt CSS");
545 Ok(())
546 }
547
548 fn process_static_changes(&self, changes: &crate::watch::ChangeSet) -> Result<()> {
550 use crate::assets::copy_single_static_file;
551
552 let static_dir = self.output_dir.join("static");
553 let source_static = self.resolve_path(&self.config.paths.static_files);
554
555 for rel_path in &changes.image_files {
557 let src = source_static.join(rel_path.as_path());
558 let dest = static_dir.join(rel_path.as_path());
559
560 if src.exists() {
561 if let Some(parent) = dest.parent() {
562 fs::create_dir_all(parent)?;
563 }
564 copy_single_static_file(&src, &dest)?;
565 println!(" Copied: static/{}", rel_path.display());
566 }
567 }
568
569 for rel_path in &changes.static_files {
571 let src = source_static.join(rel_path.as_path());
572 let dest = static_dir.join(rel_path.as_path());
573
574 if src.exists() {
575 if let Some(parent) = dest.parent() {
576 fs::create_dir_all(parent)?;
577 }
578 copy_single_static_file(&src, &dest)?;
579 println!(" Copied: static/{}", rel_path.display());
580 }
581 }
582
583 Ok(())
584 }
585
586 pub fn reload_config(&mut self) -> Result<()> {
588 debug!("Reloading config from {:?}", self.project_dir);
589 self.config = crate::config::Config::load(&self.project_dir)?;
590 self.cached_global_data = None;
592 self.cached_pages = None;
593 info!("Config reloaded successfully");
594 Ok(())
595 }
596
597 pub fn config(&self) -> &Config {
599 &self.config
600 }
601}