Skip to main content

qualia_client_core/wellfair/
personal_profile.rs

1//! Personal Core persisted fields (PRO-01..08) — emergency contacts and profile extensions.
2
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9pub const CONTACTS_FILE: &str = "wellfair/emergency_contacts.jsonl";
10pub const MAX_CONTACTS: usize = 32;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct EmergencyContact {
14    pub id: String,
15    pub display_name: String,
16    pub relationship: String,
17    pub phone: Option<String>,
18    pub email: Option<String>,
19    pub notes: Option<String>,
20    pub created_at_unix: u32,
21}
22
23pub struct EmergencyContactStore {
24    path: PathBuf,
25}
26
27impl EmergencyContactStore {
28    pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
29        let path = storage_root.as_ref().join(CONTACTS_FILE);
30        if let Some(parent) = path.parent() {
31            fs::create_dir_all(parent)?;
32        }
33        if !path.exists() {
34            OpenOptions::new().create(true).write(true).open(&path)?;
35        }
36        Ok(Self { path })
37    }
38
39    pub fn append(&self, contact: &EmergencyContact) -> std::io::Result<()> {
40        let line =
41            serde_json::to_string(contact).map_err(|e| std::io::Error::other(e.to_string()))?;
42        let mut file = OpenOptions::new().append(true).open(&self.path)?;
43        writeln!(file, "{line}")?;
44        file.sync_all()?;
45        Ok(())
46    }
47
48    pub fn list(&self) -> std::io::Result<Vec<EmergencyContact>> {
49        let file = fs::File::open(&self.path)?;
50        let reader = BufReader::new(file);
51        let mut out = Vec::new();
52        for line in reader.lines() {
53            let line = line?;
54            if line.trim().is_empty() {
55                continue;
56            }
57            if let Ok(c) = serde_json::from_str::<EmergencyContact>(&line) {
58                out.push(c);
59            }
60        }
61        if out.len() > MAX_CONTACTS {
62            out.drain(0..out.len() - MAX_CONTACTS);
63        }
64        Ok(out)
65    }
66}
67
68pub fn new_contact_id(name: &str, unix: u32) -> String {
69    use sha2::{Digest, Sha256};
70    let digest = Sha256::digest(format!("{name}:{unix}").as_bytes());
71    format!("ec-{}", hex::encode(&digest[..4]))
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn emergency_contact_round_trip() {
80        let dir = tempfile::tempdir().unwrap();
81        let store = EmergencyContactStore::open(dir.path()).unwrap();
82        let c = EmergencyContact {
83            id: new_contact_id("Alex", 100),
84            display_name: "Alex".into(),
85            relationship: "sibling".into(),
86            phone: Some("+1-555-0100".into()),
87            email: None,
88            notes: None,
89            created_at_unix: 100,
90        };
91        store.append(&c).unwrap();
92        let listed = store.list().unwrap();
93        assert_eq!(listed.len(), 1);
94        assert_eq!(listed[0].display_name, "Alex");
95    }
96}