@@ -26,9 +26,15 @@ use std::process;
2626use anyhow:: Result ;
2727use clap:: { Parser , Subcommand } ;
2828use console:: style;
29+ use include_dir:: { include_dir, Dir , DirEntry } ;
2930
3031use crate :: types:: DocsConfig ;
3132
33+ static EMBEDDED_TEMPLATE : Dir < ' _ > = include_dir ! ( "$CARGO_MANIFEST_DIR/template" ) ;
34+ static EMBEDDED_COMPONENTS : Dir < ' _ > = include_dir ! ( "$CARGO_MANIFEST_DIR/components" ) ;
35+ const FNV_OFFSET : u64 = 0xcbf2_9ce4_8422_2325 ;
36+ const FNV_PRIME : u64 = 0x0100_0000_01b3 ;
37+
3238#[ derive( Parser ) ]
3339#[ command( name = "webui-press" , about = "WebUI documentation site builder" ) ]
3440struct Cli {
@@ -44,7 +50,7 @@ enum Commands {
4450 #[ arg( short, long, default_value = ".webui-press/config.json" ) ]
4551 config : String ,
4652
47- /// Path to the template directory (overrides built-in )
53+ /// Path to the template directory (overrides bundled assets )
4854 #[ arg( short, long) ]
4955 template : Option < String > ,
5056 } ,
@@ -55,7 +61,7 @@ enum Commands {
5561 #[ arg( short, long, default_value = ".webui-press/config.json" ) ]
5662 config : String ,
5763
58- /// Path to the template directory (overrides built-in )
64+ /// Path to the template directory (overrides bundled assets )
5965 #[ arg( short, long) ]
6066 template : Option < String > ,
6167
@@ -71,7 +77,6 @@ enum Commands {
7177
7278fn main ( ) {
7379 let cli = Cli :: parse ( ) ;
74-
7580 let result = match cli. command {
7681 Commands :: Build { config, template } => run_build ( & config, template. as_deref ( ) ) ,
7782 Commands :: Serve {
@@ -88,7 +93,7 @@ fn main() {
8893 }
8994}
9095
91- /// Resolve config + template directory + parsed config from CLI args .
96+ /// Load the config and materialize the embedded template assets .
9297/// Shared by `build` and `serve`.
9398fn load_config (
9499 config_path : & str ,
@@ -98,36 +103,101 @@ fn load_config(
98103 . map_err ( |e| anyhow:: anyhow!( "Cannot read config {}: {}" , style( config_path) . bold( ) , e) ) ?;
99104
100105 let docs_config: DocsConfig = serde_json:: from_str ( & config_str)
101- . map_err ( |e| anyhow:: anyhow!( "Invalid config JSON: {}" , e ) ) ?;
106+ . map_err ( |e| anyhow:: anyhow!( "Invalid config JSON: {e}" ) ) ?;
102107
103108 let config_dir = Path :: new ( config_path)
104109 . parent ( )
105110 . unwrap_or ( Path :: new ( "." ) )
106111 . to_path_buf ( ) ;
107112
108113 let template = match template_dir {
109- Some ( t) => Path :: new ( t) . to_path_buf ( ) ,
110- None => {
111- let exe = std:: env:: current_exe ( ) . unwrap_or_default ( ) ;
112- let exe_dir = exe. parent ( ) . unwrap_or ( Path :: new ( "." ) ) . to_path_buf ( ) ;
113-
114- exe_dir
115- . ancestors ( )
116- . find_map ( |dir| {
117- let t = dir. join ( "crates/webui-press/template" ) ;
118- if t. join ( "index.html" ) . exists ( ) {
119- Some ( t)
120- } else {
121- None
122- }
123- } )
124- . unwrap_or_else ( || exe_dir. join ( "template" ) )
125- }
114+ Some ( template_dir) => Path :: new ( template_dir) . to_path_buf ( ) ,
115+ None => extract_embedded_assets ( ) ?,
126116 } ;
127117
128118 Ok ( ( docs_config, config_dir, template) )
129119}
130120
121+ /// Materialize the embedded template + components into a per-version,
122+ /// content-addressed cache directory and return the `template` subdirectory.
123+ ///
124+ /// The cache is content-addressed (keyed by an FNV-1a hash of the embedded
125+ /// bytes), so a `.complete` directory for a given hash is always valid and is
126+ /// reused as-is. A fresh extraction is written into a sibling staging directory
127+ /// and published with a single atomic `rename`, so an interrupted run (Ctrl-C,
128+ /// crash) never leaves a half-written cache: the next run sees no `.complete`
129+ /// sentinel and re-extracts.
130+ fn extract_embedded_assets ( ) -> Result < PathBuf > {
131+ let dir_name = format ! (
132+ "webui-press-{}-{:016x}" ,
133+ env!( "CARGO_PKG_VERSION" ) ,
134+ embedded_assets_hash( )
135+ ) ;
136+ let tmp = std:: env:: temp_dir ( ) ;
137+ let root = tmp. join ( & dir_name) ;
138+ let template_dir = root. join ( "template" ) ;
139+
140+ if is_complete_cache ( & root) {
141+ return Ok ( template_dir) ;
142+ }
143+
144+ // A `root` that isn't complete is a stale or interrupted extraction. Clear
145+ // it and any leftover staging dir, extract into staging, then publish.
146+ let staging = tmp. join ( format ! ( "{dir_name}.staging" ) ) ;
147+ let _ = fs:: remove_dir_all ( & staging) ;
148+ let _ = fs:: remove_dir_all ( & root) ;
149+ EMBEDDED_TEMPLATE
150+ . extract ( staging. join ( "template" ) )
151+ . map_err ( |e| anyhow:: anyhow!( "Cannot extract embedded template: {e}" ) ) ?;
152+ EMBEDDED_COMPONENTS
153+ . extract ( staging. join ( "components" ) )
154+ . map_err ( |e| anyhow:: anyhow!( "Cannot extract embedded components: {e}" ) ) ?;
155+ fs:: write ( staging. join ( ".complete" ) , [ ] )
156+ . map_err ( |e| anyhow:: anyhow!( "Cannot finalize embedded template assets: {e}" ) ) ?;
157+
158+ // Atomic publish: the fully staged tree appears at `root` in one step.
159+ fs:: rename ( & staging, & root)
160+ . map_err ( |e| anyhow:: anyhow!( "Cannot publish embedded template assets: {e}" ) ) ?;
161+ Ok ( template_dir)
162+ }
163+
164+ /// A cache directory is usable only when fully extracted: the `.complete`
165+ /// sentinel, the template entry point, and the sibling `components/` directory
166+ /// (which `build_docs` discovers via `template_dir.parent()/components`) must
167+ /// all be present. Validating `components/` here turns an externally
168+ /// corrupted cache into a clean re-extraction instead of a confusing
169+ /// missing-component build failure later.
170+ fn is_complete_cache ( root : & Path ) -> bool {
171+ root. join ( ".complete" ) . is_file ( )
172+ && root. join ( "template" ) . join ( "index.html" ) . is_file ( )
173+ && root. join ( "components" ) . is_dir ( )
174+ }
175+
176+ fn embedded_assets_hash ( ) -> u64 {
177+ let mut hash = FNV_OFFSET ;
178+ hash = hash_dir ( hash, & EMBEDDED_TEMPLATE ) ;
179+ hash_dir ( hash, & EMBEDDED_COMPONENTS )
180+ }
181+
182+ fn hash_dir ( mut hash : u64 , dir : & Dir < ' _ > ) -> u64 {
183+ for entry in dir. entries ( ) {
184+ hash = hash_bytes ( hash, entry. path ( ) . to_string_lossy ( ) . as_bytes ( ) ) ;
185+ match entry {
186+ DirEntry :: Dir ( dir) => hash = hash_dir ( hash, dir) ,
187+ DirEntry :: File ( file) => hash = hash_bytes ( hash, file. contents ( ) ) ,
188+ }
189+ }
190+ hash
191+ }
192+
193+ fn hash_bytes ( mut hash : u64 , bytes : & [ u8 ] ) -> u64 {
194+ for byte in bytes {
195+ hash ^= u64:: from ( * byte) ;
196+ hash = hash. wrapping_mul ( FNV_PRIME ) ;
197+ }
198+ hash
199+ }
200+
131201fn run_build ( config_path : & str , template_dir : Option < & str > ) -> Result < ( ) > {
132202 let ( docs_config, config_dir, template) = load_config ( config_path, template_dir) ?;
133203 let _stats = build:: build_docs ( & docs_config, & config_dir, & template) ?;
@@ -154,3 +224,47 @@ fn run_serve_blocking(
154224 port,
155225 } ) )
156226}
227+
228+ #[ cfg( test) ]
229+ mod tests {
230+ use super :: * ;
231+
232+ #[ test]
233+ fn embedded_assets_extract_template_and_components ( ) -> Result < ( ) > {
234+ let template = extract_embedded_assets ( ) ?;
235+ let root = template
236+ . parent ( )
237+ . ok_or_else ( || anyhow:: anyhow!( "template has no parent" ) ) ?;
238+
239+ assert ! ( template. join( "index.html" ) . is_file( ) ) ;
240+ assert ! ( root. join( "components/code-block/code-block.html" ) . is_file( ) ) ;
241+
242+ // The published cache must satisfy the completeness contract, and a
243+ // second call must reuse the same content-addressed directory.
244+ assert ! ( is_complete_cache( root) ) ;
245+ assert_eq ! ( extract_embedded_assets( ) ?, template) ;
246+ Ok ( ( ) )
247+ }
248+
249+ #[ test]
250+ fn incomplete_cache_is_not_treated_as_complete ( ) -> Result < ( ) > {
251+ let base = std:: env:: temp_dir ( ) . join ( format ! (
252+ "webui-press-test-incomplete-{}" ,
253+ std:: process:: id( )
254+ ) ) ;
255+ let _ = fs:: remove_dir_all ( & base) ;
256+ let outcome: Result < ( ) > = ( || {
257+ // `.complete` + template present, but no sibling components/ dir.
258+ fs:: create_dir_all ( base. join ( "template" ) ) ?;
259+ fs:: write ( base. join ( "template" ) . join ( "index.html" ) , b"<html></html>" ) ?;
260+ fs:: write ( base. join ( ".complete" ) , [ ] ) ?;
261+ assert ! ( !is_complete_cache( & base) ) ;
262+
263+ fs:: create_dir_all ( base. join ( "components" ) ) ?;
264+ assert ! ( is_complete_cache( & base) ) ;
265+ Ok ( ( ) )
266+ } ) ( ) ;
267+ let _ = fs:: remove_dir_all ( & base) ;
268+ outcome
269+ }
270+ }
0 commit comments