1use super::*;
2
3pub 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
21pub 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
63pub 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
165pub 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
203pub 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
220async 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
252fn 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
276fn 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
300pub 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
332fn 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
364fn 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
398pub 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
424pub 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
459pub 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
497pub 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
523pub 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 );
551 generate_html(&html_config).await?;
552 Ok(())
553}
554
555pub async fn clean_out_dir(out_dir: &Path) {
565 let mut entries: ReadDir = match read_dir(out_dir).await {
566 Ok(dir) => dir,
567 Err(_) => return,
568 };
569 while let Ok(Some(entry)) = entries.next_entry().await {
570 let path: PathBuf = entry.path();
571 if path.is_dir() {
572 if let Err(error) = remove_dir_all(&path).await {
573 log::warn!("Failed to remove directory '{}': {error}", path.display());
574 }
575 } else if let Err(error) = remove_file(&path).await {
576 log::warn!("Failed to remove file '{}': {error}", path.display());
577 }
578 }
579}
580
581pub async fn run_build_pipeline(
595 args: &ModeArgs,
596 reload_tx: Option<&broadcast::Sender<ReloadEvent>>,
597) -> Result<String, EuvError> {
598 let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
599 if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
600 log::warn!("euv fmt error: {error}");
601 }
602 match build_wasm(args).await {
603 Ok(()) => {
604 log::info!("WASM build completed successfully");
605 if let Some(sender) = reload_tx {
606 let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
607 sender.send(ReloadEvent::Reload);
608 }
609 }
610 Err(error) => {
611 log::error!("WASM build failed: {error}");
612 if let Some(sender) = reload_tx {
613 let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
614 sender.send(ReloadEvent::Error(error.to_string()));
615 }
616 }
617 }
618 let html_config: HtmlConfig = HtmlConfig::new(
619 resolve_serving_root(args).await,
620 resolve_import_path(args),
621 resolve_build_mode(args) == BuildMode::Release,
622 args.try_get_index_html().clone(),
623 );
624 let html: String = generate_html(&html_config).await?;
625 spawn(async move {
626 if let Err(error) = run_hyperlane_fmt().await {
627 log::warn!("hyperlane-cli fmt error: {error}");
628 }
629 });
630 Ok(html)
631}
632
633pub(crate) async fn watch_and_build(state: Arc<AppState>) -> Result<(), EuvError> {
643 let crate_path: PathBuf = state.get_args().get_crate_path().clone();
644 let src_path: PathBuf = crate_path.join(SRC_DIR_NAME);
645 let gitignore: Gitignore = build_gitignore(&crate_path).await;
646 let (tx, mut rx): (Sender<Event>, Receiver<Event>) = channel(32);
647 let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
648 move |result: Result<Event, notify::Error>| {
649 if let Ok(event) = result {
650 let _: Result<(), tokio::sync::mpsc::error::SendError<Event>> =
651 tx.blocking_send(event);
652 }
653 },
654 Config::default(),
655 )?;
656 watcher.watch(&src_path, RecursiveMode::Recursive)?;
657 log::info!("Watching {} for changes...", src_path.display());
658 let mut debounce: Interval = interval(Duration::from_millis(500));
659 debounce.tick().await;
660 while let Some(event) = rx.recv().await {
661 let filtered_paths: Vec<String> = event
662 .paths
663 .iter()
664 .filter(|path: &&PathBuf| !gitignore.matched(*path, path.is_dir()).is_ignore())
665 .map(|path: &PathBuf| path.display().to_string())
666 .collect();
667 if filtered_paths.is_empty() {
668 continue;
669 }
670 log::warn!("File change detected: {}", filtered_paths.join(", "));
671 debounce.reset();
672 sleep(Duration::from_millis(300)).await;
673 let mut building: RwLockWriteGuard<bool> = state.get_is_building().write().await;
674 if *building {
675 continue;
676 }
677 *building = true;
678 drop(building);
679 let state_for_build: Arc<AppState> = Arc::clone(&state);
680 spawn(async move {
681 let args: ModeArgs = state_for_build.get_args().clone();
682 let reload_tx: broadcast::Sender<ReloadEvent> = state_for_build.get_reload_tx().clone();
683 match run_build_pipeline(&args, Some(&reload_tx)).await {
684 Ok(html) => {
685 let mut content: RwLockWriteGuard<String> =
686 state_for_build.get_html_content().write().await;
687 *content = html;
688 }
689 Err(error) => {
690 log::error!("Build pipeline error: {error}");
691 }
692 }
693 let mut building: RwLockWriteGuard<bool> =
694 state_for_build.get_is_building().write().await;
695 *building = false;
696 });
697 }
698 Ok(())
699}
700
701pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> {
716 let build_mode: BuildMode = resolve_build_mode(args);
717 let build_mode_flag: &str = build_mode_to_flag(build_mode);
718 let filtered_args: Vec<String> = filter_euv_args(args.get_wasm_pack_args());
719 let has_existing_build_mode: bool = has_build_mode_flag(&filtered_args);
720 let default_out_dir: String = format!("{}/{PKG_DIR_NAME}", args.get_www_dir());
721 let mut command: Command = Command::new(WASM_PACK_COMMAND);
722 command.arg(WASM_PACK_BUILD_SUBCOMMAND);
723 if !has_existing_build_mode {
724 command.arg(build_mode_flag);
725 }
726 command
727 .args(&filtered_args)
728 .env(RUST_MIN_STACK_ENV, RUST_MIN_STACK_VALUE);
729 let has_out_dir: bool = extract_out_dir(&filtered_args).is_some();
730 if !has_out_dir {
731 command.arg(OUT_DIR_ARG).arg(&default_out_dir);
732 }
733 let has_target: bool = filtered_args
734 .iter()
735 .any(|arg: &String| arg == TARGET_ARG || arg.starts_with(&format!("{TARGET_ARG}=")));
736 if !has_target {
737 command.arg(TARGET_ARG).arg(TARGET_WEB);
738 }
739 command.current_dir(args.get_crate_path());
740 command.stdout(Stdio::piped()).stderr(Stdio::piped());
741 let display_args: Vec<String> = (if has_existing_build_mode {
742 filtered_args.to_vec()
743 } else {
744 std::iter::once(build_mode_flag.to_string())
745 .chain(filtered_args.iter().cloned())
746 .collect::<Vec<String>>()
747 })
748 .into_iter()
749 .chain(if has_out_dir {
750 Vec::new()
751 } else {
752 vec![OUT_DIR_ARG.to_string(), default_out_dir.clone()]
753 })
754 .chain(if has_target {
755 Vec::new()
756 } else {
757 vec![TARGET_ARG.to_string(), TARGET_WEB.to_string()]
758 })
759 .collect();
760 let out_dir_absolute: PathBuf = resolve_out_dir(args);
761 create_dir_all(&out_dir_absolute)
762 .await
763 .map_err(|error: IoError| EuvError::IoPath {
764 message: String::from("Failed to create output directory"),
765 path: out_dir_absolute.clone(),
766 error,
767 })?;
768 log::info!(
769 "Running: {WASM_PACK_COMMAND} {WASM_PACK_BUILD_SUBCOMMAND} {} ...",
770 display_args.join(" ")
771 );
772 let output: Output = command
773 .output()
774 .await
775 .map_err(|error: IoError| EuvError::Io {
776 message: String::from("Failed to execute wasm-pack"),
777 error,
778 })?;
779 let stdout: String = String::from_utf8_lossy(&output.stdout).to_string();
780 let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
781 if args.get_no_gitignore() {
782 let gitignore_path: PathBuf = out_dir_absolute.join(GITIGNORE_FILE_NAME);
783 if gitignore_path.exists()
784 && let Err(error) = remove_file(&gitignore_path).await
785 {
786 log::warn!("Failed to remove '{}': {error}", gitignore_path.display());
787 }
788 }
789 for line in stdout.lines().filter(|line: &&str| !line.is_empty()) {
790 log::info!("{line}");
791 }
792 if output.status.success() {
793 for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
794 log::info!("{line}");
795 }
796 } else {
797 for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
798 log::error!("{line}");
799 }
800 return Err(EuvError::Message(String::from("wasm-pack build failed")));
801 }
802 Ok(())
803}
804
805pub fn print_banner(action: Action) {
811 let version: &str = env!("CARGO_PKG_VERSION");
812 if version.is_empty() {
813 log::warn!("Failed to parse version from root Cargo.toml");
814 } else {
815 log::info!("euv v{version}");
816 }
817 let action_name: &str = match action {
818 Action::Run => ACTION_RUN,
819 Action::Build => ACTION_BUILD,
820 };
821 log::info!("Mode: {action_name}");
822 log::info!(
823 "Use .gitignore to filter file change events; pass --no-gitignore to remove .gitignore from output"
824 );
825}
826
827pub(crate) fn print_server_urls(config: &ServerUrlConfig) {
839 let port: u16 = config.get_port();
840 let route_prefix: &str = config.get_route_prefix();
841 let index_html_file_name: &str = config.get_index_html_file_name();
842 let mut addresses: Vec<IpAddr> = Vec::new();
843 match if_addrs::get_if_addrs() {
844 Ok(interfaces) => {
845 for interface in interfaces {
846 let ip: IpAddr = interface.addr.ip();
847 if !addresses.contains(&ip) {
848 addresses.push(ip);
849 }
850 }
851 }
852 Err(error) => {
853 log::warn!("Failed to enumerate network interfaces: {error}");
854 }
855 }
856 if addresses.is_empty() {
857 addresses.push(IpAddr::V4(Ipv4Addr::LOCALHOST));
858 }
859 for ip in addresses {
860 let host: String = match ip {
861 IpAddr::V6(_) => format!("[{ip}]"),
862 IpAddr::V4(_) => format!("{ip}"),
863 };
864 let url: String =
865 format!("{HTTP_SCHEME}://{host}:{port}/{route_prefix}/{index_html_file_name}");
866 log::info!("Server: {url}");
867 match QrCode::new(url.as_str()) {
868 Ok(code) => {
869 let string: String = code.render::<Dense1x2>().quiet_zone(false).build();
870 for line in string.lines() {
871 log::info!("{line}");
872 }
873 }
874 Err(error) => {
875 log::warn!("Failed to generate QR code: {error}");
876 }
877 }
878 }
879}
880
881pub async fn run_hyperlane_fmt() -> Result<(), EuvError> {
887 let args: hyperlane_cli::Args = hyperlane_cli::Args {
888 command: hyperlane_cli::CommandType::Fmt,
889 check: false,
890 manifest_path: None,
891 bump_type: None,
892 max_retries: 0,
893 project_name: None,
894 template_type: None,
895 model_sub_type: None,
896 component_name: None,
897 };
898 hyperlane_cli::execute_fmt(&args)
899 .await
900 .map_err(|error: IoError| EuvError::Io {
901 message: String::from("hyperlane-cli fmt error"),
902 error,
903 })
904}