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::config::{Config, PageDef};
11use crate::markdown::{Pipeline, TransformContext};
12use crate::templates::Templates;
13use crate::tracker::{BuildTracker, CachedDeps, SharedTracker};
14
15/// Cache file name
16const CACHE_FILE: &str = ".rs-web-cache/deps.bin";
17
18/// Main build orchestrator
19pub struct Builder {
20    config: Config,
21    output_dir: PathBuf,
22    project_dir: PathBuf,
23    /// Build dependency tracker
24    tracker: SharedTracker,
25    /// Cached dependency info from previous build
26    cached_deps: Option<CachedDeps>,
27    /// Cached global data from last build
28    cached_global_data: Option<serde_json::Value>,
29    /// Cached page definitions from last build
30    cached_pages: Option<Vec<PageDef>>,
31}
32
33impl Builder {
34    pub fn new(config: Config, output_dir: PathBuf, project_dir: PathBuf) -> Self {
35        // Load cached deps from previous build
36        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        // Get the tracker from config (it was created during config loading)
43        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    /// Create a new builder with a fresh tracker (for full rebuilds)
57    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        // Load cached deps from previous build
62        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    /// Resolve a path relative to the project directory
80    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        // Stage 1: Clean output directory
95        trace!("Stage 1: Cleaning output directory");
96        self.clean()?;
97
98        // Run before_build hook (after clean, so it can write to output_dir)
99        trace!("Running before_build hook");
100        self.config.call_before_build()?;
101
102        // Stage 2: Call data() to get global data
103        trace!("Stage 2: Calling data() function");
104        let global_data = self.config.call_data()?;
105        debug!("Global data loaded");
106
107        // Stage 3: Call pages(global) to get page definitions
108        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        // Cache for incremental builds
113        self.cached_global_data = Some(global_data.clone());
114        self.cached_pages = Some(pages.clone());
115
116        // Stage 4: Load templates
117        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        // Stage 6: Render all pages in parallel
124        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        // Run after_build hook
132        trace!("Running after_build hook");
133        self.config.call_after_build()?;
134
135        // Merge all thread-local tracking data and save
136        self.tracker.merge_all_threads();
137        self.save_cached_deps()?;
138
139        Ok(())
140    }
141
142    /// Save tracked dependencies to cache file
143    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    /// Get files that have changed since last build
157    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(), // No cache means full rebuild needed
161        }
162    }
163
164    /// Check if a full rebuild is needed (no cache or config changed)
165    pub fn needs_full_rebuild(&self) -> bool {
166        self.cached_deps.is_none()
167    }
168
169    /// Check if a file was tracked as a dependency in the last build
170    pub fn is_tracked_file(&self, path: &Path) -> bool {
171        if let Some(ref cached) = self.cached_deps {
172            // Canonicalize path for comparison
173            let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
174            cached.reads.contains_key(&path)
175        } else {
176            // No cache, assume all files are relevant
177            true
178        }
179    }
180
181    /// Check if any tracked files have changed since last build
182    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 // No cache means we need to build
187        }
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    /// Remove pages that existed in the old build but not in the new one
204    fn remove_stale_pages(&self, old_pages: &[PageDef], new_pages: &[PageDef]) -> Result<()> {
205        use std::collections::HashSet;
206
207        // Collect new page paths
208        let new_paths: HashSet<&str> = new_pages.iter().map(|p| p.path.as_str()).collect();
209
210        // Find and remove stale pages
211        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                // Check if path has a file extension
216                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                    // Try to remove empty parent directory
237                    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        // Render all pages in parallel
258        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        // Process content through markdown pipeline if provided
275        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        // If no template, output html directly (for raw text/xml files)
287        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        // Write output file
294        let relative_path = page.path.trim_matches('/');
295
296        // Check if path has a file extension (e.g., feed.xml, sitemap.json)
297        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            // Write directly to file path (e.g., /feed.xml -> dist/feed.xml)
307            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            // Write to directory with index.html (e.g., /about/ -> dist/about/index.html)
314            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    /// Perform an incremental build based on what changed
327    /// Uses tracker data to filter changes to only files that were actually used
328    pub fn incremental_build(&mut self, changes: &crate::watch::ChangeSet) -> Result<()> {
329        debug!("Starting incremental build");
330        trace!("Change set: {:?}", changes);
331
332        // Config changed - full rebuild needed (Lua functions may have changed)
333        if changes.full_rebuild {
334            return self.build();
335        }
336
337        // Filter content changes to only files that were tracked as dependencies
338        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        // Content files changed - try incremental update
353        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        // Template changes - re-render affected pages with cached data (skip Lua calls)
368        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        // Handle CSS-only changes
376        if changes.rebuild_css {
377            self.rebuild_css_only()?;
378        }
379
380        Ok(())
381    }
382
383    /// Rebuild content - use incremental update if available, otherwise full data reload
384    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        // Print changed files
391        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        // Try incremental update if update_data function exists and we have cached data
400        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            // Convert absolute paths to relative paths for Lua
404            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        // Remove stale pages that no longer exist in the new page list
421        if let Some(ref old_pages) = self.cached_pages {
422            self.remove_stale_pages(old_pages, &pages)?;
423        }
424
425        // Update cache
426        self.cached_global_data = Some(global_data.clone());
427        self.cached_pages = Some(pages.clone());
428
429        // Reload templates and re-render
430        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        // Merge thread-local tracking data and save
438        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    /// Rebuild only by re-rendering templates with cached data
446    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                // No cache available, do a full build to populate it
454                log::info!("No cached data available, performing full build");
455                return self.build();
456            }
457        };
458
459        // Reload templates and get dependency graph
460        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        // Find all affected templates (transitively)
465        let mut affected_templates = std::collections::HashSet::new();
466        for changed_path in changed_template_files {
467            // Find template name from path
468            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                // Try relative path as template name
473                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        // Filter pages to only those using affected templates
482        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        // Re-render only affected pages with cached data
508        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    /// Rebuild CSS by calling before_build hook (CSS is now handled via Lua)
519    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    /// Reload config from disk
527    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        // Clear cache since Lua functions might produce different output
531        self.cached_global_data = None;
532        self.cached_pages = None;
533        info!("Config reloaded successfully");
534        Ok(())
535    }
536
537    /// Get a reference to the current config
538    pub fn config(&self) -> &Config {
539        &self.config
540    }
541}