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