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` 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}