Skip to main content

euv_cli/build/
fn.rs

1use super::*;
2
3/// Checks whether `wasm_pack_args` already contains a build mode flag.
4///
5/// Returns `true` if any of `--dev`, `--release`, or `--profiling`
6/// is present in the arguments list.
7///
8/// # Arguments
9///
10/// - `&[String]` - The wasm-pack arguments to search.
11///
12/// # Returns
13///
14/// - `bool` - Whether a build mode flag is already present.
15pub fn has_build_mode_flag(wasm_pack_args: &[String]) -> bool {
16    wasm_pack_args
17        .iter()
18        .any(|arg: &String| arg == DEV_FLAG || arg == RELEASE_FLAG || arg == PROFILING_FLAG)
19}
20
21/// Filters out euv-specific arguments from the wasm-pack arguments.
22///
23/// First locates the genuine passthrough arguments by taking everything
24/// after the last `--` separator (or the full list if no `--` is present).
25/// Then removes all known euv-specific flags and their values so that
26/// only wasm-pack-compatible arguments remain.
27///
28/// # Arguments
29///
30/// - `&[String]` - The raw wasm-pack arguments to filter.
31///
32/// # Returns
33///
34/// - `Vec<String>` - The filtered arguments safe for wasm-pack.
35pub fn filter_euv_args(wasm_pack_args: &[String]) -> Vec<String> {
36    let raw_args: &[String] = if let Some(position) = wasm_pack_args
37        .iter()
38        .rposition(|arg: &String| arg == DOUBLE_DASH)
39    {
40        &wasm_pack_args[position + 1..]
41    } else {
42        wasm_pack_args
43    };
44    let mut filtered: Vec<String> = Vec::new();
45    let mut skip_next: bool = false;
46    for arg in raw_args {
47        if skip_next {
48            skip_next = false;
49            continue;
50        }
51        if EUV_ARGS.contains(&arg.as_str()) {
52            if arg.contains('=') {
53                continue;
54            }
55            skip_next = true;
56            continue;
57        }
58        filtered.push(arg.clone());
59    }
60    filtered
61}
62
63/// Reconciles euv-specific arguments that may have been collected into
64/// `wasm_pack_args` (e.g. when placed after `--`) back into the
65/// corresponding `ModeArgs` fields.
66///
67/// Because clap's `trailing_var_arg` collects all unrecognized arguments
68/// into `wasm_pack_args`, any euv flag placed after `--` is not parsed
69/// by clap into its dedicated field. This function scans `wasm_pack_args`
70/// for known euv flags and overwrites the `ModeArgs` fields so that the
71/// rest of the codebase can rely on the typed accessors regardless of
72/// argument order.
73///
74/// # Arguments
75///
76/// - `&mut ModeArgs` - The CLI arguments to reconcile in-place.
77pub fn reconcile_args(args: &mut ModeArgs) {
78    let wasm_pack_args: Vec<String> = args.get_wasm_pack_args().clone();
79    let mut crate_path: Option<PathBuf> = None;
80    let mut port: Option<u16> = None;
81    let mut www_dir: Option<String> = None;
82    let mut index_html: Option<Option<PathBuf>> = None;
83    let mut no_gitignore: Option<bool> = None;
84    let mut dev: Option<bool> = None;
85    let mut release: Option<bool> = None;
86    let mut profiling: Option<bool> = None;
87    let mut iter: Iter<String> = wasm_pack_args.iter();
88    while let Some(arg) = iter.next() {
89        match arg.as_str() {
90            CRATE_PATH_ARG | CRATE_PATH_ARG_SHORT => {
91                if let Some(value) = iter.next() {
92                    crate_path = Some(PathBuf::from(value));
93                }
94            }
95            PORT_ARG | PORT_ARG_SHORT => {
96                if let Some(value) = iter.next()
97                    && let Ok(parsed_port) = value.parse::<u16>()
98                {
99                    port = Some(parsed_port);
100                }
101            }
102            WWW_DIR_ARG => {
103                if let Some(value) = iter.next() {
104                    www_dir = Some(value.clone());
105                }
106            }
107            INDEX_HTML_ARG => {
108                if let Some(value) = iter.next() {
109                    index_html = Some(Some(PathBuf::from(value)));
110                }
111            }
112            NO_GITIGNORE_ARG => {
113                no_gitignore = Some(true);
114            }
115            DEV_FLAG => {
116                dev = Some(true);
117            }
118            RELEASE_FLAG => {
119                release = Some(true);
120            }
121            PROFILING_FLAG => {
122                profiling = Some(true);
123            }
124            other => {
125                if let Some(value) = other.strip_prefix(&format!("{CRATE_PATH_ARG}=")) {
126                    crate_path = Some(PathBuf::from(value));
127                } else if let Some(value) = other.strip_prefix(&format!("{PORT_ARG}=")) {
128                    if let Ok(parsed_port) = value.parse::<u16>() {
129                        port = Some(parsed_port);
130                    }
131                } else if let Some(value) = other.strip_prefix(&format!("{WWW_DIR_ARG}=")) {
132                    www_dir = Some(value.to_string());
133                } else if let Some(value) = other.strip_prefix(&format!("{INDEX_HTML_ARG}=")) {
134                    index_html = Some(Some(PathBuf::from(value)));
135                }
136            }
137        }
138    }
139    if let Some(value) = crate_path {
140        args.set_crate_path(value);
141    }
142    if let Some(value) = port {
143        args.set_port(value);
144    }
145    if let Some(value) = www_dir {
146        args.set_www_dir(value);
147    }
148    if let Some(value) = index_html {
149        args.set_index_html(value);
150    }
151    if let Some(value) = no_gitignore {
152        args.set_no_gitignore(value);
153    }
154    if let Some(value) = dev {
155        args.set_dev(value);
156    }
157    if let Some(value) = release {
158        args.set_release(value);
159    }
160    if let Some(value) = profiling {
161        args.set_profiling(value);
162    }
163}
164
165/// Resolves the build mode from CLI arguments.
166///
167/// First checks the explicit `--dev`, `--release`, and `--profiling` flags on `ModeArgs`.
168/// If none of those are set, inspects `wasm_pack_args` for any build mode flag
169/// that may have been forwarded by the user.
170/// Defaults to `BuildMode::Dev` if no build mode flag is found anywhere.
171///
172/// # Arguments
173///
174/// - `&ModeArgs` - The CLI arguments containing the build mode flags and wasm_pack_args.
175///
176/// # Returns
177///
178/// - `BuildMode` - The resolved build mode.
179pub fn resolve_build_mode(args: &ModeArgs) -> BuildMode {
180    if args.get_profiling() {
181        BuildMode::Profiling
182    } else if args.get_release() {
183        BuildMode::Release
184    } else if args.get_dev() {
185        BuildMode::Dev
186    } else if args
187        .get_wasm_pack_args()
188        .iter()
189        .any(|arg: &String| arg == PROFILING_FLAG)
190    {
191        BuildMode::Profiling
192    } else if args
193        .get_wasm_pack_args()
194        .iter()
195        .any(|arg: &String| arg == RELEASE_FLAG)
196    {
197        BuildMode::Release
198    } else {
199        BuildMode::Dev
200    }
201}
202
203/// Converts a `BuildMode` to the corresponding wasm-pack flag string.
204///
205/// # Arguments
206///
207/// - `BuildMode` - The build mode to convert.
208///
209/// # Returns
210///
211/// - `&'static str` - The wasm-pack command-line flag.
212pub fn build_mode_to_flag(build_mode: BuildMode) -> &'static str {
213    match build_mode {
214        BuildMode::Dev => DEV_FLAG,
215        BuildMode::Release => RELEASE_FLAG,
216        BuildMode::Profiling => PROFILING_FLAG,
217    }
218}
219
220/// Builds a `Gitignore` matcher from the `.gitignore` file at the given root path.
221///
222/// # Arguments
223///
224/// - `&PathBuf` - The root directory where `.gitignore` is located.
225///
226/// # Returns
227///
228/// - `Gitignore` - The compiled gitignore matcher.
229async fn build_gitignore(root: &PathBuf) -> Gitignore {
230    let gitignore_path: PathBuf = root.join(GITIGNORE_FILE_NAME);
231    let mut builder: GitignoreBuilder = GitignoreBuilder::new(root);
232    let gitignore_exists: bool = metadata(&gitignore_path).await.is_ok();
233    if gitignore_exists && let Some(error) = builder.add(&gitignore_path) {
234        log::warn!("Failed to load .gitignore: {error}");
235    }
236    match builder.build() {
237        Ok(gitignore) => {
238            if gitignore_exists {
239                log::info!("Loaded .gitignore to filter file change events");
240            }
241            gitignore
242        }
243        Err(error) => {
244            log::warn!("Failed to build gitignore matcher: {error}");
245            GitignoreBuilder::new(root)
246                .build()
247                .unwrap_or_else(|_error: ignore::Error| Gitignore::empty())
248        }
249    }
250}
251
252/// Extracts the value of `--out-name` from the wasm-pack arguments.
253///
254/// Returns `None` if `--out-name` is not specified.
255///
256/// # Arguments
257///
258/// - `&[String]` - The wasm-pack arguments to search.
259///
260/// # Returns
261///
262/// - `Option<String>` - The value of `--out-name` if found.
263fn extract_out_name(wasm_pack_args: &[String]) -> Option<String> {
264    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
265    while let Some(arg) = iter.next() {
266        if arg == OUT_NAME_ARG {
267            return iter.next().cloned();
268        }
269        if let Some(value) = arg.strip_prefix(&format!("{OUT_NAME_ARG}=")) {
270            return Some(value.to_string());
271        }
272    }
273    None
274}
275
276/// Extracts the value of `--out-dir` from the wasm-pack arguments.
277///
278/// Returns `None` if `--out-dir` is not specified.
279///
280/// # Arguments
281///
282/// - `&[String]` - The wasm-pack arguments to search.
283///
284/// # Returns
285///
286/// - `Option<String>` - The value of `--out-dir` if found.
287fn extract_out_dir(wasm_pack_args: &[String]) -> Option<String> {
288    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
289    while let Some(arg) = iter.next() {
290        if arg == OUT_DIR_ARG {
291            return iter.next().cloned();
292        }
293        if let Some(value) = arg.strip_prefix(&format!("{OUT_DIR_ARG}=")) {
294            return Some(value.to_string());
295        }
296    }
297    None
298}
299
300/// Resolves the output JS filename for HTML generation.
301///
302/// Uses `--out-name` from wasm-pack args if specified,
303/// otherwise reads the crate name from `Cargo.toml` `[package] name` field
304/// and replaces hyphens with underscores (matching wasm-pack behavior).
305/// Appends `.js` extension to form the complete JS filename.
306///
307/// # Arguments
308///
309/// - `&ModeArgs` - The CLI arguments containing crate_path and wasm_pack_args.
310///
311/// # Returns
312///
313/// - `String` - The resolved JS filename with `.js` extension (e.g. `euv_example.js`).
314pub fn resolve_out_name(args: &ModeArgs) -> String {
315    let name: String = if let Some(out_name) = extract_out_name(args.get_wasm_pack_args()) {
316        out_name.replace(STR_HYPHEN, STR_UNDERSCORE)
317    } else {
318        let cargo_toml_path: PathBuf = args.get_crate_path().join(CARGO_TOML_FILE_NAME);
319        read_crate_name_from_toml(&cargo_toml_path)
320            .unwrap_or_else(|| {
321                args.get_crate_path()
322                    .file_name()
323                    .unwrap_or_default()
324                    .to_string_lossy()
325                    .to_string()
326            })
327            .replace(STR_HYPHEN, STR_UNDERSCORE)
328    };
329    format!("{name}{JS_EXTENSION}")
330}
331
332/// Reads the `name` field from a Cargo.toml file.
333///
334/// Parses the file line-by-line looking for `name = "..."` within the `[package]` section.
335///
336/// # Arguments
337///
338/// - `&Path` - The path to the Cargo.toml file.
339///
340/// # Returns
341///
342/// - `Option<String>` - The crate name if found.
343fn read_crate_name_from_toml(path: &Path) -> Option<String> {
344    let content: String = std::fs::read_to_string(path).ok()?;
345    let mut in_package: bool = false;
346    for line in content.lines() {
347        let trimmed: &str = line.trim();
348        if trimmed.starts_with('[') {
349            in_package = trimmed == "[package]";
350            continue;
351        }
352        if in_package
353            && trimmed.starts_with("name")
354            && let Some(value) = trimmed.strip_prefix("name")
355        {
356            let value: &str = value.trim().strip_prefix('=')?.trim();
357            let value: &str = value.strip_prefix('"')?.strip_suffix('"')?;
358            return Some(value.to_string());
359        }
360    }
361    None
362}
363
364/// Computes the relative path from a base directory to a target directory.
365///
366/// Compares the component sequences of both paths to find the common prefix,
367/// then emits `..` for each remaining base component followed by the remaining
368/// target components.
369///
370/// # Arguments
371///
372/// - `&Path` - The base directory path.
373/// - `&Path` - The target directory path.
374///
375/// # Returns
376///
377/// - `PathBuf` - The relative path from base to target.
378fn compute_relative_path(base: &Path, target: &Path) -> PathBuf {
379    let base_components: Vec<Component> = base.components().collect();
380    let target_components: Vec<Component> = target.components().collect();
381    let common_len: usize = base_components
382        .iter()
383        .zip(target_components.iter())
384        .take_while(|(base_component, target_component)| base_component == target_component)
385        .count();
386    let mut result: PathBuf = PathBuf::new();
387    for _ in &base_components[common_len..] {
388        result.push("..");
389    }
390    for component in &target_components[common_len..] {
391        if let Component::Normal(os_str) = component {
392            result.push(os_str);
393        }
394    }
395    result
396}
397
398/// Resolves the serving root directory for the development server.
399///
400/// When the output directory is inside the www directory, returns the resolved www directory.
401/// When the output directory is outside the www directory, returns the parent of the output directory
402/// so that `index.html` and WASM artifacts are co-located under the same serving root.
403///
404/// # Arguments
405///
406/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
407///
408/// # Returns
409///
410/// - `PathBuf` - The resolved serving root directory.
411pub async fn resolve_serving_root(args: &ModeArgs) -> PathBuf {
412    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
413    let out_dir_absolute: PathBuf = resolve_out_dir(args);
414    if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
415        resolve_www_dir(&www_absolute).await
416    } else {
417        out_dir_absolute
418            .parent()
419            .map(|p: &Path| p.to_path_buf())
420            .unwrap_or_else(|| www_absolute)
421    }
422}
423
424/// Resolves the serving route prefix relative to the crate path.
425///
426/// Returns the forward-slash-separated path of the serving root relative to the crate path.
427/// Used for server route registration and URL display.
428///
429/// # Arguments
430///
431/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
432///
433/// # Returns
434///
435/// - `String` - The serving route prefix (e.g. `www` or `wwws`).
436pub fn resolve_serving_route_prefix(args: &ModeArgs) -> String {
437    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
438    let out_dir_absolute: PathBuf = resolve_out_dir(args);
439    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
440        www_absolute
441    } else {
442        out_dir_absolute
443            .parent()
444            .map(|p: &Path| p.to_path_buf())
445            .unwrap_or_else(|| www_absolute)
446    };
447    serving_root
448        .strip_prefix(args.get_crate_path())
449        .map(|rel: &Path| {
450            rel.to_string_lossy()
451                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
452        })
453        .unwrap_or_else(|_| {
454            args.get_www_dir()
455                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
456        })
457}
458
459/// Resolves the JS import path for HTML generation.
460///
461/// Computes the relative path from the serving root to the output directory,
462/// then appends the JS filename (from `resolve_out_name`, which includes `.js`)
463/// to form the full import path (e.g. `./pkg/euv.js` or `./pksg/cc.js`).
464///
465/// # Arguments
466///
467/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
468///
469/// # Returns
470///
471/// - `String` - The resolved JS import path relative to the serving root.
472pub fn resolve_import_path(args: &ModeArgs) -> String {
473    let out_name: String = resolve_out_name(args);
474    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
475    let out_dir_absolute: PathBuf = resolve_out_dir(args);
476    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
477        www_absolute
478    } else {
479        out_dir_absolute
480            .parent()
481            .map(|p: &Path| p.to_path_buf())
482            .unwrap_or_else(|| www_absolute)
483    };
484    let relative: PathBuf = compute_relative_path(&serving_root, &out_dir_absolute);
485    let mut components: Vec<String> = relative
486        .components()
487        .filter_map(|component: Component| match component {
488            Component::Normal(os_str) => os_str.to_str().map(|text: &str| text.to_string()),
489            Component::ParentDir => Some(PARENT_DIR.to_string()),
490            _ => None,
491        })
492        .collect();
493    components.push(out_name);
494    format!("{RELATIVE_PATH_PREFIX}{}", components.join(PATH_SEPARATOR))
495}
496
497/// Resolves the output directory for wasm-pack artifacts.
498///
499/// Uses `--out-dir` from wasm-pack args if specified,
500/// otherwise defaults to `{www_dir}/pkg` so that build artifacts
501/// are placed directly inside the www directory served
502/// by the development server.
503///
504/// # Arguments
505///
506/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
507///
508/// # Returns
509///
510/// - `PathBuf` - The resolved output directory (absolute if crate_path is joined).
511pub fn resolve_out_dir(args: &ModeArgs) -> PathBuf {
512    let out_dir_path: PathBuf = PathBuf::from(
513        extract_out_dir(args.get_wasm_pack_args())
514            .unwrap_or_else(|| format!("{}/{PKG_DIR_NAME}", args.get_www_dir())),
515    );
516    if out_dir_path.is_absolute() {
517        out_dir_path
518    } else {
519        args.get_crate_path().join(&out_dir_path)
520    }
521}
522
523/// Executes a build-only pipeline: formats euv macros, cleans output directory,
524/// builds WASM, and generates HTML.
525///
526/// Unlike `run_build_pipeline`, this cleans the output directory before building
527/// and skips reload notifications — only the essential WASM build artifacts are kept.
528///
529/// # Arguments
530///
531/// - `&ModeArgs` - The CLI arguments.
532///
533/// # Returns
534///
535/// - `Result<(), EuvError>` - Indicates success or failure of the build.
536pub async fn run_build_only_pipeline(args: &ModeArgs) -> Result<(), EuvError> {
537    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
538    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
539        log::warn!("euv fmt error: {error}");
540    }
541    let out_dir: PathBuf = resolve_out_dir(args);
542    clean_out_dir(&out_dir).await;
543    build_wasm(args).await?;
544    log::info!("WASM build completed successfully");
545    let html_config: HtmlConfig = HtmlConfig::new(
546        resolve_serving_root(args).await,
547        resolve_import_path(args),
548        resolve_build_mode(args) == BuildMode::Release,
549        args.try_get_index_html().clone(),
550        false,
551    );
552    generate_html(&html_config).await?;
553    Ok(())
554}
555
556/// Cleans the output directory before a fresh build.
557///
558/// Removes all files and subdirectories within the output directory
559/// so that stale artifacts from previous builds do not remain.
560/// The directory itself is preserved (recreated if missing).
561///
562/// # Arguments
563///
564/// - `&Path` - The output directory to clean.
565pub async fn clean_out_dir(out_dir: &Path) {
566    let mut entries: ReadDir = match read_dir(out_dir).await {
567        Ok(dir) => dir,
568        Err(_) => return,
569    };
570    while let Ok(Some(entry)) = entries.next_entry().await {
571        let path: PathBuf = entry.path();
572        if path.is_dir() {
573            if let Err(error) = remove_dir_all(&path).await {
574                log::warn!("Failed to remove directory '{}': {error}", path.display());
575            }
576        } else if let Err(error) = remove_file(&path).await {
577            log::warn!("Failed to remove file '{}': {error}", path.display());
578        }
579    }
580}
581
582/// Executes a full build pipeline: euv fmt, build wasm, generate HTML.
583/// After the serial pipeline completes, hyperlane-cli fmt is spawned in the
584/// background so it does not block the caller.
585/// Notifies the reload channel on build success or failure.
586///
587/// # Arguments
588///
589/// - `&ModeArgs` - The CLI arguments.
590/// - `Option<&broadcast::Sender<ReloadEvent>>` - Optional reload channel for notifying clients.
591///
592/// # Returns
593///
594/// - `Result<String, EuvError>` - The generated HTML with reload script injected on success.
595pub async fn run_build_pipeline(
596    args: &ModeArgs,
597    reload_tx: Option<&broadcast::Sender<ReloadEvent>>,
598) -> Result<String, EuvError> {
599    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
600    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
601        log::warn!("euv fmt error: {error}");
602    }
603    match build_wasm(args).await {
604        Ok(()) => {
605            log::info!("WASM build completed successfully");
606            if let Some(sender) = reload_tx {
607                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
608                    sender.send(ReloadEvent::Reload);
609            }
610        }
611        Err(error) => {
612            log::error!("WASM build failed: {error}");
613            if let Some(sender) = reload_tx {
614                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
615                    sender.send(ReloadEvent::Error(error.to_string()));
616            }
617        }
618    }
619    let html_config: HtmlConfig = HtmlConfig::new(
620        resolve_serving_root(args).await,
621        resolve_import_path(args),
622        resolve_build_mode(args) == BuildMode::Release,
623        args.try_get_index_html().clone(),
624        true,
625    );
626    let html: String = generate_html(&html_config).await?;
627    spawn(async move {
628        if let Err(error) = run_hyperlane_fmt().await {
629            log::warn!("hyperlane-cli fmt error: {error}");
630        }
631    });
632    Ok(html)
633}
634
635/// Watches source files and triggers WASM builds.
636///
637/// # Arguments
638///
639/// - `Arc<AppState>` - The shared application state.
640///
641/// # Returns
642///
643/// - `Result<(), EuvError>` - Indicates success or failure of the file watcher.
644pub(crate) async fn watch_and_build(state: Arc<AppState>) -> Result<(), EuvError> {
645    let crate_path: PathBuf = state.get_args().get_crate_path().clone();
646    let src_path: PathBuf = crate_path.join(SRC_DIR_NAME);
647    let gitignore: Gitignore = build_gitignore(&crate_path).await;
648    let (tx, mut rx): (Sender<Event>, Receiver<Event>) = channel(32);
649    let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
650        move |result: Result<Event, notify::Error>| {
651            if let Ok(event) = result {
652                let _: Result<(), tokio::sync::mpsc::error::SendError<Event>> =
653                    tx.blocking_send(event);
654            }
655        },
656        Config::default(),
657    )?;
658    watcher.watch(&src_path, RecursiveMode::Recursive)?;
659    log::info!("Watching {} for changes...", src_path.display());
660    let mut debounce: Interval = interval(Duration::from_millis(500));
661    debounce.tick().await;
662    while let Some(event) = rx.recv().await {
663        let filtered_paths: Vec<String> = event
664            .paths
665            .iter()
666            .filter(|path: &&PathBuf| !gitignore.matched(*path, path.is_dir()).is_ignore())
667            .map(|path: &PathBuf| path.display().to_string())
668            .collect();
669        if filtered_paths.is_empty() {
670            continue;
671        }
672        log::warn!("File change detected: {}", filtered_paths.join(", "));
673        debounce.reset();
674        sleep(Duration::from_millis(300)).await;
675        let mut building: RwLockWriteGuard<bool> = state.get_is_building().write().await;
676        if *building {
677            continue;
678        }
679        *building = true;
680        drop(building);
681        let state_for_build: Arc<AppState> = Arc::clone(&state);
682        spawn(async move {
683            let args: ModeArgs = state_for_build.get_args().clone();
684            let reload_tx: broadcast::Sender<ReloadEvent> = state_for_build.get_reload_tx().clone();
685            match run_build_pipeline(&args, Some(&reload_tx)).await {
686                Ok(html) => {
687                    let mut content: RwLockWriteGuard<String> =
688                        state_for_build.get_html_content().write().await;
689                    *content = html;
690                }
691                Err(error) => {
692                    log::error!("Build pipeline error: {error}");
693                }
694            }
695            let mut building: RwLockWriteGuard<bool> =
696                state_for_build.get_is_building().write().await;
697            *building = false;
698        });
699    }
700    Ok(())
701}
702
703/// Runs wasm-pack build for the target crate.
704///
705/// All arguments in `args.wasm_pack_args` are transparently forwarded
706/// to `wasm-pack build`. If `--out-dir` is not specified by the user,
707/// `--out-dir {www_dir}/pkg` is automatically injected so that build artifacts
708/// are placed inside the www directory served by the development server.
709///
710/// # Arguments
711///
712/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
713///
714/// # Returns
715///
716/// - `Result<(), EuvError>` - Indicates success or failure of the wasm-pack build.
717pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> {
718    let build_mode: BuildMode = resolve_build_mode(args);
719    let build_mode_flag: &str = build_mode_to_flag(build_mode);
720    let filtered_args: Vec<String> = filter_euv_args(args.get_wasm_pack_args());
721    let has_existing_build_mode: bool = has_build_mode_flag(&filtered_args);
722    let default_out_dir: String = format!("{}/{PKG_DIR_NAME}", args.get_www_dir());
723    let mut command: Command = Command::new(WASM_PACK_COMMAND);
724    command.arg(WASM_PACK_BUILD_SUBCOMMAND);
725    if !has_existing_build_mode {
726        command.arg(build_mode_flag);
727    }
728    command
729        .args(&filtered_args)
730        .env(RUST_MIN_STACK_ENV, RUST_MIN_STACK_VALUE);
731    let has_out_dir: bool = extract_out_dir(&filtered_args).is_some();
732    if !has_out_dir {
733        command.arg(OUT_DIR_ARG).arg(&default_out_dir);
734    }
735    let has_target: bool = filtered_args
736        .iter()
737        .any(|arg: &String| arg == TARGET_ARG || arg.starts_with(&format!("{TARGET_ARG}=")));
738    if !has_target {
739        command.arg(TARGET_ARG).arg(TARGET_WEB);
740    }
741    command.current_dir(args.get_crate_path());
742    command.stdout(Stdio::piped()).stderr(Stdio::piped());
743    let display_args: Vec<String> = (if has_existing_build_mode {
744        filtered_args.to_vec()
745    } else {
746        std::iter::once(build_mode_flag.to_string())
747            .chain(filtered_args.iter().cloned())
748            .collect::<Vec<String>>()
749    })
750    .into_iter()
751    .chain(if has_out_dir {
752        Vec::new()
753    } else {
754        vec![OUT_DIR_ARG.to_string(), default_out_dir.clone()]
755    })
756    .chain(if has_target {
757        Vec::new()
758    } else {
759        vec![TARGET_ARG.to_string(), TARGET_WEB.to_string()]
760    })
761    .collect();
762    let out_dir_absolute: PathBuf = resolve_out_dir(args);
763    create_dir_all(&out_dir_absolute)
764        .await
765        .map_err(|error: io::Error| EuvError::IoPath {
766            message: String::from("Failed to create output directory"),
767            path: out_dir_absolute.clone(),
768            error,
769        })?;
770    log::info!(
771        "Running: {WASM_PACK_COMMAND} {WASM_PACK_BUILD_SUBCOMMAND} {} ...",
772        display_args.join(" ")
773    );
774    let output: Output = command
775        .output()
776        .await
777        .map_err(|error: io::Error| EuvError::Io {
778            message: String::from("Failed to execute wasm-pack"),
779            error,
780        })?;
781    let stdout: String = String::from_utf8_lossy(&output.stdout).to_string();
782    let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
783    if args.get_no_gitignore() {
784        let gitignore_path: PathBuf = out_dir_absolute.join(GITIGNORE_FILE_NAME);
785        if gitignore_path.exists()
786            && let Err(error) = remove_file(&gitignore_path).await
787        {
788            log::warn!("Failed to remove '{}': {error}", gitignore_path.display());
789        }
790    }
791    for line in stdout.lines().filter(|line: &&str| !line.is_empty()) {
792        log::info!("{line}");
793    }
794    if output.status.success() {
795        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
796            log::info!("{line}");
797        }
798    } else {
799        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
800            log::error!("{line}");
801        }
802        return Err(EuvError::Message(String::from("wasm-pack build failed")));
803    }
804    Ok(())
805}
806
807/// Prints the startup banner and command information.
808///
809/// # Arguments
810///
811/// - `Action` - The action to perform (run or build).
812pub fn print_banner(action: Action) {
813    let version: &str = env!("CARGO_PKG_VERSION");
814    if version.is_empty() {
815        log::warn!("Failed to parse version from root Cargo.toml");
816    } else {
817        log::info!("euv v{version}");
818    }
819    let action_name: &str = match action {
820        Action::Run => ACTION_RUN,
821        Action::Build => ACTION_BUILD,
822    };
823    log::info!("Mode: {action_name}");
824    log::info!(
825        "Use .gitignore to filter file change events; pass --no-gitignore to remove .gitignore from output"
826    );
827}
828
829/// Enumerates all network interface IP addresses and prints each server URL
830/// along with its corresponding QR code to the console.
831///
832/// Includes both loopback (127.0.0.1) and all private/public IPv4 addresses
833/// bound to the host's network interfaces. Each address produces one URL line
834/// followed by a Unicode QR code rendered with half-block characters,
835/// where every line carries the standard log prefix (timestamp + level).
836///
837/// # Arguments
838///
839/// - `&ServerUrlConfig` - The server URL configuration.
840pub(crate) fn print_server_urls(config: &ServerUrlConfig) {
841    let port: u16 = config.get_port();
842    let route_prefix: &str = config.get_route_prefix();
843    let index_html_file_name: &str = config.get_index_html_file_name();
844    let mut addresses: Vec<IpAddr> = Vec::new();
845    match if_addrs::get_if_addrs() {
846        Ok(interfaces) => {
847            for interface in interfaces {
848                let ip: IpAddr = interface.addr.ip();
849                if !addresses.contains(&ip) {
850                    addresses.push(ip);
851                }
852            }
853        }
854        Err(error) => {
855            log::warn!("Failed to enumerate network interfaces: {error}");
856        }
857    }
858    if addresses.is_empty() {
859        addresses.push(IpAddr::V4(Ipv4Addr::LOCALHOST));
860    }
861    for ip in addresses {
862        let host: String = match ip {
863            IpAddr::V6(_) => format!("[{ip}]"),
864            IpAddr::V4(_) => format!("{ip}"),
865        };
866        let url: String =
867            format!("{HTTP_SCHEME}://{host}:{port}/{route_prefix}/{index_html_file_name}");
868        log::info!("Server: {url}");
869        match QrCode::new(url.as_str()) {
870            Ok(code) => {
871                let string: String = code.render::<Dense1x2>().quiet_zone(false).build();
872                for line in string.lines() {
873                    log::info!("{line}");
874                }
875            }
876            Err(error) => {
877                log::warn!("Failed to generate QR code: {error}");
878            }
879        }
880    }
881}
882
883/// Executes `hyperlane-cli fmt` via the library API to format Rust source files.
884///
885/// # Returns
886///
887/// - `Result<(), EuvError>` - Indicates success or failure of the formatting operation.
888pub async fn run_hyperlane_fmt() -> Result<(), EuvError> {
889    let args: hyperlane_cli::Args = hyperlane_cli::Args {
890        command: hyperlane_cli::CommandType::Fmt,
891        check: false,
892        manifest_path: None,
893        bump_type: None,
894        max_retries: 0,
895        project_name: None,
896        template_type: None,
897        model_sub_type: None,
898        component_name: None,
899    };
900    hyperlane_cli::execute_fmt(&args)
901        .await
902        .map_err(|error: io::Error| EuvError::Io {
903            message: String::from("hyperlane-cli fmt error"),
904            error,
905        })
906}