Skip to content

Commit 4155642

Browse files
feat: add web search and appearance settings (#48)
Co-authored-by: Robert Yan <46699230+think-in-universe@users.noreply.github.com>
1 parent 32fc645 commit 4155642

File tree

6 files changed

+95
-14
lines changed

6 files changed

+95
-14
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
run: cargo test --lib --bins
6060

6161
- name: Run integration tests
62-
run: cargo test --test e2e_api_tests
62+
run: cargo test --features test
6363
env:
6464
DATABASE_HOST: localhost
6565
DATABASE_PORT: 5432

crates/api/src/models.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,34 @@ pub struct AttestationReport {
136136
pub model_attestations: Option<Vec<ModelAttestation>>,
137137
}
138138

139+
/// Appearance preference
140+
#[derive(Debug, Serialize, Deserialize, ToSchema)]
141+
pub enum Appearance {
142+
Light,
143+
Dark,
144+
System,
145+
}
146+
147+
impl From<services::user::ports::Appearance> for Appearance {
148+
fn from(appearance: services::user::ports::Appearance) -> Self {
149+
match appearance {
150+
services::user::ports::Appearance::Light => Appearance::Light,
151+
services::user::ports::Appearance::Dark => Appearance::Dark,
152+
services::user::ports::Appearance::System => Appearance::System,
153+
}
154+
}
155+
}
156+
157+
impl From<Appearance> for services::user::ports::Appearance {
158+
fn from(appearance: Appearance) -> Self {
159+
match appearance {
160+
Appearance::Light => services::user::ports::Appearance::Light,
161+
Appearance::Dark => services::user::ports::Appearance::Dark,
162+
Appearance::System => services::user::ports::Appearance::System,
163+
}
164+
}
165+
}
166+
139167
/// User settings content for API responses
140168
#[derive(Debug, Serialize, Deserialize, ToSchema)]
141169
pub struct UserSettingsContent {
@@ -144,13 +172,19 @@ pub struct UserSettingsContent {
144172
/// System prompt
145173
#[serde(skip_serializing_if = "Option::is_none")]
146174
pub system_prompt: Option<String>,
175+
/// Web search preference
176+
pub web_search: bool,
177+
/// Appearance preference
178+
pub appearance: Appearance,
147179
}
148180

149181
impl From<services::user::ports::UserSettingsContent> for UserSettingsContent {
150182
fn from(content: services::user::ports::UserSettingsContent) -> Self {
151183
Self {
152184
notification: content.notification,
153185
system_prompt: content.system_prompt,
186+
web_search: content.web_search,
187+
appearance: content.appearance.into(),
154188
}
155189
}
156190
}
@@ -172,6 +206,10 @@ pub struct UpdateUserSettingsRequest {
172206
pub notification: bool,
173207
/// System prompt
174208
pub system_prompt: Option<String>,
209+
/// Web search preference
210+
pub web_search: bool,
211+
/// Appearance preference
212+
pub appearance: Appearance,
175213
}
176214

177215
impl UpdateUserSettingsRequest {
@@ -193,6 +231,10 @@ pub struct UpdateUserSettingsPartiallyRequest {
193231
pub notification: Option<bool>,
194232
/// System prompt
195233
pub system_prompt: Option<String>,
234+
/// Web search preference
235+
pub web_search: Option<bool>,
236+
/// Appearance preference
237+
pub appearance: Option<Appearance>,
196238
}
197239

198240
impl UpdateUserSettingsPartiallyRequest {

crates/api/src/routes/users.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ pub async fn update_user_settings(
106106
let content = services::user::ports::UserSettingsContent {
107107
notification: request.notification,
108108
system_prompt: request.system_prompt,
109+
web_search: request.web_search,
110+
appearance: request.appearance.into(),
109111
};
110112

111113
let content = app_state
@@ -155,6 +157,8 @@ pub async fn update_user_settings_partially(
155157
let content = services::user::ports::PartialUserSettingsContent {
156158
notification: request.notification,
157159
system_prompt: request.system_prompt,
160+
web_search: request.web_search,
161+
appearance: request.appearance.map(Into::into),
158162
};
159163

160164
let content = app_state

crates/api/tests/common.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ use api::{create_router, AppState};
44
use axum_test::TestServer;
55
use serde_json::json;
66
use std::sync::Arc;
7+
use tokio::sync::OnceCell;
8+
9+
// Global once cell to ensure migrations only run once across all tests
10+
static MIGRATIONS_INITIALIZED: OnceCell<()> = OnceCell::const_new();
711

812
/// Create a test server with all services initialized
913
pub async fn create_test_server() -> TestServer {
@@ -18,8 +22,14 @@ pub async fn create_test_server() -> TestServer {
1822
.await
1923
.expect("Failed to connect to database");
2024

21-
// Run migrations
22-
db.run_migrations().await.expect("Failed to run migrations");
25+
// Run migrations only once, even when tests run in parallel
26+
MIGRATIONS_INITIALIZED
27+
.get_or_init(|| async {
28+
db.run_migrations()
29+
.await
30+
.expect("Failed to run database migrations");
31+
})
32+
.await;
2333

2434
// Get repositories
2535
let user_repo = db.user_repository();

crates/database/src/repositories/user_settings_repository.rs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
use crate::pool::DbPool;
22
use async_trait::async_trait;
33
use services::{
4-
user::ports::{UserSettings, UserSettingsContent, UserSettingsRepository},
4+
user::ports::{
5+
PartialUserSettingsContent, UserSettings, UserSettingsContent, UserSettingsRepository,
6+
},
57
UserId,
68
};
79

@@ -32,8 +34,14 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
3234
.await?;
3335

3436
if let Some(row) = row {
35-
let content_json: serde_json::Value = row.get(2);
36-
let content: UserSettingsContent = serde_json::from_value(content_json)?;
37+
let content_json: serde_json::Value = row.get("content");
38+
39+
let default_content = UserSettingsContent::default();
40+
// Missing fields will be filled from default settings content
41+
let partial_content =
42+
serde_json::from_value::<PartialUserSettingsContent>(content_json)?;
43+
let content = default_content.into_updated(partial_content);
44+
3745
Ok(Some(UserSettings {
3846
id: row.get(0),
3947
user_id: row.get(1),
@@ -66,20 +74,17 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
6674
VALUES ($1, $2)
6775
ON CONFLICT (user_id)
6876
DO UPDATE SET content = $2, updated_at = NOW()
69-
RETURNING id, user_id, content, created_at, updated_at",
77+
RETURNING id, user_id, created_at, updated_at",
7078
&[&user_id, &content_json],
7179
)
7280
.await?;
7381

74-
let content_json: serde_json::Value = row.get(2);
75-
let content: UserSettingsContent = serde_json::from_value(content_json)?;
76-
7782
let settings = UserSettings {
78-
id: row.get(0),
79-
user_id: row.get(1),
83+
id: row.get("id"),
84+
user_id: row.get("user_id"),
8085
content,
81-
created_at: row.get(3),
82-
updated_at: row.get(4),
86+
created_at: row.get("created_at"),
87+
updated_at: row.get("updated_at"),
8388
};
8489

8590
tracing::info!(

crates/services/src/user/ports.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,26 +108,43 @@ pub trait UserService: Send + Sync {
108108
async fn list_users(&self, limit: i64, offset: i64) -> anyhow::Result<(Vec<User>, u64)>;
109109
}
110110

111+
/// Appearance preference
112+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
113+
pub enum Appearance {
114+
Light,
115+
Dark,
116+
System,
117+
}
118+
111119
/// User settings content structure
112120
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
113121
pub struct UserSettingsContent {
114122
pub notification: bool,
115123
#[serde(skip_serializing_if = "Option::is_none")]
116124
pub system_prompt: Option<String>,
125+
pub web_search: bool,
126+
pub appearance: Appearance,
117127
}
118128

119129
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
120130
pub struct PartialUserSettingsContent {
121131
pub notification: Option<bool>,
122132
pub system_prompt: Option<String>,
133+
pub web_search: Option<bool>,
134+
pub appearance: Option<Appearance>,
123135
}
124136

125137
#[allow(clippy::derivable_impls)]
126138
impl Default for UserSettingsContent {
139+
// When retrieving settings, default values are used to fill in any unset values.
140+
// If the value type is `Option<T>`, we cannot distinguish between "unset" and "set null".
141+
// Therefore, using `Some(T)` as the default value is NOT recommended, may cause unexpected behavior.
127142
fn default() -> Self {
128143
Self {
129144
notification: false,
130145
system_prompt: None,
146+
web_search: false,
147+
appearance: Appearance::System,
131148
}
132149
}
133150
}
@@ -137,6 +154,8 @@ impl UserSettingsContent {
137154
Self {
138155
notification: content.notification.unwrap_or(self.notification),
139156
system_prompt: content.system_prompt.or(self.system_prompt),
157+
web_search: content.web_search.unwrap_or(self.web_search),
158+
appearance: content.appearance.unwrap_or(self.appearance),
140159
}
141160
}
142161
}
@@ -155,6 +174,7 @@ pub struct UserSettings {
155174
#[async_trait]
156175
pub trait UserSettingsRepository: Send + Sync {
157176
/// Get user settings by user ID
177+
/// Returns default settings if not found, and fills missing fields with default values
158178
async fn get_settings(&self, user_id: UserId) -> anyhow::Result<Option<UserSettings>>;
159179

160180
/// Create or update user settings

0 commit comments

Comments
 (0)