Skip to main content

euv_cli/build/
fn.rs

1use super::*;
2
3/// Strips redundant whitespace from an HTML template string while
4/// preserving the verbatim contents of `<script>` and `<style>` blocks.
5///
6/// Unlike a full HTML parser, this is a single-pass character-level
7/// state machine tuned for the two euv index templates
8/// (`INDEX_HTML_DEV` / `INDEX_HTML_RELEASE`):
9///
10/// - HTML comments (`<!-- ... -->`) are removed.
11/// - Whitespace between tags (`>...<`) collapses to nothing when the
12///   whitespace is purely indentation / newlines, and to a single
13///   space otherwise (preserving word boundaries inside text nodes).
14/// - Inside `<tag ...>` attributes, runs of whitespace collapse to one
15///   space, but `<pre>` / `<textarea>` / quoted attribute values are
16///   not handled — the euv templates do not use those.
17/// - `<script>` and `<style>` content is copied byte-for-byte; the
18///   inline JS / CSS inside is never touched by this pass
19///   (`minify_inline_js_snippets` already handled the wasm-bindgen
20///   snippets, and the dev-only reload script is intentionally readable).
21///
22/// The output is safe to feed directly to the browser; it differs from
23/// the source only in whitespace and the presence of comments.
24///
25/// # Arguments
26///
27/// - `&str` - The HTML template (after placeholder replacement).
28///
29/// # Returns
30///
31/// - `String` - The minified HTML.
32pub fn minify_html_template(html: &str) -> String {
33    let bytes: &[u8] = html.as_bytes();
34    let len: usize = bytes.len();
35    let mut out: Vec<u8> = Vec::with_capacity(len);
36    let mut i: usize = 0;
37    let mut in_tag: bool = false;
38    let mut in_preserve: bool = false;
39    let mut pending_strip_tag_gap: bool = false;
40    let mut last_was_space: bool = false;
41    while i < len {
42        let b: u8 = bytes[i];
43        if in_preserve {
44            out.push(b);
45            if b == HTML_LT && i + 1 < len && bytes[i + 1] == HTML_SLASH {
46                let is_close_style: bool = i + HTML_STYLE_CLOSE_PREFIX_BYTES.len() <= len
47                    && bytes[i..i + HTML_STYLE_CLOSE_PREFIX_BYTES.len()]
48                        .eq_ignore_ascii_case(HTML_STYLE_CLOSE_PREFIX_BYTES);
49                let is_close_script: bool = i + HTML_SCRIPT_CLOSE_PREFIX_BYTES.len() <= len
50                    && bytes[i..i + HTML_SCRIPT_CLOSE_PREFIX_BYTES.len()]
51                        .eq_ignore_ascii_case(HTML_SCRIPT_CLOSE_PREFIX_BYTES);
52                if is_close_style || is_close_script {
53                    let close_end: usize = i
54                        + (if is_close_style {
55                            HTML_STYLE_CLOSE_PREFIX_BYTES.len()
56                        } else {
57                            HTML_SCRIPT_CLOSE_PREFIX_BYTES.len()
58                        });
59                    if close_end >= len
60                        || bytes[close_end].is_ascii_whitespace()
61                        || bytes[close_end] == HTML_GT
62                    {
63                        let end_len: usize = if is_close_style {
64                            HTML_STYLE_CLOSE_PREFIX_BYTES.len()
65                        } else {
66                            HTML_SCRIPT_CLOSE_PREFIX_BYTES.len()
67                        };
68                        for &b in &bytes[i + 1..i + end_len] {
69                            out.push(b);
70                        }
71                        i = close_end;
72                        in_preserve = false;
73                        pending_strip_tag_gap = true;
74                        last_was_space = false;
75                        continue;
76                    }
77                }
78            }
79            i += 1;
80            continue;
81        }
82        if pending_strip_tag_gap {
83            if b.is_ascii_whitespace() {
84                i += 1;
85                continue;
86            }
87            pending_strip_tag_gap = false;
88        }
89        if b == HTML_LT {
90            if i + HTML_COMMENT_OPEN_BYTES.len() <= len
91                && &bytes[i..i + HTML_COMMENT_OPEN_BYTES.len()] == HTML_COMMENT_OPEN_BYTES
92            {
93                let mut end: usize = i + HTML_COMMENT_OPEN_BYTES.len();
94                while end + HTML_COMMENT_CLOSE_BYTES.len() <= len
95                    && &bytes[end..end + HTML_COMMENT_CLOSE_BYTES.len()] != HTML_COMMENT_CLOSE_BYTES
96                {
97                    end += 1;
98                }
99                i = if end + HTML_COMMENT_CLOSE_BYTES.len() <= len {
100                    end + HTML_COMMENT_CLOSE_BYTES.len()
101                } else {
102                    len
103                };
104                pending_strip_tag_gap = true;
105                last_was_space = false;
106                continue;
107            }
108            if i + HTML_SCRIPT_OPEN_PREFIX_BYTES.len() <= len
109                && bytes[i..i + HTML_SCRIPT_OPEN_PREFIX_BYTES.len()]
110                    .eq_ignore_ascii_case(HTML_SCRIPT_OPEN_PREFIX_BYTES)
111            {
112                let probe: usize = i + HTML_SCRIPT_OPEN_PREFIX_BYTES.len();
113                if probe >= len || bytes[probe].is_ascii_whitespace() || bytes[probe] == HTML_GT {
114                    in_preserve = true;
115                }
116            } else if i + HTML_STYLE_OPEN_PREFIX_BYTES.len() <= len
117                && bytes[i..i + HTML_STYLE_OPEN_PREFIX_BYTES.len()]
118                    .eq_ignore_ascii_case(HTML_STYLE_OPEN_PREFIX_BYTES)
119            {
120                let probe: usize = i + HTML_STYLE_OPEN_PREFIX_BYTES.len();
121                if probe >= len || bytes[probe].is_ascii_whitespace() || bytes[probe] == HTML_GT {
122                    in_preserve = true;
123                }
124            }
125            in_tag = true;
126            last_was_space = false;
127            out.push(b);
128            i += 1;
129            continue;
130        }
131        if b == HTML_GT {
132            in_tag = false;
133            out.push(b);
134            i += 1;
135            pending_strip_tag_gap = true;
136            last_was_space = false;
137            continue;
138        }
139        if in_tag {
140            if b.is_ascii_whitespace() {
141                if !last_was_space {
142                    out.push(HTML_SPACE);
143                    last_was_space = true;
144                }
145            } else {
146                out.push(b);
147                last_was_space = false;
148            }
149            i += 1;
150            continue;
151        }
152        if b.is_ascii_whitespace() {
153            if !last_was_space {
154                out.push(HTML_SPACE);
155                last_was_space = true;
156            }
157            i += 1;
158            continue;
159        }
160        out.push(b);
161        last_was_space = false;
162        i += 1;
163    }
164    String::from_utf8(out).unwrap_or_else(|_| String::from(html))
165}
166
167/// Checks whether `wasm_pack_args` already contains a build mode flag.
168///
169/// Returns `true` if any of `--dev`, `--release`, or `--profiling`
170/// is present in the arguments list.
171///
172/// # Arguments
173///
174/// - `&[String]` - The wasm-pack arguments to search.
175///
176/// # Returns
177///
178/// - `bool` - Whether a build mode flag is already present.
179pub fn has_build_mode_flag(wasm_pack_args: &[String]) -> bool {
180    wasm_pack_args
181        .iter()
182        .any(|arg: &String| arg == DEV_FLAG || arg == RELEASE_FLAG || arg == PROFILING_FLAG)
183}
184
185/// Filters out euv-specific arguments from the wasm-pack arguments.
186///
187/// First locates the genuine passthrough arguments by taking everything
188/// after the last `--` separator (or the full list if no `--` is present).
189/// Then removes all known euv-specific flags and their values so that
190/// only wasm-pack-compatible arguments remain.
191///
192/// # Arguments
193///
194/// - `&[String]` - The raw wasm-pack arguments to filter.
195///
196/// # Returns
197///
198/// - `Vec<String>` - The filtered arguments safe for wasm-pack.
199pub fn filter_euv_args(wasm_pack_args: &[String]) -> Vec<String> {
200    let raw_args: &[String] = if let Some(position) = wasm_pack_args
201        .iter()
202        .rposition(|arg: &String| arg == DOUBLE_DASH)
203    {
204        &wasm_pack_args[position + 1..]
205    } else {
206        wasm_pack_args
207    };
208    let mut filtered: Vec<String> = Vec::new();
209    let mut skip_next: bool = false;
210    for arg in raw_args {
211        if skip_next {
212            skip_next = false;
213            continue;
214        }
215        if EUV_ARGS.contains(&arg.as_str()) {
216            if arg.contains('=') {
217                continue;
218            }
219            skip_next = true;
220            continue;
221        }
222        filtered.push(arg.clone());
223    }
224    filtered
225}
226
227/// Reconciles euv-specific arguments that may have been collected into
228/// `wasm_pack_args` (e.g. when placed after `--`) back into the
229/// corresponding `ModeArgs` fields.
230///
231/// Because clap's `trailing_var_arg` collects all unrecognized arguments
232/// into `wasm_pack_args`, any euv flag placed after `--` is not parsed
233/// by clap into its dedicated field. This function scans `wasm_pack_args`
234/// for known euv flags and overwrites the `ModeArgs` fields so that the
235/// rest of the codebase can rely on the typed accessors regardless of
236/// argument order.
237///
238/// # Arguments
239///
240/// - `&mut ModeArgs` - The CLI arguments to reconcile in-place.
241pub fn reconcile_args(args: &mut ModeArgs) {
242    let wasm_pack_args: Vec<String> = args.get_wasm_pack_args().clone();
243    let mut crate_path: Option<PathBuf> = None;
244    let mut port: Option<u16> = None;
245    let mut www_dir: Option<String> = None;
246    let mut index_html: Option<Option<PathBuf>> = None;
247    let mut no_gitignore: Option<bool> = None;
248    let mut dev: Option<bool> = None;
249    let mut release: Option<bool> = None;
250    let mut profiling: Option<bool> = None;
251    let mut iter: Iter<String> = wasm_pack_args.iter();
252    while let Some(arg) = iter.next() {
253        match arg.as_str() {
254            CRATE_PATH_ARG | CRATE_PATH_ARG_SHORT => {
255                if let Some(value) = iter.next() {
256                    crate_path = Some(PathBuf::from(value));
257                }
258            }
259            PORT_ARG | PORT_ARG_SHORT => {
260                if let Some(value) = iter.next()
261                    && let Ok(parsed_port) = value.parse::<u16>()
262                {
263                    port = Some(parsed_port);
264                }
265            }
266            WWW_DIR_ARG => {
267                if let Some(value) = iter.next() {
268                    www_dir = Some(value.clone());
269                }
270            }
271            INDEX_HTML_ARG => {
272                if let Some(value) = iter.next() {
273                    index_html = Some(Some(PathBuf::from(value)));
274                }
275            }
276            NO_GITIGNORE_ARG => {
277                no_gitignore = Some(true);
278            }
279            DEV_FLAG => {
280                dev = Some(true);
281            }
282            RELEASE_FLAG => {
283                release = Some(true);
284            }
285            PROFILING_FLAG => {
286                profiling = Some(true);
287            }
288            other => {
289                if let Some(value) = other.strip_prefix(&format!("{CRATE_PATH_ARG}=")) {
290                    crate_path = Some(PathBuf::from(value));
291                } else if let Some(value) = other.strip_prefix(&format!("{PORT_ARG}=")) {
292                    if let Ok(parsed_port) = value.parse::<u16>() {
293                        port = Some(parsed_port);
294                    }
295                } else if let Some(value) = other.strip_prefix(&format!("{WWW_DIR_ARG}=")) {
296                    www_dir = Some(value.to_string());
297                } else if let Some(value) = other.strip_prefix(&format!("{INDEX_HTML_ARG}=")) {
298                    index_html = Some(Some(PathBuf::from(value)));
299                }
300            }
301        }
302    }
303    if let Some(value) = crate_path {
304        args.set_crate_path(value);
305    }
306    if let Some(value) = port {
307        args.set_port(value);
308    }
309    if let Some(value) = www_dir {
310        args.set_www_dir(value);
311    }
312    if let Some(value) = index_html {
313        args.set_index_html(value);
314    }
315    if let Some(value) = no_gitignore {
316        args.set_no_gitignore(value);
317    }
318    if let Some(value) = dev {
319        args.set_dev(value);
320    }
321    if let Some(value) = release {
322        args.set_release(value);
323    }
324    if let Some(value) = profiling {
325        args.set_profiling(value);
326    }
327}
328
329/// Resolves the build mode from CLI arguments.
330///
331/// First checks the explicit `--dev`, `--release`, and `--profiling` flags on `ModeArgs`.
332/// If none of those are set, inspects `wasm_pack_args` for any build mode flag
333/// that may have been forwarded by the user.
334/// Defaults to `BuildMode::Dev` if no build mode flag is found anywhere.
335///
336/// # Arguments
337///
338/// - `&ModeArgs` - The CLI arguments containing the build mode flags and wasm_pack_args.
339///
340/// # Returns
341///
342/// - `BuildMode` - The resolved build mode.
343pub fn resolve_build_mode(args: &ModeArgs) -> BuildMode {
344    if args.get_profiling() {
345        BuildMode::Profiling
346    } else if args.get_release() {
347        BuildMode::Release
348    } else if args.get_dev() {
349        BuildMode::Dev
350    } else if args
351        .get_wasm_pack_args()
352        .iter()
353        .any(|arg: &String| arg == PROFILING_FLAG)
354    {
355        BuildMode::Profiling
356    } else if args
357        .get_wasm_pack_args()
358        .iter()
359        .any(|arg: &String| arg == RELEASE_FLAG)
360    {
361        BuildMode::Release
362    } else {
363        BuildMode::Dev
364    }
365}
366
367/// Converts a `BuildMode` to the corresponding wasm-pack flag string.
368///
369/// # Arguments
370///
371/// - `BuildMode` - The build mode to convert.
372///
373/// # Returns
374///
375/// - `&'static str` - The wasm-pack command-line flag.
376pub fn build_mode_to_flag(build_mode: BuildMode) -> &'static str {
377    match build_mode {
378        BuildMode::Dev => DEV_FLAG,
379        BuildMode::Release => RELEASE_FLAG,
380        BuildMode::Profiling => PROFILING_FLAG,
381    }
382}
383
384/// Builds a `Gitignore` matcher from the `.gitignore` file at the given root path.
385///
386/// # Arguments
387///
388/// - `&PathBuf` - The root directory where `.gitignore` is located.
389///
390/// # Returns
391///
392/// - `Gitignore` - The compiled gitignore matcher.
393async fn build_gitignore(root: &PathBuf) -> Gitignore {
394    let gitignore_path: PathBuf = root.join(GITIGNORE_FILE_NAME);
395    let mut builder: GitignoreBuilder = GitignoreBuilder::new(root);
396    let gitignore_exists: bool = metadata(&gitignore_path).await.is_ok();
397    if gitignore_exists && let Some(error) = builder.add(&gitignore_path) {
398        log::warn!("Failed to load .gitignore: {error}");
399    }
400    match builder.build() {
401        Ok(gitignore) => {
402            if gitignore_exists {
403                log::info!("Loaded .gitignore to filter file change events");
404            }
405            gitignore
406        }
407        Err(error) => {
408            log::warn!("Failed to build gitignore matcher: {error}");
409            GitignoreBuilder::new(root)
410                .build()
411                .unwrap_or_else(|_error: ignore::Error| Gitignore::empty())
412        }
413    }
414}
415
416/// Extracts the value of `--out-name` from the wasm-pack arguments.
417///
418/// Returns `None` if `--out-name` is not specified.
419///
420/// # Arguments
421///
422/// - `&[String]` - The wasm-pack arguments to search.
423///
424/// # Returns
425///
426/// - `Option<String>` - The value of `--out-name` if found.
427fn extract_out_name(wasm_pack_args: &[String]) -> Option<String> {
428    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
429    while let Some(arg) = iter.next() {
430        if arg == OUT_NAME_ARG {
431            return iter.next().cloned();
432        }
433        if let Some(value) = arg.strip_prefix(&format!("{OUT_NAME_ARG}=")) {
434            return Some(value.to_string());
435        }
436    }
437    None
438}
439
440/// Extracts the value of `--out-dir` from the wasm-pack arguments.
441///
442/// Returns `None` if `--out-dir` is not specified.
443///
444/// # Arguments
445///
446/// - `&[String]` - The wasm-pack arguments to search.
447///
448/// # Returns
449///
450/// - `Option<String>` - The value of `--out-dir` if found.
451fn extract_out_dir(wasm_pack_args: &[String]) -> Option<String> {
452    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
453    while let Some(arg) = iter.next() {
454        if arg == OUT_DIR_ARG {
455            return iter.next().cloned();
456        }
457        if let Some(value) = arg.strip_prefix(&format!("{OUT_DIR_ARG}=")) {
458            return Some(value.to_string());
459        }
460    }
461    None
462}
463
464/// Resolves the output JS filename for HTML generation.
465///
466/// Uses `--out-name` from wasm-pack args if specified,
467/// otherwise reads the crate name from `Cargo.toml` `[package] name` field
468/// and replaces hyphens with underscores (matching wasm-pack behavior).
469/// Appends `.js` extension to form the complete JS filename.
470///
471/// # Arguments
472///
473/// - `&ModeArgs` - The CLI arguments containing crate_path and wasm_pack_args.
474///
475/// # Returns
476///
477/// - `String` - The resolved JS filename with `.js` extension (e.g. `euv_example.js`).
478pub fn resolve_out_name(args: &ModeArgs) -> String {
479    let name: String = if let Some(out_name) = extract_out_name(args.get_wasm_pack_args()) {
480        out_name
481    } else {
482        let cargo_toml_path: PathBuf = args.get_crate_path().join(CARGO_TOML_FILE_NAME);
483        read_crate_name_from_toml(&cargo_toml_path).unwrap_or_else(|| {
484            args.get_crate_path()
485                .file_name()
486                .unwrap_or_default()
487                .to_string_lossy()
488                .to_string()
489        })
490    };
491    format!("{name}{JS_EXTENSION}")
492}
493
494/// Reads the `name` field from a Cargo.toml file.
495///
496/// Parses the file line-by-line looking for `name = "..."` within the `[package]` section.
497///
498/// # Arguments
499///
500/// - `&Path` - The path to the Cargo.toml file.
501///
502/// # Returns
503///
504/// - `Option<String>` - The crate name if found.
505fn read_crate_name_from_toml(path: &Path) -> Option<String> {
506    let content: String = std::fs::read_to_string(path).ok()?;
507    let mut in_package: bool = false;
508    for line in content.lines() {
509        let trimmed: &str = line.trim();
510        if trimmed.starts_with('[') {
511            in_package = trimmed == "[package]";
512            continue;
513        }
514        if in_package
515            && trimmed.starts_with("name")
516            && let Some(value) = trimmed.strip_prefix("name")
517        {
518            let value: &str = value.trim().strip_prefix('=')?.trim();
519            let value: &str = value.strip_prefix('"')?.strip_suffix('"')?;
520            return Some(value.to_string());
521        }
522    }
523    None
524}
525
526/// Computes the relative path from a base directory to a target directory.
527///
528/// Compares the component sequences of both paths to find the common prefix,
529/// then emits `..` for each remaining base component followed by the remaining
530/// target components.
531///
532/// # Arguments
533///
534/// - `&Path` - The base directory path.
535/// - `&Path` - The target directory path.
536///
537/// # Returns
538///
539/// - `PathBuf` - The relative path from base to target.
540fn compute_relative_path(base: &Path, target: &Path) -> PathBuf {
541    let base_components: Vec<Component> = base.components().collect();
542    let target_components: Vec<Component> = target.components().collect();
543    let common_len: usize = base_components
544        .iter()
545        .zip(target_components.iter())
546        .take_while(|(base_component, target_component)| base_component == target_component)
547        .count();
548    let mut result: PathBuf = PathBuf::new();
549    for _ in &base_components[common_len..] {
550        result.push("..");
551    }
552    for component in &target_components[common_len..] {
553        if let Component::Normal(os_str) = component {
554            result.push(os_str);
555        }
556    }
557    result
558}
559
560/// Resolves the serving root directory for the development server.
561///
562/// When the output directory is inside the www directory, returns the resolved www directory.
563/// When the output directory is outside the www directory, returns the parent of the output directory
564/// so that `index.html` and WASM artifacts are co-located under the same serving root.
565///
566/// # Arguments
567///
568/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
569///
570/// # Returns
571///
572/// - `PathBuf` - The resolved serving root directory.
573pub async fn resolve_serving_root(args: &ModeArgs) -> PathBuf {
574    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
575    let out_dir_absolute: PathBuf = resolve_out_dir(args);
576    if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
577        resolve_www_dir(&www_absolute).await
578    } else {
579        out_dir_absolute
580            .parent()
581            .map(|p: &Path| p.to_path_buf())
582            .unwrap_or_else(|| www_absolute)
583    }
584}
585
586/// Resolves the serving route prefix relative to the crate path.
587///
588/// Returns the forward-slash-separated path of the serving root relative to the crate path.
589/// Used for server route registration and URL display.
590///
591/// # Arguments
592///
593/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
594///
595/// # Returns
596///
597/// - `String` - The serving route prefix (e.g. `www` or `wwws`).
598pub fn resolve_serving_route_prefix(args: &ModeArgs) -> String {
599    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
600    let out_dir_absolute: PathBuf = resolve_out_dir(args);
601    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
602        www_absolute
603    } else {
604        out_dir_absolute
605            .parent()
606            .map(|p: &Path| p.to_path_buf())
607            .unwrap_or_else(|| www_absolute)
608    };
609    serving_root
610        .strip_prefix(args.get_crate_path())
611        .map(|rel: &Path| {
612            rel.to_string_lossy()
613                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
614        })
615        .unwrap_or_else(|_| {
616            args.get_www_dir()
617                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
618        })
619}
620
621/// Resolves the JS import path for HTML generation.
622///
623/// Computes the relative path from the serving root to the output directory,
624/// then appends the JS filename (from `resolve_out_name`, which includes `.js`)
625/// to form the full import path (e.g. `./pkg/euv.js` or `./pksg/cc.js`).
626///
627/// # Arguments
628///
629/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
630///
631/// # Returns
632///
633/// - `String` - The resolved JS import path relative to the serving root.
634pub fn resolve_import_path(args: &ModeArgs) -> String {
635    let out_name: String = resolve_out_name(args);
636    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
637    let out_dir_absolute: PathBuf = resolve_out_dir(args);
638    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
639        www_absolute
640    } else {
641        out_dir_absolute
642            .parent()
643            .map(|p: &Path| p.to_path_buf())
644            .unwrap_or_else(|| www_absolute)
645    };
646    let relative: PathBuf = compute_relative_path(&serving_root, &out_dir_absolute);
647    let mut components: Vec<String> = relative
648        .components()
649        .filter_map(|component: Component| match component {
650            Component::Normal(os_str) => os_str.to_str().map(|text: &str| text.to_string()),
651            Component::ParentDir => Some(PARENT_DIR.to_string()),
652            _ => None,
653        })
654        .collect();
655    components.push(out_name);
656    format!("{RELATIVE_PATH_PREFIX}{}", components.join(PATH_SEPARATOR))
657}
658
659/// Resolves the output directory for wasm-pack artifacts.
660///
661/// Uses `--out-dir` from wasm-pack args if specified,
662/// otherwise defaults to `{www_dir}/pkg` so that build artifacts
663/// are placed directly inside the www directory served
664/// by the development server.
665///
666/// # Arguments
667///
668/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
669///
670/// # Returns
671///
672/// - `PathBuf` - The resolved output directory (absolute if crate_path is joined).
673pub fn resolve_out_dir(args: &ModeArgs) -> PathBuf {
674    let out_dir_path: PathBuf = PathBuf::from(
675        extract_out_dir(args.get_wasm_pack_args())
676            .unwrap_or_else(|| format!("{}/{PKG_DIR_NAME}", args.get_www_dir())),
677    );
678    if out_dir_path.is_absolute() {
679        out_dir_path
680    } else {
681        args.get_crate_path().join(&out_dir_path)
682    }
683}
684
685/// Executes a build-only pipeline: formats euv macros, cleans output directory,
686/// builds WASM, and generates HTML.
687///
688/// Unlike `run_build_pipeline`, this cleans the output directory before building
689/// and skips reload notifications — only the essential WASM build artifacts are kept.
690///
691/// # Arguments
692///
693/// - `&ModeArgs` - The CLI arguments.
694///
695/// # Returns
696///
697/// - `Result<(), EuvError>` - Indicates success or failure of the build.
698pub async fn run_build_only_pipeline(args: &ModeArgs) -> Result<(), EuvError> {
699    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
700    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
701        log::warn!("euv fmt error: {error}");
702    }
703    let out_dir: PathBuf = resolve_out_dir(args);
704    clean_out_dir(&out_dir).await;
705    build_wasm(args).await?;
706    log::info!("WASM build completed successfully");
707    let out_dir_path: PathBuf = resolve_out_dir(args);
708    if let Err(error) = minify_inline_js_snippets(&out_dir_path).await {
709        log::warn!("inline-js minify step error: {error}");
710    }
711    let html_config: HtmlConfig = HtmlConfig::new(
712        resolve_serving_root(args).await,
713        resolve_import_path(args),
714        resolve_build_mode(args) == BuildMode::Release,
715        args.try_get_index_html().clone(),
716    );
717    generate_html(&html_config).await?;
718    Ok(())
719}
720
721/// Minifies every wasm-bindgen `inline_js` snippet file emitted under
722/// `<out_dir>/<pkg>/snippets/**/inline*.js`.
723///
724/// `#[wasm_bindgen(inline_js = ...)]` blocks are extracted verbatim from
725/// the Rust source by wasm-bindgen — preserving indentation, comments,
726/// and original identifier names — and written into per-crate snippet
727/// directories inside `pkg/snippets/<crate-hash>/`. On a typical euv
728/// build this costs roughly 2 KB of uncompressed JS to ship to every
729/// browser; minifying in-place brings the payload down to ~700 B with
730/// no semantic change. The export names are preserved so the wasm
731/// glue loader (`euv_example.js`'s `__wbg_*` init) can still resolve
732/// them by name.
733///
734/// Failures are logged and skipped so a single bad snippet does not
735/// abort the overall build — a syntax error in an inline snippet will
736/// already have been caught by wasm-pack itself. We only touch files
737/// whose stem starts with `SNIPPET_FILE_PREFIX` and whose extension
738/// matches `JS_EXTENSION`, leaving any other file under `snippets/`
739/// alone.
740///
741/// # Arguments
742///
743/// - `&Path` - The build output directory (typically `<crate>/www/pkg`).
744///
745/// # Returns
746///
747/// - `Result<(), EuvError>` - `Ok(())` on success, or a wrapped I/O error.
748pub async fn minify_inline_js_snippets(out_dir: &Path) -> Result<(), EuvError> {
749    let snippets_root: PathBuf = out_dir.join(SNIPPETS_DIR_NAME);
750    if !snippets_root.is_dir() {
751        return Ok(());
752    }
753    let mut crate_dirs: ReadDir = match read_dir(&snippets_root).await {
754        Ok(dir) => dir,
755        Err(_) => return Ok(()),
756    };
757    let session: Session = Session::new();
758    let mut total_in: u64 = 0;
759    let mut total_out: u64 = 0;
760    let mut touched: u32 = 0;
761    while let Ok(Some(entry)) = crate_dirs.next_entry().await {
762        let crate_dir: PathBuf = entry.path();
763        if !crate_dir.is_dir() {
764            continue;
765        }
766        let mut files: ReadDir = match read_dir(&crate_dir).await {
767            Ok(dir) => dir,
768            Err(error) => {
769                log::warn!(
770                    "Failed to read snippet dir '{}': {error}",
771                    crate_dir.display()
772                );
773                continue;
774            }
775        };
776        while let Ok(Some(file_entry)) = files.next_entry().await {
777            let path: PathBuf = file_entry.path();
778            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
779                continue;
780            };
781            if !name.starts_with(SNIPPET_FILE_PREFIX) || !name.ends_with(JS_EXTENSION) {
782                continue;
783            }
784            let input: Vec<u8> = match tokio::fs::read(&path).await {
785                Ok(bytes) => bytes,
786                Err(error) => {
787                    log::warn!("Failed to read snippet '{}': {error}", path.display());
788                    continue;
789                }
790            };
791            total_in = total_in.saturating_add(input.len() as u64);
792            let mut output: Vec<u8> = Vec::with_capacity(input.len());
793            if let Err(error) = minify(&session, TopLevelMode::Module, &input, &mut output) {
794                log::warn!(
795                    "Failed to minify snippet '{}' (left as-is): {}",
796                    path.display(),
797                    error
798                );
799                continue;
800            }
801            if let Err(error) = tokio::fs::write(&path, &output).await {
802                log::warn!(
803                    "Failed to write minified snippet '{}': {error}",
804                    path.display()
805                );
806                continue;
807            }
808            total_out = total_out.saturating_add(output.len() as u64);
809            touched = touched.saturating_add(1);
810        }
811    }
812    if touched > 0 && total_in > 0 {
813        log::info!(
814            "Minified {touched} inline-js snippet(s): {total_in} -> {total_out} bytes ({:.1}%)",
815            (total_out as f64 / total_in as f64) * 100.0
816        );
817    }
818    Ok(())
819}
820
821/// Cleans the output directory before a fresh build.
822///
823/// Removes all files and subdirectories within the output directory
824/// so that stale artifacts from previous builds do not remain.
825/// The directory itself is preserved (recreated if missing).
826///
827/// # Arguments
828///
829/// - `&Path` - The output directory to clean.
830pub async fn clean_out_dir(out_dir: &Path) {
831    let mut entries: ReadDir = match read_dir(out_dir).await {
832        Ok(dir) => dir,
833        Err(_) => return,
834    };
835    while let Ok(Some(entry)) = entries.next_entry().await {
836        let path: PathBuf = entry.path();
837        if path.is_dir() {
838            if let Err(error) = remove_dir_all(&path).await {
839                log::warn!("Failed to remove directory '{}': {error}", path.display());
840            }
841        } else if let Err(error) = remove_file(&path).await {
842            log::warn!("Failed to remove file '{}': {error}", path.display());
843        }
844    }
845}
846
847/// Executes a full build pipeline: euv fmt, build wasm, generate HTML.
848/// After the serial pipeline completes, hyperlane-cli fmt is spawned in the
849/// background so it does not block the caller.
850/// Notifies the reload channel on build success or failure.
851///
852/// # Arguments
853///
854/// - `&ModeArgs` - The CLI arguments.
855/// - `Option<&broadcast::Sender<ReloadEvent>>` - Optional reload channel for notifying clients.
856///
857/// # Returns
858///
859/// - `Result<String, EuvError>` - The generated HTML with reload script injected on success.
860pub async fn run_build_pipeline(
861    args: &ModeArgs,
862    reload_tx: Option<&broadcast::Sender<ReloadEvent>>,
863) -> Result<String, EuvError> {
864    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
865    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
866        log::warn!("euv fmt error: {error}");
867    }
868    match build_wasm(args).await {
869        Ok(()) => {
870            log::info!("WASM build completed successfully");
871            let out_dir_path: PathBuf = resolve_out_dir(args);
872            if let Err(error) = minify_inline_js_snippets(&out_dir_path).await {
873                log::warn!("inline-js minify step error: {error}");
874            }
875            if let Some(sender) = reload_tx {
876                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
877                    sender.send(ReloadEvent::Reload);
878            }
879        }
880        Err(error) => {
881            log::error!("WASM build failed: {error}");
882            if let Some(sender) = reload_tx {
883                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
884                    sender.send(ReloadEvent::Error(error.to_string()));
885            }
886        }
887    }
888    let html_config: HtmlConfig = HtmlConfig::new(
889        resolve_serving_root(args).await,
890        resolve_import_path(args),
891        resolve_build_mode(args) == BuildMode::Release,
892        args.try_get_index_html().clone(),
893    );
894    let html: String = generate_html(&html_config).await?;
895    spawn(async move {
896        if let Err(error) = run_hyperlane_fmt().await {
897            log::warn!("hyperlane-cli fmt error: {error}");
898        }
899    });
900    Ok(html)
901}
902
903/// Watches source files and triggers WASM builds.
904///
905/// # Arguments
906///
907/// - `Arc<AppState>` - The shared application state.
908///
909/// # Returns
910///
911/// - `Result<(), EuvError>` - Indicates success or failure of the file watcher.
912pub(crate) async fn watch_and_build(state: Arc<AppState>) -> Result<(), EuvError> {
913    let crate_path: PathBuf = state.get_args().get_crate_path().clone();
914    let src_path: PathBuf = crate_path.join(SRC_DIR_NAME);
915    let gitignore: Gitignore = build_gitignore(&crate_path).await;
916    let (tx, mut rx): (Sender<Event>, Receiver<Event>) = channel(32);
917    let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
918        move |result: Result<Event, notify::Error>| {
919            if let Ok(event) = result {
920                let _: Result<(), tokio::sync::mpsc::error::SendError<Event>> =
921                    tx.blocking_send(event);
922            }
923        },
924        Config::default(),
925    )?;
926    watcher.watch(&src_path, RecursiveMode::Recursive)?;
927    log::info!("Watching {} for changes...", src_path.display());
928    let mut debounce: Interval = interval(Duration::from_millis(500));
929    debounce.tick().await;
930    while let Some(event) = rx.recv().await {
931        let filtered_paths: Vec<String> = event
932            .paths
933            .iter()
934            .filter(|path: &&PathBuf| !gitignore.matched(*path, path.is_dir()).is_ignore())
935            .map(|path: &PathBuf| path.display().to_string())
936            .collect();
937        if filtered_paths.is_empty() {
938            continue;
939        }
940        log::warn!("File change detected: {}", filtered_paths.join(", "));
941        debounce.reset();
942        sleep(Duration::from_millis(300)).await;
943        let mut building: RwLockWriteGuard<bool> = state.get_is_building().write().await;
944        if *building {
945            continue;
946        }
947        *building = true;
948        drop(building);
949        let state_for_build: Arc<AppState> = Arc::clone(&state);
950        spawn(async move {
951            let args: ModeArgs = state_for_build.get_args().clone();
952            let reload_tx: broadcast::Sender<ReloadEvent> = state_for_build.get_reload_tx().clone();
953            match run_build_pipeline(&args, Some(&reload_tx)).await {
954                Ok(html) => {
955                    let mut content: RwLockWriteGuard<String> =
956                        state_for_build.get_html_content().write().await;
957                    *content = html;
958                }
959                Err(error) => {
960                    log::error!("Build pipeline error: {error}");
961                }
962            }
963            let mut building: RwLockWriteGuard<bool> =
964                state_for_build.get_is_building().write().await;
965            *building = false;
966        });
967    }
968    Ok(())
969}
970
971/// Runs wasm-pack build for the target crate.
972///
973/// All arguments in `args.wasm_pack_args` are transparently forwarded
974/// to `wasm-pack build`. If `--out-dir` is not specified by the user,
975/// `--out-dir {www_dir}/pkg` is automatically injected so that build artifacts
976/// are placed inside the www directory served by the development server.
977///
978/// # Arguments
979///
980/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
981///
982/// # Returns
983///
984/// - `Result<(), EuvError>` - Indicates success or failure of the wasm-pack build.
985pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> {
986    let build_mode: BuildMode = resolve_build_mode(args);
987    let build_mode_flag: &str = build_mode_to_flag(build_mode);
988    let filtered_args: Vec<String> = filter_euv_args(args.get_wasm_pack_args());
989    let has_existing_build_mode: bool = has_build_mode_flag(&filtered_args);
990    let default_out_dir: String = format!("{}/{PKG_DIR_NAME}", args.get_www_dir());
991    let mut command: Command = Command::new(WASM_PACK_COMMAND);
992    command.arg(WASM_PACK_BUILD_SUBCOMMAND);
993    if !has_existing_build_mode {
994        command.arg(build_mode_flag);
995    }
996    command
997        .args(&filtered_args)
998        .env(RUST_MIN_STACK_ENV, RUST_MIN_STACK_VALUE);
999    let has_out_dir: bool = extract_out_dir(&filtered_args).is_some();
1000    if !has_out_dir {
1001        command.arg(OUT_DIR_ARG).arg(&default_out_dir);
1002    }
1003    let has_target: bool = filtered_args
1004        .iter()
1005        .any(|arg: &String| arg == TARGET_ARG || arg.starts_with(&format!("{TARGET_ARG}=")));
1006    if !has_target {
1007        command.arg(TARGET_ARG).arg(TARGET_WEB);
1008    }
1009    command.current_dir(args.get_crate_path());
1010    command.stdout(Stdio::piped()).stderr(Stdio::piped());
1011    let display_args: Vec<String> = (if has_existing_build_mode {
1012        filtered_args.to_vec()
1013    } else {
1014        std::iter::once(build_mode_flag.to_string())
1015            .chain(filtered_args.iter().cloned())
1016            .collect::<Vec<String>>()
1017    })
1018    .into_iter()
1019    .chain(if has_out_dir {
1020        Vec::new()
1021    } else {
1022        vec![OUT_DIR_ARG.to_string(), default_out_dir.clone()]
1023    })
1024    .chain(if has_target {
1025        Vec::new()
1026    } else {
1027        vec![TARGET_ARG.to_string(), TARGET_WEB.to_string()]
1028    })
1029    .collect();
1030    let out_dir_absolute: PathBuf = resolve_out_dir(args);
1031    create_dir_all(&out_dir_absolute)
1032        .await
1033        .map_err(|error: IoError| EuvError::IoPath {
1034            message: String::from("Failed to create output directory"),
1035            path: out_dir_absolute.clone(),
1036            error,
1037        })?;
1038    log::info!(
1039        "Running: {WASM_PACK_COMMAND} {WASM_PACK_BUILD_SUBCOMMAND} {} ...",
1040        display_args.join(" ")
1041    );
1042    let output: Output = command
1043        .output()
1044        .await
1045        .map_err(|error: IoError| EuvError::Io {
1046            message: String::from("Failed to execute wasm-pack"),
1047            error,
1048        })?;
1049    let stdout: String = String::from_utf8_lossy(&output.stdout).to_string();
1050    let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
1051    if args.get_no_gitignore() {
1052        let gitignore_path: PathBuf = out_dir_absolute.join(GITIGNORE_FILE_NAME);
1053        if gitignore_path.exists()
1054            && let Err(error) = remove_file(&gitignore_path).await
1055        {
1056            log::warn!("Failed to remove '{}': {error}", gitignore_path.display());
1057        }
1058    }
1059    for line in stdout.lines().filter(|line: &&str| !line.is_empty()) {
1060        log::info!("{line}");
1061    }
1062    if output.status.success() {
1063        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
1064            log::info!("{line}");
1065        }
1066    } else {
1067        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
1068            log::error!("{line}");
1069        }
1070        return Err(EuvError::Message(String::from("wasm-pack build failed")));
1071    }
1072    Ok(())
1073}
1074
1075/// Prints the startup banner and command information.
1076///
1077/// # Arguments
1078///
1079/// - `Action` - The action to perform (run or build).
1080pub fn print_banner(action: Action) {
1081    let version: &str = env!("CARGO_PKG_VERSION");
1082    if version.is_empty() {
1083        log::warn!("Failed to parse version from root Cargo.toml");
1084    } else {
1085        log::info!("euv v{version}");
1086    }
1087    let action_name: &str = match action {
1088        Action::Run => ACTION_RUN,
1089        Action::Build => ACTION_BUILD,
1090    };
1091    log::info!("Mode: {action_name}");
1092    log::info!(
1093        "Use .gitignore to filter file change events; pass --no-gitignore to remove .gitignore from output"
1094    );
1095}
1096
1097/// Enumerates all network interface IP addresses and prints each server URL
1098/// along with its corresponding QR code to the console.
1099///
1100/// Includes both loopback (127.0.0.1) and all private/public IPv4 addresses
1101/// bound to the host's network interfaces. Each address produces one URL line
1102/// followed by a Unicode QR code rendered with half-block characters,
1103/// where every line carries the standard log prefix (timestamp + level).
1104///
1105/// # Arguments
1106///
1107/// - `&ServerUrlConfig` - The server URL configuration.
1108pub(crate) fn print_server_urls(config: &ServerUrlConfig) {
1109    let port: u16 = config.get_port();
1110    let route_prefix: &str = config.get_route_prefix();
1111    let index_html_file_name: &str = config.get_index_html_file_name();
1112    let mut addresses: Vec<IpAddr> = Vec::new();
1113    match if_addrs::get_if_addrs() {
1114        Ok(interfaces) => {
1115            for interface in interfaces {
1116                let ip: IpAddr = interface.addr.ip();
1117                if !addresses.contains(&ip) {
1118                    addresses.push(ip);
1119                }
1120            }
1121        }
1122        Err(error) => {
1123            log::warn!("Failed to enumerate network interfaces: {error}");
1124        }
1125    }
1126    if addresses.is_empty() {
1127        addresses.push(IpAddr::V4(Ipv4Addr::LOCALHOST));
1128    }
1129    for ip in addresses {
1130        let host: String = match ip {
1131            IpAddr::V6(_) => format!("[{ip}]"),
1132            IpAddr::V4(_) => format!("{ip}"),
1133        };
1134        let url: String =
1135            format!("{HTTP_SCHEME}://{host}:{port}/{route_prefix}/{index_html_file_name}");
1136        log::info!("Server: {url}");
1137        match QrCode::new(url.as_str()) {
1138            Ok(code) => {
1139                let string: String = code.render::<Dense1x2>().quiet_zone(false).build();
1140                for line in string.lines() {
1141                    log::info!("{line}");
1142                }
1143            }
1144            Err(error) => {
1145                log::warn!("Failed to generate QR code: {error}");
1146            }
1147        }
1148    }
1149}
1150
1151/// Executes `hyperlane-cli fmt` via the library API to format Rust source files.
1152///
1153/// # Returns
1154///
1155/// - `Result<(), EuvError>` - Indicates success or failure of the formatting operation.
1156pub async fn run_hyperlane_fmt() -> Result<(), EuvError> {
1157    let args: hyperlane_cli::Args = hyperlane_cli::Args {
1158        command: hyperlane_cli::CommandType::Fmt,
1159        check: false,
1160        manifest_path: None,
1161        bump_type: None,
1162        max_retries: 0,
1163        project_name: None,
1164        template_type: None,
1165        model_sub_type: None,
1166        component_name: None,
1167    };
1168    hyperlane_cli::execute_fmt(&args)
1169        .await
1170        .map_err(|error: IoError| EuvError::Io {
1171            message: String::from("hyperlane-cli fmt error"),
1172            error,
1173        })
1174}