Skip to main content

bhtune_db/
seed.rs

1//! Seeds a catalog of DCS/PLC templates into `dcs_templates` on startup, so a fresh database
2//! always has the built-in presets available without a separate "first run" wizard step, and
3//! so a future user-supplied catalog file can be kept in sync the same way.
4//!
5//! This is an upsert, not a plain insert, because a template's suffix/unit conventions can
6//! be corrected in a later catalog revision, and an existing install's `dcs_templates` table
7//! should pick up that fix on the next seed rather than being frozen at whatever was current
8//! when the row was first created.
9
10use bhtune_core::{DcsTemplate, built_in_templates};
11use chrono::{DateTime, Utc};
12use sqlx::SqlitePool;
13
14use crate::{
15    error::DbResult,
16    models::{DcsTemplateRow, TemplateOrigin},
17};
18
19/// What [`seed_templates`] did with one catalog template.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SeedOutcome {
22    /// No row existed with this name; a new row was inserted with the seeded `origin`.
23    Inserted,
24    /// A row already existed with the seeded `origin`; its fields were overwritten to match
25    /// the current catalog definition.
26    Updated,
27    /// A row already existed with this name but a *different* `origin` — some other catalog
28    /// (or a user, via `bhtune template import`) created a template that happens to share a
29    /// name with one in this catalog. Left untouched: a row is never silently overwritten by
30    /// a seed pass it doesn't belong to, even if its name collides with one that does.
31    SkippedUserOwned,
32}
33
34/// One template's seeding result, returned so a caller (`cli-commands`, the web GUI's
35/// startup routine) can log what happened — `bhtune-db` itself has no logging dependency.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SeedResult {
38    pub name: String,
39    pub outcome: SeedOutcome,
40}
41
42/// Upserts every template in `templates` into `dcs_templates`, all attributed to `origin`.
43///
44/// Safe to call on every startup: inserts any missing template, brings existing rows that
45/// already carry `origin` in line with the current definition, and never touches a row whose
46/// name collides with one being seeded but which carries a *different* `origin` — that row
47/// belongs to a different catalog (or a user), not this seed pass.
48///
49/// The one caller today is [`seed_builtin_templates`], seeding
50/// [`bhtune_core::built_in_templates`] with [`TemplateOrigin::Builtin`]. `template-user-catalog`
51/// will be the first caller to seed a user-supplied catalog file with [`TemplateOrigin::Catalog`],
52/// reusing this exact upsert logic rather than duplicating it.
53pub async fn seed_templates(
54    pool: &SqlitePool,
55    templates: Vec<DcsTemplate>,
56    origin: TemplateOrigin,
57    now: DateTime<Utc>,
58) -> DbResult<Vec<SeedResult>> {
59    let mut results = Vec::new();
60
61    for template in templates {
62        let outcome = match DcsTemplateRow::get_by_name(pool, &template.name).await? {
63            None => {
64                DcsTemplateRow::insert(pool, &template, origin, now).await?;
65                SeedOutcome::Inserted
66            }
67            Some(existing) if existing.origin == origin => {
68                DcsTemplateRow::update(pool, existing.id, &template, now).await?;
69                SeedOutcome::Updated
70            }
71            Some(_) => SeedOutcome::SkippedUserOwned,
72        };
73
74        results.push(SeedResult {
75            name: template.name,
76            outcome,
77        });
78    }
79
80    Ok(results)
81}
82
83/// Upserts every [`bhtune_core::built_in_templates`] entry into `dcs_templates` with
84/// [`TemplateOrigin::Builtin`]. A thin, ergonomic wrapper around [`seed_templates`] for the
85/// common startup case — see its docs for the upsert semantics.
86pub async fn seed_builtin_templates(
87    pool: &SqlitePool,
88    now: DateTime<Utc>,
89) -> DbResult<Vec<SeedResult>> {
90    seed_templates(pool, built_in_templates(), TemplateOrigin::Builtin, now).await
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::pool::connect_in_memory;
97
98    fn now() -> DateTime<Utc> {
99        DateTime::from_timestamp(1_700_000_000, 0).unwrap()
100    }
101
102    #[tokio::test]
103    async fn seeds_all_builtins_into_empty_database() {
104        let pool = connect_in_memory().await.unwrap();
105
106        let results = seed_builtin_templates(&pool, now()).await.unwrap();
107
108        assert_eq!(results.len(), built_in_templates().len());
109        assert!(results.iter().all(|r| r.outcome == SeedOutcome::Inserted));
110
111        let rows = DcsTemplateRow::list(&pool).await.unwrap();
112        assert_eq!(rows.len(), built_in_templates().len());
113        assert!(rows.iter().all(|r| r.origin == TemplateOrigin::Builtin));
114
115        // Every seeded row round-trips back to exactly the template that was seeded.
116        for template in built_in_templates() {
117            let row = DcsTemplateRow::get_by_name(&pool, &template.name)
118                .await
119                .unwrap()
120                .expect("seeded template should be found by name");
121            assert_eq!(row.template, template);
122        }
123    }
124
125    #[tokio::test]
126    async fn reseeding_is_idempotent_and_does_not_duplicate() {
127        let pool = connect_in_memory().await.unwrap();
128
129        seed_builtin_templates(&pool, now()).await.unwrap();
130        let second = seed_builtin_templates(&pool, now()).await.unwrap();
131
132        assert!(second.iter().all(|r| r.outcome == SeedOutcome::Updated));
133        let rows = DcsTemplateRow::list(&pool).await.unwrap();
134        assert_eq!(rows.len(), built_in_templates().len());
135    }
136
137    #[tokio::test]
138    async fn reseeding_corrects_a_drifted_builtin_row_in_place() {
139        let pool = connect_in_memory().await.unwrap();
140        seed_builtin_templates(&pool, now()).await.unwrap();
141
142        // Simulate an older/corrupted row: hand-edit one builtin's suffix away from the
143        // canonical value.
144        let existing = DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
145            .await
146            .unwrap()
147            .unwrap();
148        sqlx::query("UPDATE dcs_templates SET manipulated_variable_suffix = 'WRONG' WHERE id = ?")
149            .bind(existing.id)
150            .execute(&pool)
151            .await
152            .unwrap();
153
154        seed_builtin_templates(&pool, now()).await.unwrap();
155
156        let corrected = DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
157            .await
158            .unwrap()
159            .unwrap();
160        assert_eq!(
161            corrected.id, existing.id,
162            "must update in place, not re-insert"
163        );
164        assert_eq!(corrected.template.manipulated_variable_suffix, "MV");
165    }
166
167    #[tokio::test]
168    async fn never_overwrites_a_user_owned_row_with_a_colliding_name() {
169        let pool = connect_in_memory().await.unwrap();
170
171        let mut custom = built_in_templates().remove(0); // "Yokogawa CentumVP"
172        custom.manipulated_variable_suffix = "CUSTOM_MV".to_string();
173        let inserted = DcsTemplateRow::insert(&pool, &custom, TemplateOrigin::User, now())
174            .await
175            .unwrap();
176
177        let results = seed_builtin_templates(&pool, now()).await.unwrap();
178
179        let yokogawa_result = results
180            .iter()
181            .find(|r| r.name == "Yokogawa CentumVP")
182            .unwrap();
183        assert_eq!(yokogawa_result.outcome, SeedOutcome::SkippedUserOwned);
184
185        let still_custom = DcsTemplateRow::get(&pool, inserted.id)
186            .await
187            .unwrap()
188            .unwrap();
189        assert_eq!(
190            still_custom.template.manipulated_variable_suffix,
191            "CUSTOM_MV"
192        );
193        assert_eq!(still_custom.origin, TemplateOrigin::User);
194    }
195}