feat(mcp): expose the style-guide catalogue to MCP and the CLI
get_style_guide answers "give me a guide matching this" and searches the shipped corpus only, so an agent choosing an asset could neither see what exists nor reach the DESIGN.md files the user imported — the material most worth picking was invisible to MCP entirely. list_style_guides merges both, imports first as the Asset Center paints them, and keeps them apart by id so an import cannot take a shipped guide's place. Passing id returns that one guide with its markdown, which is what closes the loop for imports. No data moved: the canonical registries already live in op-ai-skills, and only the swatch colours and summary line were ever widget-layer concerns. The tool reads the host's imported files, so it is a LocalFilesystem surface and denied online for the same reason list_theme_presets is.
This commit is contained in:
parent
507510ee0c
commit
fbff8857f3
|
|
@ -230,3 +230,29 @@ fn templates_filters_are_optional() {
|
|||
Command::Templates { scene: Some(ref scene), .. } if scene == "slides"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_takes_a_bare_id_or_filters() {
|
||||
let listing = parse_args(&args(&["styles"])).expect("parse styles");
|
||||
assert!(matches!(
|
||||
listing.command,
|
||||
Command::Styles {
|
||||
id: None,
|
||||
tag: None,
|
||||
platform: None
|
||||
}
|
||||
));
|
||||
|
||||
let one = parse_args(&args(&["styles", "user:my-brand"])).expect("parse styles id");
|
||||
assert!(matches!(
|
||||
one.command,
|
||||
Command::Styles { id: Some(ref id), .. } if id == "user:my-brand"
|
||||
));
|
||||
|
||||
let filtered =
|
||||
parse_args(&args(&["styles", "--platform", "slides"])).expect("parse styles filter");
|
||||
assert!(matches!(
|
||||
filtered.command,
|
||||
Command::Styles { platform: Some(ref platform), .. } if platform == "slides"
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,6 +139,13 @@ fn run(args: &[String]) -> Result<String, CliError> {
|
|||
Command::UseTemplate { template_id } => {
|
||||
template_cli::run_use_template(target_port, &target_token, &template_id)?
|
||||
}
|
||||
Command::Styles { id, tag, platform } => template_cli::run_styles(
|
||||
target_port,
|
||||
&target_token,
|
||||
id.as_deref(),
|
||||
tag.as_deref(),
|
||||
platform.as_deref(),
|
||||
)?,
|
||||
Command::Export {
|
||||
item_id,
|
||||
selection: _,
|
||||
|
|
@ -235,6 +242,11 @@ enum Command {
|
|||
UseTemplate {
|
||||
template_id: String,
|
||||
},
|
||||
Styles {
|
||||
id: Option<String>,
|
||||
tag: Option<String>,
|
||||
platform: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
type Flags = BTreeMap<String, Option<String>>;
|
||||
|
|
@ -362,6 +374,7 @@ fn command_from_positionals(positionals: &[String], flags: &Flags) -> Result<Com
|
|||
"export-deck" => export_cli::map_export_deck(flags),
|
||||
"templates" => template_cli::map_templates(flags),
|
||||
"use-template" => template_cli::map_use_template(flags, positionals),
|
||||
"styles" => template_cli::map_styles(flags, positionals),
|
||||
"skill:export" => Ok(Command::SkillExport {
|
||||
name: required_pos(
|
||||
positionals,
|
||||
|
|
|
|||
|
|
@ -66,3 +66,36 @@ pub(crate) fn run_use_template(
|
|||
&tool_call_body("use_scene_template", &Value::Object(arguments).to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn map_styles(flags: &Flags, positionals: &[String]) -> Result<Command, CliError> {
|
||||
Ok(Command::Styles {
|
||||
// `positionals[0]` is the command word; a bare id after it selects one
|
||||
// guide and pulls its markdown down with it.
|
||||
id: positionals
|
||||
.get(1)
|
||||
.cloned()
|
||||
.or_else(|| flag_value(flags, "id")),
|
||||
tag: flag_value(flags, "tag"),
|
||||
platform: flag_value(flags, "platform"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn run_styles(
|
||||
port: u16,
|
||||
token: &str,
|
||||
id: Option<&str>,
|
||||
tag: Option<&str>,
|
||||
platform: Option<&str>,
|
||||
) -> Result<String, CliError> {
|
||||
let mut arguments = serde_json::Map::new();
|
||||
for (key, value) in [("id", id), ("tag", tag), ("platform", platform)] {
|
||||
if let Some(value) = value {
|
||||
arguments.insert(key.into(), Value::String(value.into()));
|
||||
}
|
||||
}
|
||||
post(
|
||||
port,
|
||||
token,
|
||||
&tool_call_body("list_style_guides", &Value::Object(arguments).to_string()),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ COMMON COMMANDS:
|
|||
op use-template <id> start from a scene template: takes
|
||||
over a blank starter page, otherwise
|
||||
appends its boards
|
||||
op styles [<id>] [--tag T] [--platform P] list style guides: the shipped
|
||||
corpus plus your imported DESIGN.md
|
||||
files; a bare id prints its markdown
|
||||
op get [--type T] [--name N] [--id ID] [--depth N] [--parent P]
|
||||
op selection get current selection
|
||||
op insert <json|@file|-> [--parent P] [--page PAGE] [--post-process]
|
||||
|
|
|
|||
|
|
@ -37,27 +37,27 @@ use op_mcp::{
|
|||
get_style_guide_tags_snapshot, get_variables_snapshot, get_viewport_snapshot,
|
||||
group_selected_snapshot, import_svg_snapshot, insert_node_snapshot,
|
||||
instantiate_component_snapshot, lint_document_snapshot, list_components_snapshot,
|
||||
list_node_kinds_snapshot, list_pages_snapshot, list_theme_presets_snapshot,
|
||||
list_variables_snapshot, load_theme_preset_snapshot, move_node_snapshot,
|
||||
nudge_selected_snapshot, open_document_snapshot, paste_clipboard_snapshot, read_nodes_snapshot,
|
||||
redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot, rename_component_snapshot,
|
||||
rename_page_snapshot, rename_variable_snapshot, reorder_page_snapshot,
|
||||
reorder_selected_snapshot, replace_all_matching_properties_snapshot, replace_node_snapshot,
|
||||
run_stdio_with_applier, save_document_snapshot, save_theme_preset_snapshot,
|
||||
search_all_unique_properties_snapshot, selection_snapshot, set_active_axis_value_snapshot,
|
||||
set_active_page_snapshot, set_active_tool_snapshot, set_design_md_snapshot,
|
||||
set_ellipse_arc_snapshot, set_node_collapsed_snapshot, set_node_corner_radius_snapshot,
|
||||
set_node_fill_hex_snapshot, set_node_flip_snapshot, set_node_font_size_snapshot,
|
||||
set_node_font_weight_snapshot, set_node_hidden_snapshot, set_node_locked_snapshot,
|
||||
set_node_name_snapshot, set_node_rotation_snapshot, set_node_stroke_hex_snapshot,
|
||||
set_node_stroke_side_width_snapshot, set_node_stroke_width_snapshot, set_node_text_snapshot,
|
||||
set_selection_set_snapshot, set_selection_snapshot, set_themes_snapshot,
|
||||
set_variable_boolean_snapshot, set_variable_color_snapshot, set_variable_number_snapshot,
|
||||
set_variable_string_snapshot, set_variables_snapshot, set_viewport_snapshot,
|
||||
snapshot_layout_snapshot, spawn_agents_snapshot, toggle_node_selection_snapshot,
|
||||
tool_search_snapshot, undo_snapshot, ungroup_selected_snapshot, update_node_snapshot,
|
||||
upsert_component_snapshot, upsert_screen_snapshot, upsert_variables_snapshot, McpTool,
|
||||
ToolRegistry,
|
||||
list_node_kinds_snapshot, list_pages_snapshot, list_style_guides_snapshot,
|
||||
list_theme_presets_snapshot, list_variables_snapshot, load_theme_preset_snapshot,
|
||||
move_node_snapshot, nudge_selected_snapshot, open_document_snapshot, paste_clipboard_snapshot,
|
||||
read_nodes_snapshot, redo_snapshot, remove_node_effect_snapshot, remove_page_snapshot,
|
||||
rename_component_snapshot, rename_page_snapshot, rename_variable_snapshot,
|
||||
reorder_page_snapshot, reorder_selected_snapshot, replace_all_matching_properties_snapshot,
|
||||
replace_node_snapshot, run_stdio_with_applier, save_document_snapshot,
|
||||
save_theme_preset_snapshot, search_all_unique_properties_snapshot, selection_snapshot,
|
||||
set_active_axis_value_snapshot, set_active_page_snapshot, set_active_tool_snapshot,
|
||||
set_design_md_snapshot, set_ellipse_arc_snapshot, set_node_collapsed_snapshot,
|
||||
set_node_corner_radius_snapshot, set_node_fill_hex_snapshot, set_node_flip_snapshot,
|
||||
set_node_font_size_snapshot, set_node_font_weight_snapshot, set_node_hidden_snapshot,
|
||||
set_node_locked_snapshot, set_node_name_snapshot, set_node_rotation_snapshot,
|
||||
set_node_stroke_hex_snapshot, set_node_stroke_side_width_snapshot,
|
||||
set_node_stroke_width_snapshot, set_node_text_snapshot, set_selection_set_snapshot,
|
||||
set_selection_snapshot, set_themes_snapshot, set_variable_boolean_snapshot,
|
||||
set_variable_color_snapshot, set_variable_number_snapshot, set_variable_string_snapshot,
|
||||
set_variables_snapshot, set_viewport_snapshot, snapshot_layout_snapshot, spawn_agents_snapshot,
|
||||
toggle_node_selection_snapshot, tool_search_snapshot, undo_snapshot, ungroup_selected_snapshot,
|
||||
update_node_snapshot, upsert_component_snapshot, upsert_screen_snapshot,
|
||||
upsert_variables_snapshot, McpTool, ToolRegistry,
|
||||
};
|
||||
#[cfg(feature = "mcp-debug-tools")]
|
||||
use op_mcp::{
|
||||
|
|
@ -631,6 +631,7 @@ fn rebuild_registry(doc: &EditorState, requested_tool: Option<&str>) -> ToolRegi
|
|||
register_tool!("export_design_md", export_design_md_snapshot(doc));
|
||||
register_tool!("get_style_guide_tags", get_style_guide_tags_snapshot());
|
||||
register_tool!("get_style_guide", get_style_guide_snapshot());
|
||||
register_tool!("list_style_guides", list_style_guides_snapshot());
|
||||
register_tool!("get_guidelines", get_guidelines_snapshot());
|
||||
// Phase 0: always register spawn_agents (validates + returns request result).
|
||||
// Actual parallel execution is deferred to Phase 3 (Task 3.1).
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ pub const TOOL_SCHEMAS: &[&str] = &[
|
|||
r#"{"name":"export_design_md","description":"Export design.md markdown, falling back to best-effort extraction when none is persisted.","inputSchema":{"type":"object","properties":{"filePath":{"type":"string","description":"Optional target .op file path; omit to use the server document"}}}}"#,
|
||||
r#"{"name":"get_style_guide_tags","description":"Return all available style guide tags for filtering light/dark visual styles.","inputSchema":{"type":"object","properties":{},"additionalProperties":false}}"#,
|
||||
r#"{"name":"get_style_guide","description":"Return a style guide by name or best tag match. Provide tags array/string, name, and optional platform.","inputSchema":{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"platform":{"type":"string","enum":["webapp","mobile","landing-page","slides"]}}}}"#,
|
||||
r#"{"name":"list_style_guides","description":"List every style guide the editor can offer: the shipped corpus plus the DESIGN.md files the user imported (imports first). Pass id to fetch one guide with its markdown — get_style_guide reaches the shipped corpus only.","inputSchema":{"type":"object","properties":{"id":{"type":"string","description":"Guide id: a corpus guide's name, or user:<slug> for an import"},"tag":{"type":"string"},"platform":{"type":"string","enum":["webapp","mobile","landing-page","slides"]}}}}"#,
|
||||
r#"{"name":"get_guidelines","description":"Return OpenPencil product-design guidelines. Default category is guide, which reads topic. category=style resolves an OpenPencil style using flat scalar string args.","inputSchema":{"type":"object","properties":{"category":{"type":"string","enum":["guide","style"],"default":"guide","description":"guide returns the existing topic guideline path; style resolves the style catalog"},"topic":{"type":"string","enum":["web-app","mobile","code-to-design"],"description":"Guide topic for category=guide"},"name":{"type":"string","description":"Style catalog name for category=style"},"colorPalette":{"type":"string","description":"Style palette name for category=style"},"roundness":{"type":"string","description":"Roundness profile for category=style"},"elevation":{"type":"string","description":"Elevation profile for category=style"},"headings":{"type":"string","description":"Heading font family for category=style"},"body":{"type":"string","description":"Body font family for category=style"},"captions":{"type":"string","description":"Caption font family for category=style"},"data":{"type":"string","description":"Data font family for category=style"},"decorativeImagery":{"type":"string","description":"Optional decorative imagery guidance for category=style"}}}}"#,
|
||||
r#"{"name":"spawn_agents","description":"Split a large design task into parallel subtasks. Each config item gives a prompt, the container node(s) to fill, and the styleguide + guideline NAMES to pass to the subagent (subagents cannot search styleguides). Returns the spawned agent ids. Execution runs the subagents in parallel.","inputSchema":{"type":"object","properties":{"config":{"type":"array","items":{"type":"object","properties":{"prompt":{"type":"string"},"containerNodes":{"type":"array","items":{"type":"string"}},"styleguideName":{"type":"string"},"guidelineNames":{"type":"array","items":{"type":"string"}}},"required":["prompt","styleguideName"]}}},"required":["config"]}}"#,
|
||||
r#"{"name":"ToolSearch","description":"Discover tools by keyword or exact selection. Use 'select:Name1,Name2' to load specific tools, or a keyword query to search names+descriptions.","inputSchema":{"type":"object","properties":{"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"default":5}},"required":["query"]}}"#,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ fn tools_list_response_includes_all_registered_tools() {
|
|||
// TOOL_SCHEMAS without being added to the list below.
|
||||
assert_eq!(
|
||||
TOOL_SCHEMAS.len(),
|
||||
126,
|
||||
127,
|
||||
"tools/list catalog count must match the registered tools — add the new tool to this test"
|
||||
);
|
||||
// Production catalog excludes debug tools (we removed the
|
||||
|
|
|
|||
|
|
@ -380,6 +380,13 @@ pub const TOOL_PROFILES: &[ToolProfile] = &[
|
|||
ToolAccess::Read,
|
||||
ToolSurface::LocalFilesystem,
|
||||
),
|
||||
// Reads the user's imported DESIGN.md files off the daemon host, not
|
||||
// just the embedded corpus, so it is not an in-memory surface.
|
||||
ToolProfile::new(
|
||||
"list_style_guides",
|
||||
ToolAccess::Read,
|
||||
ToolSurface::LocalFilesystem,
|
||||
),
|
||||
ToolProfile::new(
|
||||
"list_scene_templates",
|
||||
ToolAccess::Read,
|
||||
|
|
|
|||
|
|
@ -70,6 +70,9 @@ fn the_denied_set_is_exactly_the_reviewed_list() {
|
|||
"import_html_url",
|
||||
"import_svg",
|
||||
"import_web_snapshot",
|
||||
// Reads the host's imported DESIGN.md files, which carry no tenant
|
||||
// dimension — denied online for the same reason list_theme_presets is.
|
||||
"list_style_guides",
|
||||
"list_theme_presets",
|
||||
"load_theme_preset",
|
||||
"open_document",
|
||||
|
|
|
|||
|
|
@ -268,7 +268,8 @@ pub use spawn_agents_tool::{spawn_agents_snapshot, SpawnAgents, SpawnSpec};
|
|||
pub mod tool_search;
|
||||
pub use editor_state_tool::{get_editor_state_snapshot, GetEditorState};
|
||||
pub use style_guide_tools::{
|
||||
get_style_guide_snapshot, get_style_guide_tags_snapshot, GetStyleGuide, GetStyleGuideTags,
|
||||
get_style_guide_snapshot, get_style_guide_tags_snapshot, list_style_guides_snapshot,
|
||||
GetStyleGuide, GetStyleGuideTags, ListStyleGuides,
|
||||
};
|
||||
pub use style_ops_tools::{
|
||||
replace_all_matching_properties_snapshot, search_all_unique_properties_snapshot,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
use std::collections::BTreeMap;
|
||||
|
||||
use op_ai_skills::style_guide::{
|
||||
select_style_guide, style_guide_registry, Platform, SelectOptions, STYLE_GUIDE_TAGS,
|
||||
select_style_guide, style_guide_registry, user_style_guides, Platform, SelectOptions,
|
||||
STYLE_GUIDE_TAGS,
|
||||
};
|
||||
|
||||
use super::{McpTool, ToolErrorCode, ToolOutcome};
|
||||
|
|
@ -79,6 +80,106 @@ impl McpTool for GetStyleGuide {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Asset Center's Styles tab, as a tool.
|
||||
///
|
||||
/// `get_style_guide` answers "give me *a* guide matching this", and only ever
|
||||
/// from the shipped corpus. Neither half serves an agent choosing an asset: it
|
||||
/// cannot see what exists, and the user's own imported `DESIGN.md` files —
|
||||
/// the material most worth picking — were invisible to MCP entirely.
|
||||
///
|
||||
/// Imports come first, as they do in the panel: a list where your own material
|
||||
/// sits below fifty shipped entries is a list you scroll past your own work in.
|
||||
/// Ids keep the two apart, so an import that names itself after a shipped guide
|
||||
/// cannot quietly take its place.
|
||||
///
|
||||
/// Passing `id` returns that one entry with its markdown, which is what closes
|
||||
/// the loop for user guides — `get_style_guide` cannot reach them by name.
|
||||
pub struct ListStyleGuides;
|
||||
|
||||
impl McpTool for ListStyleGuides {
|
||||
fn name(&self) -> &str {
|
||||
"list_style_guides"
|
||||
}
|
||||
|
||||
fn call(&self, args: &BTreeMap<String, String>) -> ToolOutcome {
|
||||
let arg = |key: &str| {
|
||||
args.get(key)
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
};
|
||||
let wanted_id = arg("id");
|
||||
let tag_filter = arg("tag");
|
||||
let platform_filter = arg("platform");
|
||||
|
||||
let user_guides = user_style_guides();
|
||||
let mut entries: Vec<serde_json::Value> = Vec::new();
|
||||
|
||||
for user in &user_guides {
|
||||
entries.push(serde_json::json!({
|
||||
"id": user.id,
|
||||
"name": user.guide.name,
|
||||
"platform": user.guide.platform.as_str(),
|
||||
"tags": user.guide.tags,
|
||||
"isUser": true,
|
||||
"swatches": user.swatches,
|
||||
}));
|
||||
}
|
||||
for guide in style_guide_registry() {
|
||||
entries.push(serde_json::json!({
|
||||
// A corpus guide is pinned by its bare name; that is its id.
|
||||
"id": guide.name,
|
||||
"name": guide.name,
|
||||
"platform": guide.platform.as_str(),
|
||||
"tags": guide.tags,
|
||||
"isUser": false,
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(wanted_id) = wanted_id {
|
||||
let Some(mut entry) = entries
|
||||
.into_iter()
|
||||
.find(|entry| entry["id"].as_str() == Some(wanted_id))
|
||||
else {
|
||||
return ToolOutcome::Err(
|
||||
ToolErrorCode::InvalidArgument,
|
||||
format!("unknown style guide id {wanted_id:?}"),
|
||||
);
|
||||
};
|
||||
let content = user_guides
|
||||
.iter()
|
||||
.find(|user| user.id == wanted_id)
|
||||
.map(|user| user.guide.content.clone())
|
||||
.or_else(|| {
|
||||
style_guide_registry()
|
||||
.iter()
|
||||
.find(|guide| guide.name == wanted_id)
|
||||
.map(|guide| guide.content.clone())
|
||||
});
|
||||
if let Some(content) = content {
|
||||
entry["content"] = serde_json::Value::String(content);
|
||||
}
|
||||
return ToolOutcome::OkJson(serde_json::json!({ "guides": [entry] }).to_string());
|
||||
}
|
||||
|
||||
entries.retain(|entry| {
|
||||
let tag_ok = tag_filter.is_none_or(|tag| {
|
||||
entry["tags"]
|
||||
.as_array()
|
||||
.is_some_and(|tags| tags.iter().any(|candidate| candidate == tag))
|
||||
});
|
||||
let platform_ok =
|
||||
platform_filter.is_none_or(|platform| entry["platform"].as_str() == Some(platform));
|
||||
tag_ok && platform_ok
|
||||
});
|
||||
let count = entries.len();
|
||||
ToolOutcome::OkJson(serde_json::json!({ "guides": entries, "count": count }).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_style_guides_snapshot() -> ListStyleGuides {
|
||||
ListStyleGuides
|
||||
}
|
||||
|
||||
pub fn get_style_guide_tags_snapshot() -> GetStyleGuideTags {
|
||||
GetStyleGuideTags
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{get_style_guide_snapshot, get_style_guide_tags_snapshot, McpTool, ToolOutcome};
|
||||
use super::{
|
||||
get_style_guide_snapshot, get_style_guide_tags_snapshot, list_style_guides_snapshot, McpTool,
|
||||
ToolErrorCode, ToolOutcome,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn get_style_guide_tags_returns_light_and_dark_vocab() {
|
||||
|
|
@ -40,3 +43,58 @@ fn get_style_guide_finds_specific_guide_by_name() {
|
|||
other => panic!("expected guide ok, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_style_guides_covers_the_whole_shipped_corpus() {
|
||||
let out = match list_style_guides_snapshot().call(&BTreeMap::new()) {
|
||||
ToolOutcome::OkJson(json) => json,
|
||||
other => panic!("unexpected outcome: {other:?}"),
|
||||
};
|
||||
let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
|
||||
let guides = value["guides"].as_array().expect("guides array");
|
||||
let corpus = op_ai_skills::style_guide::style_guide_registry().len();
|
||||
assert!(
|
||||
guides.len() >= corpus,
|
||||
"listing dropped corpus entries: {} < {corpus}",
|
||||
guides.len()
|
||||
);
|
||||
assert_eq!(value["count"].as_u64(), Some(guides.len() as u64));
|
||||
// Every corpus entry is addressable by the id the list hands back.
|
||||
for guide in op_ai_skills::style_guide::style_guide_registry() {
|
||||
assert!(
|
||||
guides
|
||||
.iter()
|
||||
.any(|entry| entry["id"].as_str() == Some(guide.name.as_str())),
|
||||
"{} missing from the listing",
|
||||
guide.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requesting_one_style_guide_by_id_carries_its_markdown() {
|
||||
let first = &op_ai_skills::style_guide::style_guide_registry()[0];
|
||||
let mut args = BTreeMap::new();
|
||||
args.insert("id".to_string(), first.name.clone());
|
||||
let out = match list_style_guides_snapshot().call(&args) {
|
||||
ToolOutcome::OkJson(json) => json,
|
||||
other => panic!("unexpected outcome: {other:?}"),
|
||||
};
|
||||
let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
|
||||
let guides = value["guides"].as_array().expect("guides array");
|
||||
assert_eq!(guides.len(), 1);
|
||||
assert_eq!(guides[0]["content"].as_str(), Some(first.content.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_style_guide_id_is_a_named_argument_error() {
|
||||
let mut args = BTreeMap::new();
|
||||
args.insert("id".to_string(), "no-such-guide".to_string());
|
||||
match list_style_guides_snapshot().call(&args) {
|
||||
ToolOutcome::Err(code, message) => {
|
||||
assert_eq!(code, ToolErrorCode::InvalidArgument);
|
||||
assert!(message.contains("no-such-guide"), "{message}");
|
||||
}
|
||||
other => panic!("unexpected outcome: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue