1use std::collections::BTreeMap;
4
5use super::{AccountScreen, AreaAccess, NewCode, NewMenuEntry, NewRoute};
6
7pub struct NewCommand {
8 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 pub shown: &'static str,
20 pub description: &'static str,
21 pub optional: bool,
23}
24
25impl NewArgument {
26 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 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 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}