Skip to main content

hermes_agent_cli_core/
pairings.rs

1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7/// Pairing status
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub enum PairingStatus {
10    Pending,
11    Approved,
12    Revoked,
13}
14
15/// A pairing entry for platform connection
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Pairing {
18    pub platform: String,
19    pub user_id: String,
20    pub code: Option<String>,
21    pub status: PairingStatus,
22    #[serde(default)]
23    pub display_name: String,
24    #[serde(default = "default_created_at")]
25    pub created_at: String,
26    #[serde(default)]
27    pub approved_at: Option<String>,
28}
29
30fn default_created_at() -> String {
31    chrono::Utc::now().to_rfc3339()
32}
33
34/// Storage for pairing configurations
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct PairingStore {
37    #[serde(default)]
38    pub pairings: Vec<Pairing>,
39}
40
41impl PairingStore {
42    /// Load pairing store from HERMES_HOME/pairings.json
43    pub fn load() -> Result<Self> {
44        let path = Self::pairings_path();
45        if !path.exists() {
46            return Ok(PairingStore::default());
47        }
48        let content = fs::read_to_string(&path)
49            .with_context(|| format!("failed to read pairings store from {:?}", path))?;
50        let store: PairingStore = serde_json::from_str(&content)
51            .with_context(|| format!("failed to parse pairings store from {:?}", path))?;
52        Ok(store)
53    }
54
55    /// Save pairing store to HERMES_HOME/pairings.json
56    pub fn save(&self) -> Result<()> {
57        let path = Self::pairings_path();
58        if let Some(parent) = path.parent() {
59            fs::create_dir_all(parent)
60                .with_context(|| format!("failed to create pairings directory {:?}", parent))?;
61        }
62        let content =
63            serde_json::to_string_pretty(self).context("failed to serialize pairings store")?;
64        fs::write(&path, content)
65            .with_context(|| format!("failed to write pairings store to {:?}", path))?;
66        Ok(())
67    }
68
69    /// Get pairings path
70    pub fn pairings_path() -> PathBuf {
71        if let Ok(home) = std::env::var("HERMES_HOME") {
72            return PathBuf::from(home).join("pairings.json");
73        }
74        if let Ok(profile) = std::env::var("HERMES_PROFILE") {
75            if let Some(proj_dirs) =
76                ProjectDirs::from("ai", "hermes", &format!("hermes-{}", profile))
77            {
78                return proj_dirs.config_dir().join("pairings.json");
79            }
80        }
81        if let Some(proj_dirs) = ProjectDirs::from("ai", "hermes", "hermes-cli") {
82            return proj_dirs.config_dir().join("pairings.json");
83        }
84        if let Ok(home) = std::env::var("USERPROFILE") {
85            return PathBuf::from(home).join(".hermes").join("pairings.json");
86        }
87        PathBuf::from(".hermes").join("pairings.json")
88    }
89
90    /// Add a pairing
91    pub fn add_pairing(&mut self, pairing: Pairing) -> Result<()> {
92        // Check if already exists
93        if self
94            .pairings
95            .iter()
96            .any(|p| p.platform == pairing.platform && p.user_id == pairing.user_id)
97        {
98            anyhow::bail!(
99                "Pairing for platform '{}' and user '{}' already exists",
100                pairing.platform,
101                pairing.user_id
102            );
103        }
104
105        self.pairings.push(pairing);
106        Ok(())
107    }
108
109    /// Approve a pairing
110    pub fn approve_pairing(&mut self, platform: &str, code: &str) -> Result<()> {
111        let pairing = self
112            .pairings
113            .iter_mut()
114            .find(|p| p.platform == platform && p.code.as_deref() == Some(code));
115
116        match pairing {
117            Some(p) => {
118                p.status = PairingStatus::Approved;
119                p.approved_at = Some(chrono::Utc::now().to_rfc3339());
120                Ok(())
121            }
122            None => anyhow::bail!(
123                "No pending pairing found for platform '{}' with code '{}'",
124                platform,
125                code
126            ),
127        }
128    }
129
130    /// Revoke a pairing
131    pub fn revoke_pairing(&mut self, platform: &str, user_id: &str) -> Result<()> {
132        let len = self.pairings.len();
133        self.pairings.retain(|p| !(p.platform == platform && p.user_id == user_id));
134        if self.pairings.len() == len {
135            anyhow::bail!("Pairing for platform '{}' and user '{}' not found", platform, user_id);
136        }
137        Ok(())
138    }
139
140    /// Clear all pending pairings
141    pub fn clear_pending(&mut self) -> Result<()> {
142        let initial_count = self.pairings.len();
143        self.pairings.retain(|p| p.status != PairingStatus::Pending);
144        if self.pairings.len() == initial_count {
145            anyhow::bail!("No pending pairings to clear");
146        }
147        Ok(())
148    }
149
150    /// List all pairings
151    pub fn list_pairings(&self) -> &[Pairing] {
152        &self.pairings
153    }
154
155    /// List pairings by status
156    pub fn list_by_status(&self, status: &PairingStatus) -> Vec<&Pairing> {
157        self.pairings.iter().filter(|p| &p.status == status).collect()
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_pairing_store_default() {
167        let store = PairingStore::default();
168        assert!(store.pairings.is_empty());
169    }
170
171    #[test]
172    fn test_pairing_store_add() {
173        let mut store = PairingStore::default();
174        let pairing = Pairing {
175            platform: "telegram".to_string(),
176            user_id: "user123".to_string(),
177            code: Some("ABC123".to_string()),
178            status: PairingStatus::Pending,
179            display_name: "Test User".to_string(),
180            created_at: "2026-01-01T00:00:00Z".to_string(),
181            approved_at: None,
182        };
183        store.add_pairing(pairing).unwrap();
184        assert_eq!(store.pairings.len(), 1);
185    }
186
187    #[test]
188    fn test_pairing_store_approve() {
189        let mut store = PairingStore::default();
190        let pairing = Pairing {
191            platform: "telegram".to_string(),
192            user_id: "user123".to_string(),
193            code: Some("ABC123".to_string()),
194            status: PairingStatus::Pending,
195            display_name: "Test User".to_string(),
196            created_at: "2026-01-01T00:00:00Z".to_string(),
197            approved_at: None,
198        };
199        store.add_pairing(pairing).unwrap();
200        store.approve_pairing("telegram", "ABC123").unwrap();
201        assert_eq!(store.pairings[0].status, PairingStatus::Approved);
202    }
203
204    #[test]
205    fn test_pairing_store_revoke() {
206        let mut store = PairingStore::default();
207        let pairing = Pairing {
208            platform: "telegram".to_string(),
209            user_id: "user123".to_string(),
210            code: None,
211            status: PairingStatus::Approved,
212            display_name: "Test User".to_string(),
213            created_at: "2026-01-01T00:00:00Z".to_string(),
214            approved_at: Some("2026-01-01T00:00:00Z".to_string()),
215        };
216        store.add_pairing(pairing).unwrap();
217        store.revoke_pairing("telegram", "user123").unwrap();
218        assert!(store.pairings.is_empty());
219    }
220}