Skip to main content

sofie_model/
naming.rs

1//! One function per name, so the generator and the checks cannot disagree.
2
3use std::path::{Component, Path, PathBuf};
4
5pub use sofie_core::case::snake_case;
6
7use crate::{
8    Aggregate, App, AreaContribution, AreaDefinition, Crate, Method, Module, Route, TypeName,
9};
10
11pub const WEB_AREA: &str = "web";
12const WEB_AREA_TYPE: &str = "sofie::http::Web";
13
14pub const IDENTITY_PACKAGE: &str = "sofie-identity";
15pub const TENANCY_PACKAGE: &str = "sofie-tenancy";
16pub const CONTENT_PACKAGE: &str = "sofie-content";
17pub const BILLING_PACKAGE: &str = "sofie-billing";
18pub const IDENTITY_AREA: &str = WEB_AREA;
19
20pub(crate) const GEN_MODULE: &str = "gen";
21
22/// The language the app's names are written in: labels and enum variants are those
23/// names humanized, so it needs no file.
24pub const SOURCE_LANGUAGE: &str = "en";
25
26pub const QUERY_MODULE: &str = "queries";
27const ACTION_MODULE: &str = "actions";
28
29const VISITOR: &str = "Visitor";
30
31const MEMBER_PRINCIPAL: &str = "Account";
32
33/// The `sofie` functions an app's `main` hands its tasks to.
34pub const TASK_RUNNERS: [&str; 2] = ["run_with_tasks", "run_with_flags_and_tasks"];
35
36pub const DEFAULT_BEHAVIORS: [&str; 2] = ["Versioned", "Timestamps"];
37
38pub struct BehaviorColumn {
39    pub name: &'static str,
40    pub sql_type: &'static str,
41    pub nullable: bool,
42    /// What the behavior stores when a write leaves the column empty, so rows written before it can hold it too.
43    pub backfill: Option<&'static str>,
44}
45
46const fn column(name: &'static str, sql_type: &'static str) -> BehaviorColumn {
47    BehaviorColumn {
48        name,
49        sql_type,
50        nullable: false,
51        backfill: None,
52    }
53}
54
55const fn optional(name: &'static str, sql_type: &'static str) -> BehaviorColumn {
56    BehaviorColumn {
57        nullable: true,
58        ..column(name, sql_type)
59    }
60}
61
62const fn filled(
63    name: &'static str,
64    sql_type: &'static str,
65    backfill: &'static str,
66) -> BehaviorColumn {
67    BehaviorColumn {
68        backfill: Some(backfill),
69        ..column(name, sql_type)
70    }
71}
72
73/// Must match sofie-db's `Behavior::columns`.
74#[must_use]
75pub fn behavior_columns(behavior: &str) -> &'static [BehaviorColumn] {
76    match behavior {
77        "Versioned" => const { &[column("version", "bigint")] },
78        "Timestamps" => {
79            const {
80                &[
81                    filled("created_at", "timestamptz", "now()"),
82                    filled("updated_at", "timestamptz", "now()"),
83                ]
84            }
85        }
86        "SoftDelete" => const { &[optional("deleted_at", "timestamptz")] },
87        "PublishLock" => const { &[optional("published_at", "timestamptz")] },
88        "Audited" => {
89            const {
90                &[
91                    optional("created_by", "uuid"),
92                    optional("updated_by", "uuid"),
93                ]
94            }
95        }
96        "Active" => const { &[filled("is_active", "boolean", "true")] },
97        "Default" => const { &[filled("is_default", "boolean", "false")] },
98        "Slugged" => const { &[column("slug", "text")] },
99        "UniqueCode" => const { &[column("code", "text")] },
100        "Searched" => const { &[column("search_text", "text")] },
101        _ => &[],
102    }
103}
104
105pub struct StoredColumn {
106    /// Appended to the field's name: `total` and `total_currency` for a `Money` field `total`.
107    pub suffix: &'static str,
108    pub sql_type: &'static str,
109}
110
111const fn stored(suffix: &'static str, sql_type: &'static str) -> StoredColumn {
112    StoredColumn { suffix, sql_type }
113}
114
115/// Must match each value's `Cast::COLUMNS`. `None` for a type stored another way:
116/// an `Option`, an enum, a value of the app's own.
117#[must_use]
118pub fn stored_columns(ty: &TypeName) -> Option<&'static [StoredColumn]> {
119    if let ("Secret", [inner]) = (ty.name.as_str(), ty.arguments.as_slice()) {
120        return stored_columns(inner);
121    }
122    if ty.record().is_some() || ty.name == "Uuid" {
123        return Some(const { &[stored("", "uuid")] });
124    }
125    Some(match ty.name.as_str() {
126        "bool" => const { &[stored("", "boolean")] },
127        "i32" => const { &[stored("", "integer")] },
128        "i64" | "Percentage" => const { &[stored("", "bigint")] },
129        "f64" => const { &[stored("", "double precision")] },
130        "String" | "Email" | "Url" | "Slug" | "Locale" | "Currency" | "TimeZone" | "TimeOfDay"
131        | "Weekday" | "PersonName" | "Password" => const { &[stored("", "text")] },
132        "Instant" | "Timestamp" => const { &[stored("", "timestamptz")] },
133        "Date" => const { &[stored("", "date")] },
134        "Money" => const { &[stored("", "bigint"), stored("_currency", "text")] },
135        "Measurement" => const { &[stored("", "double precision"), stored("_unit", "text")] },
136        "LocalDateTime" => const { &[stored("", "timestamp"), stored("_zone", "text")] },
137        "Coordinate" => {
138            const {
139                &[
140                    stored("_latitude", "double precision"),
141                    stored("_longitude", "double precision"),
142                ]
143            }
144        }
145        "PhoneNumber" => const { &[stored("_code", "bigint"), stored("_number", "text")] },
146        _ => return None,
147    })
148}
149
150/// Must match how `#[derive(Enum)]` stores a variant.
151#[must_use]
152pub fn stored_name(variant: &str) -> String {
153    snake_case(variant).to_uppercase()
154}
155
156/// `weekly_news` as `WeeklyNews`.
157#[must_use]
158pub fn pascal_case(snake: &str) -> String {
159    snake
160        .split('_')
161        .map(|word| {
162            let mut letters = word.chars();
163            letters.next().map_or_else(String::new, |first| {
164                first.to_ascii_uppercase().to_string() + letters.as_str()
165            })
166        })
167        .collect()
168}
169
170pub(crate) fn route_name(module: &Module, area: &str, route: &Route) -> String {
171    format!(
172        "{}.{area}.{}",
173        module.krate.crate_name,
174        route.handler.last().map_or("", |handler| unraw(handler))
175    )
176}
177
178/// A name is the identifier as read, so `r#match` is named `match`.
179#[must_use]
180pub fn unraw(segment: &str) -> &str {
181    segment.strip_prefix("r#").unwrap_or(segment)
182}
183
184/// A GET never changes state and an action is never a GET, so each lives in its own module.
185#[must_use]
186pub fn handler_module(method: Method) -> &'static str {
187    match method {
188        Method::Get => QUERY_MODULE,
189        Method::Post | Method::Put | Method::Patch | Method::Delete => ACTION_MODULE,
190    }
191}
192
193#[must_use]
194pub fn contract_package(module_package: &str) -> String {
195    format!("{module_package}-contract")
196}
197
198#[must_use]
199pub fn crate_name(package: &str) -> String {
200    package.replace('-', "_")
201}
202
203#[must_use]
204pub fn screen_key(module: &str, area: &str, name: &str) -> String {
205    format!("{module}/{area}/{name}")
206}
207
208#[must_use]
209pub fn job_name(module: &Module, function: &[String]) -> String {
210    let function: Vec<&str> = function.iter().map(|segment| unraw(segment)).collect();
211    format!("{}.{}", module.krate.crate_name, function.join("."))
212}
213
214#[must_use]
215pub fn task_name(module: &Module, function: &[String]) -> String {
216    let name = function.last().map_or("", |name| unraw(name));
217    format!("{}.{name}", module.krate.crate_name)
218}
219
220/// A screen name becomes a file path and must not leave `screens/`; an htmx target names one element, never a selector.
221pub(crate) fn is_plain_name(name: &str) -> bool {
222    !name.is_empty()
223        && name
224            .chars()
225            .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-'))
226}
227
228#[must_use]
229pub fn file_stem(file: &Path) -> String {
230    file.file_stem()
231        .map(|stem| stem.to_string_lossy().into_owned())
232        .unwrap_or_default()
233}
234
235impl AreaContribution {
236    #[must_use]
237    pub fn screen_file(&self, name: &str) -> PathBuf {
238        self.routes_file
239            .with_file_name("screens")
240            .join(format!("{name}.html"))
241    }
242}
243
244impl Crate {
245    #[must_use]
246    pub fn depends_on(&self, package: &str) -> bool {
247        self.dependencies
248            .iter()
249            .any(|dependency| dependency.package == package)
250    }
251
252    #[must_use]
253    pub fn gen_file(&self) -> PathBuf {
254        self.path.join("src").join(GEN_MODULE).join("mod.rs")
255    }
256
257    #[must_use]
258    pub fn wiring_file(&self) -> PathBuf {
259        self.path.join("src").join("wiring.rs")
260    }
261}
262
263/// Which generated file names a module's wired item: its own `src/wiring.rs`, so the item
264/// may stay private to the crate, or the app's.
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266pub enum WiringSide {
267    Module,
268    App,
269}
270
271impl Module {
272    /// A gated job runs with the app's started `Flags`, which only the app's wiring holds.
273    #[must_use]
274    pub fn job_side(&self, function: &[String]) -> WiringSide {
275        match self.code.features.gate_on(function) {
276            Some(_) => WiringSide::App,
277            None => WiringSide::Module,
278        }
279    }
280}
281
282impl AreaDefinition {
283    #[must_use]
284    pub fn needs_generated_member_area(&self) -> bool {
285        !self.implements_member_area
286            && (self.is_permitted()
287                || self
288                    .principal
289                    .last()
290                    .is_some_and(|name| name == MEMBER_PRINCIPAL))
291    }
292}
293
294impl KnownArea<'_> {
295    #[must_use]
296    pub fn is_public(&self) -> bool {
297        self.definition.is_none_or(|definition| {
298            definition
299                .principal
300                .last()
301                .is_some_and(|name| name == VISITOR)
302        })
303    }
304
305    #[must_use]
306    pub fn prefix(&self) -> Option<&str> {
307        match self.definition {
308            None => Some(""),
309            Some(definition) => definition.prefix.as_deref(),
310        }
311    }
312}
313
314#[derive(Clone, Debug, PartialEq, Eq)]
315pub struct KnownArea<'app> {
316    pub name: &'app str,
317    pub type_path: String,
318    pub contract: Option<&'app str>,
319    pub definition: Option<&'app AreaDefinition>,
320}
321
322impl App {
323    #[must_use]
324    pub fn areas(&self) -> Vec<KnownArea<'_>> {
325        let web = KnownArea {
326            name: WEB_AREA,
327            type_path: WEB_AREA_TYPE.to_owned(),
328            contract: None,
329            definition: None,
330        };
331        let local = self.modules.iter().map(|module| {
332            let contract = module.contract.as_ref().map(|krate| krate.package.as_str());
333            (contract, &module.defined_areas)
334        });
335        let remote = self.split.remote.iter().map(|module| {
336            (
337                Some(module.contract.package.as_str()),
338                &module.defined_areas,
339            )
340        });
341        let defined = local.chain(remote).flat_map(|(contract, definitions)| {
342            definitions.iter().map(move |definition| KnownArea {
343                name: &definition.name,
344                type_path: definition.type_path.join("::"),
345                contract,
346                definition: Some(definition),
347            })
348        });
349        std::iter::once(web).chain(defined).collect()
350    }
351
352    #[must_use]
353    pub fn area(&self, name: &str) -> Option<KnownArea<'_>> {
354        self.areas().into_iter().find(|area| area.name == name)
355    }
356
357    #[must_use]
358    pub fn member_home(&self, area: &AreaDefinition) -> Option<String> {
359        let prefix = area.prefix.as_deref()?;
360        self.modules_by_name()
361            .into_iter()
362            .flat_map(|module| &module.areas)
363            .filter(|contribution| contribution.area == area.name)
364            .flat_map(|contribution| &contribution.routes)
365            .find(|route| route.screen.is_some() && !route.path.contains('{'))
366            .map(|route| format!("{prefix}{}", route.path))
367    }
368
369    #[must_use]
370    pub fn depends_on(&self, package: &str) -> bool {
371        self.app.depends_on(package)
372    }
373
374    #[must_use]
375    pub fn wiring_file(&self) -> PathBuf {
376        self.app.wiring_file()
377    }
378
379    /// From the app crate's root, where `embed!` starts.
380    #[must_use]
381    pub fn embedded_path(&self, file: &Path) -> Option<String> {
382        relative_path(&self.app.path, file)
383    }
384
385    #[must_use]
386    pub fn route_sites(&self) -> Vec<RouteSite<'_>> {
387        let mut sites = Vec::new();
388        for module in self.modules_by_name() {
389            let mut areas: Vec<&AreaContribution> = module.areas.iter().collect();
390            areas.sort_by(|left, right| left.area.cmp(&right.area));
391            for area in areas {
392                sites.extend(area.routes.iter().map(|route| RouteSite {
393                    module,
394                    area,
395                    route,
396                }));
397            }
398        }
399        sites
400    }
401
402    /// Generated output must not depend on the order modules were found in.
403    fn modules_by_name(&self) -> Vec<&Module> {
404        let mut modules: Vec<&Module> = self.modules.iter().collect();
405        modules.sort_by(|left, right| left.krate.crate_name.cmp(&right.krate.crate_name));
406        modules
407    }
408}
409
410#[derive(Clone, Copy, Debug)]
411pub struct RouteSite<'app> {
412    pub module: &'app Module,
413    pub area: &'app AreaContribution,
414    pub route: &'app Route,
415}
416
417impl RouteSite<'_> {
418    #[must_use]
419    pub fn name(&self) -> String {
420        route_name(self.module, &self.area.area, self.route)
421    }
422
423    #[must_use]
424    pub fn full_path(&self, app: &App) -> Option<String> {
425        let area = app.area(&self.area.area)?;
426        Some(format!("{}{}", area.prefix()?, self.route.path))
427    }
428}
429
430/// `orders.count` is `ORDERS_COUNT` in a contract's `keys`.
431#[must_use]
432pub fn key_constant(key: &str) -> String {
433    key.chars()
434        .map(|character| match character {
435            '.' | '-' => '_',
436            other => other.to_ascii_uppercase(),
437        })
438        .collect()
439}
440
441#[must_use]
442pub fn path_parameters(path: &str) -> Vec<&str> {
443    written_parameters(path)
444        .map(|name| name.trim_start_matches('*'))
445        .collect()
446}
447
448/// `{*rest}` keeps its `*`.
449fn written_parameters(path: &str) -> impl Iterator<Item = &str> {
450    path.split('/')
451        .filter_map(|segment| segment.strip_prefix('{')?.strip_suffix('}'))
452}
453
454/// Must match sofie-db's `bind`, which loads the aggregate named like the segment.
455#[must_use]
456pub fn bound_aggregate<'module>(
457    module: &'module Module,
458    parameter: &str,
459) -> Option<&'module Aggregate> {
460    module
461        .code
462        .aggregates
463        .iter()
464        .find(|aggregate| snake_case(&aggregate.name) == parameter)
465}
466
467/// What a route builder takes for a path parameter: what a handler can read from it.
468#[derive(Clone, Debug)]
469pub enum PathParameter<'app> {
470    /// Loaded by sofie-db's `bind`.
471    Aggregate(&'app Aggregate),
472    /// A parent's id held by a field, which children and listings are kept within.
473    Reference {
474        module: &'app Module,
475        aggregate: &'app Aggregate,
476    },
477    /// Another field's value, which a listing filters by, typed as a contract names it.
478    Value(String),
479    /// `{*rest}`, the rest of the path with its slashes.
480    Rest,
481}
482
483/// `None` for a parameter no handler can read.
484#[must_use]
485pub fn route_parameters<'app, 'path>(
486    app: &'app App,
487    module: &'app Module,
488    path: &'path str,
489) -> Vec<(&'path str, Option<PathParameter<'app>>)> {
490    written_parameters(path)
491        .map(|name| match name.strip_prefix('*') {
492            Some(rest) => (rest, Some(PathParameter::Rest)),
493            None => (name, path_parameter(app, module, name)),
494        })
495        .collect()
496}
497
498fn path_parameter<'app>(
499    app: &'app App,
500    module: &'app Module,
501    parameter: &str,
502) -> Option<PathParameter<'app>> {
503    if let Some(aggregate) = bound_aggregate(module, parameter) {
504        return Some(PathParameter::Aggregate(aggregate));
505    }
506    let field = module
507        .code
508        .aggregates
509        .iter()
510        .flat_map(|aggregate| &aggregate.fields)
511        .find(|field| field.name == parameter)?;
512    let Some(record) = field.ty.record() else {
513        return module
514            .code
515            .contract_type(&field.ty)
516            .map(PathParameter::Value);
517    };
518    let referenced = app.modules.iter().find_map(|module| {
519        let aggregate = module
520            .code
521            .aggregates
522            .iter()
523            .find(|aggregate| aggregate.name == record)?;
524        Some(PathParameter::Reference { module, aggregate })
525    });
526    Some(referenced.unwrap_or_else(|| PathParameter::Value("sofie::db::Uuid".to_owned())))
527}
528
529/// Joined with `/`, as Rust paths in `concat!` need on every platform.
530#[must_use]
531pub fn relative_path(from: &Path, to: &Path) -> Option<String> {
532    let from: Vec<Component> = from.components().collect();
533    let to: Vec<Component> = to.components().collect();
534    let common = from
535        .iter()
536        .zip(&to)
537        .take_while(|(left, right)| left == right)
538        .count();
539    let mut parts = Vec::new();
540    for component in &from[common..] {
541        if !matches!(component, Component::Normal(_)) {
542            return None;
543        }
544        parts.push("..");
545    }
546    for component in &to[common..] {
547        parts.push(component.as_os_str().to_str()?);
548    }
549    Some(parts.join("/"))
550}
551
552#[cfg(test)]
553mod tests {
554    use std::path::Path;
555
556    use super::{is_plain_name, relative_path};
557
558    #[test]
559    fn relative_paths_climb_out_of_the_app_crate() {
560        let relative = relative_path(
561            Path::new("./app/src"),
562            Path::new("./modules/order/migrations/0001_a.sql"),
563        );
564
565        assert_eq!(
566            relative.as_deref(),
567            Some("../../modules/order/migrations/0001_a.sql")
568        );
569    }
570
571    #[test]
572    fn a_plain_name_holds_letters_digits_underscores_and_dashes() {
573        assert!(is_plain_name("orders_table-2"));
574        for name in ["", "../x", "#orders", "a b", "a.b"] {
575            assert!(!is_plain_name(name), "{name}");
576        }
577    }
578}