1use std::fs;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14pub struct SharingPolicy {
15 pub share_display_name: bool,
16 pub share_public_did: bool,
17 pub share_active_model: bool,
18 pub share_llm_outcomes: bool,
20 pub share_ontology_scope: bool,
21 pub share_installed_qapps: bool,
22 pub share_daemon_status: bool,
23 pub allow_group_chat_invites: bool,
24 pub allow_directory_lookup: bool,
25 pub allow_email_invites: bool,
26}
27
28impl Default for SharingPolicy {
29 fn default() -> Self {
30 Self {
31 share_display_name: true,
32 share_public_did: true,
33 share_active_model: false,
34 share_llm_outcomes: false,
35 share_ontology_scope: false,
36 share_installed_qapps: false,
37 share_daemon_status: false,
38 allow_group_chat_invites: true,
39 allow_directory_lookup: true,
40 allow_email_invites: true,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct UserProfile {
47 pub display_name: String,
48 pub bio: Option<String>,
49 pub public_did: String,
50 pub active_front_door_id: Option<String>,
51 #[serde(default)]
52 pub relay_base_url: Option<String>,
53 pub sharing: SharingPolicy,
54 pub updated_at: u64,
55}
56
57impl Default for UserProfile {
58 fn default() -> Self {
59 Self {
60 display_name: "Qualia User".to_string(),
61 bio: None,
62 public_did: String::new(),
63 active_front_door_id: None,
64 relay_base_url: None,
65 sharing: SharingPolicy::default(),
66 updated_at: 0,
67 }
68 }
69}
70
71pub fn profile_path() -> PathBuf {
72 crate::state::app_meta_dir().join("profile.json")
73}
74
75pub fn load_profile() -> UserProfile {
76 let path = profile_path();
77 if let Ok(text) = fs::read_to_string(&path) {
78 if let Ok(mut p) = serde_json::from_str::<UserProfile>(&text) {
79 if p.public_did.is_empty() {
80 p.public_did = resolve_public_did(&p);
81 }
82 return p;
83 }
84 }
85 let mut profile = UserProfile::default();
86 profile.public_did = resolve_public_did(&profile);
87 profile.updated_at = unix_now();
88 let _ = save_profile(&profile);
89 profile
90}
91
92pub fn save_profile(profile: &UserProfile) -> Result<(), String> {
93 let path = profile_path();
94 if let Some(parent) = path.parent() {
95 fs::create_dir_all(parent).map_err(|e| e.to_string())?;
96 }
97 let mut p = profile.clone();
98 p.updated_at = unix_now();
99 if p.public_did.is_empty() {
100 p.public_did = resolve_public_did(&p);
101 }
102 let text = serde_json::to_string_pretty(&p).map_err(|e| e.to_string())?;
103 fs::write(path, text).map_err(|e| e.to_string())
104}
105
106pub fn apply_profile_patch(
112 base: &UserProfile,
113 patch: &serde_json::Value,
114) -> Result<UserProfile, String> {
115 if !patch.is_object() {
116 return Err("profile patch must be a JSON object".into());
117 }
118 let mut out = base.clone();
119
120 if let Some(v) = patch.get("display_name") {
121 out.display_name = v
122 .as_str()
123 .ok_or_else(|| "display_name must be a string".to_string())?
124 .to_string();
125 }
126 if let Some(v) = patch.get("bio") {
127 out.bio = match v {
128 serde_json::Value::Null => None,
129 serde_json::Value::String(s) => Some(s.clone()),
130 _ => return Err("bio must be a string or null".into()),
131 };
132 }
133 if let Some(v) = patch.get("public_did") {
134 if let Some(s) = v.as_str() {
135 if !s.is_empty() {
136 out.public_did = s.to_string();
137 }
138 } else if !v.is_null() {
139 return Err("public_did must be a string".into());
140 }
141 }
142 if let Some(v) = patch.get("active_front_door_id") {
143 out.active_front_door_id = match v {
144 serde_json::Value::Null => None,
145 serde_json::Value::String(s) => Some(s.clone()),
146 _ => return Err("active_front_door_id must be a string or null".into()),
147 };
148 }
149 if let Some(v) = patch.get("relay_base_url") {
150 out.relay_base_url = match v {
151 serde_json::Value::Null => None,
152 serde_json::Value::String(s) => Some(s.clone()),
153 _ => return Err("relay_base_url must be a string or null".into()),
154 };
155 }
156 if let Some(sharing) = patch.get("sharing") {
157 apply_sharing_patch(&mut out.sharing, sharing)?;
158 }
159
160 Ok(out)
161}
162
163fn apply_sharing_patch(
164 policy: &mut SharingPolicy,
165 patch: &serde_json::Value,
166) -> Result<(), String> {
167 let obj = patch
168 .as_object()
169 .ok_or_else(|| "sharing must be a JSON object".to_string())?;
170 for (key, value) in obj {
171 let Some(flag) = value.as_bool() else {
172 return Err(format!("sharing.{key} must be a boolean"));
173 };
174 match key.as_str() {
175 "share_display_name" => policy.share_display_name = flag,
176 "share_public_did" => policy.share_public_did = flag,
177 "share_active_model" => policy.share_active_model = flag,
178 "share_llm_outcomes" => policy.share_llm_outcomes = flag,
179 "share_ontology_scope" => policy.share_ontology_scope = flag,
180 "share_installed_qapps" => policy.share_installed_qapps = flag,
181 "share_daemon_status" => policy.share_daemon_status = flag,
182 "allow_group_chat_invites" => policy.allow_group_chat_invites = flag,
183 "allow_directory_lookup" => policy.allow_directory_lookup = flag,
184 "allow_email_invites" => policy.allow_email_invites = flag,
185 _ => {}
187 }
188 }
189 Ok(())
190}
191
192pub fn resolve_public_did(profile: &UserProfile) -> String {
193 let state = match crate::state::APP_STATE.get() {
194 Some(s) => s,
195 None => return format!("did:qualia:local:{}", unix_now()),
196 };
197
198 if let Some(ref fd_id) = profile.active_front_door_id {
199 let doors = state.front_doors.lock().unwrap();
200 if let Some(door) = doors.iter().find(|d| d.id == *fd_id) {
201 return door.did_uri.clone();
202 }
203 }
204
205 let doors = state.front_doors.lock().unwrap();
206 if let Some(door) = doors.first() {
207 return door.did_uri.clone();
208 }
209
210 let vault = state.key_vault.lock().unwrap();
211 let key = vault.derive_key("profile-root");
212 let pub_hex = hex::encode(ed25519_dalek::VerifyingKey::from(&key).as_bytes());
213 format!("did:qualia:root:{pub_hex}")
214}
215
216pub fn public_profile_card(profile: &UserProfile) -> serde_json::Value {
217 let mut card = serde_json::json!({
218 "version": 1,
219 "updated_at": profile.updated_at,
220 });
221
222 if profile.sharing.share_display_name {
223 card["display_name"] = serde_json::Value::String(profile.display_name.clone());
224 }
225 if profile.sharing.share_public_did {
226 card["public_did"] = serde_json::Value::String(profile.public_did.clone());
227 }
228 if let Some(ref bio) = profile.bio {
229 if profile.sharing.share_display_name {
230 card["bio"] = serde_json::Value::String(bio.clone());
231 }
232 }
233
234 card
235}
236
237fn unix_now() -> u64 {
238 std::time::SystemTime::now()
239 .duration_since(std::time::UNIX_EPOCH)
240 .unwrap_or_default()
241 .as_secs()
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use serde_json::json;
248
249 #[test]
250 fn partial_people_tab_patch_enables_invites_without_wiping_fields() {
251 let base = UserProfile {
252 display_name: "Old Name".into(),
253 bio: Some("keeps bio".into()),
254 public_did: "did:qualia:test:abc".into(),
255 active_front_door_id: Some("door-1".into()),
256 relay_base_url: Some("https://relay.example".into()),
257 sharing: SharingPolicy {
258 allow_group_chat_invites: false,
259 share_active_model: true, ..SharingPolicy::default()
261 },
262 updated_at: 1,
263 };
264 let patch = json!({
267 "display_name": "Timothy",
268 "sharing": { "allow_group_chat_invites": true }
269 });
270 let out = apply_profile_patch(&base, &patch).expect("partial patch must apply");
271 assert_eq!(out.display_name, "Timothy");
272 assert!(out.sharing.allow_group_chat_invites);
273 assert!(
274 out.sharing.share_active_model,
275 "unmentioned sharing flags must be preserved"
276 );
277 assert_eq!(out.bio.as_deref(), Some("keeps bio"));
278 assert_eq!(out.public_did, "did:qualia:test:abc");
279 assert_eq!(out.active_front_door_id.as_deref(), Some("door-1"));
280 assert_eq!(out.relay_base_url.as_deref(), Some("https://relay.example"));
281 }
282
283 #[test]
284 fn sharing_policy_deserializes_when_fields_are_omitted() {
285 let p: SharingPolicy = serde_json::from_str(r#"{"allow_group_chat_invites":false}"#)
286 .expect("#[serde(default)] must fill omitted SharingPolicy fields");
287 assert!(!p.allow_group_chat_invites);
288 assert!(p.share_display_name);
289 assert!(p.share_public_did);
290 }
291}