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"}},"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
92/// A string-array argument (the schema has the server refuse anything
93/// else before the tool runs).
94fn 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}