Skip to main content

sofie_cli/new_command/
commands.rs

1//! Each `sofie new` generator described once, for the command line and the MCP tools alike.
2
3use std::collections::BTreeMap;
4
5use super::{AccountScreen, AreaAccess, NewCode, NewMenuEntry, NewRoute};
6
7pub struct NewCommand {
8    /// As in `sofie new <name>`.
9    pub name: &'static str,
10    pub tool: &'static str,
11    pub description: &'static str,
12    pub arguments: &'static [NewArgument],
13    build: fn(&Values) -> Result<NewCode, String>,
14}
15
16pub struct NewArgument {
17    pub name: &'static str,
18    /// As the usage line shows it.
19    pub shown: &'static str,
20    pub description: &'static str,
21    /// Written `--<name> VALUE` on the command line, after the others.
22    pub optional: bool,
23}
24
25impl NewArgument {
26    /// The option's command-line flag, `read_model` written `read-model`.
27    fn flag(&self) -> String {
28        self.name.replace('_', "-")
29    }
30}
31
32struct Values(BTreeMap<&'static str, String>);
33
34impl Values {
35    fn text(&self, name: &str) -> String {
36        self.0.get(name).cloned().unwrap_or_default()
37    }
38
39    fn optional(&self, name: &str) -> Option<String> {
40        self.0.get(name).cloned()
41    }
42}
43
44impl NewCommand {
45    /// The code to write and the arguments after this command's own, or what is wrong with them.
46    pub fn parse<'arguments>(
47        &self,
48        mut arguments: &'arguments [String],
49    ) -> Result<(NewCode, &'arguments [String]), String> {
50        let mut values = BTreeMap::new();
51        for argument in self.arguments.iter().filter(|argument| !argument.optional) {
52            let (value, rest) = arguments.split_first().ok_or_else(|| {
53                format!("`{}` is missing; usage: {}", argument.shown, self.usage())
54            })?;
55            values.insert(argument.name, value.clone());
56            arguments = rest;
57        }
58        while let [flag, value, rest @ ..] = arguments
59            && let Some(argument) = self.arguments.iter().find(|argument| {
60                argument.optional && flag.strip_prefix("--") == Some(argument.flag().as_str())
61            })
62        {
63            values.insert(argument.name, value.clone());
64            arguments = rest;
65        }
66        let code = (self.build)(&Values(values))?;
67        Ok((code, arguments))
68    }
69
70    pub fn build(&self, value: impl Fn(&str) -> Option<String>) -> Result<NewCode, String> {
71        let values = self
72            .arguments
73            .iter()
74            .filter_map(|argument| Some((argument.name, value(argument.name)?)))
75            .collect();
76        (self.build)(&Values(values))
77    }
78
79    #[must_use]
80    pub fn usage(&self) -> String {
81        self.invocation() + " [ROOT]"
82    }
83
84    /// Run from the app root.
85    pub(crate) fn invocation(&self) -> String {
86        let mut line = format!("sofie new {}", self.name);
87        for argument in self.arguments {
88            if argument.optional {
89                line = line + " [--" + &argument.flag() + " " + argument.shown + "]";
90            } else {
91                line.push(' ');
92                line.push_str(argument.shown);
93            }
94        }
95        line
96    }
97}
98
99const fn required(
100    name: &'static str,
101    shown: &'static str,
102    description: &'static str,
103) -> NewArgument {
104    NewArgument {
105        name,
106        shown,
107        description,
108        optional: false,
109    }
110}
111
112const fn optional(
113    name: &'static str,
114    shown: &'static str,
115    description: &'static str,
116) -> NewArgument {
117    NewArgument {
118        name,
119        shown,
120        description,
121        optional: true,
122    }
123}
124
125const MODULE: NewArgument = required(
126    "module",
127    "MODULE",
128    "The module's name, as in `modules/<module>/`.",
129);
130
131const HANDLER: NewArgument = required("handler", "handler", "The function's name in snake_case.");
132
133pub const NEW_COMMANDS: &[NewCommand] = &[
134    NewCommand {
135        name: "migration",
136        tool: "new_migration",
137        description: "`sofie new migration`: the module's next empty `migrations/NNNN_<name>.sql`; \
138                      then `sofie gen` and `sofie check`.",
139        arguments: &[
140            MODULE,
141            required("name", "NAME", "The migration's name in snake_case."),
142        ],
143        build: |values| {
144            Ok(NewCode::Migration {
145                module: values.text("module"),
146                name: values.text("name"),
147            })
148        },
149    },
150    NewCommand {
151        name: "aggregate",
152        tool: "new_aggregate",
153        description: "`sofie new aggregate`: `src/<name>/mod.rs` with `#[derive(Aggregate)]`, its \
154                      `mod` line and re-export in `lib.rs`, and a migration creating its table; \
155                      then `sofie gen` and `sofie check`, which reports it unused until code names it.",
156        arguments: &[
157            MODULE,
158            required("name", "Name", "The aggregate's type name, as `Invoice`."),
159        ],
160        build: |values| {
161            Ok(NewCode::Aggregate {
162                module: values.text("module"),
163                name: values.text("name"),
164            })
165        },
166    },
167    NewCommand {
168        name: "event",
169        tool: "new_event",
170        description: "`sofie new event`: an event in the module's `contract/src/events.rs`, \
171                      re-exported by the contract; then `sofie gen` and `sofie check`.",
172        arguments: &[
173            MODULE,
174            required("name", "Name", "The event's type name, as `InvoicePaid`."),
175        ],
176        build: |values| {
177            Ok(NewCode::Event {
178                module: values.text("module"),
179                name: values.text("name"),
180            })
181        },
182    },
183    NewCommand {
184        name: "listener",
185        tool: "new_listener",
186        description: "`sofie new listener`: an `#[on]` function in the module's `src/listeners.rs`, \
187                      importing the event from the contract declaring it; then `sofie gen` and \
188                      `sofie check`.",
189        arguments: &[
190            MODULE,
191            required("event", "Event", "The event's type name."),
192            HANDLER,
193        ],
194        build: |values| {
195            Ok(NewCode::Listener {
196                module: values.text("module"),
197                event: values.text("event"),
198                handler: values.text("handler"),
199            })
200        },
201    },
202    NewCommand {
203        name: "task",
204        tool: "new_task",
205        description: "`sofie new task`: a `#[task]` function in the module's `src/tasks.rs`, the \
206                      `tasks` feature on its `sofie` dependency and, for the app's first task, \
207                      `main` running the wiring's `tasks`; then `sofie gen` and `sofie check`.",
208        arguments: &[MODULE, HANDLER],
209        build: |values| {
210            Ok(NewCode::Task {
211                module: values.text("module"),
212                name: values.text("handler"),
213            })
214        },
215    },
216    NewCommand {
217        name: "search",
218        tool: "new_search",
219        description: "`sofie new search`: full-text search over an aggregate's fields whose values \
220                      are searched by their words or whole: `Searched` among its behaviors, a \
221                      migration adding `search_text`, one building its index concurrently, and the \
222                      `search` feature on the module's `sofie` dependency. Query with `Aggregate::matching(\"words\")`; then `sofie gen` \
223                      and `sofie check`.",
224        arguments: &[
225            MODULE,
226            required(
227                "aggregate",
228                "Aggregate",
229                "The aggregate's type name, as `Product`.",
230            ),
231        ],
232        build: |values| {
233            Ok(NewCode::Search {
234                module: values.text("module"),
235                aggregate: values.text("aggregate"),
236            })
237        },
238    },
239    NewCommand {
240        name: "plan",
241        tool: "new_plan",
242        description: "`sofie new plan`: a `Plan` constant in the module's `src/plans.rs`, listed in \
243                      its `PLANS` (the first plan is the default every owner without a \
244                      subscription holds), and the `billing` feature on its `sofie` dependency; \
245                      one module per app declares plans. Then grant entitlements, limits and \
246                      quotas on it, and run `sofie gen` and `sofie check`.",
247        arguments: &[
248            MODULE,
249            required("name", "name", "The plan's name in snake case, as `pro`."),
250        ],
251        build: |values| {
252            Ok(NewCode::Plan {
253                module: values.text("module"),
254                name: values.text("name"),
255            })
256        },
257    },
258    NewCommand {
259        name: "mail",
260        tool: "new_mail",
261        description: "`sofie new mail`: a template in the module's `src/mails/<name>.html` and a \
262                      function in `src/mails.rs` sending it, with the `mail` feature on its \
263                      `sofie` dependency. With `list`, bulk mail of that list: one recipient, a \
264                      one-click unsubscribe link, and `mail-web`, whose \
265                      `sofie::mail::web::routes` a `Web` area mounts. Then `sofie gen` and \
266                      `sofie check`.",
267        arguments: &[
268            MODULE,
269            required(
270                "name",
271                "name",
272                "The mail's name in snake case, as `weekly_news`.",
273            ),
274            optional(
275                "list",
276                "LIST",
277                "For bulk mail: the list it belongs to, as `news`.",
278            ),
279        ],
280        build: |values| {
281            Ok(NewCode::Mail {
282                module: values.text("module"),
283                name: values.text("name"),
284                list: values.optional("list"),
285            })
286        },
287    },
288    NewCommand {
289        name: "area",
290        tool: "new_area",
291        description: "`sofie new area`: an area in the module's `contract/src/area.rs` under the \
292                      prefix `/<name>`, whose principal fixes its access: `public` (a `Visitor`), \
293                      `owned` (a sofie-identity `Account`, on its own records) or `permitted` \
294                      (sofie-identity's `Permitted`, each route with its permission). Its first \
295                      page is a `get /home` with a screen in `src/areas/<name>/`, where members \
296                      land after signing in. An area members sign in to makes the contract and \
297                      the app depend on sofie-identity, whose module and sign-in routes the \
298                      wiring then adds. Then `sofie gen` and `sofie check`.",
299        arguments: &[
300            MODULE,
301            required(
302                "name",
303                "NAME",
304                "The area's name in snake case, as `console`.",
305            ),
306            required("access", "ACCESS", "`public`, `owned` or `permitted`."),
307        ],
308        build: |values| {
309            let access = AreaAccess::parse(&values.text("access")).ok_or_else(|| {
310                let names = AreaAccess::ALL.map(AreaAccess::name);
311                format!("`access` is one of {}", names.join(", "))
312            })?;
313            Ok(NewCode::Area {
314                module: values.text("module"),
315                name: values.text("name"),
316                access,
317            })
318        },
319    },
320    NewCommand {
321        name: "account-screen",
322        tool: "new_account_screen",
323        description: "`sofie new account-screen`: mounts one of sofie-identity's screens of a \
324                      member's own account (`sessions`, `two-factor`, `passkeys`, `connections`, \
325                      `account-data`) for a member area in the app's `app`, and turns its feature \
326                      on in the app's `sofie-identity` dependency. `connections` also needs \
327                      `sofie_identity::oauth::install` at boot. Then `sofie gen` and `sofie check`.",
328        arguments: &[
329            required(
330                "area",
331                "AREA",
332                "The member area the screen joins, as `member`.",
333            ),
334            required(
335                "screen",
336                "SCREEN",
337                "`sessions`, `two-factor`, `passkeys`, `connections` or `account-data`.",
338            ),
339        ],
340        build: |values| {
341            let screen = AccountScreen::parse(&values.text("screen")).ok_or_else(|| {
342                let names = AccountScreen::ALL.map(AccountScreen::name);
343                format!("`screen` is one of {}", names.join(", "))
344            })?;
345            Ok(NewCode::AccountScreen {
346                area: values.text("area"),
347                screen,
348            })
349        },
350    },
351    NewCommand {
352        name: "impersonation",
353        tool: "new_impersonation",
354        description: "`sofie new impersonation`: lets a principal of a `Permitted` area holding \
355                      `sofie_identity.impersonation.*.create` sign in as a member of a member \
356                      area, each time recorded with its reason, ended after half an hour or by \
357                      `/impersonation/stop`: mounts both halves in the app's `app` and turns \
358                      sofie-identity's `impersonation` on. Then `sofie gen` and `sofie check`.",
359        arguments: &[
360            required(
361                "permitted",
362                "PERMITTED_AREA",
363                "The `Permitted` area impersonating, as `console`.",
364            ),
365            required(
366                "member",
367                "MEMBER_AREA",
368                "The member area impersonated, as `member`.",
369            ),
370        ],
371        build: |values| {
372            Ok(NewCode::Impersonation {
373                permitted: values.text("permitted"),
374                member: values.text("member"),
375            })
376        },
377    },
378    NewCommand {
379        name: "tenancy",
380        tool: "new_tenancy",
381        description: "`sofie new tenancy`: organizations that own records, members with roles and \
382                      invitations by email (sofie-tenancy). The organization area's principal \
383                      is `sofie_tenancy::OrganizationId` and it implements \
384                      `sofie_tenancy::OrganizationArea`; the member area lists, founds and enters \
385                      organizations and accepts invitations. Mounts both and makes the app depend \
386                      on sofie-tenancy, whose migrations the wiring then adds. Then `sofie gen` and \
387                      `sofie check`.",
388        arguments: &[
389            required(
390                "organization",
391                "ORGANIZATION_AREA",
392                "The area acting for an organization, as `workspace`.",
393            ),
394            required(
395                "member",
396                "MEMBER_AREA",
397                "The member area of accounts, as `member`.",
398            ),
399        ],
400        build: |values| {
401            Ok(NewCode::Tenancy {
402                organization: values.text("organization"),
403                member: values.text("member"),
404            })
405        },
406    },
407    NewCommand {
408        name: "route",
409        tool: "new_route",
410        description: "`sofie new route`: a handler stub in `actions` (writes) or `queries` (`get`), \
411                      the route in `src/areas/<area>/routes.rs` and, with `screen`, the screen \
412                      file. The path is relative to the area's prefix: `/orders/{order}` in an \
413                      area prefixed `/account` serves `/account/orders/{order}`. A write whose \
414                      path names an aggregate (`{invoice}`) takes and returns it, a `get` takes a \
415                      read model of it loaded from the path (named after the handler, or \
416                      `read_model`), with the aggregate's fields (or `fields`), and its screen \
417                      shows them, an instant through `date(…)`, under `title` (its heading and \
418                      tab) and a link back to \
419                      the area's page listing the aggregate (or `back`), each choice named in the \
420                      output. By default the read model leaves out the owner, another record's id \
421                      and a secret: a field marked `#[sensitive]` or typed `Secret<…>` or a value \
422                      the app marks `#[sensitive]`. Personal data such as an email stays, as the \
423                      page shows the viewer's own record, unless `fields` leaves it out. The page \
424                      shows the record's id only when `fields` names `id`. Then `sofie gen` and \
425                      `sofie check`.",
426        arguments: &[
427            MODULE,
428            required("area", "AREA", "An area the app knows, as `web`."),
429            required(
430                "method",
431                "METHOD",
432                "`get`, `post`, `put`, `patch` or `delete`, in either case.",
433            ),
434            required(
435                "path",
436                "PATH",
437                "The path relative to the area's prefix, starting with `/`.",
438            ),
439            HANDLER,
440            optional(
441                "screen",
442                "NAME",
443                "For a `get`: the screen it shows, written to `screens/<screen>.html`.",
444            ),
445            optional(
446                "fields",
447                "FIELDS",
448                "For a `get` naming an aggregate: the fields its read model reads, comma \
449                 separated; without it, all but the owner, another record's id and a secret \
450                 (marked `#[sensitive]`, or typed `Secret<…>` or a value the app marks \
451                 `#[sensitive]`), each named in the output. Personal data such as an email \
452                 stays unless `fields` leaves it out.",
453            ),
454            optional(
455                "read_model",
456                "Name",
457                "For a `get` with a screen or naming an aggregate: the read model's type name, \
458                 as `InvoiceDetail`; named after the handler without it.",
459            ),
460            optional(
461                "title",
462                "TITLE",
463                "For a `get` with a screen: the page's heading and its browser tab's title, as \
464                 `Your order`; the screen's name humanized without it.",
465            ),
466            optional(
467                "back",
468                "HANDLER",
469                "For a `get` with a screen: the handler of the area's `get` page without \
470                 parameters it links back to, or `none`; without it, a page naming an aggregate \
471                 links to the area's page listing it (its read model reads the aggregate, or its \
472                 path is the record path's plural, as `/orders` for `/orders/{order}`).",
473            ),
474        ],
475        build: |values| {
476            Ok(NewCode::Route(NewRoute {
477                module: values.text("module"),
478                area: values.text("area"),
479                method: values.text("method"),
480                path: values.text("path"),
481                handler: values.text("handler"),
482                screen: values.optional("screen"),
483                fields: values.optional("fields").map(|fields| {
484                    fields
485                        .split(',')
486                        .map(|field| field.trim().to_owned())
487                        .collect()
488                }),
489                read_model: values.optional("read_model"),
490                title: values.optional("title"),
491                back: values.optional("back"),
492            }))
493        },
494    },
495    NewCommand {
496        name: "menu",
497        tool: "new_menu_entry",
498        description: "`sofie new menu`: links a parameterless `get` page of the module's area from \
499                      the area's menu in `src/areas/<area>/menu.rs`, declaring that file and \
500                      turning the `menu` feature on for the module; then `sofie gen` and \
501                      `sofie check`.",
502        arguments: &[
503            MODULE,
504            required("area", "AREA", "The area the page is routed in, as `web`."),
505            required(
506                "handler",
507                "handler",
508                "The page's handler, as routed in the area.",
509            ),
510            required("label", "LABEL", "The entry's text, as `Orders`."),
511        ],
512        build: |values| {
513            Ok(NewCode::MenuEntry(NewMenuEntry {
514                module: values.text("module"),
515                area: values.text("area"),
516                handler: values.text("handler"),
517                label: values.text("label"),
518            }))
519        },
520    },
521];
522
523#[cfg(test)]
524mod tests {
525    use super::NEW_COMMANDS;
526    use crate::new_command::NewCode;
527
528    fn arguments(words: &[&str]) -> Vec<String> {
529        words.iter().map(|word| (*word).to_owned()).collect()
530    }
531
532    fn command(name: &str) -> &'static super::NewCommand {
533        NEW_COMMANDS
534            .iter()
535            .find(|command| command.name == name)
536            .unwrap()
537    }
538
539    #[test]
540    fn reads_the_arguments_in_order_then_each_option_and_leaves_the_root() {
541        let words = arguments(&["billing", "weekly_news", "--list", "news", "shop"]);
542
543        let (code, rest) = command("mail").parse(&words).unwrap();
544
545        let NewCode::Mail { module, name, list } = code else {
546            panic!("not a mail");
547        };
548        assert_eq!(
549            (module.as_str(), name.as_str(), list.as_deref()),
550            ("billing", "weekly_news", Some("news"))
551        );
552        assert_eq!(rest, ["shop"]);
553        let missing = command("listener").parse(&arguments(&["billing"])).err();
554        let unknown = command("account-screen")
555            .parse(&arguments(&["customer", "profile"]))
556            .err();
557
558        assert!(
559            missing
560                .unwrap()
561                .starts_with("`Event` is missing; usage: sofie new listener")
562        );
563        assert!(unknown.unwrap().starts_with("`screen` is one of sessions"));
564    }
565
566    #[test]
567    fn an_area_takes_one_of_the_access_kinds() {
568        let (code, _) = command("area")
569            .parse(&arguments(&["billing", "console", "permitted"]))
570            .unwrap();
571        let refused = command("area")
572            .parse(&arguments(&["billing", "console", "private"]))
573            .err();
574
575        let NewCode::Area { name, access, .. } = code else {
576            panic!("not an area");
577        };
578        assert_eq!(
579            (name.as_str(), access),
580            ("console", super::AreaAccess::Permitted)
581        );
582        assert_eq!(
583            refused.as_deref(),
584            Some("`access` is one of public, owned, permitted")
585        );
586    }
587
588    #[test]
589    fn the_usage_line_shows_each_argument() {
590        assert_eq!(
591            command("route").usage(),
592            "sofie new route MODULE AREA METHOD PATH handler [--screen NAME] [--fields FIELDS] \
593             [--read-model Name] [--title TITLE] [--back HANDLER] [ROOT]"
594        );
595    }
596
597    #[test]
598    fn route_fields_are_comma_separated_and_the_read_model_is_a_flag() {
599        let words = arguments(&[
600            "billing",
601            "web",
602            "get",
603            "/a/{a}",
604            "show_a",
605            "--fields",
606            "title, price",
607            "--read-model",
608            "ADetail",
609        ]);
610
611        let (code, _) = command("route").parse(&words).unwrap();
612
613        let NewCode::Route(route) = code else {
614            panic!("not a route");
615        };
616        assert_eq!(route.fields.unwrap(), ["title", "price"]);
617        assert_eq!(route.read_model.as_deref(), Some("ADetail"));
618    }
619}