#![forbid(unsafe_code)]
use js_sys::{Error as JsError, JSON};
use wasm_bindgen::prelude::*;
#[cfg(test)]
use serde_json::Value;
#[cfg(feature = "analysis")]
use tokmd_core::CORE_ANALYSIS_SCHEMA_VERSION;
use tokmd_core::error::{ResponseEnvelope, TokmdError};
fn to_js_error(message: impl Into<String>) -> JsValue {
JsError::new(&message.into()).into()
}
#[cfg(test)]
fn serialize_args(args: &Value) -> Result<String, String> {
serde_json::to_string(args).map_err(|err| format!("JSON encode error: {err}"))
}
fn extract_mode_data_json(mode: &str, args_json: &str) -> Result<String, String> {
validate_mode_args_json(mode, args_json).map_err(|err| err.to_string())?;
let result_json = tokmd_core::ffi::run_json(mode, args_json);
tokmd_envelope::ffi::extract_data_json(&result_json).map_err(|err| err.to_string())
}
#[cfg(test)]
fn run_mode_value(mode: &str, args: &Value) -> Result<Value, String> {
let args_json = serialize_args(args)?;
let data_json = extract_mode_data_json(mode, &args_json)?;
serde_json::from_str(&data_json).map_err(|err| format!("JSON decode error: {err}"))
}
fn js_args_to_json(args: JsValue) -> Result<String, JsValue> {
if args.is_null() || args.is_undefined() {
return Ok("{}".to_string());
}
JSON::stringify(&args)
.map_err(|_| to_js_error("failed to serialize JS arguments"))?
.as_string()
.ok_or_else(|| to_js_error("failed to serialize JS arguments"))
}
fn run_mode_js(mode: &str, args: JsValue) -> Result<JsValue, JsValue> {
let args_json = js_args_to_json(args)?;
let data_json = extract_mode_data_json(mode, &args_json).map_err(to_js_error)?;
JSON::parse(&data_json).map_err(|_| to_js_error("failed to parse tokmd result JSON"))
}
#[cfg(feature = "analysis")]
fn validate_analyze_args_json(args_json: &str) -> Result<(), TokmdError> {
let args: serde_json::Value =
serde_json::from_str(args_json).map_err(TokmdError::invalid_json)?;
let obj = args.get("analyze").unwrap_or(&args);
match obj.get("preset").and_then(serde_json::Value::as_str) {
Some(preset) if tokmd_core::supports_rootless_in_memory_analyze_preset(preset) => Ok(()),
Some(preset) => Err(TokmdError::not_implemented(format!(
"tokmd-wasm currently supports analyze only with preset=\"receipt\" or preset=\"estimate\" for in-memory inputs; got {preset:?}"
))),
None => Ok(()),
}
}
fn validate_mode_args_json(mode: &str, args_json: &str) -> Result<(), TokmdError> {
#[cfg(feature = "analysis")]
if mode == "analyze" {
return validate_analyze_args_json(args_json);
}
let _ = (mode, args_json);
Ok(())
}
#[cfg(feature = "analysis")]
fn run_analyze_js(args: JsValue) -> Result<JsValue, JsValue> {
let args_json = js_args_to_json(args)?;
validate_analyze_args_json(&args_json).map_err(|err| to_js_error(err.to_string()))?;
let data_json = extract_mode_data_json("analyze", &args_json).map_err(to_js_error)?;
JSON::parse(&data_json).map_err(|_| to_js_error("failed to parse tokmd result JSON"))
}
#[wasm_bindgen]
pub fn version() -> String {
tokmd_core::ffi::version().to_string()
}
#[wasm_bindgen(js_name = schemaVersion)]
pub fn schema_version() -> u32 {
tokmd_core::ffi::schema_version()
}
#[cfg(feature = "analysis")]
#[wasm_bindgen(js_name = analysisSchemaVersion)]
pub fn analysis_schema_version() -> u32 {
CORE_ANALYSIS_SCHEMA_VERSION
}
#[wasm_bindgen(js_name = runJson)]
pub fn run_json(mode: &str, args_json: &str) -> String {
if let Err(err) = validate_mode_args_json(mode, args_json) {
return ResponseEnvelope::error(&err).to_json();
}
tokmd_core::ffi::run_json(mode, args_json)
}
#[wasm_bindgen(js_name = run)]
pub fn run(mode: &str, args: JsValue) -> Result<JsValue, JsValue> {
run_mode_js(mode, args)
}
#[wasm_bindgen(js_name = runLang)]
pub fn run_lang(args: JsValue) -> Result<JsValue, JsValue> {
run_mode_js("lang", args)
}
#[wasm_bindgen(js_name = runModule)]
pub fn run_module(args: JsValue) -> Result<JsValue, JsValue> {
run_mode_js("module", args)
}
#[wasm_bindgen(js_name = runExport)]
pub fn run_export(args: JsValue) -> Result<JsValue, JsValue> {
run_mode_js("export", args)
}
#[cfg(feature = "analysis")]
#[wasm_bindgen(js_name = runAnalyze)]
pub fn run_analyze(args: JsValue) -> Result<JsValue, JsValue> {
run_analyze_js(args)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn fixture_inputs() -> Value {
json!([
{
"path": "crates/app/src/lib.rs",
"text": "pub fn alpha() -> usize { 1 }\n"
},
{
"path": "src/main.rs",
"text": "fn main() {}\n"
},
{
"path": "tests/basic.py",
"text": "# TODO: keep smoke\nprint('ok')\n"
}
])
}
#[test]
fn run_json_returns_valid_envelope() {
let result = run_json("version", "{}");
let envelope = tokmd_envelope::ffi::parse_envelope(&result).expect("valid JSON envelope");
assert_eq!(envelope["ok"], true);
assert_eq!(envelope["data"]["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn run_mode_value_lang_supports_in_memory_inputs() {
let data = run_mode_value(
"lang",
&json!({
"inputs": fixture_inputs(),
"files": true
}),
)
.expect("lang data");
assert_eq!(data["mode"], "lang");
assert_eq!(data["scan"]["paths"][0], "crates/app/src/lib.rs");
assert_eq!(data["total"]["files"], 3);
}
#[test]
fn run_mode_value_export_preserves_logical_paths() {
let data = run_mode_value(
"export",
&json!({
"inputs": fixture_inputs()
}),
)
.expect("export data");
let paths: Vec<&str> = data["rows"]
.as_array()
.expect("rows array")
.iter()
.map(|row| row["path"].as_str().expect("row path"))
.collect();
assert_eq!(data["mode"], "export");
assert!(paths.contains(&"crates/app/src/lib.rs"));
assert!(paths.contains(&"tests/basic.py"));
}
#[cfg(feature = "analysis")]
#[test]
fn run_mode_value_analyze_estimate_returns_effort_payload() {
let data = run_mode_value(
"analyze",
&json!({
"inputs": fixture_inputs(),
"preset": "estimate"
}),
)
.expect("analysis data");
assert_eq!(data["mode"], "analysis");
assert_eq!(data["source"]["inputs"][1], "src/main.rs");
assert_eq!(data["effort"]["model"], "cocomo81-basic");
assert_eq!(data["effort"]["size_basis"]["total_lines"], 3);
assert!(
data["effort"]["results"]["effort_pm_p50"]
.as_f64()
.expect("effort p50")
> 0.0
);
}
#[cfg(feature = "analysis")]
#[test]
fn run_mode_value_analyze_receipt_returns_rootless_receipt_payload() {
let data = run_mode_value(
"analyze",
&json!({
"inputs": fixture_inputs(),
"preset": "receipt"
}),
)
.expect("analysis data");
assert_eq!(data["mode"], "analysis");
assert_eq!(data["source"]["inputs"][2], "tests/basic.py");
assert_eq!(data["derived"]["totals"]["files"], 3);
assert_eq!(data["effort"], Value::Null);
assert_eq!(data["git"], Value::Null);
assert!(
data["warnings"]
.as_array()
.expect("warnings array")
.iter()
.filter_map(Value::as_str)
.any(|warning| warning.contains("no host root") && warning.contains("file-backed"))
);
assert!(
data["warnings"]
.as_array()
.expect("warnings array")
.iter()
.filter_map(Value::as_str)
.any(|warning| warning.contains("no host root") && warning.contains("git"))
);
}
#[cfg(feature = "analysis")]
#[test]
fn run_mode_value_analyze_without_preset_defaults_to_receipt_payload() {
let data = run_mode_value(
"analyze",
&json!({
"inputs": fixture_inputs()
}),
)
.expect("analysis data");
assert_eq!(data["mode"], "analysis");
assert_eq!(data["source"]["inputs"][0], "crates/app/src/lib.rs");
assert_eq!(data["derived"]["totals"]["files"], 3);
assert_eq!(data["effort"], Value::Null);
}
#[cfg(feature = "analysis")]
#[test]
fn validate_analyze_args_accepts_rootless_receipt_and_estimate() {
validate_analyze_args_json(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }]
}"#,
)
.expect("missing preset should default to receipt");
validate_analyze_args_json(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"analyze": { "preset": "Receipt" }
}"#,
)
.expect("nested mixed-case receipt should be allowed");
validate_analyze_args_json(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"preset": "estimate"
}"#,
)
.expect("estimate should be allowed");
validate_analyze_args_json(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"analyze": { "preset": "Estimate" }
}"#,
)
.expect("nested mixed-case estimate should be allowed");
let err = validate_analyze_args_json(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"preset": "health"
}"#,
)
.expect_err("unsupported preset should be rejected");
assert!(err.message.contains("preset=\"receipt\""));
assert!(err.message.contains("preset=\"estimate\""));
}
#[cfg(feature = "analysis")]
#[test]
fn run_json_analyze_rejects_unsupported_presets() {
let result = run_json(
"analyze",
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"preset": "health"
}"#,
);
let envelope = tokmd_envelope::ffi::parse_envelope(&result).expect("valid JSON envelope");
assert_eq!(envelope["ok"], false);
assert_eq!(envelope["error"]["code"], "not_implemented");
assert!(
envelope["error"]["message"]
.as_str()
.expect("error message")
.contains("preset=\"receipt\"")
);
assert!(
envelope["error"]["message"]
.as_str()
.expect("error message")
.contains("preset=\"estimate\"")
);
}
#[cfg(feature = "analysis")]
#[test]
fn run_mode_value_analyze_accepts_nested_case_insensitive_estimate() {
let data = run_mode_value(
"analyze",
&json!({
"inputs": fixture_inputs(),
"analyze": { "preset": "Estimate" }
}),
)
.expect("analysis data");
assert_eq!(data["mode"], "analysis");
assert_eq!(data["source"]["inputs"][0], "crates/app/src/lib.rs");
assert_eq!(data["effort"]["model"], "cocomo81-basic");
}
#[test]
fn run_mode_value_surfaces_upstream_errors() {
let err = run_mode_value(
"lang",
&json!({
"inputs": fixture_inputs(),
"paths": ["src"]
}),
)
.expect_err("paths + inputs should error");
assert!(err.contains("[invalid_settings]"));
assert!(err.contains("cannot be combined with in-memory inputs"));
}
#[test]
fn schema_version_matches_core_receipts() {
assert_eq!(schema_version(), tokmd_types::SCHEMA_VERSION);
}
#[cfg(feature = "analysis")]
#[test]
fn analysis_schema_version_matches_analysis_receipts() {
assert_eq!(analysis_schema_version(), CORE_ANALYSIS_SCHEMA_VERSION);
}
}
#[cfg(all(test, target_arch = "wasm32"))]
mod wasm_tests {
use super::*;
use serde_json::Value;
use wasm_bindgen::JsCast;
use wasm_bindgen_test::*;
fn parse_js_args(json: &str) -> JsValue {
JSON::parse(json).expect("valid JS object")
}
fn js_value_to_json(value: &JsValue) -> Value {
let json = JSON::stringify(value)
.expect("serializable JS value")
.as_string()
.expect("JSON string");
serde_json::from_str(&json).expect("valid JSON value")
}
fn core_mode_value(mode: &str, args_json: &str) -> Value {
let envelope_json = tokmd_core::ffi::run_json(mode, args_json);
let data_json =
tokmd_envelope::ffi::extract_data_json(&envelope_json).expect("core data payload");
serde_json::from_str(&data_json).expect("valid core JSON value")
}
fn assert_generated_at_ms_nonzero(label: &str, value: &Value) {
let timestamp = value
.get("generated_at_ms")
.and_then(Value::as_u64)
.unwrap_or_else(|| panic!("{label} missing numeric generated_at_ms"));
assert!(timestamp > 0, "{label} generated_at_ms must not be 0");
}
fn normalize_volatile_timestamps(value: &mut Value) {
match value {
Value::Array(items) => {
for item in items {
normalize_volatile_timestamps(item);
}
}
Value::Object(object) => {
for (key, value) in object {
if key == "generated_at_ms" || key == "export_generated_at_ms" {
if !value.is_null() {
*value = Value::from(1);
}
} else {
normalize_volatile_timestamps(value);
}
}
}
_ => {}
}
}
fn values_match_js_boundary(actual: &Value, expected: &Value) -> bool {
match (actual, expected) {
(Value::Null, Value::Null)
| (Value::Bool(_), Value::Bool(_))
| (Value::String(_), Value::String(_)) => actual == expected,
(Value::Number(actual), Value::Number(expected)) => {
numbers_match_js_boundary(actual, expected)
}
(Value::Array(actual), Value::Array(expected)) => {
actual.len() == expected.len()
&& actual
.iter()
.zip(expected.iter())
.all(|(actual, expected)| values_match_js_boundary(actual, expected))
}
(Value::Object(actual), Value::Object(expected)) => {
actual.len() == expected.len()
&& actual.iter().all(|(key, actual_value)| {
expected.get(key).is_some_and(|expected_value| {
values_match_js_boundary(actual_value, expected_value)
})
})
}
_ => false,
}
}
fn numbers_match_js_boundary(
actual: &serde_json::Number,
expected: &serde_json::Number,
) -> bool {
const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
if actual == expected {
return true;
}
if let (Some(actual), Some(expected)) = (actual.as_i64(), expected.as_i64()) {
return actual == expected;
}
if let (Some(actual), Some(expected)) = (actual.as_u64(), expected.as_u64()) {
return actual == expected;
}
let (Some(actual), Some(expected)) = (actual.as_f64(), expected.as_f64()) else {
return false;
};
if actual != expected {
return false;
}
let both_integral = actual.fract() == 0.0 && expected.fract() == 0.0;
if both_integral && (actual.abs() > MAX_SAFE_INTEGER || expected.abs() > MAX_SAFE_INTEGER) {
return false;
}
true
}
#[wasm_bindgen_test]
fn run_lang_exercises_js_value_boundary() {
let args_json = r#"{
"inputs": [
{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
{ "path": "tests/basic.py", "text": "print('ok')\n" }
],
"files": true
}"#;
let data = run_lang(parse_js_args(args_json)).expect("lang data");
let mut parsed = js_value_to_json(&data);
let mut expected = core_mode_value("lang", args_json);
assert_eq!(parsed["mode"], "lang");
assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
assert_eq!(parsed["total"]["files"], 2);
assert_generated_at_ms_nonzero("lang wasm payload", &parsed);
assert_generated_at_ms_nonzero("lang core payload", &expected);
normalize_volatile_timestamps(&mut parsed);
normalize_volatile_timestamps(&mut expected);
assert!(
values_match_js_boundary(&parsed, &expected),
"wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
);
}
#[wasm_bindgen_test]
fn run_module_exercises_js_value_boundary() {
let args_json = r#"{
"inputs": [
{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
{ "path": "tests/basic.py", "text": "print('ok')\n" }
]
}"#;
let data = run_module(parse_js_args(args_json)).expect("module data");
let mut parsed = js_value_to_json(&data);
let mut expected = core_mode_value("module", args_json);
assert_eq!(parsed["mode"], "module");
assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
assert!(parsed["rows"].as_array().is_some());
assert_generated_at_ms_nonzero("module wasm payload", &parsed);
assert_generated_at_ms_nonzero("module core payload", &expected);
normalize_volatile_timestamps(&mut parsed);
normalize_volatile_timestamps(&mut expected);
assert!(
values_match_js_boundary(&parsed, &expected),
"wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
);
}
#[wasm_bindgen_test]
fn run_export_exercises_js_value_boundary() {
let args_json = r#"{
"inputs": [
{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
{ "path": "tests/basic.py", "text": "print('ok')\n" }
]
}"#;
let data = run_export(parse_js_args(args_json)).expect("export data");
let mut parsed = js_value_to_json(&data);
let mut expected = core_mode_value("export", args_json);
assert_eq!(parsed["mode"], "export");
assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
assert_eq!(parsed["rows"][0]["path"], "src/lib.rs");
assert_generated_at_ms_nonzero("export wasm payload", &parsed);
assert_generated_at_ms_nonzero("export core payload", &expected);
normalize_volatile_timestamps(&mut parsed);
normalize_volatile_timestamps(&mut expected);
assert!(
values_match_js_boundary(&parsed, &expected),
"wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
);
}
#[wasm_bindgen_test]
fn run_surfaces_js_facing_errors() {
let err = run(
"lang",
parse_js_args(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"paths": ["src"]
}"#,
),
)
.expect_err("conflicting inputs should error")
.dyn_into::<JsError>()
.expect("js error");
let message = err.message().as_string().expect("js string message");
assert!(message.contains("[invalid_settings]"));
}
#[cfg(feature = "analysis")]
#[wasm_bindgen_test]
fn run_analyze_estimate_reports_analysis_schema_and_matches_core_payload() {
let args_json = r#"{
"inputs": [
{ "path": "crates/app/src/lib.rs", "text": "pub fn alpha() -> usize { 1 }\n" },
{ "path": "src/main.rs", "text": "fn main() {}\n" }
],
"preset": "estimate"
}"#;
let data = run_analyze(parse_js_args(args_json)).expect("analysis data");
let mut parsed = js_value_to_json(&data);
let mut expected = core_mode_value("analyze", args_json);
assert_eq!(analysis_schema_version(), CORE_ANALYSIS_SCHEMA_VERSION);
assert_eq!(parsed["mode"], "analysis");
assert_eq!(parsed["source"]["inputs"][0], "crates/app/src/lib.rs");
assert_eq!(parsed["effort"]["model"], "cocomo81-basic");
assert_generated_at_ms_nonzero("analysis estimate wasm payload", &parsed);
assert_generated_at_ms_nonzero("analysis estimate core payload", &expected);
normalize_volatile_timestamps(&mut parsed);
normalize_volatile_timestamps(&mut expected);
assert!(
values_match_js_boundary(&parsed, &expected),
"wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
);
}
#[cfg(feature = "analysis")]
#[wasm_bindgen_test]
fn run_analyze_receipt_matches_core_payload() {
let args_json = r#"{
"inputs": [
{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
],
"preset": "receipt"
}"#;
let data = run_analyze(parse_js_args(args_json)).expect("analysis data");
let mut parsed = js_value_to_json(&data);
let mut expected = core_mode_value("analyze", args_json);
assert_eq!(parsed["mode"], "analysis");
assert_eq!(parsed["source"]["inputs"][0], "src/lib.rs");
assert_eq!(parsed["derived"]["totals"]["files"], 1);
assert_eq!(parsed["effort"], Value::Null);
assert_generated_at_ms_nonzero("analysis receipt wasm payload", &parsed);
assert_generated_at_ms_nonzero("analysis receipt core payload", &expected);
normalize_volatile_timestamps(&mut parsed);
normalize_volatile_timestamps(&mut expected);
assert!(
values_match_js_boundary(&parsed, &expected),
"wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
);
}
#[cfg(feature = "analysis")]
#[wasm_bindgen_test]
fn run_analyze_without_preset_defaults_to_receipt() {
let data = run_analyze(parse_js_args(
r#"{
"inputs": [
{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
]
}"#,
))
.expect("analysis data");
let parsed = js_value_to_json(&data);
assert_eq!(parsed["mode"], "analysis");
assert_eq!(parsed["source"]["inputs"][0], "src/lib.rs");
assert_eq!(parsed["derived"]["totals"]["files"], 1);
assert_eq!(parsed["effort"], Value::Null);
}
#[cfg(feature = "analysis")]
#[wasm_bindgen_test]
fn run_analyze_rejects_unsupported_presets() {
let err = run_analyze(parse_js_args(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"preset": "health"
}"#,
))
.expect_err("non-estimate preset should be rejected")
.dyn_into::<JsError>()
.expect("js error");
let message = err.message().as_string().expect("js string message");
assert!(message.contains("preset=\"receipt\""));
assert!(message.contains("preset=\"estimate\""));
}
#[cfg(feature = "analysis")]
#[wasm_bindgen_test]
fn run_accepts_nested_case_insensitive_analyze_preset() {
let data = run(
"analyze",
parse_js_args(
r#"{
"inputs": [
{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
],
"analyze": { "preset": "Estimate" }
}"#,
),
)
.expect("analysis data");
let parsed = js_value_to_json(&data);
assert_eq!(parsed["mode"], "analysis");
assert_eq!(parsed["effort"]["model"], "cocomo81-basic");
}
#[cfg(feature = "analysis")]
#[wasm_bindgen_test]
fn run_rejects_unsupported_analyze_presets() {
let err = run(
"analyze",
parse_js_args(
r#"{
"inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
"preset": "health"
}"#,
),
)
.expect_err("non-estimate preset should be rejected")
.dyn_into::<JsError>()
.expect("js error");
let message = err.message().as_string().expect("js string message");
assert!(message.contains("preset=\"receipt\""));
assert!(message.contains("preset=\"estimate\""));
}
}