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"}},"timestamps":{{"type":"boolean","description":"Add #[timestamps]: created_at and updated_at, set by save"}},"soft_delete":{{"type":"boolean","description":"Add #[soft_delete]: deleted_at, set by delete; queries skip deleted rows"}},{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 let flag = |name: &str| extract_arg(args, name).is_some_and(|v| v == "true");
36 Ok(Generate::Model { name: required(args, "name")?, fields: fields(args)?, db, timestamps: flag("timestamps"), soft_delete: flag("soft_delete") })
37 })();
38 answer(&model_dir, args, request)
39 },
40 )
41 .tool(
42 "gen_route",
43 "Generate typed handlers for a resource in src/routes/<resource>.rs with register(app), and register them in app(), as `rws gen route` does",
44 &format!(
45 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"]}}"#
46 ),
47 move |args| {
48 let request = (|| {
49 let actions = match list(args, "methods") {
50 Some(methods) => RouteAction::parse_list(&methods.join(","))?,
51 None => RouteAction::ALL.to_vec(),
52 };
53 Ok(Generate::Route { resource: required(args, "resource")?, fields: fields(args)?, actions, prefix: extract_arg(args, "prefix").unwrap_or_default() })
54 })();
55 answer(&route_dir, args, request)
56 },
57 )
58 .tool(
59 "gen_migration",
60 "Generate an empty migration pair, migrations/NNNN_<name>.sql and .down.sql, numbered after the existing ones",
61 &format!(r#"{{"type":"object","properties":{{"name":{{"type":"string","description":"snake_case, e.g. add_index_to_posts"}},{DRY_RUN}}},"required":["name"]}}"#),
62 move |args| {
63 let request = required(args, "name").map(|name| Generate::Migration { name });
64 answer(&migration_dir, args, request)
65 },
66 )
67 .tool(
68 "gen_test",
69 "Generate tests/<resource>.rs: TestClient tests against app() for the routes rws gen route wrote in src/routes/<resource>.rs",
70 &format!(
71 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"]}}"#
72 ),
73 move |args| {
74 let request = required(args, "resource").map(|resource| Generate::Test { resource, app_expr: extract_arg(args, "app_expr") });
75 answer(&test_dir, args, request)
76 },
77 )
78 .tool_annotations("gen_model", super::ops::changes(false))
79 .tool_annotations("gen_route", super::ops::changes(false))
80 .tool_annotations("gen_migration", super::ops::changes(false))
81 .tool_annotations("gen_test", super::ops::changes(false))
82}
83
84fn usage(message: String) -> GenError {
85 GenError { usage: true, message }
86}
87
88fn required(args: &str, name: &str) -> Result<String, GenError> {
89 extract_arg(args, name).filter(|v| !v.trim().is_empty()).ok_or_else(|| usage(format!("`{name}` is required")))
90}
91
92fn list(args: &str, name: &str) -> Option<Vec<String>> {
95 let members = json_rpc::object_members(args)?;
96 let (_, raw) = members.into_iter().find(|(key, _)| key == name)?;
97 let raw = raw.trim();
98 raw.starts_with('[').then(|| {
99 json_rpc::split_array_elements(raw).iter().filter_map(|e| json_rpc::extract_str(&format!(r#"{{"v":{e}}}"#), "v")).collect()
100 })
101}
102
103fn fields(args: &str) -> Result<Vec<Field>, GenError> {
104 crate::generator::parse_fields(&list(args, "fields").unwrap_or_default())
105}
106
107fn answer(dir: &std::path::Path, args: &str, request: Result<Generate, GenError>) -> Result<McpContent, String> {
108 let dry_run = extract_arg(args, "dry_run").is_some_and(|v| v == "true");
109 let plan = request.and_then(|r| plan(dir, &r)).map_err(|e| e.to_json())?;
110 if plan.has_conflicts() {
111 return Err(plan.to_json(false));
112 }
113 if dry_run || plan.is_unchanged() {
114 return Ok(McpContent::json(plan.to_json(false)));
115 }
116 plan.apply(dir).map_err(|e| e.to_json())?;
117 Ok(McpContent::json(plan.to_json(true)))
118}