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`, whose wiring registers its table, and `sofie check`.",
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: "topic",
218        tool: "new_topic",
219        description: "`sofie new topic`: a `sofie-notify` `Topic` constant named \
220                      `<module>.<name>`, in the inbox by default, in the module's `src/topics.rs`, \
221                      and the `notify` feature on its `sofie` dependency; send it with \
222                      `Notify::send`; then `sofie gen` and `sofie check`.",
223        arguments: &[
224            MODULE,
225            required(
226                "name",
227                "name",
228                "The topic's name in snake_case, as `order_shipped`.",
229            ),
230        ],
231        build: |values| {
232            Ok(NewCode::Topic {
233                module: values.text("module"),
234                name: values.text("name"),
235            })
236        },
237    },
238    NewCommand {
239        name: "search",
240        tool: "new_search",
241        description: "`sofie new search`: full-text search over an aggregate's fields whose values \
242                      are searched by their words or whole: `Searched` among its behaviors, a \
243                      migration adding `search_text`, one building its index concurrently, and the \
244                      `search` feature on the module's `sofie` dependency. Query with `Aggregate::matching(\"words\")`; then `sofie gen` \
245                      and `sofie check`.",
246        arguments: &[
247            MODULE,
248            required(
249                "aggregate",
250                "Aggregate",
251                "The aggregate's type name, as `Product`.",
252            ),
253        ],
254        build: |values| {
255            Ok(NewCode::Search {
256                module: values.text("module"),
257                aggregate: values.text("aggregate"),
258            })
259        },
260    },
261    NewCommand {
262        name: "plan",
263        tool: "new_plan",
264        description: "`sofie new plan`: a `Plan` constant in the module's `src/plans.rs`, listed in \
265                      its `PLANS` (the first plan is the default every owner without a \
266                      subscription holds), and the `billing` feature on its `sofie` dependency; \
267                      one module per app declares plans. Then grant entitlements, limits and \
268                      quotas on it, and run `sofie gen` and `sofie check`.",
269        arguments: &[
270            MODULE,
271            required("name", "name", "The plan's name in snake case, as `pro`."),
272        ],
273        build: |values| {
274            Ok(NewCode::Plan {
275                module: values.text("module"),
276                name: values.text("name"),
277            })
278        },
279    },
280    NewCommand {
281        name: "mail",
282        tool: "new_mail",
283        description: "`sofie new mail`: a template in the module's `src/mails/<name>.html` and a \
284                      function in `src/mails.rs` sending it, with the `mail` feature on its \
285                      `sofie` dependency. With `list`, bulk mail of that list: one recipient, a \
286                      one-click unsubscribe link, and `mail-web`, whose \
287                      `sofie::mail::web::routes` a `Web` area mounts. Then `sofie gen` and \
288                      `sofie check`.",
289        arguments: &[
290            MODULE,
291            required(
292                "name",
293                "name",
294                "The mail's name in snake case, as `weekly_news`.",
295            ),
296            optional(
297                "list",
298                "LIST",
299                "For bulk mail: the list it belongs to, as `news`.",
300            ),
301        ],
302        build: |values| {
303            Ok(NewCode::Mail {
304                module: values.text("module"),
305                name: values.text("name"),
306                list: values.optional("list"),
307            })
308        },
309    },
310    NewCommand {
311        name: "area",
312        tool: "new_area",
313        description: "`sofie new area`: an area in the module's `contract/src/area.rs` under the \
314                      prefix `/<name>`, whose principal fixes its access: `public` (a `Visitor`), \
315                      `owned` (a sofie-identity `Account`, on its own records) or `permitted` \
316                      (sofie-identity's `Permitted`, each route with its permission). Its first \
317                      page is a `get /home` with a screen in `src/areas/<name>/`, where members \
318                      land after signing in. An area members sign in to makes the contract and \
319                      the app depend on sofie-identity, whose module and sign-in routes the \
320                      wiring then adds. Then `sofie gen` and `sofie check`.",
321        arguments: &[
322            MODULE,
323            required(
324                "name",
325                "NAME",
326                "The area's name in snake case, as `console`.",
327            ),
328            required("access", "ACCESS", "`public`, `owned` or `permitted`."),
329        ],
330        build: |values| {
331            let access = AreaAccess::parse(&values.text("access")).ok_or_else(|| {
332                let names = AreaAccess::ALL.map(AreaAccess::name);
333                format!("`access` is one of {}", names.join(", "))
334            })?;
335            Ok(NewCode::Area {
336                module: values.text("module"),
337                name: values.text("name"),
338                access,
339            })
340        },
341    },
342    NewCommand {
343        name: "account-screen",
344        tool: "new_account_screen",
345        description: "`sofie new account-screen`: mounts one of sofie-identity's screens of a \
346                      member's own account (`sessions`, `two-factor`, `passkeys`, `connections`, \
347                      `account-data`) for a member area in the app's `app`, and turns its feature \
348                      on in the app's `sofie-identity` dependency. `connections` also needs \
349                      `sofie_identity::oauth::install` at boot. Then `sofie gen` and `sofie check`.",
350        arguments: &[
351            required(
352                "area",
353                "AREA",
354                "The member area the screen joins, as `member`.",
355            ),
356            required(
357                "screen",
358                "SCREEN",
359                "`sessions`, `two-factor`, `passkeys`, `connections` or `account-data`.",
360            ),
361        ],
362        build: |values| {
363            let screen = AccountScreen::parse(&values.text("screen")).ok_or_else(|| {
364                let names = AccountScreen::ALL.map(AccountScreen::name);
365                format!("`screen` is one of {}", names.join(", "))
366            })?;
367            Ok(NewCode::AccountScreen {
368                area: values.text("area"),
369                screen,
370            })
371        },
372    },
373    NewCommand {
374        name: "impersonation",
375        tool: "new_impersonation",
376        description: "`sofie new impersonation`: lets a principal of a `Permitted` area holding \
377                      `sofie_identity.impersonation.*.create` sign in as a member of a member \
378                      area, each time recorded with its reason, ended after half an hour or by \
379                      `/impersonation/stop`: mounts both halves in the app's `app` and turns \
380                      sofie-identity's `impersonation` on. Then `sofie gen` and `sofie check`.",
381        arguments: &[
382            required(
383                "permitted",
384                "PERMITTED_AREA",
385                "The `Permitted` area impersonating, as `console`.",
386            ),
387            required(
388                "member",
389                "MEMBER_AREA",
390                "The member area impersonated, as `member`.",
391            ),
392        ],
393        build: |values| {
394            Ok(NewCode::Impersonation {
395                permitted: values.text("permitted"),
396                member: values.text("member"),
397            })
398        },
399    },
400    NewCommand {
401        name: "tenancy",
402        tool: "new_tenancy",
403        description: "`sofie new tenancy`: organizations that own records, members with roles and \
404                      invitations by email (sofie-tenancy). The organization area's principal \
405                      is `sofie_tenancy::OrganizationId` and it implements \
406                      `sofie_tenancy::OrganizationArea`; the member area lists, founds and enters \
407                      organizations and accepts invitations. Mounts both and makes the app depend \
408                      on sofie-tenancy, whose migrations the wiring then adds. Then `sofie gen` and \
409                      `sofie check`.",
410        arguments: &[
411            required(
412                "organization",
413                "ORGANIZATION_AREA",
414                "The area acting for an organization, as `workspace`.",
415            ),
416            required(
417                "member",
418                "MEMBER_AREA",
419                "The member area of accounts, as `member`.",
420            ),
421        ],
422        build: |values| {
423            Ok(NewCode::Tenancy {
424                organization: values.text("organization"),
425                member: values.text("member"),
426            })
427        },
428    },
429    NewCommand {
430        name: "route",
431        tool: "new_route",
432        description: "`sofie new route`: a handler stub in `actions` (writes) or `queries` (`get`), \
433                      the route in `src/areas/<area>/routes.rs` and, with `screen`, the screen \
434                      file. The path is relative to the area's prefix: `/orders/{order}` in an \
435                      area prefixed `/account` serves `/account/orders/{order}`. A write whose \
436                      path names an aggregate (`{invoice}`) takes and returns it, a `get` takes a \
437                      read model of it loaded from the path (named after the handler, or \
438                      `read_model`), with the aggregate's fields (or `fields`), and its screen \
439                      shows them, an instant through `date(…)`, under `title` (its heading and \
440                      tab) and a link back to \
441                      the area's page listing the aggregate (or `back`), each choice named in the \
442                      output. By default the read model leaves out the owner, another record's id \
443                      and a secret: a field marked `#[sensitive]` or typed `Secret<…>` or a value \
444                      the app marks `#[sensitive]`. Personal data such as an email stays, as the \
445                      page shows the viewer's own record, unless `fields` leaves it out. The page \
446                      shows the record's id only when `fields` names `id`. Then `sofie gen` and \
447                      `sofie check`.",
448        arguments: &[
449            MODULE,
450            required("area", "AREA", "An area the app knows, as `web`."),
451            required(
452                "method",
453                "METHOD",
454                "`get`, `post`, `put`, `patch` or `delete`, in either case.",
455            ),
456            required(
457                "path",
458                "PATH",
459                "The path relative to the area's prefix, starting with `/`.",
460            ),
461            HANDLER,
462            optional(
463                "screen",
464                "NAME",
465                "For a `get`: the screen it shows, written to `screens/<screen>.html`.",
466            ),
467            optional(
468                "fields",
469                "FIELDS",
470                "For a `get` naming an aggregate: the fields its read model reads, comma \
471                 separated; without it, all but the owner, another record's id and a secret \
472                 (marked `#[sensitive]`, or typed `Secret<…>` or a value the app marks \
473                 `#[sensitive]`), each named in the output. Personal data such as an email \
474                 stays unless `fields` leaves it out.",
475            ),
476            optional(
477                "read_model",
478                "Name",
479                "For a `get` with a screen or naming an aggregate: the read model's type name, \
480                 as `InvoiceDetail`; named after the handler without it.",
481            ),
482            optional(
483                "title",
484                "TITLE",
485                "For a `get` with a screen: the page's heading and its browser tab's title, as \
486                 `Your order`; the screen's name humanized without it.",
487            ),
488            optional(
489                "back",
490                "HANDLER",
491                "For a `get` with a screen: the handler of the area's `get` page without \
492                 parameters it links back to, or `none`; without it, a page naming an aggregate \
493                 links to the area's page listing it (its read model reads the aggregate, or its \
494                 path is the record path's plural, as `/orders` for `/orders/{order}`).",
495            ),
496        ],
497        build: |values| {
498            Ok(NewCode::Route(NewRoute {
499                module: values.text("module"),
500                area: values.text("area"),
501                method: values.text("method"),
502                path: values.text("path"),
503                handler: values.text("handler"),
504                screen: values.optional("screen"),
505                fields: values.optional("fields").map(|fields| {
506                    fields
507                        .split(',')
508                        .map(|field| field.trim().to_owned())
509                        .collect()
510                }),
511                read_model: values.optional("read_model"),
512                title: values.optional("title"),
513                back: values.optional("back"),
514            }))
515        },
516    },
517    NewCommand {
518        name: "menu",
519        tool: "new_menu_entry",
520        description: "`sofie new menu`: links a parameterless `get` page of the module's area from \
521                      the area's menu in `src/areas/<area>/menu.rs`, declaring that file and \
522                      turning the `menu` feature on for the module; then `sofie gen` and \
523                      `sofie check`.",
524        arguments: &[
525            MODULE,
526            required("area", "AREA", "The area the page is routed in, as `web`."),
527            required(
528                "handler",
529                "handler",
530                "The page's handler, as routed in the area.",
531            ),
532            required("label", "LABEL", "The entry's text, as `Orders`."),
533        ],
534        build: |values| {
535            Ok(NewCode::MenuEntry(NewMenuEntry {
536                module: values.text("module"),
537                area: values.text("area"),
538                handler: values.text("handler"),
539                label: values.text("label"),
540            }))
541        },
542    },
543];
544
545#[cfg(test)]
546mod tests {
547    use super::NEW_COMMANDS;
548    use crate::new_command::NewCode;
549
550    fn arguments(words: &[&str]) -> Vec<String> {
551        words.iter().map(|word| (*word).to_owned()).collect()
552    }
553
554    fn command(name: &str) -> &'static super::NewCommand {
555        NEW_COMMANDS
556            .iter()
557            .find(|command| command.name == name)
558            .unwrap()
559    }
560
561    #[test]
562    fn reads_the_arguments_in_order_then_each_option_and_leaves_the_root() {
563        let words = arguments(&["billing", "weekly_news", "--list", "news", "shop"]);
564
565        let (code, rest) = command("mail").parse(&words).unwrap();
566
567        let NewCode::Mail { module, name, list } = code else {
568            panic!("not a mail");
569        };
570        assert_eq!(
571            (module.as_str(), name.as_str(), list.as_deref()),
572            ("billing", "weekly_news", Some("news"))
573        );
574        assert_eq!(rest, ["shop"]);
575        let missing = command("listener").parse(&arguments(&["billing"])).err();
576        let unknown = command("account-screen")
577            .parse(&arguments(&["customer", "profile"]))
578            .err();
579
580        assert!(
581            missing
582                .unwrap()
583                .starts_with("`Event` is missing; usage: sofie new listener")
584        );
585        assert!(unknown.unwrap().starts_with("`screen` is one of sessions"));
586    }
587
588    #[test]
589    fn an_area_takes_one_of_the_access_kinds() {
590        let (code, _) = command("area")
591            .parse(&arguments(&["billing", "console", "permitted"]))
592            .unwrap();
593        let refused = command("area")
594            .parse(&arguments(&["billing", "console", "private"]))
595            .err();
596
597        let NewCode::Area { name, access, .. } = code else {
598            panic!("not an area");
599        };
600        assert_eq!(
601            (name.as_str(), access),
602            ("console", super::AreaAccess::Permitted)
603        );
604        assert_eq!(
605            refused.as_deref(),
606            Some("`access` is one of public, owned, permitted")
607        );
608    }
609
610    #[test]
611    fn the_usage_line_shows_each_argument() {
612        assert_eq!(
613            command("route").usage(),
614            "sofie new route MODULE AREA METHOD PATH handler [--screen NAME] [--fields FIELDS] \
615             [--read-model Name] [--title TITLE] [--back HANDLER] [ROOT]"
616        );
617    }
618
619    #[test]
620    fn route_fields_are_comma_separated_and_the_read_model_is_a_flag() {
621        let words = arguments(&[
622            "billing",
623            "web",
624            "get",
625            "/a/{a}",
626            "show_a",
627            "--fields",
628            "title, price",
629            "--read-model",
630            "ADetail",
631        ]);
632
633        let (code, _) = command("route").parse(&words).unwrap();
634
635        let NewCode::Route(route) = code else {
636            panic!("not a route");
637        };
638        assert_eq!(route.fields.unwrap(), ["title", "price"]);
639        assert_eq!(route.read_model.as_deref(), Some("ADetail"));
640    }
641}