1use std::env;
2use std::ffi::{CStr, CString, c_char};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6use std::slice;
7use std::sync::OnceLock;
8
9use directories::ProjectDirs;
10use fs_extra::dir::{CopyOptions, copy};
11use log::{debug, error};
12use serde::{Deserialize, Serialize};
13
14pub(crate) static PROJ_DIRS: OnceLock<ProjectDirs> = OnceLock::new();
15
16const GIT_REPO: &str = "https://github.com/igna-778/cotis-web.git";
17
18#[derive(Debug)]
19pub enum WebCotisError {
20 Io(std::io::Error),
21 WasmPackFailed,
22 CopyFailed(fs_extra::error::Error),
23 GitCloneFailed,
24 GitPullFailed,
25 InvalidPkgJson(serde_json::Error),
26 MissingWasmGlueMain,
27}
28
29impl From<std::io::Error> for WebCotisError {
30 fn from(err: std::io::Error) -> Self {
31 WebCotisError::Io(err)
32 }
33}
34
35#[derive(Debug, Clone)]
36pub struct WebCotisOptions {
37 pub output: PathBuf,
38 pub features: Vec<String>,
39}
40
41impl Default for WebCotisOptions {
42 fn default() -> Self {
43 Self {
44 output: PathBuf::from("./target/cotis_web"),
45 features: Vec::new(),
46 }
47 }
48}
49
50pub fn execute_web_build(opts: WebCotisOptions) -> Result<(), WebCotisError> {
51 init_proj_dirs();
52 fetch_canvas_resources()?;
53
54 debug!("Building web wasm to {}", opts.output.display());
55
56 let mut command = Command::new("wasm-pack");
58 command.args(["build", "--target", "web"]);
59 command.stdout(Stdio::inherit());
60 command.stderr(Stdio::inherit());
61 command.args(["-d", opts.output.join("pkg").to_str().unwrap()]);
62 if !opts.features.is_empty() {
63 command.arg("--features");
64 command.arg(opts.features.join(","));
65 }
66
67 let status = command.status()?;
68 if !status.success() {
69 error!("wasm-pack failed: {status}");
70 return Err(WebCotisError::WasmPackFailed);
71 }
72
73 copy_resources(&opts.output)?;
74 sync_wasm_snippet_renderer(&opts.output)?;
75 patch_index_wasm_import_path(&opts.output)?;
76 Ok(())
77}
78
79const WASM_IMPORT_PATH_PLACEHOLDER: &str = "__COTIS_WEB_WASM_IMPORT_PATH__";
81
82const DEFAULT_ON_ROOT_INDEX_HTML: &str = include_str!("../resources/on-root/index.html");
84
85#[derive(Deserialize)]
86struct WasmPkgJson {
87 main: String,
88}
89
90fn wasm_import_path_from_pkg(output: &Path) -> Result<String, WebCotisError> {
91 let pkg_json = output.join("pkg/package.json");
92 let raw = fs::read_to_string(&pkg_json)?;
93 let meta: WasmPkgJson = serde_json::from_str(&raw).map_err(WebCotisError::InvalidPkgJson)?;
94 if meta.main.is_empty() {
95 return Err(WebCotisError::MissingWasmGlueMain);
96 }
97 Ok(format!("./pkg/{}", meta.main))
98}
99
100fn patch_index_wasm_import_path(output: &Path) -> Result<(), WebCotisError> {
101 let index_path = output.join("index.html");
102 if !index_path.exists() {
103 return Ok(());
104 }
105 let mut html = fs::read_to_string(&index_path)?;
106 if !html.contains(WASM_IMPORT_PATH_PLACEHOLDER) {
107 return Ok(());
108 }
109 let path = wasm_import_path_from_pkg(output)?;
110 html = html.replace(WASM_IMPORT_PATH_PLACEHOLDER, &path);
111 fs::write(&index_path, html)?;
112 Ok(())
113}
114
115fn init_proj_dirs() {
116 if PROJ_DIRS.get().is_some() {
117 return;
118 }
119 if let Some(proj_dirs) = ProjectDirs::from("", "cotis-web", "cotis-web-builder") {
120 let _ = PROJ_DIRS.set(proj_dirs);
121 }
122}
123
124fn fetch_canvas_resources() -> Result<(), WebCotisError> {
125 let original_dir = env::current_dir()?;
126 let cotis_web_root = PROJ_DIRS
127 .get()
128 .expect("ProjectDirs not initialized")
129 .cache_dir()
130 .to_path_buf();
131 let repo_path_str = cotis_web_root
132 .join("cotis-web")
133 .to_str()
134 .ok_or_else(|| {
135 std::io::Error::new(std::io::ErrorKind::InvalidInput, "cache path is not UTF-8")
136 })?
137 .to_string();
138
139 let repo_path = PathBuf::from(&repo_path_str);
140 if !repo_path.exists() {
141 fs::create_dir_all(&cotis_web_root)?;
142 env::set_current_dir(&cotis_web_root)?;
143
144 let out = Command::new("git")
145 .args(["clone", GIT_REPO])
146 .stdout(Stdio::inherit())
147 .stderr(Stdio::inherit())
148 .status()?;
149 if !out.success() {
150 env::set_current_dir(&original_dir)?;
151 return Err(WebCotisError::GitCloneFailed);
152 }
153 }
154
155 let fetch_status = Command::new("git")
157 .args(["-C", &repo_path_str, "fetch", "--prune", "origin"])
158 .stdout(Stdio::inherit())
159 .stderr(Stdio::inherit())
160 .status()?;
161 if !fetch_status.success() {
162 env::set_current_dir(&original_dir)?;
163 return Err(WebCotisError::GitPullFailed);
164 }
165
166 let head_ref = Command::new("git")
167 .args([
168 "-C",
169 &repo_path_str,
170 "symbolic-ref",
171 "refs/remotes/origin/HEAD",
172 ])
173 .stdout(Stdio::piped())
174 .stderr(Stdio::inherit())
175 .output()?;
176 if !head_ref.status.success() {
177 env::set_current_dir(&original_dir)?;
178 return Err(WebCotisError::GitPullFailed);
179 }
180 let head_ref = String::from_utf8_lossy(&head_ref.stdout).trim().to_string();
181 let reset_status = Command::new("git")
182 .args(["-C", &repo_path_str, "reset", "--hard", &head_ref])
183 .stdout(Stdio::inherit())
184 .stderr(Stdio::inherit())
185 .status()?;
186 if !reset_status.success() {
187 env::set_current_dir(&original_dir)?;
188 return Err(WebCotisError::GitPullFailed);
189 }
190
191 let clean_status = Command::new("git")
192 .args(["-C", &repo_path_str, "clean", "-fd"])
193 .stdout(Stdio::inherit())
194 .stderr(Stdio::inherit())
195 .status()?;
196 if !clean_status.success() {
197 env::set_current_dir(&original_dir)?;
198 return Err(WebCotisError::GitPullFailed);
199 }
200 env::set_current_dir(&original_dir)?;
201 Ok(())
202}
203
204fn copy_resources(output: &Path) -> Result<(), WebCotisError> {
205 let cache_root = PROJ_DIRS
206 .get()
207 .expect("ProjectDirs not initialized")
208 .cache_dir()
209 .to_path_buf();
210 let candidate_roots = [
211 PathBuf::from("../cotis-web/resources"),
212 PathBuf::from("./cotis-web/resources"),
213 cache_root.join("cotis-web/cotis-web/resources"),
214 cache_root.join("cotis_web/cotis-web/resources"),
215 cache_root.join("cotis_web/resources"),
216 ];
217 let resources_root = candidate_roots
218 .iter()
219 .find(|p| p.is_dir())
220 .cloned()
221 .unwrap_or_else(|| candidate_roots[0].clone());
222 let on_root = resources_root.join("on-root");
223 let web_renderer = resources_root.join("web-renderer");
224 let local = Path::new("./web_resources");
225 if on_root.is_dir() {
226 let mut options = CopyOptions::new();
227 options.copy_inside = true;
228 options.content_only = true;
229 options.overwrite = true;
230 copy(&on_root, output, &options).map_err(WebCotisError::CopyFailed)?;
231 } else {
232 fs::create_dir_all(output)?;
233 fs::write(output.join("index.html"), DEFAULT_ON_ROOT_INDEX_HTML)?;
234 }
235 if web_renderer.is_dir() {
236 let mut options = CopyOptions::new();
237 options.copy_inside = true;
238 options.content_only = true;
239 options.overwrite = true;
240 let output_web_renderer = output.join("web-renderer");
241 fs::create_dir_all(&output_web_renderer)?;
242 copy(&web_renderer, &output_web_renderer, &options).map_err(WebCotisError::CopyFailed)?;
243 }
244
245 if local.exists() {
246 let mut options = CopyOptions::new();
247 options.copy_inside = true;
248 options.content_only = true;
249 options.overwrite = true;
250 copy(local, output, &options).map_err(WebCotisError::CopyFailed)?;
251 }
252 Ok(())
253}
254
255fn sync_wasm_snippet_renderer(output: &Path) -> Result<(), WebCotisError> {
256 let cache_root = PROJ_DIRS
257 .get()
258 .expect("ProjectDirs not initialized")
259 .cache_dir()
260 .to_path_buf();
261 let candidate_roots = [
262 PathBuf::from("../cotis-web/resources"),
263 PathBuf::from("./cotis-web/resources"),
264 cache_root.join("cotis-web/cotis-web/resources"),
265 cache_root.join("cotis_web/cotis-web/resources"),
266 cache_root.join("cotis_web/resources"),
267 ];
268 let resources_root = candidate_roots
269 .iter()
270 .find(|p| p.is_dir())
271 .cloned()
272 .unwrap_or_else(|| candidate_roots[0].clone());
273 let cache_renderer = resources_root.join("web-renderer/renderer.js");
274 let snippets_root = output.join("pkg/snippets");
275 let mut snippet_renderer_files = Vec::new();
276 collect_snippet_renderer_files(&snippets_root, &mut snippet_renderer_files)?;
277 if !cache_renderer.exists() || snippet_renderer_files.is_empty() {
278 return Ok(());
279 }
280 let content = fs::read(&cache_renderer)?;
281 for target in snippet_renderer_files {
282 fs::write(&target, &content)?;
283 }
284 Ok(())
285}
286
287fn collect_snippet_renderer_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), WebCotisError> {
288 if !dir.is_dir() {
289 return Ok(());
290 }
291 for entry in fs::read_dir(dir)? {
292 let entry = entry?;
293 let path = entry.path();
294 if entry.file_type()?.is_dir() {
295 collect_snippet_renderer_files(&path, out)?;
296 continue;
297 }
298 let path_string = path.to_string_lossy().replace('\\', "/");
299 if path_string.ends_with("resources/web-renderer/renderer.js") {
300 out.push(path);
301 }
302 }
303 Ok(())
304}
305
306#[unsafe(no_mangle)]
311pub extern "C" fn cotis_plugin_api_version() -> u32 {
312 1
313}
314
315#[derive(Serialize)]
316struct Descriptor<'a> {
317 name: &'a str,
318 version: &'a str,
319}
320
321#[unsafe(no_mangle)]
322pub extern "C" fn cotis_plugin_descriptor_json() -> *mut c_char {
323 let d = Descriptor {
324 name: "cotis-web-builder",
325 version: "0.1.0-alpha",
326 };
327 let json = serde_json::to_string(&d).unwrap();
328 CString::new(json).unwrap().into_raw()
329}
330
331#[unsafe(no_mangle)]
332pub extern "C" fn cotis_plugin_help() -> *mut c_char {
333 let msg = "Web Cotis Builder Routine\n\
334 Builds the Web/WASM package via wasm-pack.\n\
335 Args (plugin/standalone): --output <path> --features <f1,f2,...>\n";
336 CString::new(msg).unwrap().into_raw()
337}
338
339#[unsafe(no_mangle)]
340#[allow(clippy::not_unsafe_ptr_arg_deref)]
341pub extern "C" fn cotis_plugin_free_string(ptr: *mut c_char) {
342 if ptr.is_null() {
343 return;
344 }
345 unsafe {
346 drop(CString::from_raw(ptr));
347 }
348}
349
350#[unsafe(no_mangle)]
351#[allow(clippy::not_unsafe_ptr_arg_deref)]
352pub extern "C" fn cotis_plugin_run(argc: i32, argv: *const *const c_char) -> i32 {
353 let mut args = Vec::new();
354 unsafe {
355 if !argv.is_null() {
356 let slice = slice::from_raw_parts(argv, argc as usize);
357 for &arg_ptr in slice {
358 if !arg_ptr.is_null()
359 && let Ok(s) = CStr::from_ptr(arg_ptr).to_str()
360 {
361 args.push(s.to_string());
362 }
363 }
364 }
365 }
366 match run_from_args(&args) {
367 Ok(()) => 0,
368 Err(_) => 1,
369 }
370}
371
372fn run_from_args(args: &[String]) -> Result<(), WebCotisError> {
373 let mut opts = WebCotisOptions::default();
375 let mut it = args.iter().skip(1);
376 while let Some(a) = it.next() {
377 match a.as_str() {
378 "--output" => {
379 if let Some(v) = it.next() {
380 opts.output = PathBuf::from(v);
381 }
382 }
383 "--features" => {
384 if let Some(v) = it.next() {
385 append_features(&mut opts.features, v);
386 }
387 }
388 _ => {}
389 }
390 }
391 execute_web_build(opts)
392}
393
394fn append_features(features: &mut Vec<String>, raw: &str) {
395 for feature in raw.split(',') {
396 let trimmed = feature.trim().trim_matches('"').trim_matches('\'');
397 if trimmed.is_empty() {
398 continue;
399 }
400 if !features.iter().any(|existing| existing == trimmed) {
401 features.push(trimmed.to_string());
402 }
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use std::time::{SystemTime, UNIX_EPOCH};
410
411 fn temp_output_dir() -> PathBuf {
412 let unique = SystemTime::now()
413 .duration_since(UNIX_EPOCH)
414 .unwrap()
415 .as_nanos();
416 env::temp_dir().join(format!("cotis-web-builder-test-{unique}"))
417 }
418
419 #[test]
420 fn patch_index_wasm_import_path_replaces_placeholder() {
421 let output = temp_output_dir();
422 fs::create_dir_all(output.join("pkg")).unwrap();
423 fs::write(
424 output.join("pkg/package.json"),
425 r#"{"main":"web_example.js"}"#,
426 )
427 .unwrap();
428 fs::write(
429 output.join("index.html"),
430 format!(
431 "import init from '{WASM_IMPORT_PATH_PLACEHOLDER}';\nrun('{WASM_IMPORT_PATH_PLACEHOLDER}');"
432 ),
433 )
434 .unwrap();
435
436 patch_index_wasm_import_path(&output).unwrap();
437
438 let html = fs::read_to_string(output.join("index.html")).unwrap();
439 assert!(!html.contains(WASM_IMPORT_PATH_PLACEHOLDER));
440 assert!(html.contains("./pkg/web_example.js"));
441 let _ = fs::remove_dir_all(&output);
442 }
443
444 #[test]
445 fn bundled_index_html_uses_same_placeholder() {
446 assert!(DEFAULT_ON_ROOT_INDEX_HTML.contains(WASM_IMPORT_PATH_PLACEHOLDER));
447 }
448}