use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde_json::Value;
#[cfg(feature = "analysis")]
use crate::analyze_workflow_from_inputs;
use crate::error::{ResponseEnvelope, TokmdError};
use crate::settings::{
AnalyzeSettings, ChildIncludeMode, ChildrenMode, ConfigMode, DiffSettings, ExportFormat,
ExportSettings, LangSettings, ModuleSettings, RedactMode, ScanSettings,
};
use crate::{
InMemoryFile, export_workflow, export_workflow_from_inputs, lang_workflow,
lang_workflow_from_inputs, module_workflow, module_workflow_from_inputs,
};
pub fn run_json(mode: &str, args_json: &str) -> String {
match run_json_inner(mode, args_json) {
Ok(data) => ResponseEnvelope::success(data).to_json(),
Err(err) => ResponseEnvelope::error(&err).to_json(),
}
}
fn run_json_inner(mode: &str, args_json: &str) -> Result<Value, TokmdError> {
let args: Value =
serde_json::from_str(args_json).map_err(|err| TokmdError::invalid_json(err.to_string()))?;
if !args.is_object() {
return Err(TokmdError::invalid_json(
"Top-level JSON value must be an object",
));
}
let inputs = parse_in_memory_inputs(&args)?;
let scan = parse_scan_settings(&args)?;
match mode {
"lang" => {
let settings = parse_lang_settings(&args)?;
let receipt = if let Some(inputs) = inputs.as_deref() {
lang_workflow_from_inputs(inputs, &scan.options, &settings)?
} else {
lang_workflow(&scan, &settings)?
};
Ok(serde_json::to_value(receipt)?)
}
"module" => {
let settings = parse_module_settings(&args)?;
let receipt = if let Some(inputs) = inputs.as_deref() {
module_workflow_from_inputs(inputs, &scan.options, &settings)?
} else {
module_workflow(&scan, &settings)?
};
Ok(serde_json::to_value(receipt)?)
}
"export" => {
let settings = parse_export_settings(&args)?;
let receipt = if let Some(inputs) = inputs.as_deref() {
export_workflow_from_inputs(inputs, &scan.options, &settings)?
} else {
export_workflow(&scan, &settings)?
};
Ok(serde_json::to_value(receipt)?)
}
"analyze" => {
#[cfg(feature = "analysis")]
{
let settings = parse_analyze_settings(&args)?;
let receipt = if let Some(inputs) = inputs.as_deref() {
analyze_workflow_from_inputs(inputs, &scan.options, &settings)?
} else {
crate::analyze_workflow(&scan, &settings)?
};
Ok(serde_json::to_value(receipt)?)
}
#[cfg(not(feature = "analysis"))]
{
Err(TokmdError::not_implemented(
"analyze mode requires 'analysis' feature: enable in Cargo.toml or use CLI",
))
}
}
"cockpit" => {
#[cfg(feature = "cockpit")]
{
let settings = parse_cockpit_settings(&args)?;
let receipt = crate::cockpit_workflow(&settings)?;
Ok(serde_json::to_value(receipt)?)
}
#[cfg(not(feature = "cockpit"))]
{
Err(TokmdError::not_implemented(
"cockpit mode requires 'cockpit' feature: enable in Cargo.toml or use CLI",
))
}
}
"diff" => {
let settings = parse_diff_settings(&args)?;
let receipt = crate::diff_workflow(&settings)?;
Ok(serde_json::to_value(receipt)?)
}
"version" => {
#[cfg(feature = "analysis")]
let version_info = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"schema_version": tokmd_types::SCHEMA_VERSION,
"analysis_schema_version": tokmd_analysis_types::ANALYSIS_SCHEMA_VERSION,
});
#[cfg(not(feature = "analysis"))]
let version_info = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"schema_version": tokmd_types::SCHEMA_VERSION,
"analysis_schema_version": serde_json::Value::Null,
});
Ok(version_info)
}
_ => Err(TokmdError::unknown_mode(mode)),
}
}
fn scan_arg_object(args: &Value) -> &Value {
args.get("scan").unwrap_or(args)
}
fn parse_bool(args: &Value, field: &str, default: bool) -> Result<bool, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_bool()
.ok_or_else(|| TokmdError::invalid_field(field, "a boolean (true or false)")),
}
}
fn parse_usize(args: &Value, field: &str, default: usize) -> Result<usize, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_u64()
.map(|n| n as usize)
.ok_or_else(|| TokmdError::invalid_field(field, "a non-negative integer")),
}
}
fn parse_optional_u64(args: &Value, field: &str) -> Result<Option<u64>, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.map(Some)
.ok_or_else(|| TokmdError::invalid_field(field, "a non-negative integer")),
}
}
fn parse_optional_usize(args: &Value, field: &str) -> Result<Option<usize>, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.map(|n| Some(n as usize))
.ok_or_else(|| TokmdError::invalid_field(field, "a non-negative integer")),
}
}
fn parse_optional_bool(args: &Value, field: &str) -> Result<Option<bool>, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_bool()
.map(Some)
.ok_or_else(|| TokmdError::invalid_field(field, "a boolean (true or false)")),
}
}
fn parse_optional_string(args: &Value, field: &str) -> Result<Option<String>, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_str()
.map(|s| Some(s.to_string()))
.ok_or_else(|| TokmdError::invalid_field(field, "a string")),
}
}
fn parse_string(args: &Value, field: &str, default: &str) -> Result<String, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(default.to_string()),
Some(v) => v
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| TokmdError::invalid_field(field, "a string")),
}
}
fn parse_required_string(args: &Value, field: &str) -> Result<String, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Err(TokmdError::invalid_field(field, "required but missing")),
Some(v) => v
.as_str()
.map(String::from)
.ok_or_else(|| TokmdError::invalid_field(field, "a string")),
}
}
fn parse_string_array(
args: &Value,
field: &str,
default: Vec<String>,
) -> Result<Vec<String>, TokmdError> {
match args.get(field) {
None | Some(Value::Null) => Ok(default),
Some(Value::Array(arr)) => arr
.iter()
.enumerate()
.map(|(i, v)| {
v.as_str().map(String::from).ok_or_else(|| {
TokmdError::invalid_field(&format!("{}[{}]", field, i), "a string")
})
})
.collect(),
Some(_) => Err(TokmdError::invalid_field(field, "an array of strings")),
}
}
fn parse_in_memory_inputs(args: &Value) -> Result<Option<Vec<InMemoryFile>>, TokmdError> {
let scan_obj = args.get("scan");
let root_inputs = args.get("inputs").filter(|value| !value.is_null());
let nested_inputs = scan_obj
.and_then(Value::as_object)
.and_then(|scan| scan.get("inputs"))
.filter(|value| !value.is_null());
let raw_inputs = match (root_inputs, nested_inputs) {
(Some(_), Some(_)) => {
return Err(TokmdError::invalid_field(
"inputs",
"provide in-memory inputs either at the top level or under 'scan', not both",
));
}
(Some(inputs), None) => inputs,
(None, Some(inputs)) => inputs,
(None, None) => return Ok(None),
};
let root_has_paths = args.get("paths").is_some_and(|value| !value.is_null());
let scan_has_paths = scan_obj
.and_then(Value::as_object)
.and_then(|scan| scan.get("paths"))
.is_some_and(|value| !value.is_null());
if root_has_paths || scan_has_paths {
return Err(TokmdError::invalid_field(
"paths",
"cannot be combined with in-memory inputs",
));
}
let arr = raw_inputs
.as_array()
.ok_or_else(|| TokmdError::invalid_field("inputs", "an array of input objects"))?;
let mut inputs = Vec::with_capacity(arr.len());
for (idx, raw_input) in arr.iter().enumerate() {
let input = raw_input.as_object().ok_or_else(|| {
TokmdError::invalid_field(
&format!("inputs[{idx}]"),
"an object with 'path' and exactly one of 'text' or 'base64'",
)
})?;
let path = input
.get("path")
.and_then(Value::as_str)
.ok_or_else(|| TokmdError::invalid_field(&format!("inputs[{idx}].path"), "a string"))?
.to_string();
validate_in_memory_input_path(&path, idx)?;
let text = input.get("text");
let base64 = input.get("base64");
let bytes = match (text, base64) {
(Some(text), None) => text
.as_str()
.ok_or_else(|| {
TokmdError::invalid_field(&format!("inputs[{idx}].text"), "a string")
})?
.as_bytes()
.to_vec(),
(None, Some(base64)) => {
let encoded = base64.as_str().ok_or_else(|| {
TokmdError::invalid_field(&format!("inputs[{idx}].base64"), "a string")
})?;
BASE64.decode(encoded).map_err(|_| {
TokmdError::invalid_field(&format!("inputs[{idx}].base64"), "valid base64")
})?
}
(Some(_), Some(_)) => {
return Err(TokmdError::invalid_field(
&format!("inputs[{idx}]"),
"provide exactly one of 'text' or 'base64'",
));
}
(None, None) => {
return Err(TokmdError::invalid_field(
&format!("inputs[{idx}]"),
"missing content: provide exactly one of 'text' or 'base64'",
));
}
};
inputs.push(InMemoryFile::new(path, bytes));
}
Ok(Some(inputs))
}
fn validate_in_memory_input_path(path: &str, idx: usize) -> Result<(), TokmdError> {
let field = format!("inputs[{idx}].path");
if path.is_empty() {
return Err(TokmdError::invalid_field(
&field,
"a non-empty relative file path",
));
}
if path.starts_with('/') || path.starts_with('\\') {
return Err(TokmdError::invalid_field(
&field,
"a relative path, not an absolute path",
));
}
if looks_like_windows_drive_path(path) {
return Err(TokmdError::invalid_field(
&field,
"a relative path without a Windows drive prefix",
));
}
for component in std::path::Path::new(path).components() {
match component {
std::path::Component::Prefix(_) | std::path::Component::RootDir => {
return Err(TokmdError::invalid_field(
&field,
"a relative path, not an absolute path",
));
}
std::path::Component::ParentDir => {
return Err(TokmdError::invalid_field(
&field,
"a path without parent traversal (..)",
));
}
std::path::Component::CurDir | std::path::Component::Normal(_) => {}
}
}
if path
.split(['/', '\\'])
.all(|segment| segment.is_empty() || segment == ".")
{
return Err(TokmdError::invalid_field(
&field,
"a path that resolves to a file",
));
}
for segment in path.split(['/', '\\']) {
if segment == ".." {
return Err(TokmdError::invalid_field(
&field,
"a path without parent traversal (..)",
));
}
}
Ok(())
}
fn looks_like_windows_drive_path(path: &str) -> bool {
let bytes = path.as_bytes();
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}
fn parse_children_mode(args: &Value, default: ChildrenMode) -> Result<ChildrenMode, TokmdError> {
match args.get("children") {
None => Ok(default),
Some(v) => serde_json::from_value::<ChildrenMode>(v.clone())
.map_err(|_| TokmdError::invalid_field("children", "'collapse' or 'separate'")),
}
}
fn parse_child_include_mode(
args: &Value,
default: ChildIncludeMode,
) -> Result<ChildIncludeMode, TokmdError> {
match args.get("children") {
None => Ok(default),
Some(v) => serde_json::from_value::<ChildIncludeMode>(v.clone())
.map_err(|_| TokmdError::invalid_field("children", "'separate' or 'parents-only'")),
}
}
fn parse_redact_mode(args: &Value, default: RedactMode) -> Result<RedactMode, TokmdError> {
match args.get("redact") {
None => Ok(default),
Some(v) => serde_json::from_value::<RedactMode>(v.clone())
.map_err(|_| TokmdError::invalid_field("redact", "'none', 'paths', or 'all'")),
}
}
fn parse_effort_model(args: &Value, field: &str) -> Result<Option<String>, TokmdError> {
match parse_optional_string(args, field)? {
None => Ok(None),
Some(value) => {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"cocomo81-basic" => Ok(Some(normalized)),
"cocomo2-early" | "ensemble" => Err(TokmdError::invalid_field(
field,
"only 'cocomo81-basic' is currently supported",
)),
_ => Err(TokmdError::invalid_field(field, "'cocomo81-basic'")),
}
}
}
}
fn parse_effort_layer(args: &Value, field: &str) -> Result<Option<String>, TokmdError> {
match parse_optional_string(args, field)? {
None => Ok(None),
Some(value) => {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"headline" | "why" | "full" => Ok(Some(normalized)),
_ => Err(TokmdError::invalid_field(
field,
"'headline', 'why', or 'full'",
)),
}
}
}
}
fn parse_optional_redact_mode(args: &Value) -> Result<Option<RedactMode>, TokmdError> {
match args.get("redact") {
None => Ok(None),
Some(v) => serde_json::from_value::<RedactMode>(v.clone())
.map(Some)
.map_err(|_| TokmdError::invalid_field("redact", "'none', 'paths', or 'all'")),
}
}
fn parse_config_mode(args: &Value, default: ConfigMode) -> Result<ConfigMode, TokmdError> {
match args.get("config") {
None => Ok(default),
Some(v) => serde_json::from_value::<ConfigMode>(v.clone())
.map_err(|_| TokmdError::invalid_field("config", "'auto' or 'none'")),
}
}
fn parse_export_format(args: &Value, default: ExportFormat) -> Result<ExportFormat, TokmdError> {
match args.get("format") {
None => Ok(default),
Some(v) => serde_json::from_value::<ExportFormat>(v.clone()).map_err(|_| {
TokmdError::invalid_field("format", "'csv', 'jsonl', 'json', or 'cyclonedx'")
}),
}
}
fn parse_analyze_preset(args: &Value, default: &str) -> Result<String, TokmdError> {
let preset = parse_string(args, "preset", default)?;
let normalized = preset.trim().to_ascii_lowercase();
match normalized.as_str() {
"receipt" | "estimate" | "health" | "risk" | "supply" | "architecture" | "topics"
| "security" | "identity" | "git" | "deep" | "fun" => Ok(normalized),
_ => Err(TokmdError::invalid_field(
"preset",
"'receipt', 'estimate', 'health', 'risk', 'supply', 'architecture', 'topics', 'security', 'identity', 'git', 'deep', or 'fun'",
)),
}
}
fn parse_import_granularity(args: &Value, default: &str) -> Result<String, TokmdError> {
let granularity = parse_string(args, "granularity", default)?;
let normalized = granularity.trim().to_ascii_lowercase();
match normalized.as_str() {
"module" | "file" => Ok(normalized),
_ => Err(TokmdError::invalid_field(
"granularity",
"'module' or 'file'",
)),
}
}
fn parse_scan_settings(args: &Value) -> Result<ScanSettings, TokmdError> {
let obj = scan_arg_object(args);
Ok(ScanSettings {
paths: parse_string_array(obj, "paths", vec![".".to_string()])?,
options: crate::settings::ScanOptions {
excluded: parse_string_array(obj, "excluded", vec![])?,
config: parse_config_mode(obj, ConfigMode::Auto)?,
hidden: parse_bool(obj, "hidden", false)?,
no_ignore: parse_bool(obj, "no_ignore", false)?,
no_ignore_parent: parse_bool(obj, "no_ignore_parent", false)?,
no_ignore_dot: parse_bool(obj, "no_ignore_dot", false)?,
no_ignore_vcs: parse_bool(obj, "no_ignore_vcs", false)?,
treat_doc_strings_as_comments: parse_bool(obj, "treat_doc_strings_as_comments", false)?,
},
})
}
fn parse_lang_settings(args: &Value) -> Result<LangSettings, TokmdError> {
let obj = args.get("lang").unwrap_or(args);
Ok(LangSettings {
top: parse_usize(obj, "top", 0)?,
files: parse_bool(obj, "files", false)?,
children: parse_children_mode(obj, ChildrenMode::Collapse)?,
redact: parse_optional_redact_mode(obj)?,
})
}
fn parse_module_settings(args: &Value) -> Result<ModuleSettings, TokmdError> {
let obj = args.get("module").unwrap_or(args);
Ok(ModuleSettings {
top: parse_usize(obj, "top", 0)?,
module_roots: parse_string_array(
obj,
"module_roots",
vec!["crates".to_string(), "packages".to_string()],
)?,
module_depth: parse_usize(obj, "module_depth", 2)?,
children: parse_child_include_mode(obj, ChildIncludeMode::Separate)?,
redact: parse_optional_redact_mode(obj)?,
})
}
fn parse_export_settings(args: &Value) -> Result<ExportSettings, TokmdError> {
let obj = args.get("export").unwrap_or(args);
Ok(ExportSettings {
format: parse_export_format(obj, ExportFormat::Jsonl)?,
module_roots: parse_string_array(
obj,
"module_roots",
vec!["crates".to_string(), "packages".to_string()],
)?,
module_depth: parse_usize(obj, "module_depth", 2)?,
children: parse_child_include_mode(obj, ChildIncludeMode::Separate)?,
min_code: parse_usize(obj, "min_code", 0)?,
max_rows: parse_usize(obj, "max_rows", 0)?,
redact: parse_redact_mode(obj, RedactMode::None)?,
meta: parse_bool(obj, "meta", true)?,
strip_prefix: parse_optional_string(obj, "strip_prefix")?,
})
}
#[allow(dead_code)]
fn parse_analyze_settings(args: &Value) -> Result<AnalyzeSettings, TokmdError> {
let obj = args.get("analyze").unwrap_or(args);
let effort_base_ref = parse_optional_string(obj, "effort_base_ref")?;
let effort_head_ref = parse_optional_string(obj, "effort_head_ref")?;
if (effort_base_ref.is_some() && effort_head_ref.is_none())
|| (effort_base_ref.is_none() && effort_head_ref.is_some())
{
return Err(TokmdError::invalid_field(
"effort_base_ref/effort_head_ref",
"both effort_base_ref and effort_head_ref must be provided together",
));
}
if let Some(iterations) = parse_optional_usize(obj, "effort_mc_iterations")?
&& iterations == 0
{
return Err(TokmdError::invalid_field(
"effort_mc_iterations",
"must be greater than 0",
));
}
Ok(AnalyzeSettings {
preset: parse_analyze_preset(obj, "receipt")?,
window: parse_optional_usize(obj, "window")?,
git: parse_optional_bool(obj, "git")?,
max_files: parse_optional_usize(obj, "max_files")?,
max_bytes: parse_optional_u64(obj, "max_bytes")?,
max_file_bytes: parse_optional_u64(obj, "max_file_bytes")?,
max_commits: parse_optional_usize(obj, "max_commits")?,
max_commit_files: parse_optional_usize(obj, "max_commit_files")?,
granularity: parse_import_granularity(obj, "module")?,
effort_base_ref,
effort_head_ref,
effort_model: parse_effort_model(obj, "effort_model")?,
effort_layer: parse_effort_layer(obj, "effort_layer")?,
effort_monte_carlo: parse_optional_bool(obj, "effort_monte_carlo")?,
effort_mc_iterations: parse_optional_usize(obj, "effort_mc_iterations")?,
effort_mc_seed: parse_optional_u64(obj, "effort_mc_seed")?,
})
}
#[allow(dead_code)]
fn parse_cockpit_settings(args: &Value) -> Result<crate::settings::CockpitSettings, TokmdError> {
let obj = args.get("cockpit").unwrap_or(args);
Ok(crate::settings::CockpitSettings {
base: parse_string(obj, "base", "main")?,
head: parse_string(obj, "head", "HEAD")?,
range_mode: parse_string(obj, "range_mode", "two-dot")?,
baseline: parse_optional_string(obj, "baseline")?,
})
}
fn parse_diff_settings(args: &Value) -> Result<DiffSettings, TokmdError> {
let obj = args.get("diff").unwrap_or(args);
let from = parse_required_string(obj, "from")?;
let to = parse_required_string(obj, "to")?;
Ok(DiffSettings { from, to })
}
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn schema_version() -> u32 {
tokmd_types::SCHEMA_VERSION
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_json_version() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("version", "{}");
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], true);
assert!(
parsed["data"]["version"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains(env!("CARGO_PKG_VERSION"))
);
assert!(parsed["data"]["schema_version"].is_number());
#[cfg(feature = "analysis")]
assert!(parsed["data"]["analysis_schema_version"].is_number());
#[cfg(not(feature = "analysis"))]
assert!(parsed["data"]["analysis_schema_version"].is_null());
Ok(())
}
#[test]
fn run_json_unknown_mode() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("unknown", "{}");
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "unknown_mode");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("unknown")
);
Ok(())
}
#[test]
fn run_json_invalid_json() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", "not valid json");
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_json");
Ok(())
}
#[test]
fn run_json_rejects_top_level_scalar_payload() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", "0");
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_json");
assert_eq!(
parsed["error"]["message"].as_str(),
Some("Invalid JSON: Top-level JSON value must be an object")
);
Ok(())
}
#[test]
fn parse_scan_settings_defaults() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({});
let settings = parse_scan_settings(&args)?;
assert_eq!(settings.paths, vec!["."]);
assert!(!settings.options.hidden);
Ok(())
}
#[test]
fn parse_scan_settings_with_paths() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({
"paths": ["src", "lib"],
"hidden": true
});
let settings = parse_scan_settings(&args)?;
assert_eq!(settings.paths, vec!["src", "lib"]);
assert!(settings.options.hidden);
Ok(())
}
#[test]
fn parse_lang_settings_defaults() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({});
let settings = parse_lang_settings(&args)?;
assert_eq!(settings.top, 0);
assert!(!settings.files);
Ok(())
}
#[test]
fn parse_module_settings_defaults() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({});
let settings = parse_module_settings(&args)?;
assert_eq!(settings.module_depth, 2);
assert!(settings.module_roots.contains(&"crates".to_string()));
Ok(())
}
#[test]
fn version_returns_valid_string() {
let v = version();
assert!(!v.is_empty());
}
#[test]
fn schema_version_returns_current() {
let sv = schema_version();
assert_eq!(sv, tokmd_types::SCHEMA_VERSION);
}
#[test]
fn strict_parsing_invalid_bool() {
let args: Value = serde_json::json!({"hidden": "yes"});
let err = parse_scan_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("hidden"));
assert!(err.message.contains("boolean"));
}
#[test]
fn strict_parsing_invalid_usize() {
let args: Value = serde_json::json!({"top": "ten"});
let err = parse_lang_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("top"));
assert!(err.message.contains("integer"));
}
#[test]
fn strict_parsing_invalid_children_mode() {
let args: Value = serde_json::json!({"children": "invalid"});
let err = parse_lang_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("children"));
assert!(err.message.contains("collapse"));
}
#[test]
fn strict_parsing_invalid_child_include_mode() {
let args: Value = serde_json::json!({"children": "invalid"});
let err = parse_module_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("children"));
assert!(err.message.contains("separate"));
}
#[test]
fn strict_parsing_invalid_redact_mode() {
let args: Value = serde_json::json!({"redact": "invalid"});
let err = parse_export_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("redact"));
}
#[test]
fn strict_parsing_invalid_format() {
let args: Value = serde_json::json!({"format": "yaml"});
let err = parse_export_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("format"));
}
#[test]
fn strict_parsing_invalid_string_array() {
let args: Value = serde_json::json!({"paths": "not-an-array"});
let err = parse_scan_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("paths"));
assert!(err.message.contains("array"));
}
#[test]
fn strict_parsing_invalid_config_mode() {
let args: Value = serde_json::json!({"config": "invalid"});
let err = parse_scan_settings(&args).expect_err("should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("config"));
}
#[test]
fn run_json_invalid_children_returns_error_envelope() -> Result<(), Box<dyn std::error::Error>>
{
let result = run_json("lang", r#"{"children": "invalid"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("children")
);
Ok(())
}
#[test]
fn run_json_invalid_format_returns_error_envelope() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("export", r#"{"format": "yaml"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("format")
);
Ok(())
}
#[test]
fn run_json_always_returns_valid_json() -> Result<(), Box<dyn std::error::Error>> {
let test_cases = vec![
("", ""),
("lang", ""),
("lang", "null"),
("lang", "[]"),
("lang", "123"),
("lang", r#"{"paths": null}"#),
("lang", r#"{"top": -1}"#),
("\0", "{}"),
("lang", r#"{"paths": [1, 2, 3]}"#),
("export", r#"{"format": "invalid"}"#),
("unknown_mode", "{}"),
];
for (mode, args) in test_cases {
let result = run_json(mode, args);
let parsed: Result<Value, _> = serde_json::from_str(&result);
assert!(
parsed.is_ok(),
"Invalid JSON for mode={:?} args={:?}: {}",
mode,
args,
result
);
let parsed = parsed?;
assert!(
parsed.get("ok").is_some(),
"Missing 'ok' field for mode={:?} args={:?}",
mode,
args
);
}
Ok(())
}
#[test]
fn nested_scan_object_invalid_bool_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"scan": {"hidden": "yes"}}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("hidden")
);
Ok(())
}
#[test]
fn nested_lang_object_invalid_top_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"lang": {"top": "ten"}}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("top")
);
Ok(())
}
#[test]
fn null_values_use_defaults() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({"top": null, "files": null});
let settings = parse_lang_settings(&args)?;
assert_eq!(settings.top, 0);
assert!(!settings.files);
Ok(())
}
#[test]
fn null_paths_uses_default() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({"paths": null});
let settings = parse_scan_settings(&args)?;
assert_eq!(settings.paths, vec!["."]);
Ok(())
}
#[test]
fn array_element_error_includes_index() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({"paths": ["valid", 123, "also_valid"]});
let err = parse_scan_settings(&args).expect_err("should fail");
assert!(
err.message.contains("paths[1]"),
"Error should include index: {}",
err.message
);
Ok(())
}
#[test]
fn diff_missing_from_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("diff", r#"{"to": "receipt.json"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("from")
);
Ok(())
}
#[test]
fn diff_wrong_type_from_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("diff", r#"{"from": 123, "to": "receipt.json"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("from")
);
Ok(())
}
#[test]
#[cfg(feature = "analysis")]
fn invalid_analyze_preset_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("analyze", r#"{"preset":"unknown"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("preset")
);
Ok(())
}
#[test]
#[cfg(feature = "analysis")]
fn invalid_import_granularity_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("analyze", r#"{"granularity":"package"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("granularity")
);
Ok(())
}
#[test]
#[cfg(feature = "analysis")]
fn parse_analyze_settings_rejects_unsupported_effort_model()
-> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({
"preset": "estimate",
"effort_model": "cocomo2-early"
});
let err = parse_analyze_settings(&args).expect_err("unsupported model should fail");
assert_eq!(err.code, crate::error::ErrorCode::InvalidSettings);
assert!(err.message.contains("only 'cocomo81-basic'"));
Ok(())
}
#[test]
#[cfg(feature = "analysis")]
fn analyze_with_feature_returns_receipt() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("analyze", r#"{"paths":["src"],"preset":"receipt"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], true, "analyze failed: {}", result);
assert_eq!(parsed["data"]["mode"], "analysis");
Ok(())
}
#[test]
#[cfg(not(feature = "analysis"))]
fn analyze_without_feature_returns_not_implemented() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("analyze", "{}");
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "not_implemented");
Ok(())
}
#[test]
#[cfg(not(feature = "cockpit"))]
fn cockpit_without_feature_returns_not_implemented() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("cockpit", "{}");
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "not_implemented");
Ok(())
}
#[test]
fn parse_cockpit_settings_defaults() -> Result<(), Box<dyn std::error::Error>> {
let args: Value = serde_json::json!({});
let settings = parse_cockpit_settings(&args)?;
assert_eq!(settings.base, "main");
assert_eq!(settings.head, "HEAD");
assert_eq!(settings.range_mode, "two-dot");
assert!(settings.baseline.is_none());
Ok(())
}
#[test]
fn invalid_utf8_bytes_in_mode_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let invalid_utf8 = vec![0x80, 0x81, 0x82]; let mode = String::from_utf8_lossy(&invalid_utf8);
let result = run_json(&mode, r#"{"paths": ["."]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert!(
parsed.get("ok").is_some(),
"Must return envelope with ok field"
);
Ok(())
}
#[test]
fn invalid_utf8_in_args_json_returns_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"paths": ["\u0000"]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert!(parsed.get("ok").is_some());
Ok(())
}
#[test]
fn unicode_edge_cases_in_paths() -> Result<(), Box<dyn std::error::Error>> {
let test_cases = vec![
("lang", r#"{"paths": ["src/caf\u{0301}"]}"#),
("lang", r#"{"paths": ["src/\u{202E}file"]}"#),
("lang", r#"{"paths": ["src/file\u{200D}name"]}"#),
("lang", r#"{"paths": ["src/ファイル"]}"#),
];
for (mode, args) in test_cases {
let result = run_json(mode, args);
let parsed: Value = serde_json::from_str(&result)?;
assert!(
parsed.get("ok").is_some(),
"Must return envelope for mode={} args={}",
mode,
args
);
}
Ok(())
}
#[test]
fn null_byte_in_strings_handled() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"paths": ["src\u0000file"]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert!(parsed.get("ok").is_some());
Ok(())
}
#[test]
fn in_memory_inputs_requires_path_field() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"inputs": [{"text": "hello"}]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("path")
);
Ok(())
}
#[test]
fn in_memory_inputs_requires_content() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"inputs": [{"path": "test.rs"}]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("text")
|| parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("base64")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_both_text_and_base64() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "test.rs", "text": "hello", "base64": "aGVsbG8="}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("text")
|| parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("base64")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_invalid_base64() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "test.rs", "base64": "not-valid!!!"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("base64")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_non_array() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"inputs": "not-an-array"}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
Ok(())
}
#[test]
fn in_memory_inputs_rejects_absolute_path() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "/absolute/path.rs", "text": "fn main() {}"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("absolute path")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_backslash_root_path() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "\\absolute\\path.rs", "text": "fn main() {}"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("absolute path")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_windows_drive_path() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "C:\\absolute\\path.rs", "text": "fn main() {}"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("Windows drive prefix")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_parent_traversal() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "../out/path.rs", "text": "fn main() {}"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("parent traversal")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_backslash_parent_traversal()
-> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "..\\out\\path.rs", "text": "fn main() {}"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("parent traversal")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_empty_path() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "", "text": "fn main() {}"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("non-empty")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_dot_only_path() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "./.", "text": "fn main() {}"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("resolves to a file")
);
Ok(())
}
#[test]
fn in_memory_inputs_rejects_paths_combination() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"paths": ["."], "inputs": [{"path": "test.rs", "text": "hello"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
assert!(
parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("paths")
|| parsed["error"]["message"]
.as_str()
.ok_or_else(|| std::io::Error::other("not a string"))?
.contains("inputs")
);
Ok(())
}
#[test]
fn in_memory_inputs_under_scan_object() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"scan": {"inputs": [{"path": "test.rs", "text": "fn main() {}"}]}}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert!(parsed.get("ok").is_some());
Ok(())
}
#[test]
fn in_memory_inputs_duplicate_location_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "a.rs", "text": ""}], "scan": {"inputs": [{"path": "b.rs", "text": ""}]}}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "invalid_settings");
Ok(())
}
#[test]
fn in_memory_inputs_valid_base64_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(
"lang",
r#"{"inputs": [{"path": "test.rs", "base64": "Zm4gbWFpbigpIHt9"}]}"#,
);
let parsed: Value = serde_json::from_str(&result)?;
assert!(parsed.get("ok").is_some());
Ok(())
}
#[test]
fn in_memory_inputs_empty_array_succeeds() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"inputs": []}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert!(parsed.get("ok").is_some());
Ok(())
}
#[test]
fn empty_mode_returns_unknown_mode_error() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("", r#"{"paths": ["."]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "unknown_mode");
Ok(())
}
#[test]
fn very_long_mode_string_handled() -> Result<(), Box<dyn std::error::Error>> {
let long_mode = "a".repeat(10000);
let result = run_json(&long_mode, r#"{"paths": ["."]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "unknown_mode");
Ok(())
}
#[test]
fn deeply_nested_json_handled() -> Result<(), Box<dyn std::error::Error>> {
let mut nested = "{\"a\":0}".to_string();
for _ in 0..100 {
nested = format!("{{\"nested\":{}}}", nested);
}
let result = run_json("lang", &nested);
let parsed: Value = serde_json::from_str(&result)?;
assert!(parsed.get("ok").is_some());
Ok(())
}
#[test]
fn special_characters_in_error_messages() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("lang", r#"{"paths": ["<script>alert(1)</script>"]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert!(parsed.get("ok").is_some());
let re_encoded = serde_json::to_string(&parsed)?;
assert!(!re_encoded.is_empty());
Ok(())
}
#[test]
fn whitespace_only_mode() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json(" ", r#"{"paths": ["."]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "unknown_mode");
Ok(())
}
#[test]
fn case_sensitive_mode() -> Result<(), Box<dyn std::error::Error>> {
let result = run_json("LANG", r#"{"paths": ["."]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], false);
assert_eq!(parsed["error"]["code"], "unknown_mode");
let result = run_json("lang", r#"{"paths": ["."]}"#);
let parsed: Value = serde_json::from_str(&result)?;
assert_eq!(parsed["ok"], true);
Ok(())
}
}