rs_web/
build.rs

1//! Build orchestrator for static site generation
2
3use 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
16/// Cache file name
17const CACHE_FILE: &str = ".rs-web-cache/deps.bin";
18
19/// Main build orchestrator
20pub struct Builder {
21    config: Config,
22    output_dir: PathBuf,
23    project_dir: PathBuf,
24    /// Build dependency tracker
25    tracker: SharedTracker,
26    /// Cached dependency info from previous build
27    cached_deps: Option<CachedDeps>,
28    /// Cached global data from last build
29    cached_global_data: Option<serde_json::Value>,
30    /// Cached page definitions from last build
31    cached_pages: Option<Vec<PageDef>>,
32}
33
34impl Builder {
35    pub fn new(config: Config, output_dir: PathBuf, project_dir: PathBuf) -> Self {
36        // Load cached deps from previous build
37        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        // Get the tracker from config (it was created during config loading)
44        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    /// Create a new builder with a fresh tracker (for full rebuilds)
58    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        // Load cached deps from previous build
63        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    /// Resolve a path relative to the project directory
81    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        // Stage 1: Clean output directory
96        trace!("Stage 1: Cleaning output directory");
97        self.clean()?;
98
99        // Run before_build hook (after clean, so it can write to output_dir)
100        trace!("Running before_build hook");
101        self.config.call_before_build()?;
102
103        // Stage 2: Call data() to get global data
104        trace!("Stage 2: Calling data() function");
105        let global_data = self.config.call_data()?;
106        debug!("Global data loaded");
107
108        // Stage 3: Call pages(global) to get page definitions
109        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        // Cache for incremental builds
114        self.cached_global_data = Some(global_data.clone());
115        self.cached_pages = Some(pages.clone());
116
117        // Stage 4: Process assets
118        trace!("Stage 4: Processing assets");
119        self.process_assets()?;
120
121        // Stage 5: Load templates
122        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        // Stage 6: Render all pages in parallel
129        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        // Run after_build hook
137        trace!("Running after_build hook");
138        self.config.call_after_build()?;
139
140        // Merge all thread-local tracking data and save
141        self.tracker.merge_all_threads();
142        self.save_cached_deps()?;
143
144        Ok(())
145    }
146
147    /// Save tracked dependencies to cache file
148    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    /// Get files that have changed since last build
162    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(), // No cache means full rebuild needed
166        }
167    }
168
169    /// Check if a full rebuild is needed (no cache or config changed)
170    pub fn needs_full_rebuild(&self) -> bool {
171        self.cached_deps.is_none()
172    }
173
174    /// Check if a file was tracked as a dependency in the last build
175    pub fn is_tracked_file(&self, path: &Path) -> bool {
176        if let Some(ref cached) = self.cached_deps {
177            // Canonicalize path for comparison
178            let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
179            cached.reads.contains_key(&path)
180        } else {
181            // No cache, assume all files are relevant
182            true
183        }
184    }
185
186    /// Check if any tracked files have changed since last build
187    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 // No cache means we need to build
192        }
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    /// Remove pages that existed in the old build but not in the new one
209    fn remove_stale_pages(&self, old_pages: &[PageDef], new_pages: &[PageDef]) -> Result<()> {
210        use std::collections::HashSet;
211
212        // Collect new page paths
213        let new_paths: HashSet<&str> = new_pages.iter().map(|p| p.path.as_str()).collect();
214
215        // Find and remove stale pages
216        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                // Check if path has a file extension
221                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                    // Try to remove empty parent directory
242                    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        // Copy static files (CSS and images are handled explicitly via Lua hooks)
260        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        // Render all pages in parallel
277        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        // Process content through markdown pipeline if provided
294        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        // If no template, output html directly (for raw text/xml files)
306        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        // Write output file
313        let relative_path = page.path.trim_matches('/');
314
315        // Check if path has a file extension (e.g., feed.xml, sitemap.json)
316        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            // Write directly to file path (e.g., /feed.xml -> dist/feed.xml)
326            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            // Write to directory with index.html (e.g., /about/ -> dist/about/index.html)
333            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    /// Perform an incremental build based on what changed
346    /// Uses tracker data to filter changes to only files that were actually used
347    pub fn incremental_build(&mut self, changes: &crate::watch::ChangeSet) -> Result<()> {
348        debug!("Starting incremental build");
349        trace!("Change set: {:?}", changes);
350
351        // Config changed - full rebuild needed (Lua functions may have changed)
352        if changes.full_rebuild {
353            return self.build();
354        }
355
356        // Filter content changes to only files that were tracked as dependencies
357        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        // Content files changed - try incremental update
372        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        // Template changes - re-render affected pages with cached data (skip Lua calls)
387        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        // Handle CSS-only changes
395        if changes.rebuild_css {
396            self.rebuild_css_only()?;
397        }
398
399        // Handle static/image changes
400        self.process_static_changes(changes)?;
401
402        Ok(())
403    }
404
405    /// Rebuild content - use incremental update if available, otherwise full data reload
406    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        // Print changed files
413        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        // Try incremental update if update_data function exists and we have cached data
422        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            // Convert absolute paths to relative paths for Lua
426            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        // Remove stale pages that no longer exist in the new page list
443        if let Some(ref old_pages) = self.cached_pages {
444            self.remove_stale_pages(old_pages, &pages)?;
445        }
446
447        // Update cache
448        self.cached_global_data = Some(global_data.clone());
449        self.cached_pages = Some(pages.clone());
450
451        // Reload templates and re-render
452        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        // Merge thread-local tracking data and save
460        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    /// Rebuild only by re-rendering templates with cached data
468    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                // No cache available, do a full build to populate it
476                log::info!("No cached data available, performing full build");
477                return self.build();
478            }
479        };
480
481        // Reload templates and get dependency graph
482        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        // Find all affected templates (transitively)
487        let mut affected_templates = std::collections::HashSet::new();
488        for changed_path in changed_template_files {
489            // Find template name from path
490            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                // Try relative path as template name
495                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        // Filter pages to only those using affected templates
504        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        // Re-render only affected pages with cached data
530        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    /// Rebuild CSS by calling before_build hook (CSS is now handled via Lua)
541    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    /// Process static file and image changes (just copies, optimization handled via Lua hooks)
549    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        // Process changed images (just copy, optimization handled via Lua hooks)
556        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        // Process changed static files
570        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    /// Reload config from disk
587    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        // Clear cache since Lua functions might produce different output
591        self.cached_global_data = None;
592        self.cached_pages = None;
593        info!("Config reloaded successfully");
594        Ok(())
595    }
596
597    /// Get a reference to the current config
598    pub fn config(&self) -> &Config {
599        &self.config
600    }
601}