novos 0.1.0

A self-contained, high-performance SSG for systems thinkers.
//! The core build engine for `novos`.
//!
//! This module handles the orchestration of the static site generation process,
//! including asset copying, Sass compilation via `grass`, Markdown processing
//! via `pulldown-cmark`, and search index generation.

use crate::{config::Config, parser, rss, models::Post};
use rayon::prelude::*;
use serde_json::json;
use std::{
    collections::HashMap,
    fs, io,
    path::{Path, PathBuf},
    sync::{Arc, Mutex},
    time::{Instant, SystemTime},
};

/// Recursively copies all files from the source directory to the destination.
fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> io::Result<()> {
    fs::create_dir_all(&dst)?;
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let ty = entry.file_type()?;
        if ty.is_dir() {
            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
        } else {
            fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
        }
    }
    Ok(())
}

/// Compiles SCSS/SASS files located in the `sass/` directory using the `grass` crate.
/// Respects the `sass_style` setting (expanded vs compressed) from novos.toml.
pub fn compile_sass(config: &Config, verbose: bool) -> io::Result<()> {
    let sass_dir = Path::new("sass");
    if !sass_dir.exists() {
        return Ok(());
    }

    let css_dir = config.output_dir.join("css");
    fs::create_dir_all(&css_dir)?;

    let style = match config.build.sass_style.as_str() {
        "compressed" => grass::OutputStyle::Compressed,
        _ => grass::OutputStyle::Expanded,
    };
    let options = grass::Options::default().style(style);

    for entry in fs::read_dir(sass_dir)? {
        let entry = entry?;
        let path = entry.path();

        if path.extension().map_or(false, |ext| ext == "scss" || ext == "sass") {
            let file_name = path.file_name().unwrap().to_str().unwrap();
            
            if file_name.starts_with('_') {
                continue;
            }

            if verbose {
                println!("\x1b[2m  compiling\x1b[0m {}", file_name);
            }

            match grass::from_path(&path, &options) {
                Ok(css) => {
                    let mut out_path = css_dir.join(file_name);
                    out_path.set_extension("css");
                    fs::write(out_path, css)?;
                }
                Err(e) => {
                    return Err(io::Error::new(io::ErrorKind::Other, format!("Sass Error: {}", e)));
                }
            }
        }
    }
    Ok(())
}

