Skip to main content

cotis_web_builder/
lib.rs

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    // wasm-pack build --target web -d <output>/pkg
57    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
79/// wasm-pack writes `pkg/package.json` with a `main` entry (Rust lib name, e.g. `web_example.js`).
80const WASM_IMPORT_PATH_PLACEHOLDER: &str = "__COTIS_WEB_WASM_IMPORT_PATH__";
81
82/// Bundled with the crate so `index.html` is available without a local monorepo or git cache layout.
83const 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    // Always force-sync the cache to latest origin default branch.
156    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// -------------------------------------------------------------------------------------------------
307// Plugin ABI exports
308// -------------------------------------------------------------------------------------------------
309
310#[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    // args[0] is routine name when invoked by cotis-cli; keep parsing tolerant.
374    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}