website-htmx-rustelo-code/crates/server/build.rs
2026-07-10 03:44:13 +01:00

1537 lines
56 KiB
Rust

//! Server build script - Uses Rustelo Foundation tools
//!
//! This build script uses rustelo_tools for all generation instead of
//! custom implementations, following the PAP-compliant approach.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
// Note: HashMap is imported locally within functions (generate_resource_contributor, etc.)
// not used at module level, so keeping it local to avoid confusion
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
/// Server configuration structure
#[derive(Deserialize, Serialize, Debug)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
pub workers: Option<usize>,
pub content_root: String,
pub public_path: String,
}
/// Smart cache for server generation
struct ServerCache {
cache_dir: PathBuf,
}
impl ServerCache {
fn new() -> Result<Self, Box<dyn std::error::Error>> {
let manifest = rustelo_utils::ManifestResolver::load()?;
let cache_dir = manifest
.root()
.join("target/site_build/devtools/build-cache/server");
fs::create_dir_all(&cache_dir)?;
Ok(Self { cache_dir })
}
fn get_content_hash(
&self,
routes_dir: &Path,
components_dir: Option<&Path>,
) -> Result<String, Box<dyn std::error::Error>> {
let mut hasher = Sha256::new();
// Hash the routes source. `routes_path()` is a stem (e.g. site/config/routes)
// whose real source is `routes.ncl` alongside it — NCL, not TOML. Hashing only
// `.toml` entries of a non-existent dir yielded an EMPTY hash → a constant cache
// key (e3b0c44…, the SHA of "") → a permanent stale CACHE HIT that never picked up
// route changes. Hash the `.ncl` file directly, plus any `.toml`/`.ncl` in a dir.
let ncl_file = routes_dir.with_extension("ncl");
if ncl_file.is_file() {
let content = fs::read_to_string(&ncl_file)?;
hasher.update(ncl_file.to_string_lossy().as_bytes());
hasher.update(content.as_bytes());
}
if routes_dir.is_dir() {
let mut route_files: Vec<_> = fs::read_dir(routes_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.is_some_and(|ext| ext == "toml" || ext == "ncl")
})
.collect();
route_files.sort_by_key(|entry| entry.path());
for entry in route_files {
let content = fs::read_to_string(entry.path())?;
hasher.update(entry.path().to_string_lossy().as_bytes());
hasher.update(content.as_bytes());
}
}
// Hash component files if provided
if let Some(comp_dir) = components_dir {
if comp_dir.exists() {
let mut comp_files: Vec<_> = std::fs::read_dir(comp_dir)?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "rs"))
.collect();
comp_files.sort_by_key(|entry| entry.path());
for entry in comp_files {
let content = fs::read_to_string(entry.path())?;
hasher.update(entry.path().to_string_lossy().as_bytes());
hasher.update(content.as_bytes());
}
}
}
let hash = hasher.finalize();
Ok(hash.iter().fold(String::new(), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
})[..16]
.to_string())
}
#[allow(dead_code)]
fn get_cache_status(&self, cache_key: &str, cache_type: &str) -> String {
let cache_file = self
.cache_dir
.join(format!("{}_{}.cache", cache_type, cache_key));
if cache_file.exists() {
"CACHE HIT".to_string()
} else {
"CACHE MISS".to_string()
}
}
fn is_cached(&self, cache_key: &str, cache_type: &str) -> bool {
let cache_file = self
.cache_dir
.join(format!("{}_{}.cache", cache_type, cache_key));
cache_file.exists()
}
fn save_cache(
&self,
cache_key: &str,
cache_type: &str,
content: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let cache_file = self
.cache_dir
.join(format!("{}_{}.cache", cache_type, cache_key));
fs::write(cache_file, content)?;
Ok(())
}
fn read_cache(
&self,
cache_key: &str,
cache_type: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let cache_file = self
.cache_dir
.join(format!("{}_{}.cache", cache_type, cache_key));
Ok(fs::read_to_string(cache_file)?)
}
}
/// Walk `site/i18n/locales/` and collect all FTL file contents keyed as
/// `{lang}_{path_underscore}` (e.g. `en_pages_home`). This matches the key
/// format expected by `get_parsed_ftl_for_language`. Replaces the legacy
/// `build_config::load_config()` approach that required `config.dev.toml`.
fn load_ftl_from_locales(project_root: &Path) -> std::collections::HashMap<String, String> {
let locales_dir = project_root.join("site").join("i18n").join("locales");
let mut ftl_files = std::collections::HashMap::new();
if !locales_dir.exists() {
println!(
"cargo:warning= ⚠️ FTL locales dir not found: {}",
locales_dir.display()
);
return ftl_files;
}
let lang_entries = match std::fs::read_dir(&locales_dir) {
Ok(r) => r,
Err(e) => {
println!("cargo:warning= ⚠️ Cannot read FTL locales dir: {}", e);
return ftl_files;
}
};
for lang_entry in lang_entries.filter_map(|e| e.ok()) {
let lang_path = lang_entry.path();
if !lang_path.is_dir() {
continue;
}
let lang = match lang_path.file_name().and_then(|n| n.to_str()) {
Some(l) => l.to_string(),
None => continue,
};
// BFS walk to collect all .ftl files under this language directory.
let mut stack = vec![lang_path.clone()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|x| x == "ftl") {
let rel = path.strip_prefix(&lang_path).unwrap_or(&path);
let key_suffix = rel
.with_extension("")
.components()
.filter_map(|c| {
if let std::path::Component::Normal(n) = c {
n.to_str().map(|s| s.to_string())
} else {
None
}
})
.collect::<Vec<_>>()
.join("_");
let key = format!("{}_{}", lang, key_suffix);
match std::fs::read_to_string(&path) {
Ok(content) => {
println!(
"cargo:warning= ✓ Loaded FTL: {} ({} bytes)",
key,
content.len()
);
println!("cargo:rerun-if-changed={}", path.display());
ftl_files.insert(key, content);
}
Err(e) => {
println!("cargo:warning= ⚠️ Cannot read {}: {}", path.display(), e);
}
}
}
}
}
}
ftl_files
}
/// Generate ResourceContributor implementation for menus, footers, themes, and FTL files.
///
/// In htmx-ssr mode, menus and footers are resolved at runtime from NCL via J2 partials.
/// The build script emits empty maps for those two, so MENU_REGISTRY stays empty and
/// the htmx shell reads routes.ncl / footer.ncl directly — no baked data, no rebuild needed
/// when config changes.
fn generate_resource_contributor(
config_path: &Path,
out_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
use std::collections::HashMap;
println!("cargo:warning=🔨 Generating ResourceContributor implementation");
println!("cargo:warning= Config path: site/config");
let manifest_root = rustelo_utils::ManifestResolver::load()
.map(|m| m.root().to_path_buf())
.unwrap_or_else(|_| config_path.parent().unwrap_or(config_path).to_path_buf());
let site_cfg = build_config::site_config::load_unified_site_config(&manifest_root)
.unwrap_or_else(|e| {
panic!(
"Cannot load site/config/index.ncl for menu generation: {e}\n\
Install Nickel: cargo install nickel-lang-cli"
)
});
// In htmx-ssr mode, nav/menu/footer are resolved at runtime from J2 partials +
// routes.ncl / footer.ncl. Baking them here would require a rebuild on every
// config change and would conflict with runtime NCL loading. Emit empty maps.
let is_htmx_ssr = site_cfg
.rendering
.as_ref()
.map(|r| r.default_profile.skips_wasm())
.unwrap_or(false);
let mut menus = HashMap::new();
if !is_htmx_ssr {
let rbac_deny_patterns =
build_config::rbac_config::load_rbac_anonymous_deny_patterns(&manifest_root)
.unwrap_or_default();
let menu_toml = site_cfg.menu_toml_unified();
let len = menu_toml.len();
menus.insert("main".to_string(), menu_toml);
println!(
"cargo:warning= ✓ Loaded unified menu from site/config/index.ncl ({} bytes)",
len
);
// Per-language header-zone entries so get_menu("en"/"es") resolves from MENU_REGISTRY.
for lang in site_cfg.languages() {
let lang_toml = site_cfg.menu_toml_for_lang_with_rbac("header", lang, &rbac_deny_patterns);
let lang_len = lang_toml.len();
menus.insert(lang.to_string(), lang_toml);
println!(
"cargo:warning= ✓ Loaded per-language menu for '{}' ({} bytes)",
lang, lang_len
);
}
} // end if !is_htmx_ssr (menus)
// Footers: empty for htmx-ssr (loaded at runtime from footer.ncl + routes.ncl)
let mut footers = HashMap::new();
if !is_htmx_ssr {
{
use serde::Serialize;
#[derive(Serialize)]
struct FooterToml {
title: String,
#[serde(rename = "company-desc")]
company_desc: String,
copyright_text: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
social_links: Vec<SocialLinkToml>,
#[serde(skip_serializing_if = "Vec::is_empty")]
menu: Vec<MenuItemToml>,
}
#[derive(Serialize)]
struct SocialLinkToml {
name: String,
url: String,
icon_class: String,
}
#[derive(Serialize)]
struct MenuItemToml {
route: String,
label: HashMap<String, String>,
}
let footer = &site_cfg.footer;
for lang in site_cfg.languages() {
let company_desc = footer
.company_desc
.get(lang.as_str())
.cloned()
.unwrap_or_default();
let copyright_text = footer
.copyright_text
.get(lang.as_str())
.cloned()
.unwrap_or_default();
let footer_menu_items = site_cfg.menu_items_for_zone("footer", lang);
let footer_data = FooterToml {
title: footer.title.clone(),
company_desc,
copyright_text,
social_links: footer
.social_links
.iter()
.map(|l| SocialLinkToml {
name: l.name.clone(),
url: l.url.clone(),
icon_class: l.icon.clone(),
})
.collect(),
menu: footer_menu_items
.iter()
.map(|item| MenuItemToml {
route: item.route.clone(),
label: {
let mut m = HashMap::new();
m.insert(lang.to_string(), item.label.clone());
m
},
})
.collect(),
};
let toml_str = toml::to_string(&footer_data)?;
let len = toml_str.len();
footers.insert(lang.to_string(), toml_str);
println!("cargo:warning= ✓ Generated footer for '{lang}' ({len} bytes, from site/config/index.ncl)");
}
}
} // end if !is_htmx_ssr (footers)
// Load themes from config/themes/*.toml
let mut themes = HashMap::new();
let themes_dir = config_path.join("themes");
println!("cargo:warning= Looking for themes in: site/config/themes");
if themes_dir.exists() {
for entry in fs::read_dir(&themes_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "toml") {
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
let content = fs::read_to_string(&path)?;
let content_len = content.len();
themes.insert(filename.to_string(), content);
println!(
"cargo:warning= ✓ Loaded theme: {} ({} bytes)",
filename, content_len
);
}
}
}
}
// Routes are baked even in htmx-ssr mode because the runtime fallback
// (rustelo_core_lib::routing::load_routes_from_directory) only knows how to
// read TOML files from a site/config/routes/ subdirectory, which this project
// does not have — routes live in site/config/index.ncl (NCL).
// TODO(rustelo): add a runtime NCL→routes path in rustelo_core_lib so that
// htmx-ssr can skip this step and pick up route changes without a rebuild.
let mut routes = HashMap::new();
{
use build_config::site_config::RoutesConfig;
let expanded = site_cfg.routes_expanded(); // HashMap<lang, Vec<RouteConfigToml>>
for (lang, route_vec) in expanded {
let cfg = RoutesConfig { routes: route_vec };
match toml::to_string(&cfg) {
Ok(toml_str) => {
let len = toml_str.len();
println!(
"cargo:warning= ✓ Generated routes for '{lang}' \
({len} bytes, from site/config/index.ncl)"
);
routes.insert(lang, toml_str);
}
Err(e) => {
println!("cargo:warning= ⚠️ Failed to serialize routes for '{lang}': {e}");
}
}
}
}
// htmx-ssr: skip build-time FTL baking — rustelo_core_lib now walks
// site/i18n/locales/ recursively at runtime via load_ftl_from_config.
// Translation changes take effect without a rebuild.
let ftl_files = if !is_htmx_ssr {
println!("cargo:warning= Looking for FTL files in: site/i18n/locales");
load_ftl_from_locales(&manifest_root)
} else {
println!("cargo:warning= htmx-ssr: FTL deferred to runtime (site/i18n/locales)");
std::collections::HashMap::new()
};
// Delegate code generation to rustelo_tools
rustelo_tools::build::generate_resource_contributor_code(
"WebsiteResourceContributor",
&menus,
&footers,
&themes,
&ftl_files,
&routes,
out_dir,
)?;
// Generate server_theme_constants.rs — same pattern as client's theme_constants.rs
// Single source of truth: site/config/index.ncl [logo]
let logo = &site_cfg.logo;
let width_const = match &logo.width {
Some(w) => format!("Some(\"{}\")", w),
None => "None".to_string(),
};
let height_const = match &logo.height {
Some(h) => format!("Some(\"{}\")", h),
None => "None".to_string(),
};
let server_theme = format!(
r#"/// Theme configuration constants for SSR server
/// Generated at build time from site/config/index.ncl [logo]
#[allow(clippy::module_inception)]
pub mod theme {{
pub const DEFAULT_THEME_NAME: &str = "default";
/// Logo configuration — single source of truth: site/config/index.ncl
pub mod logo {{
pub const LIGHT_SRC: &str = "{}";
pub const DARK_SRC: &str = "{}";
pub const ALT: &str = "{}";
pub const SHOW_IN_NAV: bool = {};
pub const SHOW_IN_FOOTER: bool = {};
pub const WIDTH: Option<&str> = {};
pub const HEIGHT: Option<&str> = {};
}}
}}
"#,
logo.light_src,
logo.dark_src,
logo.alt,
logo.show_in_nav,
logo.show_in_footer,
width_const,
height_const
);
let server_theme_file = out_dir.join("server_theme_constants.rs");
fs::write(&server_theme_file, server_theme)?;
println!(
"cargo:warning= ✓ Logo config generated from site/config/index.ncl → server_theme_constants.rs"
);
Ok(())
}
/// Emit three scope-specific aggregator files consumed by server/src/lib.rs.
///
/// `include!()` expands into the surrounding item context — so top-level,
/// `pub mod generated {}`, and `pub mod config_constants {}` each need their
/// own aggregator file; a single flat file cannot span module scopes.
fn generate_server_includes_aggregator(
top_level: &[&str],
generated_mod: &[&str],
config_mod: &[&str],
out_dir: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let header = "// Auto-generated aggregator — do not edit directly.\n";
let write_scope = |names: &[&str], filename: &str| -> Result<(), Box<dyn std::error::Error>> {
let mut content = header.to_string();
for name in names {
content.push_str(&format!(
"include!(concat!(env!(\"OUT_DIR\"), \"/{}\"));\n",
name
));
}
std::fs::write(std::path::Path::new(out_dir).join(filename), content)?;
Ok(())
};
write_scope(top_level, "server_top_includes.rs")?;
write_scope(generated_mod, "server_generated_mod_includes.rs")?;
write_scope(config_mod, "server_config_mod_includes.rs")?;
Ok(())
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Use new PAP-compliant manifest system
let manifest = rustelo_utils::get_manifest()?;
let content_path = rustelo_utils::manifest_content_path();
let config_path = rustelo_utils::manifest_config_path();
println!("cargo:rerun-if-changed={}/config", content_path.display());
println!("cargo:rerun-if-changed={}", config_path.display());
println!("cargo:rerun-if-changed=src/");
println!("cargo:rerun-if-changed={}/content", content_path.display());
println!("cargo:rerun-if-changed={}", content_path.display());
let out_dir = env::var("OUT_DIR")?;
// config_constants module: NCL-sourced files (local generator — owns its artifact names)
let ncl_consts = generate_config_constants()?;
// ResourceContributor (not included in lib.rs — build artifact only)
generate_resource_contributor(&config_path, Path::new(&out_dir))?;
// Initialize cache for server builds
let cache = ServerCache::new()?;
// Top-level: server config + shell asset paths
let server_cfg = generate_server_config()?;
let shell_assets = generate_shell_asset_paths(manifest)?;
// config_constants module: env-var helpers (external tool — filename declared here as the
// single point of knowledge between the call site and the aggregator)
const TOOLS_CONFIG: &str = "config_constants.rs";
rustelo_tools::build::config_constants::generate_config_constants(&out_dir)?;
// config_constants module: content types from site/config/index.ncl [content_types]
let content_types_const = generate_content_types_constants(manifest)?;
// generated module: RouteComponent enum from site/config/index.ncl
let route_handlers = generate_route_handlers_with_tools()?;
// resource_registry.rs path notification (build artifact only, not included in lib.rs)
let resource_registry_path = Path::new(&out_dir).join("resource_registry.rs");
if resource_registry_path.exists() {
println!(
"cargo:rustc-env=RUSTELO_RESOURCE_REGISTRY_PATH={}",
resource_registry_path.display()
);
println!("cargo:warning=✅ Set RUSTELO_RESOURCE_REGISTRY_PATH for rustelo_core_lib");
}
// route_table.rs (not included in lib.rs — build artifact only)
{
let manifest_dir = manifest.root();
let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir)
.unwrap_or_else(|e| {
panic!(
"Cannot load site/config/index.ncl for route table: {e}\n\
Install Nickel: cargo install nickel-lang-cli"
)
});
let mut routes = std::collections::BTreeMap::new();
for (_lang, route_list) in site_cfg.routes_expanded() {
for route in route_list {
if route.path.starts_with('/') {
routes.insert(route.path, route.component);
}
}
}
println!(
"cargo:warning=Server: Route table built from site/config/index.ncl ({} entries)",
routes.len()
);
rustelo_tools::build::generate_route_table(&routes, Path::new(&out_dir))?;
}
// Top-level: server routing (page dispatch + full-path OnceLock lookup)
const SERVER_ROUTING: &str = "server_routing.rs";
{
let manifest_dir = manifest.root();
let cache_build_path = manifest.manifest().build.cache_build_path.clone();
let pages_cache_dir = manifest_dir
.join("target")
.join(&cache_build_path)
.join("pages");
let crate_relative = format!("../../target/{}/pages", cache_build_path);
let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir)
.unwrap_or_else(|e| {
panic!(
"Cannot load site/config/index.ncl for page provider: {e}\n\
Install Nickel: cargo install nickel-lang-cli"
)
});
let mut route_mappings = std::collections::HashMap::new();
let mut path_to_content_type = std::collections::HashMap::new();
for (_lang, route_list) in site_cfg.routes_expanded() {
for route in route_list {
if !route.path.starts_with('/') {
continue;
}
route_mappings
.entry(route.path.clone())
.or_insert_with(|| route.component.clone());
if let Some(ct) = route.content_type {
path_to_content_type.entry(route.path).or_insert(ct);
}
}
}
println!(
"cargo:warning=Server: Loaded {} route mappings from configuration",
route_mappings.len()
);
rustelo_tools::build::generate_server_routing(
&pages_cache_dir,
&crate_relative,
&route_mappings,
&path_to_content_type,
Path::new(&out_dir),
)?;
}
let server_routing = SERVER_ROUTING;
generate_server_documentation(manifest, &cache)?;
create_code_review_copy()?;
// Emit the 3 scope-specific aggregator files consumed by server/src/lib.rs.
// Ordering within each scope matches the original include!() order in lib.rs.
generate_server_includes_aggregator(
&[server_cfg, shell_assets, server_routing],
&[route_handlers],
&[TOOLS_CONFIG, ncl_consts[0], ncl_consts[1], content_types_const],
&out_dir,
)?;
println!("cargo:warning=Server build completed (tools + server-specific)");
Ok(())
}
/// Convert a component name like "HomePage" to its i18n key prefix "homepage-page".
fn component_to_key_prefix(component: &str) -> String {
format!("{}-page", component.to_lowercase())
}
/// Generate `generated_routes.rs` directly from site/config/index.ncl.
///
/// Replaces the `rustelo_tools::generate_route_components` call which required
/// `site/config/routes/*.toml` to exist on disk. With NCL as the single source
/// of truth, we derive the `RouteComponent` enum from the already-loaded config.
fn generate_route_handlers_with_tools() -> Result<&'static str, Box<dyn std::error::Error>> {
const OUTPUT: &str = "generated_routes.rs";
println!("cargo:warning=🚀 Generating RouteComponent enum from site/config/index.ncl");
let out_dir = env::var("OUT_DIR")?;
let manifest = rustelo_utils::ManifestResolver::load()?;
let manifest_dir = manifest.root();
let site_cfg = build_config::site_config::load_unified_site_config(manifest_dir)
.unwrap_or_else(|e| {
panic!(
"Cannot load site/config/index.ncl for route components: {e}\n\
Install Nickel: cargo install nickel-lang-cli"
)
});
// Collect unique component names in deterministic order.
// NotFound is always required as the wildcard arm.
let mut components: std::collections::BTreeSet<String> = site_cfg
.routes
.iter()
.map(|r| r.component.clone())
.collect();
components.insert("NotFound".to_string());
let output_path = std::path::Path::new(&out_dir).join(OUTPUT);
let mut code = String::new();
code.push_str("// Auto-generated route component definitions\n");
code.push_str("// Regenerated at build time from site/config/index.ncl — do not edit\n\n");
code.push_str("use serde::{Deserialize, Serialize};\n\n");
code.push_str("#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]\n");
code.push_str("pub enum RouteComponent {\n");
for c in &components {
code.push_str(&format!(" {},\n", c));
}
code.push_str("}\n\n");
code.push_str("impl RouteComponent {\n");
// from_str (inherent, mirrors trait below)
code.push_str(" #[allow(clippy::should_implement_trait)]\n");
code.push_str(" pub fn from_str(component: &str) -> Self {\n");
code.push_str(" match component {\n");
for c in &components {
if c != "NotFound" {
code.push_str(&format!(
" \"{}\" => RouteComponent::{},\n",
c, c
));
}
}
code.push_str(" _ => RouteComponent::NotFound,\n");
code.push_str(" }\n }\n\n");
// as_str
code.push_str(" pub fn as_str(&self) -> &'static str {\n");
code.push_str(" match self {\n");
for c in &components {
code.push_str(&format!(
" RouteComponent::{} => \"{}\",\n",
c, c
));
}
code.push_str(" }\n }\n\n");
// title_key / description_key / keywords_key
for suffix in &["title", "description", "keywords"] {
code.push_str(&format!(
" pub fn {}_key(&self) -> &'static str {{\n match self {{\n",
suffix
));
for c in &components {
code.push_str(&format!(
" RouteComponent::{} => \"{}-{}\",\n",
c,
component_to_key_prefix(c),
suffix
));
}
code.push_str(" }\n }\n\n");
}
// is_multilingual
code.push_str(
" pub fn is_multilingual(&self) -> bool {\n \
!matches!(self, RouteComponent::NotFound)\n }\n}\n\n",
);
// std::str::FromStr
code.push_str("impl std::str::FromStr for RouteComponent {\n");
code.push_str(" type Err = String;\n");
code.push_str(" fn from_str(s: &str) -> Result<Self, Self::Err> {\n");
code.push_str(" let component = match s {\n");
for c in &components {
if c != "NotFound" {
code.push_str(&format!(
" \"{}\" => RouteComponent::{},\n",
c, c
));
}
}
code.push_str(" _ => return Err(format!(\"Unknown route component: {}\", s)),\n");
code.push_str(" };\n Ok(component)\n }\n}\n\n");
code.push_str("// Trait-based rendering — no macro generation needed\n");
fs::write(&output_path, &code)?;
println!(
"cargo:warning=✅ RouteComponent enum written to generated_routes.rs ({} variants from site/config/index.ncl)",
components.len()
);
Ok(OUTPUT)
}
/// Generate `shell_asset_paths.rs` — CSS/JS path slices from `site/config/assets.ncl`.
///
/// Produces `source_mode::{CSS, JS}` and `bundled_mode::{CSS, JS}` static slices
/// consumed by `shell.rs`. Adding or removing stylesheets requires only editing
/// `site/config/assets.ncl` — no Rust changes needed.
fn generate_shell_asset_paths(
manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
const OUTPUT: &str = "shell_asset_paths.rs";
let site_cfg = build_config::site_config::load_unified_site_config(manifest_resolver.root())
.unwrap_or_else(|e| {
panic!(
"Cannot load site/config/index.ncl for shell assets: {e}\n\
Install Nickel: cargo install nickel-lang-cli"
)
});
let fmt_slice = |paths: &[String]| -> String {
paths
.iter()
.map(|p| format!(" \"{}\",\n", p))
.collect::<String>()
};
let code = format!(
r#"// Auto-generated shell asset paths — sourced from site/config/assets.ncl
// DO NOT EDIT — regenerated on every build.
pub mod source_mode {{
pub static CSS: &[&str] = &[
{src_css} ];
pub static JS: &[&str] = &[
{src_js} ];
}}
pub mod bundled_mode {{
pub static CSS: &[&str] = &[
{bnd_css} ];
pub static JS: &[&str] = &[
{bnd_js} ];
}}
pub mod htmx_mode {{
pub static CSS: &[&str] = &[
{htmx_css} ];
}}
"#,
src_css = fmt_slice(&site_cfg.assets.source_css),
src_js = fmt_slice(&site_cfg.assets.source_js),
bnd_css = fmt_slice(&site_cfg.assets.bundled_css),
bnd_js = fmt_slice(&site_cfg.assets.bundled_js),
htmx_css = fmt_slice(&site_cfg.assets.htmx_css),
);
let out_dir = env::var("OUT_DIR")?;
fs::write(Path::new(&out_dir).join(OUTPUT), code)?;
println!("cargo:rerun-if-changed=site/config/assets.ncl");
println!("cargo:warning=✅ Shell asset paths written to OUT_DIR/shell_asset_paths.rs");
Ok(OUTPUT)
}
fn generate_server_config() -> Result<&'static str, Box<dyn std::error::Error>> {
const OUTPUT: &str = "server_config.rs";
let out_dir = env::var("OUT_DIR")?;
let server_config_content = r#"
// Auto-generated server configuration
// This is a default configuration to prevent build errors
pub const DEFAULT_HOST: &str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 3030;
pub const DEFAULT_WORKERS: usize = 4;
#[derive(Debug, Clone)]
pub struct ServerConfigData {
pub host: String,
pub port: u16,
pub workers: usize,
pub content_root: String,
pub public_path: String,
}
impl Default for ServerConfigData {
fn default() -> Self {
Self {
host: DEFAULT_HOST.to_string(),
port: DEFAULT_PORT,
workers: DEFAULT_WORKERS,
content_root: "../site".to_string(),
public_path: "public".to_string(),
}
}
}
pub fn get_server_config() -> ServerConfigData {
ServerConfigData::default()
}
"#;
let server_config_file = Path::new(&out_dir).join(OUTPUT);
fs::write(server_config_file, server_config_content)?;
Ok(OUTPUT)
}
fn generate_config_constants() -> Result<[&'static str; 2], Box<dyn std::error::Error>> {
const NCL_DB: &str = "ncl_database_constants.rs";
const NCL_PATHS: &str = "ncl_path_constants.rs";
let out_dir = env::var("OUT_DIR")?;
// Load NCL database config — non-fatal if NCL is unavailable (e.g. CI without nickel).
let config_path = env::var("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
let manifest_root = rustelo_utils::ManifestResolver::load()
.map(|m| m.root().to_path_buf())
.unwrap_or_else(|_| {
config_path
.parent()
.unwrap_or(config_path.as_path())
.to_path_buf()
});
let ncl_cfg = build_config::site_config::load_unified_site_config(&manifest_root).ok();
let ncl_db = ncl_cfg.as_ref().map(|cfg| &cfg.database);
let ncl_db_url = ncl_db
.map(|d| d.url.as_str())
.unwrap_or("sqlite:data/dev_database.db");
let ncl_db_create = ncl_db.map(|d| d.create_if_missing).unwrap_or(true);
let ncl_db_max_conn = ncl_db.map(|d| d.max_connections).unwrap_or(5);
let ncl_db_min_conn = ncl_db.map(|d| d.min_connections).unwrap_or(1);
let ncl_db_connect_timeout = ncl_db.map(|d| d.connect_timeout).unwrap_or(30);
let ncl_db_idle_timeout = ncl_db.map(|d| d.idle_timeout).unwrap_or(600);
let ncl_db_max_lifetime = ncl_db.map(|d| d.max_lifetime).unwrap_or(1800);
// Write NCL database constants to a separate file.
// config_constants.rs (env-var helpers) is generated separately via
// rustelo_tools::build::config_constants::generate_config_constants().
let ncl_db_content = format!(
r#"// Auto-generated NCL database constants — sourced from site/config/index.ncl [database]
// DO NOT EDIT — regenerated on every build.
// .env DATABASE_URL takes priority over NCL_DATABASE_URL at runtime.
pub const NCL_DATABASE_URL: &str = "{}";
pub const NCL_DATABASE_CREATE_IF_MISSING: bool = {};
pub const NCL_DATABASE_MAX_CONNECTIONS: u32 = {};
pub const NCL_DATABASE_MIN_CONNECTIONS: u32 = {};
pub const NCL_DATABASE_CONNECT_TIMEOUT: u64 = {};
pub const NCL_DATABASE_IDLE_TIMEOUT: u64 = {};
pub const NCL_DATABASE_MAX_LIFETIME: u64 = {};
"#,
ncl_db_url,
ncl_db_create,
ncl_db_max_conn,
ncl_db_min_conn,
ncl_db_connect_timeout,
ncl_db_idle_timeout,
ncl_db_max_lifetime,
);
let ncl_db_file = Path::new(&out_dir).join(NCL_DB);
fs::write(&ncl_db_file, ncl_db_content)?;
println!("cargo:warning=✅ NCL database constants written to OUT_DIR/ncl_database_constants.rs (url={ncl_db_url})");
// Write NCL path constants — sourced from site/config/index.ncl [paths].
let ncl_paths = ncl_cfg.as_ref().map(|cfg| &cfg.paths);
let migrations_path = ncl_paths
.and_then(|p| p.migrations.as_deref())
.unwrap_or("rustelo/migrations");
let email_templates_path = ncl_paths
.and_then(|p| p.email_templates.as_deref())
.unwrap_or("email_templates");
let ncl_paths_content = format!(
r#"// Auto-generated NCL path constants — sourced from site/config/index.ncl [paths]
// DO NOT EDIT — regenerated on every build.
// Values are project-root-relative paths as declared in config.ncl.
pub const SITE_MIGRATIONS_PATH: &str = "{}";
pub const SITE_EMAIL_TEMPLATES_PATH: &str = "{}";
"#,
migrations_path, email_templates_path,
);
let ncl_paths_file = Path::new(&out_dir).join(NCL_PATHS);
fs::write(&ncl_paths_file, ncl_paths_content)?;
println!("cargo:rerun-if-changed=site/config/index.ncl");
println!("cargo:rerun-if-changed=site/rbac.ncl");
println!(
"cargo:warning=✅ NCL path constants written \
(migrations={migrations_path}, email_templates={email_templates_path})"
);
Ok([NCL_DB, NCL_PATHS])
}
/// Generate content type constants from site/config/index.ncl [content_types].
///
/// Produces `pub const CONTENT_TYPES: &[(&str, &str)]` included in the
/// `config_constants` module. At runtime, `resources::register_content_kinds()`
/// iterates the slice and builds `ContentConfig` entries — no TOML I/O needed.
fn generate_content_types_constants(
manifest_resolver: &rustelo_utils::ManifestResolver,
) -> Result<&'static str, Box<dyn std::error::Error>> {
const OUTPUT: &str = "ncl_content_types.rs";
let out_dir = env::var("OUT_DIR")?;
let site_cfg =
build_config::site_config::load_unified_site_config(manifest_resolver.root())?;
let entries: String = site_cfg
.content_types
.iter()
.filter(|ct| ct.enabled)
.map(|ct| format!(" (\"{}\", \"{}\"),\n", ct.name, ct.directory))
.collect();
let content = format!(
r#"// Auto-generated — sourced from site/config/index.ncl [content_types]. DO NOT EDIT.
/// Enabled content types as (name, directory) pairs.
/// Consumed by `resources::register_content_kinds()` at server startup.
pub const CONTENT_TYPES: &[(&str, &str)] = &[
{}];"#,
entries
);
fs::write(Path::new(&out_dir).join(OUTPUT), content)?;
println!("cargo:warning=Generated content type constants ({} types)", site_cfg.content_types.iter().filter(|ct| ct.enabled).count());
Ok(OUTPUT)
}
/// Generate server documentation using rustelo_tools
fn generate_server_documentation(
manifest: &rustelo_utils::ManifestResolver,
_cache: &ServerCache,
) -> Result<(), Box<dyn std::error::Error>> {
// Create info directory
let info_path = manifest.root().join("site/info");
std::fs::create_dir_all(&info_path)?;
// Use rustelo_tools to generate comprehensive server analysis
let project_root = manifest.root().to_string_lossy();
// Try to generate server documentation, but handle the array conversion error gracefully
if let Err(e) = rustelo_tools::comprehensive_analysis::generate_server_documentation(
&project_root,
&info_path.to_string_lossy(),
) {
// Log the specific error for debugging but continue the build
println!(
"cargo:warning=Server documentation generation failed ({}), continuing build",
e
);
// Create a basic server analysis file as fallback
let server_analysis_path = info_path.join("server_analysis.md");
let basic_analysis = format!(
"# Server Analysis\n\n\
Generated at: {}\n\
Project root: {}\n\n\
## Build Status\n\
- Route metadata: ✅ Generated\n\
- Server config: ✅ Generated\n\
- Comprehensive analysis: ❌ Failed due to array conversion\n\n\
## Notes\n\
The comprehensive server analysis failed due to TOML array conversion issues.\n\
Route metadata and basic server configuration were generated successfully.\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
project_root
);
std::fs::write(server_analysis_path, basic_analysis)?;
} else {
println!("cargo:warning=Server documentation generated successfully");
}
// Generate route metadata in data format similar to p-website
generate_route_metadata(manifest)?;
println!("cargo:warning=Generated server documentation in site/info/");
Ok(())
}
/// Generate route metadata (similar to p-website's data.toml)
fn generate_route_metadata(
manifest: &rustelo_utils::ManifestResolver,
) -> Result<(), Box<dyn std::error::Error>> {
let routes_dir = rustelo_utils::routes_path();
let info_path = manifest.root().join("site/info");
// Initialize cache for route metadata
let cache = ServerCache::new()?;
let components_dir = manifest.root().join("crates/client/src/components");
let cache_key = cache.get_content_hash(&routes_dir, Some(&components_dir))?;
let cache_status = if cache.is_cached(&cache_key, "metadata_json")
&& cache.is_cached(&cache_key, "metadata_toml")
{
"CACHE HIT".to_string()
} else {
"CACHE MISS".to_string()
};
println!(
"cargo:warning=ServerCache metadata: {} (hash: {})",
cache_status, cache_key
);
// Try to use cached metadata
if cache.is_cached(&cache_key, "metadata_json") && cache.is_cached(&cache_key, "metadata_toml")
{
let cached_json = cache.read_cache(&cache_key, "metadata_json")?;
let cached_toml = cache.read_cache(&cache_key, "metadata_toml")?;
fs::create_dir_all(&info_path)?;
fs::write(info_path.join("data.json"), cached_json)?;
fs::write(info_path.join("data.toml"), cached_toml)?;
println!("cargo:warning=✅ Using cached route metadata (CACHE HIT)");
return Ok(());
}
println!("cargo:warning=🔄 Generating route metadata (CACHE MISS)");
let mut route_data = serde_json::json!({
"page_routes": [],
"api_routes": [],
"components": [],
"generated_at": chrono::Utc::now().to_rfc3339()
});
if routes_dir.exists() {
// Use typed route loading (NCL-first, TOML-fallback)
match build_config::route_config::load_all_routes(&routes_dir) {
Ok(routes_by_lang) => {
for (lang, routes) in routes_by_lang {
for route in routes {
// Only include internal routes
if !route.is_external.unwrap_or(false) && route.path.starts_with('/') {
let route_info = serde_json::json!({
"path": route.path,
"component": route.component,
"language": lang,
"enabled": route.enabled,
"handler": format!("handle_{}_{}", lang, route.component.to_lowercase())
});
if let Some(arr) = route_data["page_routes"].as_array_mut() {
arr.push(route_info);
}
}
}
}
}
Err(e) => {
println!("cargo:warning=Failed to load routes for metadata: {}", e);
// Continue without route metadata
}
}
}
// Write JSON format
let json_content = serde_json::to_string_pretty(&route_data)?;
let json_path = info_path.join("data.json");
std::fs::write(&json_path, &json_content)?;
// Write TOML format - manually format since JSON to TOML conversion is problematic
let mut toml_content = String::new();
toml_content.push_str("# Route metadata generated at build time\n\n");
if let Some(generated_at) = route_data["generated_at"].as_str() {
toml_content.push_str(&format!("generated_at = \"{}\"\n", generated_at));
}
toml_content.push_str("components = []\n\n");
toml_content.push_str("[[page_routes]]\n");
// Write a simplified TOML format for compatibility
if let Some(routes) = route_data["page_routes"].as_array() {
for (i, route) in routes.iter().enumerate() {
if i > 0 {
toml_content.push_str("\n[[page_routes]]\n");
}
if let Some(path) = route.get("path").and_then(|p| p.as_str()) {
toml_content.push_str(&format!("path = \"{}\"\n", path));
}
if let Some(component) = route.get("component").and_then(|c| c.as_str()) {
toml_content.push_str(&format!("component = \"{}\"\n", component));
}
if let Some(language) = route.get("language").and_then(|l| l.as_str()) {
toml_content.push_str(&format!("language = \"{}\"\n", language));
}
if let Some(enabled) = route.get("enabled").and_then(|e| e.as_bool()) {
toml_content.push_str(&format!("enabled = {}\n", enabled));
}
}
}
let toml_path = info_path.join("data.toml");
std::fs::write(&toml_path, &toml_content)?;
// Save to cache for future builds
cache.save_cache(&cache_key, "metadata_json", &json_content)?;
cache.save_cache(&cache_key, "metadata_toml", &toml_content)?;
println!("cargo:warning=✅ Route metadata generated and cached");
Ok(())
}
/// Copy the generated resource registry to rustelo_core_lib's OUT_DIR
#[allow(dead_code)]
fn copy_resource_registry_to_core_lib() -> Result<(), Box<dyn std::error::Error>> {
// Find the resource registry in our OUT_DIR
let our_out_dir = env::var("OUT_DIR")?;
let resource_registry_path = Path::new(&our_out_dir).join("resource_registry.rs");
if !resource_registry_path.exists() {
println!(
"cargo:warning=Resource registry not found at {}",
resource_registry_path.display()
);
return Ok(());
}
// Find the target directory
let target_dir = Path::new(&our_out_dir)
.ancestors()
.find(|p| p.file_name().is_some_and(|name| name == "build"))
.and_then(|build_dir| build_dir.parent())
.ok_or("Could not find target directory")?;
let mut copied_count = 0;
// Copy to all possible target directories (server and client/WASM)
let target_paths = vec![
target_dir.join("debug/build"), // Server target
target_dir.join("front/wasm32-unknown-unknown/debug/build"), // Client/WASM target
];
let content = std::fs::read_to_string(&resource_registry_path)?;
for build_dir in target_paths {
if !build_dir.exists() {
continue;
}
// Look for rustelo_core_lib build directories in this target
if let Ok(entries) = std::fs::read_dir(&build_dir) {
for entry in entries.flatten() {
if entry
.file_name()
.to_string_lossy()
.starts_with("rustelo_core_lib-")
{
let core_lib_out_dir = entry.path().join("out");
let dest_path = core_lib_out_dir.join("resource_registry.rs");
if let Ok(()) = std::fs::create_dir_all(&core_lib_out_dir) {
if let Ok(()) = std::fs::write(&dest_path, &content) {
println!(
"cargo:warning=✅ Copied resource registry to rustelo_core_lib: {}",
dest_path.display()
);
copied_count += 1;
}
}
}
}
}
}
// NOTE: Removed rustc-cfg=has_generated_resources in Phase 2.11
// The registration system no longer requires conditional compilation
// Resources are registered dynamically at startup via resources::initialize()
if copied_count > 0 {
// NOTE: RUSTELO_HAS_GENERATED_RESOURCES env var kept for backward compatibility
// but no longer used by the registration system
println!("cargo:rustc-env=RUSTELO_HAS_GENERATED_RESOURCES=1");
println!(
"cargo:warning=📋 Successfully copied resource registry to {} locations",
copied_count
);
} else {
println!("cargo:warning=⚠️ No rustelo_core_lib build directories found to copy to");
println!("cargo:warning=✅ But resource_registry.rs is generated for server use");
}
Ok(())
}
/// Create a clean copy of all generated files for code review
/// Removes cache core path and creates fresh copy in works-pv/out/
fn create_code_review_copy() -> Result<(), Box<dyn std::error::Error>> {
let manifest = rustelo_utils::get_manifest()?;
let root_dir = manifest.root();
// Define out directory for code review (ephemeral, in works-pv/)
let out_dir = root_dir.join("works-pv/out");
// Remove existing out directory if it exists
if out_dir.exists() {
std::fs::remove_dir_all(&out_dir)?;
println!("cargo:warning=🧹 Removed existing works-pv/out/ directory");
}
// Create fresh out directory structure
std::fs::create_dir_all(&out_dir)?;
let generated_dir = out_dir.join("generated");
let cache_dir = out_dir.join("cache-snapshot");
std::fs::create_dir_all(&generated_dir)?;
std::fs::create_dir_all(&cache_dir)?;
println!("cargo:warning=📁 Created fresh works-pv/out/ directory structure");
// Copy generated files from BUILD_OUT_DIR
let build_out_dir = env::var("OUT_DIR")?;
copy_build_generated_files(&build_out_dir, &generated_dir)?;
// Copy files from rustelo-cache if it exists
copy_cache_files(root_dir, &cache_dir)?;
// Copy any other generated files from target/
copy_target_generated_files(root_dir, &out_dir)?;
// Create summary file
create_code_review_summary(&out_dir)?;
println!("cargo:warning=✅ Code review copy created at: works-pv/out/");
Ok(())
}
/// Copy generated files from build OUT_DIR
fn copy_build_generated_files(
build_out_dir: &str,
dest_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let build_out_path = Path::new(build_out_dir);
if !build_out_path.exists() {
return Ok(());
}
// List of generated files to copy
let generated_files = [
"resource_registry.rs",
"generated_routes.rs",
"route_handlers.rs",
"embedded_routes.toml",
"config_constants.rs",
"content_handlers.rs",
"content_kinds_generated.rs",
"server_config.rs",
];
for file_name in &generated_files {
let src_path = build_out_path.join(file_name);
if src_path.exists() {
let dest_path = dest_dir.join(file_name);
std::fs::copy(&src_path, &dest_path)?;
println!("cargo:warning=📄 Copied {}", file_name);
}
}
// Copy generated_pages directory if it exists
let generated_pages_src = build_out_path.join("generated_pages");
if generated_pages_src.exists() {
let generated_pages_dest = dest_dir.join("generated_pages");
copy_directory(&generated_pages_src, &generated_pages_dest)?;
println!("cargo:warning=📁 Copied generated_pages/ directory");
}
Ok(())
}
/// Copy files from rustelo-cache directories
fn copy_cache_files(root_dir: &Path, dest_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
// Look for rustelo-cache in various locations
let cache_locations = [
root_dir.join("target/rustelo-cache"),
root_dir.join("target/site_build"),
root_dir.join("cache"),
];
for cache_location in &cache_locations {
if cache_location.exists() {
let cache_name = cache_location
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("cache"))
.to_string_lossy();
let dest_cache_dir = dest_dir.join(&*cache_name);
copy_directory(cache_location, &dest_cache_dir)?;
println!("cargo:warning=💾 Copied cache: {}", cache_name);
}
}
Ok(())
}
/// Copy other relevant generated files from target/
fn copy_target_generated_files(
root_dir: &Path,
dest_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let target_dir = root_dir.join("target");
if !target_dir.exists() {
return Ok(());
}
let manifest_dir = dest_dir.join("build-manifests");
std::fs::create_dir_all(&manifest_dir)?;
// Copy any .toml files that might be build-generated
if let Ok(entries) = std::fs::read_dir(&target_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "toml"
&& path
.file_name()
.map(|f| f.to_string_lossy().contains("embedded"))
.unwrap_or(false)
{
if let Some(file_name) = path.file_name() {
let dest_path = manifest_dir.join(file_name);
if std::fs::copy(&path, &dest_path).is_ok() {
println!(
"cargo:warning=📋 Copied manifest: {}",
file_name.to_string_lossy()
);
}
}
}
}
}
}
Ok(())
}
/// Recursively copy a directory
fn copy_directory(src: &Path, dest: &Path) -> Result<(), Box<dyn std::error::Error>> {
if !src.exists() {
return Ok(());
}
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if src_path.is_dir() {
copy_directory(&src_path, &dest_path)?;
} else {
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
/// Create a summary file for code review
fn create_code_review_summary(out_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
let summary_content = format!(
r#"# Build Generated Files - Code Review
Generated at: {}
Build system: Rustelo website implementation
## Directory Structure
- `generated/` - Files generated during build process
- `resource_registry.rs` - Auto-generated resource registry
- `generated_routes.rs` - Route handlers
- `route_handlers.rs` - Route implementations
- `embedded_routes.toml` - Route configuration
- `config_constants.rs` - Configuration constants
- `content_handlers.rs` - Content handling code
- `content_kinds_generated.rs` - Content type definitions
- `server_config.rs` - Server configuration
- `generated_pages/` - Individual page components
- `cache-snapshot/` - Snapshot of build caches
- `rustelo-cache/` - Rustelo smart caching system
- `site_build/` - Site build cache
- `build-manifests/` - Build-time manifests and metadata
## Build Process
1. **Resource Discovery**: Scans site/ directory for configuration files
2. **Resource Generation**: Creates resource registry with all assets
3. **Route Generation**: Generates route handlers from configuration
4. **Caching**: Implements smart caching for faster rebuilds
5. **Code Review Copy**: This clean snapshot for review
## Key Features
- **Configuration-driven**: No hardcoded paths or routes
- **Language-agnostic**: Supports multiple languages dynamically
- **Smart caching**: Incremental builds with hash-based invalidation
- **PAP compliant**: Follows Pattern-Aligned Programming principles
## Files for Review
All generated files maintain the configuration-driven architecture.
No anti-PAP fallbacks or hardcoded paths should be present.
Generated on: {}
"#,
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
);
let summary_path = out_dir.join("README.md");
std::fs::write(summary_path, summary_content)?;
// Also create a simple file list
let mut file_list = String::new();
file_list.push_str("# Generated Files List\n\n");
collect_file_list(out_dir, &mut file_list, 0)?;
let file_list_path = out_dir.join("file-list.txt");
std::fs::write(file_list_path, file_list)?;
Ok(())
}
/// Recursively collect file list for summary
fn collect_file_list(
dir: &Path,
output: &mut String,
depth: usize,
) -> Result<(), Box<dyn std::error::Error>> {
if !dir.exists() {
return Ok(());
}
let indent = " ".repeat(depth);
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
output.push_str(&format!("{}📁 {}/\n", indent, name));
collect_file_list(&path, output, depth + 1)?;
} else {
let size = std::fs::metadata(&path)?.len();
output.push_str(&format!("{}📄 {} ({} bytes)\n", indent, name, size));
}
}
Ok(())
}