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 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 can log what happened —
35/// `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 runtime uses this for a user-supplied catalog with [`TemplateOrigin::Catalog`];
50/// [`seed_builtin_templates`] uses the same upsert logic for
51/// [`bhtune_core::built_in_templates`] with [`TemplateOrigin::Builtin`].
52pub async fn seed_templates(
53    pool: &SqlitePool,
54    templates: Vec<DcsTemplate>,
55    origin: TemplateOrigin,
56    now: DateTime<Utc>,
57) -> DbResult<Vec<SeedResult>> {
58    let mut results = Vec::new();
59
60    for template in templates {
61        let outcome = match DcsTemplateRow::get_by_name(pool, &template.name).await? {
62            None => {
63                DcsTemplateRow::insert(pool, &template, origin, now).await?;
64                SeedOutcome::Inserted
65            }
66            Some(existing) if existing.origin == origin => {
67                DcsTemplateRow::update(pool, existing.id, &template, now).await?;
68                SeedOutcome::Updated
69            }
70            Some(_) => SeedOutcome::SkippedUserOwned,
71        };
72
73        results.push(SeedResult {
74            name: template.name,
75            outcome,
76        });
77    }
78
79    Ok(results)
80}
81
82/// Upserts every [`bhtune_core::built_in_templates`] entry into `dcs_templates` with
83/// [`TemplateOrigin::Builtin`]. A thin, ergonomic wrapper around [`seed_templates`] for the
84/// common startup case — see its docs for the upsert semantics.
85pub async fn seed_builtin_templates(
86    pool: &SqlitePool,
87    now: DateTime<Utc>,
88) -> DbResult<Vec<SeedResult>> {
89    seed_templates(pool, built_in_templates(), TemplateOrigin::Builtin, now).await
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::pool::connect_in_memory;
96
97    fn now() -> DateTime<Utc> {
98        DateTime::from_timestamp(1_700_000_000, 0).unwrap()
99    }
100
101    #[tokio::test]
102    async fn seeds_all_builtins_into_empty_database() {
103        let pool = connect_in_memory().await.unwrap();
104
105        let results = seed_builtin_templates(&pool, now()).await.unwrap();
106
107        assert_eq!(results.len(), built_in_templates().len());
108        assert!(results.iter().all(|r| r.outcome == SeedOutcome::Inserted));
109
110        let rows = DcsTemplateRow::list(&pool).await.unwrap();
111        assert_eq!(rows.len(), built_in_templates().len());
112        assert!(rows.iter().all(|r| r.origin == TemplateOrigin::Builtin));
113
114        // Every seeded row round-trips back to exactly the template that was seeded.
115        for template in built_in_templates() {
116            let row = DcsTemplateRow::get_by_name(&pool, &template.name)
117                .await
118                .unwrap()
119                .expect("seeded template should be found by name");
120            assert_eq!(row.template, template);
121        }
122    }
123
124    #[tokio::test]
125    async fn reseeding_is_idempotent_and_does_not_duplicate() {
126        let pool = connect_in_memory().await.unwrap();
127
128        seed_builtin_templates(&pool, now()).await.unwrap();
129        let second = seed_builtin_templates(&pool, now()).await.unwrap();
130
131        assert!(second.iter().all(|r| r.outcome == SeedOutcome::Updated));
132        let rows = DcsTemplateRow::list(&pool).await.unwrap();
133        assert_eq!(rows.len(), built_in_templates().len());
134    }
135
136    #[tokio::test]
137    async fn reseeding_corrects_a_drifted_builtin_row_in_place() {
138        let pool = connect_in_memory().await.unwrap();
139        seed_builtin_templates(&pool, now()).await.unwrap();
140
141        // Simulate an older/corrupted row: hand-edit one builtin's suffix away from the
142        // canonical value.
143        let existing = DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
144            .await
145            .unwrap()
146            .unwrap();
147        sqlx::query("UPDATE dcs_templates SET manipulated_variable_suffix = 'WRONG' WHERE id = ?")
148            .bind(existing.id)
149            .execute(&pool)
150            .await
151            .unwrap();
152
153        seed_builtin_templates(&pool, now()).await.unwrap();
154
155        let corrected = DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
156            .await
157            .unwrap()
158            .unwrap();
159        assert_eq!(
160            corrected.id, existing.id,
161            "must update in place, not re-insert"
162        );
163        assert_eq!(corrected.template.manipulated_variable_suffix, "MV");
164    }
165
166    #[tokio::test]
167    async fn never_overwrites_a_user_owned_row_with_a_colliding_name() {
168        let pool = connect_in_memory().await.unwrap();
169
170        let mut custom = built_in_templates().remove(0); // "Yokogawa CentumVP"
171        custom.manipulated_variable_suffix = "CUSTOM_MV".to_string();
172        let inserted = DcsTemplateRow::insert(&pool, &custom, TemplateOrigin::User, now())
173            .await
174            .unwrap();
175
176        let results = seed_builtin_templates(&pool, now()).await.unwrap();
177
178        let yokogawa_result = results
179            .iter()
180            .find(|r| r.name == "Yokogawa CentumVP")
181            .unwrap();
182        assert_eq!(yokogawa_result.outcome, SeedOutcome::SkippedUserOwned);
183
184        let still_custom = DcsTemplateRow::get(&pool, inserted.id)
185            .await
186            .unwrap()
187            .unwrap();
188        assert_eq!(
189            still_custom.template.manipulated_variable_suffix,
190            "CUSTOM_MV"
191        );
192        assert_eq!(still_custom.origin, TemplateOrigin::User);
193    }
194}