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::config::{Config, PageDef};
11use crate::markdown::{Pipeline, TransformContext};
12use crate::templates::Templates;
13use crate::tracker::{BuildTracker, CachedDeps, SharedTracker};
14
15const CACHE_FILE: &str = ".rs-web-cache/deps.bin";
17
18pub struct Builder {
20 config: Config,
21 output_dir: PathBuf,
22 project_dir: PathBuf,
23 tracker: SharedTracker,
25 cached_deps: Option<CachedDeps>,
27 cached_global_data: Option<serde_json::Value>,
29 cached_pages: Option<Vec<PageDef>>,
31}
32
33impl Builder {
34 pub fn new(config: Config, output_dir: PathBuf, project_dir: PathBuf) -> Self {
35 let cache_path = project_dir.join(CACHE_FILE);
37 let cached_deps = CachedDeps::load(&cache_path);
38 if cached_deps.is_some() {
39 debug!("Loaded cached dependency info from {:?}", cache_path);
40 }
41
42 let tracker = config.tracker().clone();
44
45 Self {
46 config,
47 output_dir,
48 project_dir,
49 tracker,
50 cached_deps,
51 cached_global_data: None,
52 cached_pages: None,
53 }
54 }
55
56 pub fn new_with_tracker(project_dir: PathBuf, output_dir: PathBuf) -> Result<Self> {
58 let tracker = Arc::new(BuildTracker::new());
59 let config = Config::load_with_tracker(&project_dir, tracker.clone())?;
60
61 let cache_path = project_dir.join(CACHE_FILE);
63 let cached_deps = CachedDeps::load(&cache_path);
64 if cached_deps.is_some() {
65 debug!("Loaded cached dependency info from {:?}", cache_path);
66 }
67
68 Ok(Self {
69 config,
70 output_dir,
71 project_dir,
72 tracker,
73 cached_deps,
74 cached_global_data: None,
75 cached_pages: None,
76 })
77 }
78
79 fn resolve_path(&self, path: &str) -> PathBuf {
81 let p = Path::new(path);
82 if p.is_absolute() {
83 p.to_path_buf()
84 } else {
85 self.project_dir.join(path)
86 }
87 }
88
89 pub fn build(&mut self) -> Result<()> {
90 info!("Starting build");
91 debug!("Output directory: {:?}", self.output_dir);
92 debug!("Project directory: {:?}", self.project_dir);
93
94 trace!("Stage 1: Cleaning output directory");
96 self.clean()?;
97
98 trace!("Running before_build hook");
100 self.config.call_before_build()?;
101
102 trace!("Stage 2: Calling data() function");
104 let global_data = self.config.call_data()?;
105 debug!("Global data loaded");
106
107 trace!("Stage 3: Calling pages() function");
109 let pages = self.config.call_pages(&global_data)?;
110 debug!("Found {} pages to generate", pages.len());
111
112 self.cached_global_data = Some(global_data.clone());
114 self.cached_pages = Some(pages.clone());
115
116 trace!("Stage 5: Loading templates");
118 let templates = Templates::new(
119 &self.resolve_path(&self.config.paths.templates),
120 Some(self.tracker.clone()),
121 )?;
122
123 trace!("Stage 6: Rendering {} pages", pages.len());
125 let pipeline = Pipeline::from_config(&self.config);
126 self.render_pages(&pages, &global_data, &templates, &pipeline)?;
127
128 info!("Build complete: {} pages generated", pages.len());
129 println!("Generated {} pages", pages.len());
130
131 trace!("Running after_build hook");
133 self.config.call_after_build()?;
134
135 self.tracker.merge_all_threads();
137 self.save_cached_deps()?;
138
139 Ok(())
140 }
141
142 fn save_cached_deps(&self) -> Result<()> {
144 let cache_path = self.project_dir.join(CACHE_FILE);
145 let deps = CachedDeps::from_tracker(&self.tracker);
146 deps.save(&cache_path)
147 .with_context(|| format!("Failed to save dependency cache to {:?}", cache_path))?;
148 debug!(
149 "Saved dependency cache: {} reads, {} writes",
150 deps.reads.len(),
151 deps.writes.len()
152 );
153 Ok(())
154 }
155
156 pub fn get_changed_files(&self) -> Vec<PathBuf> {
158 match &self.cached_deps {
159 Some(cached) => self.tracker.get_changed_files(cached),
160 None => Vec::new(), }
162 }
163
164 pub fn needs_full_rebuild(&self) -> bool {
166 self.cached_deps.is_none()
167 }
168
169 pub fn is_tracked_file(&self, path: &Path) -> bool {
171 if let Some(ref cached) = self.cached_deps {
172 let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
174 cached.reads.contains_key(&path)
175 } else {
176 true
178 }
179 }
180
181 pub fn has_tracked_changes(&self) -> bool {
183 if let Some(ref cached) = self.cached_deps {
184 !self.tracker.get_changed_files(cached).is_empty()
185 } else {
186 true }
188 }
189
190 fn clean(&self) -> Result<()> {
191 if self.output_dir.exists() {
192 debug!("Removing existing output directory: {:?}", self.output_dir);
193 fs::remove_dir_all(&self.output_dir).with_context(|| {
194 format!("Failed to clean output directory: {:?}", self.output_dir)
195 })?;
196 }
197 trace!("Creating output directories");
198 fs::create_dir_all(&self.output_dir)?;
199 fs::create_dir_all(self.output_dir.join("static"))?;
200 Ok(())
201 }
202
203 fn remove_stale_pages(&self, old_pages: &[PageDef], new_pages: &[PageDef]) -> Result<()> {
205 use std::collections::HashSet;
206
207 let new_paths: HashSet<&str> = new_pages.iter().map(|p| p.path.as_str()).collect();
209
210 for old_page in old_pages {
212 if !new_paths.contains(old_page.path.as_str()) {
213 let relative_path = old_page.path.trim_matches('/');
214
215 let has_extension = relative_path.contains('.')
217 && !relative_path.ends_with('/')
218 && relative_path
219 .rsplit('/')
220 .next()
221 .map(|s| s.contains('.'))
222 .unwrap_or(false);
223
224 let file_path = if has_extension {
225 self.output_dir.join(relative_path)
226 } else if relative_path.is_empty() {
227 self.output_dir.join("index.html")
228 } else {
229 self.output_dir.join(relative_path).join("index.html")
230 };
231
232 if file_path.exists() {
233 println!(" Removed: {}", old_page.path);
234 fs::remove_file(&file_path)?;
235
236 if let Some(parent) = file_path.parent()
238 && parent != self.output_dir
239 && parent.read_dir()?.next().is_none()
240 {
241 let _ = fs::remove_dir(parent);
242 }
243 }
244 }
245 }
246
247 Ok(())
248 }
249
250 fn render_pages(
251 &self,
252 pages: &[PageDef],
253 global_data: &serde_json::Value,
254 templates: &Templates,
255 pipeline: &Pipeline,
256 ) -> Result<()> {
257 pages
259 .par_iter()
260 .try_for_each(|page| self.render_single_page(page, global_data, templates, pipeline))?;
261
262 Ok(())
263 }
264
265 fn render_single_page(
266 &self,
267 page: &PageDef,
268 global_data: &serde_json::Value,
269 templates: &Templates,
270 pipeline: &Pipeline,
271 ) -> Result<()> {
272 trace!("Rendering page: {}", page.path);
273
274 let html_content = if let Some(ref markdown) = page.content {
276 let ctx = TransformContext {
277 config: &self.config,
278 current_path: &self.project_dir,
279 base_url: &self.config.site.base_url,
280 };
281 Some(pipeline.process(markdown, &ctx))
282 } else {
283 page.html.clone()
284 };
285
286 let html = if page.template.is_none() {
288 html_content.unwrap_or_default()
289 } else {
290 templates.render_page(&self.config, page, global_data, html_content.as_deref())?
291 };
292
293 let relative_path = page.path.trim_matches('/');
295
296 let has_extension = relative_path.contains('.')
298 && !relative_path.ends_with('/')
299 && relative_path
300 .rsplit('/')
301 .next()
302 .map(|s| s.contains('.'))
303 .unwrap_or(false);
304
305 if has_extension {
306 let file_path = self.output_dir.join(relative_path);
308 if let Some(parent) = file_path.parent() {
309 fs::create_dir_all(parent)?;
310 }
311 fs::write(file_path, html)?;
312 } else {
313 let page_dir = if relative_path.is_empty() {
315 self.output_dir.clone()
316 } else {
317 self.output_dir.join(relative_path)
318 };
319 fs::create_dir_all(&page_dir)?;
320 fs::write(page_dir.join("index.html"), html)?;
321 }
322
323 Ok(())
324 }
325
326 pub fn incremental_build(&mut self, changes: &crate::watch::ChangeSet) -> Result<()> {
329 debug!("Starting incremental build");
330 trace!("Change set: {:?}", changes);
331
332 if changes.full_rebuild {
334 return self.build();
335 }
336
337 let relevant_changes: Vec<PathBuf> = changes
339 .content_files
340 .iter()
341 .filter(|p| {
342 let full_path = self.project_dir.join(p);
343 let is_tracked = self.is_tracked_file(&full_path);
344 if !is_tracked {
345 trace!("Skipping untracked file: {:?}", p);
346 }
347 is_tracked
348 })
349 .map(|p| self.project_dir.join(p))
350 .collect();
351
352 if !relevant_changes.is_empty() {
354 debug!(
355 "{} tracked content files changed (out of {} total)",
356 relevant_changes.len(),
357 changes.content_files.len()
358 );
359 return self.rebuild_content_only(&relevant_changes);
360 } else if !changes.content_files.is_empty() {
361 debug!(
362 "All {} changed files were untracked, skipping rebuild",
363 changes.content_files.len()
364 );
365 }
366
367 if changes.has_template_changes() {
369 for path in &changes.template_files {
370 println!(" Changed: {}", path.display());
371 }
372 return self.rebuild_templates_only(&changes.template_files);
373 }
374
375 if changes.rebuild_css {
377 self.rebuild_css_only()?;
378 }
379
380 Ok(())
381 }
382
383 fn rebuild_content_only(&mut self, changed_paths: &[PathBuf]) -> Result<()> {
385 debug!(
386 "Content-only rebuild for {} changed files",
387 changed_paths.len()
388 );
389
390 for path in changed_paths {
392 if let Ok(rel) = path.strip_prefix(&self.project_dir) {
393 println!(" Changed: {}", rel.display());
394 } else {
395 println!(" Changed: {}", path.display());
396 }
397 }
398
399 let global_data = if self.config.has_update_data() && self.cached_global_data.is_some() {
401 debug!("Using incremental update_data()");
402 let cached = self.cached_global_data.as_ref().unwrap();
403 let relative_paths: Vec<PathBuf> = changed_paths
405 .iter()
406 .filter_map(|p| {
407 p.strip_prefix(&self.project_dir)
408 .ok()
409 .map(|r| r.to_path_buf())
410 })
411 .collect();
412 self.config.call_update_data(cached, &relative_paths)?
413 } else {
414 debug!("Using full data() reload");
415 self.config.call_data()?
416 };
417
418 let pages = self.config.call_pages(&global_data)?;
419
420 if let Some(ref old_pages) = self.cached_pages {
422 self.remove_stale_pages(old_pages, &pages)?;
423 }
424
425 self.cached_global_data = Some(global_data.clone());
427 self.cached_pages = Some(pages.clone());
428
429 let templates = Templates::new(
431 &self.resolve_path(&self.config.paths.templates),
432 Some(self.tracker.clone()),
433 )?;
434 let pipeline = Pipeline::from_config(&self.config);
435 self.render_pages(&pages, &global_data, &templates, &pipeline)?;
436
437 self.tracker.merge_all_threads();
439 self.save_cached_deps()?;
440
441 println!("Re-rendered {} pages (content changed)", pages.len());
442 Ok(())
443 }
444
445 fn rebuild_templates_only(
447 &mut self,
448 changed_template_files: &std::collections::HashSet<PathBuf>,
449 ) -> Result<()> {
450 let (global_data, all_pages) = match (&self.cached_global_data, &self.cached_pages) {
451 (Some(data), Some(pages)) => (data.clone(), pages.clone()),
452 _ => {
453 log::info!("No cached data available, performing full build");
455 return self.build();
456 }
457 };
458
459 let template_dir = self.resolve_path(&self.config.paths.templates);
461 let templates = Templates::new(&template_dir, Some(self.tracker.clone()))?;
462 let deps = templates.deps();
463
464 let mut affected_templates = std::collections::HashSet::new();
466 for changed_path in changed_template_files {
467 if let Some(template_name) = deps.find_template_by_path(changed_path) {
469 let transitive = deps.get_affected_templates(template_name);
470 affected_templates.extend(transitive);
471 } else if let Ok(rel_path) = changed_path.strip_prefix(&template_dir) {
472 let template_name = rel_path.to_string_lossy().to_string();
474 let transitive = deps.get_affected_templates(&template_name);
475 affected_templates.extend(transitive);
476 }
477 }
478
479 debug!("Affected templates: {:?}", affected_templates);
480
481 let pages_to_rebuild: Vec<_> = all_pages
483 .iter()
484 .filter(|page| {
485 if let Some(ref template) = page.template {
486 affected_templates.contains(template)
487 } else {
488 false
489 }
490 })
491 .cloned()
492 .collect();
493
494 if pages_to_rebuild.is_empty() {
495 println!("No pages affected by template changes");
496 return Ok(());
497 }
498
499 debug!(
500 "Template rebuild: {} of {} pages affected",
501 pages_to_rebuild.len(),
502 all_pages.len()
503 );
504
505 let pipeline = Pipeline::from_config(&self.config);
506
507 self.render_pages(&pages_to_rebuild, &global_data, &templates, &pipeline)?;
509
510 println!(
511 "Re-rendered {} of {} pages (templates changed)",
512 pages_to_rebuild.len(),
513 all_pages.len()
514 );
515 Ok(())
516 }
517
518 fn rebuild_css_only(&self) -> Result<()> {
520 println!(" Changed: styles");
521 self.config.call_before_build()?;
522 println!("Rebuilt CSS");
523 Ok(())
524 }
525
526 pub fn reload_config(&mut self) -> Result<()> {
528 debug!("Reloading config from {:?}", self.project_dir);
529 self.config = crate::config::Config::load(&self.project_dir)?;
530 self.cached_global_data = None;
532 self.cached_pages = None;
533 info!("Config reloaded successfully");
534 Ok(())
535 }
536
537 pub fn config(&self) -> &Config {
539 &self.config
540 }
541}