use serde_json::Value;
use std::io::IsTerminal;
#[cfg(not(feature = "toolkit"))]
const TOOLKIT_MISSING_HINT: &str =
"The 'markdown' and 'skill' output formats require the 'toolkit' Cargo feature.";
#[cfg(feature = "toolkit")]
pub(crate) fn descriptor_to_scanned(m: &Value) -> apcore_toolkit::ScannedModule {
use apcore_toolkit::ScannedModule;
let module_id = extract_str(m, &["module_id", "id", "canonical_id", "name"]).to_string();
let description = extract_str(m, &["description"]).to_string();
let input_schema = m
.get("input_schema")
.cloned()
.unwrap_or(Value::Object(Default::default()));
let output_schema = m
.get("output_schema")
.cloned()
.unwrap_or(Value::Object(Default::default()));
let tags = extract_tags(m);
let mut sm = ScannedModule::new(
module_id,
description,
input_schema,
output_schema,
tags,
String::new(),
);
if let Some(metadata_obj) = m.get("metadata").and_then(|v| v.as_object()) {
for (k, v) in metadata_obj {
sm.metadata.insert(k.clone(), v.clone());
}
if let Some(display) = metadata_obj.get("display") {
if !display.is_null() {
sm.display = Some(display.clone());
}
}
}
if let Some(ann) = m.get("annotations") {
if let Ok(parsed) = serde_json::from_value::<apcore::module::ModuleAnnotations>(ann.clone())
{
sm.annotations = Some(parsed);
}
}
sm
}
pub(crate) const DESCRIPTION_TRUNCATE_LEN: usize = 80;
pub(crate) fn resolve_format_inner(explicit_format: Option<&str>, is_tty: bool) -> &'static str {
if let Some(fmt) = explicit_format {
return match fmt {
"json" => "json",
"table" => "table",
"csv" => "csv",
"yaml" => "yaml",
"jsonl" => "jsonl",
"markdown" => "markdown",
"skill" => "skill",
other => {
tracing::warn!("Unknown format '{}', defaulting to 'json'.", other);
"json"
}
};
}
if is_tty {
"table"
} else {
"json"
}
}
pub fn resolve_format(explicit_format: Option<&str>) -> &'static str {
let is_tty = std::io::stdout().is_terminal();
resolve_format_inner(explicit_format, is_tty)
}
pub(crate) fn truncate(text: &str, max_length: usize) -> String {
if text.len() <= max_length {
return text.to_string();
}
let cutoff = max_length.saturating_sub(3);
let mut end = cutoff;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
format!("{}...", &text[..end])
}
fn extract_str<'a>(v: &'a Value, keys: &[&str]) -> &'a str {
for key in keys {
if let Some(s) = v.get(key).and_then(|s| s.as_str()) {
return s;
}
}
""
}
fn extract_tags(v: &Value) -> Vec<String> {
v.get("tags")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn rows_for_tabular(value: &Value) -> Option<Vec<serde_json::Map<String, Value>>> {
match value {
Value::Null => None,
Value::Object(obj) => Some(vec![obj.clone()]),
Value::Array(arr) => {
if arr.is_empty() {
return None;
}
let mut out = Vec::with_capacity(arr.len());
for item in arr {
match item {
Value::Object(obj) => out.push(obj.clone()),
_ => return None,
}
}
Some(out)
}
_ => None,
}
}
pub fn format_module_list(modules: &[Value], format: &str, filter_tags: &[&str]) -> String {
use comfy_table::{ContentArrangement, Table};
match format {
"table" => {
if modules.is_empty() {
if !filter_tags.is_empty() {
return format!(
"No modules found matching tags: {}.",
filter_tags.join(", ")
);
}
return "No modules found.".to_string();
}
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec!["ID", "Description", "Tags"]);
for m in modules {
let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
let desc_raw = extract_str(m, &["description"]);
let desc = truncate(desc_raw, DESCRIPTION_TRUNCATE_LEN);
let tags = extract_tags(m).join(", ");
table.add_row(vec![id.to_string(), desc, tags]);
}
table.to_string()
}
"json" => {
let result: Vec<serde_json::Value> = modules
.iter()
.map(|m| {
let id = extract_str(m, &["module_id", "id", "canonical_id", "name"]);
let desc = extract_str(m, &["description"]);
let tags: Vec<serde_json::Value> = extract_tags(m)
.into_iter()
.map(serde_json::Value::String)
.collect();
serde_json::json!({
"id": id,
"description": desc,
"tags": tags,
})
})
.collect();
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "[]".to_string())
}
"markdown" | "skill" => {
#[cfg(feature = "toolkit")]
{
use apcore_toolkit::{format_modules, FormatOutput, ModuleStyle};
let style = if format == "skill" {
ModuleStyle::Skill
} else {
ModuleStyle::Markdown
};
let scanned: Vec<_> = modules.iter().map(descriptor_to_scanned).collect();
match format_modules(&scanned, style, None, true) {
FormatOutput::Text(s) => s,
other => format!("{:?}", other),
}
}
#[cfg(not(feature = "toolkit"))]
{
tracing::warn!("{}", TOOLKIT_MISSING_HINT);
format_module_list(modules, "json", filter_tags)
}
}
unknown => {
tracing::warn!(
"Unknown format '{}' in format_module_list, using json.",
unknown
);
format_module_list(modules, "json", filter_tags)
}
}
}
fn render_panel(title: &str) -> String {
use comfy_table::Table;
let mut table = Table::new();
table.load_preset(comfy_table::presets::UTF8_FULL);
table.add_row(vec![title]);
table.to_string()
}
fn render_section(title: &str, content: &str) -> Option<String> {
if content.is_empty() {
return None;
}
Some(format!("\n{}:\n{}", title, content))
}
pub fn format_module_detail(module: &Value, format: &str) -> String {
let id = extract_str(module, &["module_id", "id", "canonical_id", "name"]);
let description = extract_str(module, &["description"]);
match format {
"table" => {
let mut parts: Vec<String> = Vec::new();
parts.push(render_panel(&format!("Module: {}", id)));
parts.push(format!("\nDescription:\n {}", description));
if let Some(input_schema) = module.get("input_schema").filter(|v| !v.is_null()) {
let content =
serde_json::to_string_pretty(input_schema).unwrap_or_else(|_| "{}".to_string());
if let Some(section) = render_section("Input Schema", &content) {
parts.push(section);
}
}
if let Some(output_schema) = module.get("output_schema").filter(|v| !v.is_null()) {
let content = serde_json::to_string_pretty(output_schema)
.unwrap_or_else(|_| "{}".to_string());
if let Some(section) = render_section("Output Schema", &content) {
parts.push(section);
}
}
if let Some(ann) = module.get("annotations").and_then(|v| v.as_object()) {
if !ann.is_empty() {
let content: String = ann
.iter()
.map(|(k, v)| {
let val = v.as_str().unwrap_or(&v.to_string()).to_string();
format!(" {}: {}", k, val)
})
.collect::<Vec<_>>()
.join("\n");
if let Some(section) = render_section("Annotations", &content) {
parts.push(section);
}
}
}
let x_fields: Vec<(String, String)> = module
.as_object()
.map(|obj| {
obj.iter()
.filter(|(k, _)| k.starts_with("x-") || k.starts_with("x_"))
.map(|(k, v)| {
let val = v.as_str().unwrap_or(&v.to_string()).to_string();
(k.clone(), val)
})
.collect()
})
.unwrap_or_default();
if !x_fields.is_empty() {
let content: String = x_fields
.iter()
.map(|(k, v)| format!(" {}: {}", k, v))
.collect::<Vec<_>>()
.join("\n");
if let Some(section) = render_section("Extension Metadata", &content) {
parts.push(section);
}
}
let tags = extract_tags(module);
if !tags.is_empty() {
if let Some(section) = render_section("Tags", &format!(" {}", tags.join(", "))) {
parts.push(section);
}
}
parts.join("\n")
}
"json" => {
let mut result = serde_json::Map::new();
result.insert("id".to_string(), serde_json::Value::String(id.to_string()));
result.insert(
"description".to_string(),
serde_json::Value::String(description.to_string()),
);
for key in &["input_schema", "output_schema"] {
if let Some(v) = module.get(*key).filter(|v| !v.is_null()) {
result.insert(key.to_string(), v.clone());
}
}
if let Some(ann) = module
.get("annotations")
.filter(|v| !v.is_null() && v.as_object().is_some_and(|o| !o.is_empty()))
{
result.insert("annotations".to_string(), ann.clone());
}
let tags = extract_tags(module);
if !tags.is_empty() {
result.insert(
"tags".to_string(),
serde_json::Value::Array(
tags.into_iter().map(serde_json::Value::String).collect(),
),
);
}
if let Some(obj) = module.as_object() {
for (k, v) in obj {
if k.starts_with("x-") || k.starts_with("x_") {
result.insert(k.clone(), v.clone());
}
}
}
serde_json::to_string_pretty(&serde_json::Value::Object(result))
.unwrap_or_else(|_| "{}".to_string())
}
"markdown" | "skill" => {
#[cfg(feature = "toolkit")]
{
use apcore_toolkit::{
format_module as toolkit_format_module, FormatOutput, ModuleStyle,
};
let style = if format == "skill" {
ModuleStyle::Skill
} else {
ModuleStyle::Markdown
};
let scanned = descriptor_to_scanned(module);
match toolkit_format_module(&scanned, style, true) {
FormatOutput::Text(s) => s,
other => format!("{:?}", other),
}
}
#[cfg(not(feature = "toolkit"))]
{
tracing::warn!("{}", TOOLKIT_MISSING_HINT);
format_module_detail(module, "json")
}
}
unknown => {
tracing::warn!(
"Unknown format '{}' in format_module_detail, using json.",
unknown
);
format_module_detail(module, "json")
}
}
}
fn apply_field_selection(result: &Value, fields: &str) -> Value {
if let Some(obj) = result.as_object() {
let mut selected = serde_json::Map::new();
for field in fields.split(',') {
let field = field.trim();
if field.is_empty() {
continue;
}
let mut val: &Value = &Value::Object(obj.clone());
for part in field.split('.') {
if let Some(next) = val.get(part) {
val = next;
} else {
val = &Value::Null;
break;
}
}
selected.insert(field.to_string(), val.clone());
}
Value::Object(selected)
} else {
result.clone()
}
}
pub fn format_exec_result(result: &Value, format: &str, fields: Option<&str>) -> String {
use comfy_table::{ContentArrangement, Table};
let result = if let Some(f) = fields {
apply_field_selection(result, f)
} else {
result.clone()
};
match &result {
Value::Null => String::new(),
Value::String(s) => s.clone(),
_ if format == "csv" => {
match rows_for_tabular(&result) {
Some(rows) => {
apcore_toolkit::format_csv(&rows, false)
.trim_end_matches("\r\n")
.to_string()
}
None => serde_json::to_string(&result).unwrap_or_default(),
}
}
_ if format == "yaml" => serde_yaml_ng::to_string(&result)
.map(|s| s.trim_end().to_string())
.unwrap_or_else(|_| {
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
}),
_ if format == "jsonl" => match rows_for_tabular(&result) {
Some(rows) => apcore_toolkit::format_jsonl(&rows)
.trim_end_matches('\n')
.to_string(),
None => serde_json::to_string(&result).unwrap_or_default(),
},
Value::Object(_) if format == "table" => {
let obj = result.as_object().unwrap();
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec!["Key", "Value"]);
for (k, v) in obj {
let val_str = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
table.add_row(vec![k.clone(), val_str]);
}
table.to_string()
}
Value::Object(_) | Value::Array(_) => {
serde_json::to_string_pretty(&result).unwrap_or_else(|_| "null".to_string())
}
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_resolve_format_explicit_json_tty() {
assert_eq!(resolve_format_inner(Some("json"), true), "json");
}
#[test]
fn test_resolve_format_explicit_table_non_tty() {
assert_eq!(resolve_format_inner(Some("table"), false), "table");
}
#[test]
fn test_resolve_format_none_tty() {
assert_eq!(resolve_format_inner(None, true), "table");
}
#[test]
fn test_resolve_format_none_non_tty() {
assert_eq!(resolve_format_inner(None, false), "json");
}
#[test]
fn test_truncate_short_string() {
let s = "hello";
assert_eq!(truncate(s, 80), "hello");
}
#[test]
fn test_truncate_exact_length() {
let s = "a".repeat(80);
assert_eq!(truncate(&s, 80), s);
}
#[test]
fn test_truncate_over_limit() {
let s = "a".repeat(100);
let result = truncate(&s, 80);
assert_eq!(result.len(), 80);
assert!(result.ends_with("..."));
assert_eq!(&result[..77], &"a".repeat(77));
}
#[test]
fn test_truncate_exactly_81_chars() {
let s = "b".repeat(81);
let result = truncate(&s, 80);
assert_eq!(result.len(), 80);
assert!(result.ends_with("..."));
}
#[test]
fn test_format_module_list_json_two_modules() {
let modules = vec![
json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]}),
json!({"module_id": "text.upper", "description": "Uppercase", "tags": []}),
];
let output = format_module_list(&modules, "json", &[]);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
let arr = parsed.as_array().expect("must be array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["id"], "math.add");
assert_eq!(arr[1]["id"], "text.upper");
}
#[test]
fn test_format_module_list_json_empty() {
let output = format_module_list(&[], "json", &[]);
assert_eq!(output.trim(), "[]");
}
#[test]
fn test_format_module_list_table_two_modules() {
let modules =
vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": ["math"]})];
let output = format_module_list(&modules, "table", &[]);
assert!(output.contains("math.add"), "table must contain module ID");
assert!(
output.contains("Add numbers"),
"table must contain description"
);
}
#[test]
fn test_format_module_list_table_columns() {
let modules =
vec![json!({"module_id": "math.add", "description": "Add numbers", "tags": []})];
let output = format_module_list(&modules, "table", &[]);
assert!(output.contains("ID"), "table must have ID column");
assert!(
output.contains("Description"),
"table must have Description column"
);
assert!(output.contains("Tags"), "table must have Tags column");
}
#[test]
fn test_format_module_list_table_empty_no_tags() {
let output = format_module_list(&[], "table", &[]);
assert_eq!(output.trim(), "No modules found.");
}
#[test]
fn test_format_module_list_table_empty_with_filter_tags() {
let output = format_module_list(&[], "table", &["math", "text"]);
assert!(
output.contains("No modules found matching tags:"),
"must contain tag-filter message"
);
assert!(output.contains("math"), "must contain tag name");
assert!(output.contains("text"), "must contain tag name");
}
#[test]
fn test_format_module_list_table_description_truncated() {
let long_desc = "a".repeat(100);
let modules = vec![json!({"module_id": "x.y", "description": long_desc, "tags": []})];
let output = format_module_list(&modules, "table", &[]);
assert!(
output.contains("..."),
"long description must be truncated with '...'"
);
assert!(
!output.contains(&"a".repeat(100)),
"full description must not appear"
);
}
#[test]
fn test_format_module_list_json_tags_present() {
let modules = vec![json!({"module_id": "a.b", "description": "desc", "tags": ["x", "y"]})];
let output = format_module_list(&modules, "json", &[]);
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
let tags = parsed[0]["tags"].as_array().unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0], "x");
}
#[test]
fn test_format_exec_result_null_returns_empty() {
let output = format_exec_result(&Value::Null, "json", None);
assert_eq!(output, "", "Null result must produce empty string");
}
#[test]
fn test_format_exec_result_string_plain() {
let result = json!("hello world");
let output = format_exec_result(&result, "json", None);
assert_eq!(output, "hello world");
}
#[test]
fn test_format_exec_result_string_table_mode_also_plain() {
let result = json!("hello");
let output = format_exec_result(&result, "table", None);
assert_eq!(output, "hello");
}
#[test]
fn test_format_exec_result_object_json_mode() {
let result = json!({"sum": 42, "status": "ok"});
let output = format_exec_result(&result, "json", None);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
assert_eq!(parsed["sum"], 42);
assert_eq!(parsed["status"], "ok");
}
#[test]
fn test_format_exec_result_object_table_mode() {
let result = json!({"key": "value", "count": 3});
let output = format_exec_result(&result, "table", None);
assert!(output.contains("key"), "table must contain 'key'");
assert!(output.contains("value"), "table must contain 'value'");
assert!(output.contains("count"), "table must contain 'count'");
assert!(output.contains('3'), "table must contain '3'");
}
#[test]
fn test_format_exec_result_array_is_json() {
let result = json!([1, 2, 3]);
let output = format_exec_result(&result, "json", None);
let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
assert!(parsed.is_array());
assert_eq!(parsed.as_array().unwrap().len(), 3);
}
#[test]
fn test_format_exec_result_array_table_mode_is_json() {
let result = json!([{"a": 1}, {"b": 2}]);
let output = format_exec_result(&result, "table", None);
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("array must produce JSON");
assert!(parsed.is_array());
}
#[test]
fn test_format_exec_result_number_scalar() {
let result = json!(42);
let output = format_exec_result(&result, "json", None);
assert_eq!(output, "42");
}
#[test]
fn test_format_exec_result_bool_scalar() {
let result = json!(true);
let output = format_exec_result(&result, "json", None);
assert_eq!(output, "true");
}
#[test]
fn test_format_exec_result_float_scalar() {
let result = json!(3.15);
let output = format_exec_result(&result, "json", None);
assert!(output.starts_with("3.15"), "float must stringify correctly");
}
#[test]
fn test_format_module_detail_json_full() {
let module = json!({
"module_id": "math.add",
"description": "Add two numbers",
"input_schema": {"type": "object", "properties": {"a": {"type": "integer"}}},
"output_schema": {"type": "object", "properties": {"result": {"type": "integer"}}},
"tags": ["math"],
"annotations": {"author": "test"}
});
let output = format_module_detail(&module, "json");
let parsed: serde_json::Value = serde_json::from_str(&output).expect("must be valid JSON");
assert_eq!(parsed["id"], "math.add");
assert_eq!(parsed["description"], "Add two numbers");
assert!(
parsed.get("input_schema").is_some(),
"input_schema must be present"
);
assert!(
parsed.get("output_schema").is_some(),
"output_schema must be present"
);
let tags = parsed["tags"].as_array().unwrap();
assert_eq!(tags[0], "math");
}
#[test]
fn test_format_module_detail_json_no_output_schema() {
let module = json!({
"module_id": "text.upper",
"description": "Uppercase",
});
let output = format_module_detail(&module, "json");
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(
parsed.get("output_schema").is_none(),
"output_schema must be absent when not set"
);
}
#[test]
fn test_format_module_detail_json_no_none_fields() {
let module = json!({
"module_id": "a.b",
"description": "desc",
"input_schema": null,
"output_schema": null,
"tags": null,
});
let output = format_module_detail(&module, "json");
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(
parsed.get("input_schema").is_none(),
"null input_schema must be absent"
);
assert!(parsed.get("tags").is_none(), "null tags must be absent");
}
#[test]
fn test_format_module_detail_table_contains_description() {
let module = json!({
"module_id": "math.add",
"description": "Add two numbers",
});
let output = format_module_detail(&module, "table");
assert!(
output.contains("Add two numbers"),
"table must contain description"
);
}
#[test]
fn test_format_module_detail_table_contains_module_id() {
let module = json!({
"module_id": "math.add",
"description": "desc",
});
let output = format_module_detail(&module, "table");
assert!(output.contains("math.add"), "table must contain module ID");
}
#[test]
fn test_format_module_detail_table_input_schema_section() {
let module = json!({
"module_id": "math.add",
"description": "desc",
"input_schema": {"type": "object"}
});
let output = format_module_detail(&module, "table");
assert!(
output.contains("Input Schema"),
"table must contain Input Schema section"
);
}
#[test]
fn test_format_module_detail_table_no_output_schema_section_when_absent() {
let module = json!({
"module_id": "text.upper",
"description": "desc",
});
let output = format_module_detail(&module, "table");
assert!(
!output.contains("Output Schema"),
"Output Schema section must be absent when not set"
);
}
#[test]
fn test_format_module_detail_table_tags_section() {
let module = json!({
"module_id": "math.add",
"description": "desc",
"tags": ["math", "arithmetic"]
});
let output = format_module_detail(&module, "table");
assert!(output.contains("Tags"), "table must contain Tags section");
assert!(output.contains("math"), "table must contain tag value");
}
#[test]
fn test_format_module_detail_table_annotations_section() {
let module = json!({
"module_id": "a.b",
"description": "desc",
"annotations": {"author": "alice", "version": "1.0"}
});
let output = format_module_detail(&module, "table");
assert!(
output.contains("Annotations"),
"table must contain Annotations section"
);
assert!(
output.contains("author"),
"table must contain annotation key"
);
assert!(
output.contains("alice"),
"table must contain annotation value"
);
}
#[test]
fn test_format_module_detail_table_extension_metadata() {
let module = json!({
"module_id": "a.b",
"description": "desc",
"x-category": "utility"
});
let output = format_module_detail(&module, "table");
assert!(
output.contains("Extension Metadata"),
"must contain Extension Metadata section"
);
assert!(output.contains("x-category"), "must contain x- key");
assert!(output.contains("utility"), "must contain x- value");
}
#[cfg(feature = "toolkit")]
fn fixture_module() -> Value {
json!({
"module_id": "math.add",
"description": "Add two numbers and return the sum",
"tags": ["math"],
"input_schema": {
"type": "object",
"properties": {
"a": {"type": "integer", "description": "First operand"},
"b": {"type": "integer", "description": "Second operand"}
},
"required": ["a", "b"]
},
"output_schema": {
"type": "object",
"properties": {"sum": {"type": "integer"}},
"required": ["sum"]
}
})
}
#[cfg(feature = "toolkit")]
#[test]
fn test_format_module_list_markdown_matches_toolkit() {
use apcore_toolkit::{format_modules, FormatOutput, ModuleStyle};
let modules = vec![fixture_module()];
let scanned: Vec<_> = modules.iter().map(descriptor_to_scanned).collect();
let expected = match format_modules(&scanned, ModuleStyle::Markdown, None, true) {
FormatOutput::Text(s) => s,
_ => panic!("expected text"),
};
let got = format_module_list(&modules, "markdown", &[]);
assert_eq!(got, expected);
}
#[cfg(feature = "toolkit")]
#[test]
fn test_format_module_list_skill_matches_toolkit() {
use apcore_toolkit::{format_modules, FormatOutput, ModuleStyle};
let modules = vec![fixture_module()];
let scanned: Vec<_> = modules.iter().map(descriptor_to_scanned).collect();
let expected = match format_modules(&scanned, ModuleStyle::Skill, None, true) {
FormatOutput::Text(s) => s,
_ => panic!("expected text"),
};
let got = format_module_list(&modules, "skill", &[]);
assert_eq!(got, expected);
}
#[cfg(feature = "toolkit")]
#[test]
fn test_format_module_detail_markdown_matches_toolkit() {
use apcore_toolkit::{format_module as toolkit_fmt, FormatOutput, ModuleStyle};
let m = fixture_module();
let scanned = descriptor_to_scanned(&m);
let expected = match toolkit_fmt(&scanned, ModuleStyle::Markdown, true) {
FormatOutput::Text(s) => s,
_ => panic!("expected text"),
};
let got = format_module_detail(&m, "markdown");
assert_eq!(got, expected);
}
#[cfg(feature = "toolkit")]
#[test]
fn test_format_module_detail_skill_emits_yaml_frontmatter() {
let m = fixture_module();
let got = format_module_detail(&m, "skill");
assert!(
got.starts_with("---\n"),
"skill output must start with YAML --- delimiter"
);
let lines: Vec<&str> = got.split('\n').collect();
assert!(lines.len() > 3);
assert!(lines[1].starts_with("name: math.add"));
assert!(lines[2].starts_with("description:"));
assert_eq!(lines[3], "---");
}
#[cfg(feature = "toolkit")]
#[test]
fn test_format_module_detail_skill_matches_toolkit() {
use apcore_toolkit::{format_module as toolkit_fmt, FormatOutput, ModuleStyle};
let m = fixture_module();
let scanned = descriptor_to_scanned(&m);
let expected = match toolkit_fmt(&scanned, ModuleStyle::Skill, true) {
FormatOutput::Text(s) => s,
_ => panic!("expected text"),
};
let got = format_module_detail(&m, "skill");
assert_eq!(got, expected);
}
}