1use std::path::PathBuf;
10
11use crate::generator::{plan, Db, Field, GenError, Generate, RouteAction};
12use crate::mcp::json_rpc;
13use crate::mcp::{extract_arg, McpContent, McpServer};
14
15pub const GEN_TOOLS: [&str; 4] = ["gen_model", "gen_route", "gen_migration", "gen_test"];
17
18const DRY_RUN: &str = r#""dry_run":{"type":"boolean","description":"Answer the plan and diffs without writing anything"}"#;
19const FIELDS: &str = r#""fields":{"type":"array","items":{"type":"string"},"description":"name:type, the type one of i64, i32, f64, bool, String, Option<T>"}"#;
20
21pub(super) fn install(mcp: McpServer, dir: PathBuf) -> McpServer {
22 let (model_dir, route_dir, migration_dir, test_dir) = (dir.clone(), dir.clone(), dir.clone(), dir);
23 mcp.tool(
24 "gen_model",
25 "Generate a #[derive(Model)] type in src/models/ (id auto-increment) and the migration creating its table, as `rws gen model` does: never overwrites; a conflict answers the diff",
26 &format!(
27 r#"{{"type":"object","properties":{{"name":{{"type":"string","description":"The model, e.g. Post"}},{FIELDS},"db":{{"type":"string","enum":["sqlite","postgres","mysql"],"description":"Default: the model-* feature in Cargo.toml, else sqlite"}},{DRY_RUN}}},"required":["name","fields"]}}"#
28 ),
29 move |args| {
30 let request = (|| {
31 let db = match extract_arg(args, "db") {
32 Some(v) => Some(Db::parse(&v).ok_or_else(|| usage(format!("db `{v}`: use sqlite, postgres or mysql")))?),
33 None => None,
34 };
35 Ok(Generate::Model { name: required(args, "name")?, fields: fields(args)?, db })
36 })();
37 answer(&model_dir, args, request)
38 },
39 )
40 .tool(
41 "gen_route",
42 "Generate typed handlers for a resource in src/routes/<resource>.rs with register(app), and register them in app(), as `rws gen route` does",
43 &format!(
44 r#"{{"type":"object","properties":{{"resource":{{"type":"string","description":"The resource, snake_case plural, e.g. posts"}},{FIELDS},"methods":{{"type":"array","items":{{"type":"string","enum":["list","show","create","update","remove"]}},"description":"Default: all five"}},"prefix":{{"type":"string","description":"A path the routes go under, e.g. /api"}},{DRY_RUN}}},"required":["resource"]}}"#
45 ),
46 move |args| {
47 let request = (|| {
48 let actions = match list(args, "methods") {
49 Some(methods) => RouteAction::parse_list(&methods.join(","))?,
50 None => RouteAction::ALL.to_vec(),
51 };
52 Ok(Generate::Route { resource: required(args, "resource")?, fields: fields(args)?, actions, prefix: extract_arg(args, "prefix").unwrap_or_default() })
53 })();
54 answer(&route_dir, args, request)
55 },
56 )
57 .tool(
58 "gen_migration",
59 "Generate an empty migration pair, migrations/NNNN_<name>.sql and .down.sql, numbered after the existing ones",
60 &format!(r#"{{"type":"object","properties":{{"name":{{"type":"string","description":"snake_case, e.g. add_index_to_posts"}},{DRY_RUN}}},"required":["name"]}}"#),
61 move |args| {
62 let request = required(args, "name").map(|name| Generate::Migration { name });
63 answer(&migration_dir, args, request)
64 },
65 )
66 .tool(
67 "gen_test",
68 "Generate tests/<resource>.rs: TestClient tests against app() for the routes rws gen route wrote in src/routes/<resource>.rs",
69 &format!(
70 r#"{{"type":"object","properties":{{"resource":{{"type":"string"}},"app_expr":{{"type":"string","description":"How a test builds the app when app() takes arguments, e.g. app(Store::memory())"}},{DRY_RUN}}},"required":["resource"]}}"#
71 ),
72 move |args| {
73 let request = required(args, "resource").map(|resource| Generate::Test { resource, app_expr: extract_arg(args, "app_expr") });
74 answer(&test_dir, args, request)
75 },
76 )
77 .tool_annotations("gen_model", super::ops::changes(false))
78 .tool_annotations("gen_route", super::ops::changes(false))
79 .tool_annotations("gen_migration", super::ops::changes(false))
80 .tool_annotations("gen_test", super::ops::changes(false))
81}
82
83fn usage(message: String) -> GenError {
84 GenError { usage: true, message }
85}
86
87fn required(args: &str, name: &str) -> Result<String, GenError> {
88 extract_arg(args, name).filter(|v| !v.trim().is_empty()).ok_or_else(|| usage(format!("`{name}` is required")))
89}
90
91fn list(args: &str, name: &str) -> Option<Vec<String>> {
94 let members = json_rpc::object_members(args)?;
95 let (_, raw) = members.into_iter().find(|(key, _)| key == name)?;
96 let raw = raw.trim();
97 raw.starts_with('[').then(|| {
98 json_rpc::split_array_elements(raw).iter().filter_map(|e| json_rpc::extract_str(&format!(r#"{{"v":{e}}}"#), "v")).collect()
99 })
100}
101
102fn fields(args: &str) -> Result<Vec<Field>, GenError> {
103 crate::generator::parse_fields(&list(args, "fields").unwrap_or_default())
104}
105
106fn answer(dir: &std::path::Path, args: &str, request: Result<Generate, GenError>) -> Result<McpContent, String> {
107 let dry_run = extract_arg(args, "dry_run").is_some_and(|v| v == "true");
108 let plan = request.and_then(|r| plan(dir, &r)).map_err(|e| e.to_json())?;
109 if plan.has_conflicts() {
110 return Err(plan.to_json(false));
111 }
112 if dry_run || plan.is_unchanged() {
113 return Ok(McpContent::json(plan.to_json(false)));
114 }
115 plan.apply(dir).map_err(|e| e.to_json())?;
116 Ok(McpContent::json(plan.to_json(true)))
117}