hermes_agent_cli_core/
pairings.rs1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub enum PairingStatus {
10 Pending,
11 Approved,
12 Revoked,
13}
14
15#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct PairingStore {
37 #[serde(default)]
38 pub pairings: Vec<Pairing>,
39}
40
41impl PairingStore {
42 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 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 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 pub fn add_pairing(&mut self, pairing: Pairing) -> Result<()> {
92 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 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 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 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 pub fn list_pairings(&self) -> &[Pairing] {
152 &self.pairings
153 }
154
155 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}