/// The primary entry point for the `novos` build process.
pub fn perform_build(
    config: &Config,
    last_run_mu: Arc<Mutex<SystemTime>>,
    verbose: bool,
) -> io::Result<()> {
    let start = Instant::now();
    let lr = *last_run_mu.lock().unwrap();

    println!("novos build v{}", env!("CARGO_PKG_VERSION"));

    // Step 1: Cleaning and Static Assets
    if config.build.clean_output {
        println!("\x1b[2m[1/4]\x1b[0m Cleaning output directory...");
        if config.output_dir.exists() {
            let _ = fs::remove_dir_all(&config.output_dir);
        }
    } else {
        println!("\x1b[2m[1/4]\x1b[0m Preparing output directory...");
    }
    
    fs::create_dir_all(&config.output_dir)?;

    // Ensure the posts output directory exists
    let posts_out_path = config.output_dir.join(&config.posts_outdir);
    fs::create_dir_all(&posts_out_path)?;

    if config.static_dir.exists() {
        if verbose {
            println!("\x1b[2m  copying assets\x1b[0m");
        }
        copy_dir_all(&config.static_dir, &config.output_dir)?;
    }

    // Step 2: Stylesheets
    println!("\x1b[2m[2/4]\x1b[0m Compiling stylesheets...");
    compile_sass(config, verbose)?;

    // Step 3: Content Collection
    println!("\x1b[2m[3/4]\x1b[0m Processing content...");
    
    let main_tpl = fs::read_to_string(&config.template_path)?;
    let view_tpl = fs::read_to_string(&config.view_template_path).unwrap_or_else(|_| "<% content %>".to_string());

    let mut post_paths = Vec::new();
    if config.posts_dir.exists() {
        for e in fs::read_dir(&config.posts_dir)? {
            let p = e?.path();
            if p.extension().map(|s| s == "md").unwrap_or(false) {
                post_paths.push(p);
            }
        }
    }

    let mut posts: Vec<Post> = post_paths
        .into_par_iter()
        .map(|p| {
            let mt = fs::metadata(&p).and_then(|m| m.modified()).unwrap_or(lr);
            let raw = fs::read_to_string(&p).unwrap_or_default();
            parser::parse_frontmatter(&raw, p.file_stem().unwrap().to_str().unwrap(), mt)
        })
        .collect();

    posts.sort_by(|a, b| b.date.cmp(&a.date));

    // Generate Global Post List with correct sub-directory links
    let mut posts_html = String::from("<ul class='post-list'>\n");
    for p in &posts {
        let link_path = if config.posts_outdir.is_empty() {
            format!("{}{}.html", config.base, p.slug)
        } else {
            format!("{}{}/{}.html", config.base, config.posts_outdir, p.slug)
        };

        posts_html.push_str(&format!(
            "  <li>{} - <a href='{}'>{}</a></li>\n",
            p.date, link_path, p.title
        ));
    }
    posts_html.push_str("</ul>");

    // Step 4: Rendering and Metadata
    println!("\x1b[2m[4/4]\x1b[0m Rendering pages...");

    // Parallel render posts into the specific posts_outdir
    posts.par_iter().for_each(|p| {
        let dest = posts_out_path.join(format!("{}.html", p.slug));
        if p.mtime > lr || !dest.exists() {
            let mut vars = HashMap::new();
            let body = parser::render_markdown(&p.raw_content);
            let layout = parser::resolve_tags(&view_tpl, config, &posts_html, p, Some(&body), 0, &mut vars);
            let final_h = parser::resolve_tags(&main_tpl, config, &posts_html, p, Some(&layout), 0, &mut vars);
            fs::write(dest, final_h).ok();
        }
    });

    // Optional RSS Feed
    if config.site.generate_rss {
        if verbose { println!("\x1b[2m  generating rss\x1b[0m"); }
        let rss_xml = rss::generate_rss(&posts, config);
        fs::write(config.output_dir.join("rss.xml"), rss_xml)?;
    }

    // Optional Search Index
    if config.site.generate_search {
        if verbose { println!("\x1b[2m  indexing search content\x1b[0m"); }
        let search_index: Vec<serde_json::Value> = posts.iter().map(|p| {
            let clean_text = parser::strip_markdown(&p.raw_content);
            let snippet: String = clean_text.chars().take(140).collect();

            json!({
                "title": p.title,
                "slug": p.slug,
                "date": p.date,
                "tags": p.tags,
                "snippet": snippet
            })
        }).collect();
        
        fs::write(config.output_dir.join("search.json"), serde_json::to_string(&search_index)?)?;
    }

    // Process additional HTML pages
    if config.pages_dir.exists() {
        let mut page_paths = Vec::new();
        for e in fs::read_dir(&config.pages_dir)? {
            let p = e?.path();
            if p.extension().map(|s| s == "html").unwrap_or(false) {
                page_paths.push(p);
            }
        }
        page_paths.into_par_iter().for_each(|p| {
            let slug = p.file_stem().unwrap().to_str().unwrap();
            let raw = fs::read_to_string(&p).unwrap_or_default();
            let mt = fs::metadata(&p).and_then(|m| m.modified()).unwrap_or(lr);
            let pd = parser::parse_frontmatter(&raw, slug, mt);
            
            let mut vars = HashMap::new();
            let pb = parser::resolve_tags(&pd.raw_content, config, &posts_html, &pd, None, 0, &mut vars);
            let fh = parser::resolve_tags(&main_tpl, config, &posts_html, &pd, Some(&pb), 0, &mut vars);
            
            fs::write(config.output_dir.join(format!("{}.html", slug)), fh).ok();
        });
    }

    // Render Home Page
    let index_meta = Post {
        slug: "index".to_string(),
        title: config.site.title.clone(),
        date: "".to_string(),
        tags: vec![],
        raw_content: "<% include home.html %>".to_string(),
        mtime: SystemTime::now(),
    };
    
    let mut index_vars = HashMap::new();
    let index_body = parser::resolve_tags(&index_meta.raw_content, config, &posts_html, &index_meta, None, 0, &mut index_vars);
    let index_page = parser::resolve_tags(&main_tpl, config, &posts_html, &index_meta, Some(&index_body), 0, &mut index_vars);
    fs::write(config.output_dir.join("index.html"), index_page)?;

    // Update last run time
    *last_run_mu.lock().unwrap() = SystemTime::now();
    
    println!("\x1b[36msuccess\x1b[0m build complete.");
    println!("Done in {:.2}s.", start.elapsed().as_secs_f32());
    Ok(())
}