Skip to main content

rws_proxy/dev_mcp/
generate.rs

1//! `gen_model`, `gen_route`, `gen_migration`, `gen_test`: the generators
2//! ([`crate::generator`]) as tools. They write into the project, so they
3//! are listed only when the tools may change things (not
4//! [`super::DevTools::read_only`]); `dry_run: true` answers the plan and
5//! its diffs without writing. The answer is the plan as JSON
6//! ([`crate::generator::Plan::to_json`]); a conflict or a refusal is a
7//! tool error carrying the same JSON.
8
9use 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
15/// The tools this module adds.
16pub 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
91/// A string-array argument (the schema has the server refuse anything
92/// else before the tool runs).
93fn 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}