1#![forbid(unsafe_code)]
8
9use js_sys::{Error as JsError, JSON};
10use wasm_bindgen::prelude::*;
11
12#[cfg(test)]
13use serde_json::Value;
14#[cfg(feature = "analysis")]
15use tokmd_core::CORE_ANALYSIS_SCHEMA_VERSION;
16use tokmd_core::error::{ResponseEnvelope, TokmdError};
17
18fn to_js_error(message: impl Into<String>) -> JsValue {
19 JsError::new(&message.into()).into()
20}
21
22#[cfg(test)]
23fn serialize_args(args: &Value) -> Result<String, String> {
24 serde_json::to_string(args).map_err(|err| format!("JSON encode error: {err}"))
25}
26
27fn extract_mode_data_json(mode: &str, args_json: &str) -> Result<String, String> {
28 validate_mode_args_json(mode, args_json).map_err(|err| err.to_string())?;
29 let result_json = tokmd_core::ffi::run_json(mode, args_json);
30 tokmd_envelope::ffi::extract_data_json(&result_json).map_err(|err| err.to_string())
31}
32
33#[cfg(test)]
34fn run_mode_value(mode: &str, args: &Value) -> Result<Value, String> {
35 let args_json = serialize_args(args)?;
36 let data_json = extract_mode_data_json(mode, &args_json)?;
37 serde_json::from_str(&data_json).map_err(|err| format!("JSON decode error: {err}"))
38}
39
40fn js_args_to_json(args: JsValue) -> Result<String, JsValue> {
41 if args.is_null() || args.is_undefined() {
42 return Ok("{}".to_string());
43 }
44
45 JSON::stringify(&args)
46 .map_err(|_| to_js_error("failed to serialize JS arguments"))?
47 .as_string()
48 .ok_or_else(|| to_js_error("failed to serialize JS arguments"))
49}
50
51fn run_mode_js(mode: &str, args: JsValue) -> Result<JsValue, JsValue> {
52 let args_json = js_args_to_json(args)?;
53 let data_json = extract_mode_data_json(mode, &args_json).map_err(to_js_error)?;
54 JSON::parse(&data_json).map_err(|_| to_js_error("failed to parse tokmd result JSON"))
55}
56
57#[cfg(feature = "analysis")]
58fn validate_analyze_args_json(args_json: &str) -> Result<(), TokmdError> {
59 let args: serde_json::Value =
60 serde_json::from_str(args_json).map_err(TokmdError::invalid_json)?;
61 let obj = args.get("analyze").unwrap_or(&args);
62
63 match obj.get("preset").and_then(serde_json::Value::as_str) {
64 Some(preset) if tokmd_core::supports_rootless_in_memory_analyze_preset(preset) => Ok(()),
65 Some(preset) => Err(TokmdError::not_implemented(format!(
66 "tokmd-wasm currently supports analyze only with preset=\"receipt\" or preset=\"estimate\" for in-memory inputs; got {preset:?}"
67 ))),
68 None => Ok(()),
69 }
70}
71
72fn validate_mode_args_json(mode: &str, args_json: &str) -> Result<(), TokmdError> {
73 #[cfg(feature = "analysis")]
74 if mode == "analyze" {
75 return validate_analyze_args_json(args_json);
76 }
77
78 let _ = (mode, args_json);
79 Ok(())
80}
81
82#[cfg(feature = "analysis")]
83fn run_analyze_js(args: JsValue) -> Result<JsValue, JsValue> {
84 let args_json = js_args_to_json(args)?;
85 validate_analyze_args_json(&args_json).map_err(|err| to_js_error(err.to_string()))?;
86 let data_json = extract_mode_data_json("analyze", &args_json).map_err(to_js_error)?;
87 JSON::parse(&data_json).map_err(|_| to_js_error("failed to parse tokmd result JSON"))
88}
89
90#[wasm_bindgen]
92pub fn version() -> String {
93 tokmd_core::ffi::version().to_string()
94}
95
96#[wasm_bindgen(js_name = schemaVersion)]
98pub fn schema_version() -> u32 {
99 tokmd_core::ffi::schema_version()
100}
101
102#[cfg(feature = "analysis")]
104#[wasm_bindgen(js_name = analysisSchemaVersion)]
105pub fn analysis_schema_version() -> u32 {
106 CORE_ANALYSIS_SCHEMA_VERSION
107}
108
109#[wasm_bindgen(js_name = runJson)]
111pub fn run_json(mode: &str, args_json: &str) -> String {
112 if let Err(err) = validate_mode_args_json(mode, args_json) {
113 return ResponseEnvelope::error(&err).to_json();
114 }
115 tokmd_core::ffi::run_json(mode, args_json)
116}
117
118#[wasm_bindgen(js_name = run)]
120pub fn run(mode: &str, args: JsValue) -> Result<JsValue, JsValue> {
121 run_mode_js(mode, args)
122}
123
124#[wasm_bindgen(js_name = runLang)]
126pub fn run_lang(args: JsValue) -> Result<JsValue, JsValue> {
127 run_mode_js("lang", args)
128}
129
130#[wasm_bindgen(js_name = runModule)]
132pub fn run_module(args: JsValue) -> Result<JsValue, JsValue> {
133 run_mode_js("module", args)
134}
135
136#[wasm_bindgen(js_name = runExport)]
138pub fn run_export(args: JsValue) -> Result<JsValue, JsValue> {
139 run_mode_js("export", args)
140}
141
142#[cfg(feature = "analysis")]
149#[wasm_bindgen(js_name = runAnalyze)]
150pub fn run_analyze(args: JsValue) -> Result<JsValue, JsValue> {
151 run_analyze_js(args)
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use serde_json::json;
158
159 fn fixture_inputs() -> Value {
160 json!([
161 {
162 "path": "crates/app/src/lib.rs",
163 "text": "pub fn alpha() -> usize { 1 }\n"
164 },
165 {
166 "path": "src/main.rs",
167 "text": "fn main() {}\n"
168 },
169 {
170 "path": "tests/basic.py",
171 "text": "# TODO: keep smoke\nprint('ok')\n"
172 }
173 ])
174 }
175
176 #[test]
177 fn run_json_returns_valid_envelope() {
178 let result = run_json("version", "{}");
179 let envelope = tokmd_envelope::ffi::parse_envelope(&result).expect("valid JSON envelope");
180
181 assert_eq!(envelope["ok"], true);
182 assert_eq!(envelope["data"]["version"], env!("CARGO_PKG_VERSION"));
183 }
184
185 #[test]
186 fn run_mode_value_lang_supports_in_memory_inputs() {
187 let data = run_mode_value(
188 "lang",
189 &json!({
190 "inputs": fixture_inputs(),
191 "files": true
192 }),
193 )
194 .expect("lang data");
195
196 assert_eq!(data["mode"], "lang");
197 assert_eq!(data["scan"]["paths"][0], "crates/app/src/lib.rs");
198 assert_eq!(data["total"]["files"], 3);
199 }
200
201 #[test]
202 fn run_mode_value_export_preserves_logical_paths() {
203 let data = run_mode_value(
204 "export",
205 &json!({
206 "inputs": fixture_inputs()
207 }),
208 )
209 .expect("export data");
210 let paths: Vec<&str> = data["rows"]
211 .as_array()
212 .expect("rows array")
213 .iter()
214 .map(|row| row["path"].as_str().expect("row path"))
215 .collect();
216
217 assert_eq!(data["mode"], "export");
218 assert!(paths.contains(&"crates/app/src/lib.rs"));
219 assert!(paths.contains(&"tests/basic.py"));
220 }
221
222 #[cfg(feature = "analysis")]
223 #[test]
224 fn run_mode_value_analyze_estimate_returns_effort_payload() {
225 let data = run_mode_value(
226 "analyze",
227 &json!({
228 "inputs": fixture_inputs(),
229 "preset": "estimate"
230 }),
231 )
232 .expect("analysis data");
233
234 assert_eq!(data["mode"], "analysis");
235 assert_eq!(data["source"]["inputs"][1], "src/main.rs");
236 assert_eq!(data["effort"]["model"], "cocomo81-basic");
237 assert_eq!(data["effort"]["size_basis"]["total_lines"], 3);
238 assert!(
239 data["effort"]["results"]["effort_pm_p50"]
240 .as_f64()
241 .expect("effort p50")
242 > 0.0
243 );
244 }
245
246 #[cfg(feature = "analysis")]
247 #[test]
248 fn run_mode_value_analyze_receipt_returns_rootless_receipt_payload() {
249 let data = run_mode_value(
250 "analyze",
251 &json!({
252 "inputs": fixture_inputs(),
253 "preset": "receipt"
254 }),
255 )
256 .expect("analysis data");
257
258 assert_eq!(data["mode"], "analysis");
259 assert_eq!(data["source"]["inputs"][2], "tests/basic.py");
260 assert_eq!(data["derived"]["totals"]["files"], 3);
261 assert_eq!(data["effort"], Value::Null);
262 assert_eq!(data["git"], Value::Null);
263 assert!(
264 data["warnings"]
265 .as_array()
266 .expect("warnings array")
267 .iter()
268 .filter_map(Value::as_str)
269 .any(|warning| warning.contains("no host root") && warning.contains("file-backed"))
270 );
271 assert!(
272 data["warnings"]
273 .as_array()
274 .expect("warnings array")
275 .iter()
276 .filter_map(Value::as_str)
277 .any(|warning| warning.contains("no host root") && warning.contains("git"))
278 );
279 }
280
281 #[cfg(feature = "analysis")]
282 #[test]
283 fn run_mode_value_analyze_without_preset_defaults_to_receipt_payload() {
284 let data = run_mode_value(
285 "analyze",
286 &json!({
287 "inputs": fixture_inputs()
288 }),
289 )
290 .expect("analysis data");
291
292 assert_eq!(data["mode"], "analysis");
293 assert_eq!(data["source"]["inputs"][0], "crates/app/src/lib.rs");
294 assert_eq!(data["derived"]["totals"]["files"], 3);
295 assert_eq!(data["effort"], Value::Null);
296 }
297
298 #[cfg(feature = "analysis")]
299 #[test]
300 fn validate_analyze_args_accepts_rootless_receipt_and_estimate() {
301 validate_analyze_args_json(
302 r#"{
303 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }]
304 }"#,
305 )
306 .expect("missing preset should default to receipt");
307
308 validate_analyze_args_json(
309 r#"{
310 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
311 "analyze": { "preset": "Receipt" }
312 }"#,
313 )
314 .expect("nested mixed-case receipt should be allowed");
315
316 validate_analyze_args_json(
317 r#"{
318 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
319 "preset": "estimate"
320 }"#,
321 )
322 .expect("estimate should be allowed");
323
324 validate_analyze_args_json(
325 r#"{
326 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
327 "analyze": { "preset": "Estimate" }
328 }"#,
329 )
330 .expect("nested mixed-case estimate should be allowed");
331
332 let err = validate_analyze_args_json(
333 r#"{
334 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
335 "preset": "health"
336 }"#,
337 )
338 .expect_err("unsupported preset should be rejected");
339
340 assert!(err.message.contains("preset=\"receipt\""));
341 assert!(err.message.contains("preset=\"estimate\""));
342 }
343
344 #[cfg(feature = "analysis")]
345 #[test]
346 fn run_json_analyze_rejects_unsupported_presets() {
347 let result = run_json(
348 "analyze",
349 r#"{
350 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
351 "preset": "health"
352 }"#,
353 );
354 let envelope = tokmd_envelope::ffi::parse_envelope(&result).expect("valid JSON envelope");
355
356 assert_eq!(envelope["ok"], false);
357 assert_eq!(envelope["error"]["code"], "not_implemented");
358 assert!(
359 envelope["error"]["message"]
360 .as_str()
361 .expect("error message")
362 .contains("preset=\"receipt\"")
363 );
364 assert!(
365 envelope["error"]["message"]
366 .as_str()
367 .expect("error message")
368 .contains("preset=\"estimate\"")
369 );
370 }
371
372 #[cfg(feature = "analysis")]
373 #[test]
374 fn run_mode_value_analyze_accepts_nested_case_insensitive_estimate() {
375 let data = run_mode_value(
376 "analyze",
377 &json!({
378 "inputs": fixture_inputs(),
379 "analyze": { "preset": "Estimate" }
380 }),
381 )
382 .expect("analysis data");
383
384 assert_eq!(data["mode"], "analysis");
385 assert_eq!(data["source"]["inputs"][0], "crates/app/src/lib.rs");
386 assert_eq!(data["effort"]["model"], "cocomo81-basic");
387 }
388
389 #[test]
390 fn run_mode_value_surfaces_upstream_errors() {
391 let err = run_mode_value(
392 "lang",
393 &json!({
394 "inputs": fixture_inputs(),
395 "paths": ["src"]
396 }),
397 )
398 .expect_err("paths + inputs should error");
399
400 assert!(err.contains("[invalid_settings]"));
401 assert!(err.contains("cannot be combined with in-memory inputs"));
402 }
403
404 #[test]
405 fn schema_version_matches_core_receipts() {
406 assert_eq!(schema_version(), tokmd_types::SCHEMA_VERSION);
407 }
408
409 #[cfg(feature = "analysis")]
410 #[test]
411 fn analysis_schema_version_matches_analysis_receipts() {
412 assert_eq!(analysis_schema_version(), CORE_ANALYSIS_SCHEMA_VERSION);
413 }
414}
415
416#[cfg(all(test, target_arch = "wasm32"))]
417mod wasm_tests {
418 use super::*;
419 use serde_json::Value;
420 use wasm_bindgen::JsCast;
421 use wasm_bindgen_test::*;
422
423 fn parse_js_args(json: &str) -> JsValue {
424 JSON::parse(json).expect("valid JS object")
425 }
426
427 fn js_value_to_json(value: &JsValue) -> Value {
428 let json = JSON::stringify(value)
429 .expect("serializable JS value")
430 .as_string()
431 .expect("JSON string");
432 serde_json::from_str(&json).expect("valid JSON value")
433 }
434
435 fn core_mode_value(mode: &str, args_json: &str) -> Value {
436 let envelope_json = tokmd_core::ffi::run_json(mode, args_json);
437 let data_json =
438 tokmd_envelope::ffi::extract_data_json(&envelope_json).expect("core data payload");
439 serde_json::from_str(&data_json).expect("valid core JSON value")
440 }
441
442 fn assert_generated_at_ms_nonzero(label: &str, value: &Value) {
443 let timestamp = value
444 .get("generated_at_ms")
445 .and_then(Value::as_u64)
446 .unwrap_or_else(|| panic!("{label} missing numeric generated_at_ms"));
447 assert!(timestamp > 0, "{label} generated_at_ms must not be 0");
448 }
449
450 fn normalize_volatile_timestamps(value: &mut Value) {
451 match value {
452 Value::Array(items) => {
453 for item in items {
454 normalize_volatile_timestamps(item);
455 }
456 }
457 Value::Object(object) => {
458 for (key, value) in object {
459 if key == "generated_at_ms" || key == "export_generated_at_ms" {
460 if !value.is_null() {
461 *value = Value::from(1);
462 }
463 } else {
464 normalize_volatile_timestamps(value);
465 }
466 }
467 }
468 _ => {}
469 }
470 }
471
472 fn values_match_js_boundary(actual: &Value, expected: &Value) -> bool {
473 match (actual, expected) {
474 (Value::Null, Value::Null)
475 | (Value::Bool(_), Value::Bool(_))
476 | (Value::String(_), Value::String(_)) => actual == expected,
477 (Value::Number(actual), Value::Number(expected)) => {
478 numbers_match_js_boundary(actual, expected)
479 }
480 (Value::Array(actual), Value::Array(expected)) => {
481 actual.len() == expected.len()
482 && actual
483 .iter()
484 .zip(expected.iter())
485 .all(|(actual, expected)| values_match_js_boundary(actual, expected))
486 }
487 (Value::Object(actual), Value::Object(expected)) => {
488 actual.len() == expected.len()
489 && actual.iter().all(|(key, actual_value)| {
490 expected.get(key).is_some_and(|expected_value| {
491 values_match_js_boundary(actual_value, expected_value)
492 })
493 })
494 }
495 _ => false,
496 }
497 }
498
499 fn numbers_match_js_boundary(
500 actual: &serde_json::Number,
501 expected: &serde_json::Number,
502 ) -> bool {
503 const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
504
505 if actual == expected {
506 return true;
507 }
508
509 if let (Some(actual), Some(expected)) = (actual.as_i64(), expected.as_i64()) {
510 return actual == expected;
511 }
512
513 if let (Some(actual), Some(expected)) = (actual.as_u64(), expected.as_u64()) {
514 return actual == expected;
515 }
516
517 let (Some(actual), Some(expected)) = (actual.as_f64(), expected.as_f64()) else {
518 return false;
519 };
520
521 if actual != expected {
522 return false;
523 }
524
525 let both_integral = actual.fract() == 0.0 && expected.fract() == 0.0;
526 if both_integral && (actual.abs() > MAX_SAFE_INTEGER || expected.abs() > MAX_SAFE_INTEGER) {
527 return false;
528 }
529
530 true
531 }
532
533 #[wasm_bindgen_test]
534 fn run_lang_exercises_js_value_boundary() {
535 let args_json = r#"{
536 "inputs": [
537 { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
538 { "path": "tests/basic.py", "text": "print('ok')\n" }
539 ],
540 "files": true
541 }"#;
542 let data = run_lang(parse_js_args(args_json)).expect("lang data");
543 let mut parsed = js_value_to_json(&data);
544 let mut expected = core_mode_value("lang", args_json);
545
546 assert_eq!(parsed["mode"], "lang");
547 assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
548 assert_eq!(parsed["total"]["files"], 2);
549 assert_generated_at_ms_nonzero("lang wasm payload", &parsed);
550 assert_generated_at_ms_nonzero("lang core payload", &expected);
551 normalize_volatile_timestamps(&mut parsed);
552 normalize_volatile_timestamps(&mut expected);
553 assert!(
554 values_match_js_boundary(&parsed, &expected),
555 "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
556 );
557 }
558
559 #[wasm_bindgen_test]
560 fn run_module_exercises_js_value_boundary() {
561 let args_json = r#"{
562 "inputs": [
563 { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
564 { "path": "tests/basic.py", "text": "print('ok')\n" }
565 ]
566 }"#;
567 let data = run_module(parse_js_args(args_json)).expect("module data");
568 let mut parsed = js_value_to_json(&data);
569 let mut expected = core_mode_value("module", args_json);
570
571 assert_eq!(parsed["mode"], "module");
572 assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
573 assert!(parsed["rows"].as_array().is_some());
574 assert_generated_at_ms_nonzero("module wasm payload", &parsed);
575 assert_generated_at_ms_nonzero("module core payload", &expected);
576 normalize_volatile_timestamps(&mut parsed);
577 normalize_volatile_timestamps(&mut expected);
578 assert!(
579 values_match_js_boundary(&parsed, &expected),
580 "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
581 );
582 }
583
584 #[wasm_bindgen_test]
585 fn run_export_exercises_js_value_boundary() {
586 let args_json = r#"{
587 "inputs": [
588 { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" },
589 { "path": "tests/basic.py", "text": "print('ok')\n" }
590 ]
591 }"#;
592 let data = run_export(parse_js_args(args_json)).expect("export data");
593 let mut parsed = js_value_to_json(&data);
594 let mut expected = core_mode_value("export", args_json);
595
596 assert_eq!(parsed["mode"], "export");
597 assert_eq!(parsed["scan"]["paths"][0], "src/lib.rs");
598 assert_eq!(parsed["rows"][0]["path"], "src/lib.rs");
599 assert_generated_at_ms_nonzero("export wasm payload", &parsed);
600 assert_generated_at_ms_nonzero("export core payload", &expected);
601 normalize_volatile_timestamps(&mut parsed);
602 normalize_volatile_timestamps(&mut expected);
603 assert!(
604 values_match_js_boundary(&parsed, &expected),
605 "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
606 );
607 }
608
609 #[wasm_bindgen_test]
610 fn run_surfaces_js_facing_errors() {
611 let err = run(
612 "lang",
613 parse_js_args(
614 r#"{
615 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
616 "paths": ["src"]
617 }"#,
618 ),
619 )
620 .expect_err("conflicting inputs should error")
621 .dyn_into::<JsError>()
622 .expect("js error");
623
624 let message = err.message().as_string().expect("js string message");
625 assert!(message.contains("[invalid_settings]"));
626 }
627
628 #[cfg(feature = "analysis")]
629 #[wasm_bindgen_test]
630 fn run_analyze_estimate_reports_analysis_schema_and_matches_core_payload() {
631 let args_json = r#"{
632 "inputs": [
633 { "path": "crates/app/src/lib.rs", "text": "pub fn alpha() -> usize { 1 }\n" },
634 { "path": "src/main.rs", "text": "fn main() {}\n" }
635 ],
636 "preset": "estimate"
637 }"#;
638 let data = run_analyze(parse_js_args(args_json)).expect("analysis data");
639 let mut parsed = js_value_to_json(&data);
640 let mut expected = core_mode_value("analyze", args_json);
641
642 assert_eq!(analysis_schema_version(), CORE_ANALYSIS_SCHEMA_VERSION);
643 assert_eq!(parsed["mode"], "analysis");
644 assert_eq!(parsed["source"]["inputs"][0], "crates/app/src/lib.rs");
645 assert_eq!(parsed["effort"]["model"], "cocomo81-basic");
646 assert_generated_at_ms_nonzero("analysis estimate wasm payload", &parsed);
647 assert_generated_at_ms_nonzero("analysis estimate core payload", &expected);
648 normalize_volatile_timestamps(&mut parsed);
649 normalize_volatile_timestamps(&mut expected);
650 assert!(
651 values_match_js_boundary(&parsed, &expected),
652 "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
653 );
654 }
655
656 #[cfg(feature = "analysis")]
657 #[wasm_bindgen_test]
658 fn run_analyze_receipt_matches_core_payload() {
659 let args_json = r#"{
660 "inputs": [
661 { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
662 ],
663 "preset": "receipt"
664 }"#;
665 let data = run_analyze(parse_js_args(args_json)).expect("analysis data");
666 let mut parsed = js_value_to_json(&data);
667 let mut expected = core_mode_value("analyze", args_json);
668
669 assert_eq!(parsed["mode"], "analysis");
670 assert_eq!(parsed["source"]["inputs"][0], "src/lib.rs");
671 assert_eq!(parsed["derived"]["totals"]["files"], 1);
672 assert_eq!(parsed["effort"], Value::Null);
673 assert_generated_at_ms_nonzero("analysis receipt wasm payload", &parsed);
674 assert_generated_at_ms_nonzero("analysis receipt core payload", &expected);
675 normalize_volatile_timestamps(&mut parsed);
676 normalize_volatile_timestamps(&mut expected);
677 assert!(
678 values_match_js_boundary(&parsed, &expected),
679 "wasm payload diverged from core payload\nactual: {parsed}\nexpected: {expected}"
680 );
681 }
682
683 #[cfg(feature = "analysis")]
684 #[wasm_bindgen_test]
685 fn run_analyze_without_preset_defaults_to_receipt() {
686 let data = run_analyze(parse_js_args(
687 r#"{
688 "inputs": [
689 { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
690 ]
691 }"#,
692 ))
693 .expect("analysis data");
694 let parsed = js_value_to_json(&data);
695
696 assert_eq!(parsed["mode"], "analysis");
697 assert_eq!(parsed["source"]["inputs"][0], "src/lib.rs");
698 assert_eq!(parsed["derived"]["totals"]["files"], 1);
699 assert_eq!(parsed["effort"], Value::Null);
700 }
701
702 #[cfg(feature = "analysis")]
703 #[wasm_bindgen_test]
704 fn run_analyze_rejects_unsupported_presets() {
705 let err = run_analyze(parse_js_args(
706 r#"{
707 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
708 "preset": "health"
709 }"#,
710 ))
711 .expect_err("non-estimate preset should be rejected")
712 .dyn_into::<JsError>()
713 .expect("js error");
714
715 let message = err.message().as_string().expect("js string message");
716 assert!(message.contains("preset=\"receipt\""));
717 assert!(message.contains("preset=\"estimate\""));
718 }
719
720 #[cfg(feature = "analysis")]
721 #[wasm_bindgen_test]
722 fn run_accepts_nested_case_insensitive_analyze_preset() {
723 let data = run(
724 "analyze",
725 parse_js_args(
726 r#"{
727 "inputs": [
728 { "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }
729 ],
730 "analyze": { "preset": "Estimate" }
731 }"#,
732 ),
733 )
734 .expect("analysis data");
735 let parsed = js_value_to_json(&data);
736
737 assert_eq!(parsed["mode"], "analysis");
738 assert_eq!(parsed["effort"]["model"], "cocomo81-basic");
739 }
740
741 #[cfg(feature = "analysis")]
742 #[wasm_bindgen_test]
743 fn run_rejects_unsupported_analyze_presets() {
744 let err = run(
745 "analyze",
746 parse_js_args(
747 r#"{
748 "inputs": [{ "path": "src/lib.rs", "text": "pub fn alpha() {}\n" }],
749 "preset": "health"
750 }"#,
751 ),
752 )
753 .expect_err("non-estimate preset should be rejected")
754 .dyn_into::<JsError>()
755 .expect("js error");
756
757 let message = err.message().as_string().expect("js string message");
758 assert!(message.contains("preset=\"receipt\""));
759 assert!(message.contains("preset=\"estimate\""));
760 }
761}