Skip to main content

ag_store/
setting.rs

1//! Setting-scoped persistence adapters and query helpers.
2
3use ag_agent::ReasoningLevel;
4use ag_session::SettingName;
5use async_trait::async_trait;
6use sqlx::SqlitePool;
7
8use crate::DbError;
9
10/// Settings-focused persistence boundary used by app orchestration and tests.
11#[cfg_attr(test, mockall::automock)]
12#[async_trait]
13pub trait SettingRepository: Send + Sync {
14    /// Looks up one project-scoped setting value by project and name.
15    async fn get_project_setting(
16        &self,
17        project_id: i64,
18        name: SettingName,
19    ) -> Result<Option<String>, DbError>;
20
21    /// Looks up a setting value by name.
22    async fn get_setting(&self, name: SettingName) -> Result<Option<String>, DbError>;
23
24    /// Loads the active project identifier from application settings.
25    async fn load_active_project_id(&self) -> Result<Option<i64>, DbError>;
26
27    /// Loads one project-scoped reasoning-effort setting.
28    async fn load_project_reasoning_level(
29        &self,
30        project_id: i64,
31        name: SettingName,
32    ) -> Result<ReasoningLevel, DbError>;
33
34    /// Persists the active project identifier in application settings.
35    async fn set_active_project_id(&self, project_id: i64) -> Result<(), DbError>;
36
37    /// Inserts or updates one project-scoped setting by project and name.
38    async fn upsert_project_setting(
39        &self,
40        project_id: i64,
41        name: SettingName,
42        value: &str,
43    ) -> Result<(), DbError>;
44
45    /// Inserts or updates project-scoped settings as one transaction.
46    async fn upsert_project_settings(
47        &self,
48        project_id: i64,
49        settings: Vec<(SettingName, String)>,
50    ) -> Result<(), DbError>;
51
52    /// Inserts or updates a setting by name.
53    async fn upsert_setting(&self, name: SettingName, value: &str) -> Result<(), DbError>;
54}
55
56/// `SQLite` implementation of [`SettingRepository`].
57#[derive(Clone)]
58pub(crate) struct SqliteSettingRepository(SqlitePool);
59
60impl SqliteSettingRepository {
61    /// Creates a settings repository backed by the provided pool.
62    pub(crate) fn new(pool: SqlitePool) -> Self {
63        Self(pool)
64    }
65}
66
67/// Scalar row used to return one required setting value.
68struct RequiredSettingValueRow {
69    value: String,
70}
71
72#[async_trait]
73impl SettingRepository for SqliteSettingRepository {
74    async fn get_project_setting(
75        &self,
76        project_id: i64,
77        name: SettingName,
78    ) -> Result<Option<String>, DbError> {
79        let setting_name = name.as_str();
80        let row = sqlx::query_as!(
81            RequiredSettingValueRow,
82            r#"
83SELECT value AS "value!: _"
84FROM project_setting
85WHERE project_id = ?
86  AND name = ?
87"#,
88            project_id,
89            setting_name
90        )
91        .fetch_optional(&self.0)
92        .await?;
93
94        Ok(row.map(|row| row.value))
95    }
96
97    async fn get_setting(&self, name: SettingName) -> Result<Option<String>, DbError> {
98        let setting_name = name.as_str();
99        let row = sqlx::query_as!(
100            RequiredSettingValueRow,
101            r#"
102SELECT value AS "value!: _"
103FROM setting
104WHERE name = ?
105"#,
106            setting_name
107        )
108        .fetch_optional(&self.0)
109        .await?;
110
111        Ok(row.map(|row| row.value))
112    }
113
114    async fn load_active_project_id(&self) -> Result<Option<i64>, DbError> {
115        let setting_value = self.get_setting(SettingName::ActiveProjectId).await?;
116
117        Ok(setting_value.and_then(|value| value.parse::<i64>().ok()))
118    }
119
120    async fn load_project_reasoning_level(
121        &self,
122        project_id: i64,
123        name: SettingName,
124    ) -> Result<ReasoningLevel, DbError> {
125        let setting_value = self.get_project_setting(project_id, name).await?;
126
127        let reasoning_level = setting_value
128            .as_deref()
129            .and_then(|value| value.parse::<ReasoningLevel>().ok())
130            .unwrap_or_default();
131
132        Ok(reasoning_level)
133    }
134
135    async fn set_active_project_id(&self, project_id: i64) -> Result<(), DbError> {
136        self.upsert_setting(SettingName::ActiveProjectId, &project_id.to_string())
137            .await
138    }
139
140    async fn upsert_project_setting(
141        &self,
142        project_id: i64,
143        name: SettingName,
144        value: &str,
145    ) -> Result<(), DbError> {
146        sqlx::query!(
147            r"
148INSERT INTO project_setting (project_id, name, value)
149VALUES (?, ?, ?)
150ON CONFLICT(project_id, name) DO UPDATE
151SET value = excluded.value
152",
153            project_id,
154            name.as_str(),
155            value
156        )
157        .execute(&self.0)
158        .await?;
159
160        Ok(())
161    }
162
163    async fn upsert_project_settings(
164        &self,
165        project_id: i64,
166        settings: Vec<(SettingName, String)>,
167    ) -> Result<(), DbError> {
168        let mut transaction = self.0.begin().await?;
169
170        for (name, value) in settings {
171            sqlx::query!(
172                r"
173INSERT INTO project_setting (project_id, name, value)
174VALUES (?, ?, ?)
175ON CONFLICT(project_id, name) DO UPDATE
176SET value = excluded.value
177",
178                project_id,
179                name.as_str(),
180                value
181            )
182            .execute(&mut *transaction)
183            .await?;
184        }
185
186        transaction.commit().await?;
187
188        Ok(())
189    }
190
191    async fn upsert_setting(&self, name: SettingName, value: &str) -> Result<(), DbError> {
192        sqlx::query!(
193            r"
194INSERT INTO setting (name, value)
195VALUES (?, ?)
196ON CONFLICT(name) DO UPDATE
197SET value = excluded.value
198",
199            name.as_str(),
200            value
201        )
202        .execute(&self.0)
203        .await?;
204
205        Ok(())
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use ag_agent::AgentModel;
212
213    use super::*;
214    use crate::AppRepositories;
215
216    #[tokio::test]
217    /// Verifies project setting batches roll back when any setting write fails.
218    async fn test_upsert_project_settings_rolls_back_partial_batch() {
219        // Arrange
220        let (repositories, pool) = AppRepositories::in_memory_with_pool()
221            .await
222            .expect("db should open");
223        let project_id = repositories
224            .projects()
225            .upsert_project("/tmp/project", Some("main".to_string()))
226            .await
227            .expect("failed to insert project");
228        sqlx::query!(
229            r"
230CREATE TRIGGER fail_default_fast_agent_insert
231BEFORE INSERT ON project_setting
232WHEN NEW.name = 'DefaultFastAgent'
233BEGIN
234    SELECT RAISE(FAIL, 'forced setting failure');
235END;
236"
237        )
238        .execute(&pool)
239        .await
240        .expect("failed to install failure trigger");
241
242        // Act
243        let result = repositories
244            .settings()
245            .upsert_project_settings(
246                project_id,
247                vec![
248                    (
249                        SettingName::DefaultFastModel,
250                        AgentModel::Gemini31Pro.as_str().to_string(),
251                    ),
252                    (SettingName::DefaultFastAgent, "antigravity".to_string()),
253                ],
254            )
255            .await;
256
257        // Assert
258        assert!(result.is_err());
259        assert_eq!(
260            repositories
261                .settings()
262                .get_project_setting(project_id, SettingName::DefaultFastModel)
263                .await
264                .expect("failed to load fast model setting"),
265            None
266        );
267        assert_eq!(
268            repositories
269                .settings()
270                .get_project_setting(project_id, SettingName::DefaultFastAgent)
271                .await
272                .expect("failed to load fast agent setting"),
273            None
274        );
275    }
276}