1use crate::errors::LitError;
6use serde::{Deserialize, Serialize};
7use sha3::{Digest, Sha3_256};
8use std::fs;
9use std::path::Path;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PeerInfo {
14 pub did: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
18 pub alias: Option<String>,
19 pub endpoint: String,
21 pub public_key_hex: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub last_known_head: Option<String>,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub last_sync: Option<String>,
29 pub reachable: bool,
31 pub added: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
37pub struct ContentId {
38 pub algorithm: String,
40 pub hash: String,
42}
43
44impl ContentId {
45 pub fn from_bytes(data: &[u8]) -> Self {
47 let hash = Sha3_256::digest(data);
48 ContentId {
49 algorithm: "sha3-256".to_string(),
50 hash: hex::encode(hash),
51 }
52 }
53
54 pub fn short(&self) -> String {
56 if self.hash.len() > 12 {
57 format!("{}..{}", &self.hash[..6], &self.hash[self.hash.len() - 6..])
58 } else {
59 self.hash.clone()
60 }
61 }
62}
63
64impl std::fmt::Display for ContentId {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}:{}", self.algorithm, self.hash)
67 }
68}
69
70fn peers_dir(repo_root: &Path) -> std::path::PathBuf {
71 repo_root.join(".lit").join("federation").join("peers")
72}
73
74pub fn add_peer(repo_root: &Path, peer: &PeerInfo) -> Result<(), LitError> {
76 let dir = peers_dir(repo_root);
77 fs::create_dir_all(&dir)
78 .map_err(|e| LitError::io(format!("Failed to create peers dir: {}", e)))?;
79
80 let safe_name: String = peer
81 .did
82 .chars()
83 .map(|c| if c.is_alphanumeric() { c } else { '_' })
84 .collect();
85 let path = dir.join(format!("{}.json", safe_name));
86
87 let json = serde_json::to_string_pretty(peer)
88 .map_err(|e| LitError::general(format!("Serialize error: {}", e)))?;
89 fs::write(&path, json).map_err(|e| LitError::io(format!("Write error: {}", e)))?;
90 Ok(())
91}
92
93pub fn remove_peer(repo_root: &Path, did: &str) -> Result<(), LitError> {
95 let safe_name: String = did
96 .chars()
97 .map(|c| if c.is_alphanumeric() { c } else { '_' })
98 .collect();
99 let path = peers_dir(repo_root).join(format!("{}.json", safe_name));
100 if path.exists() {
101 fs::remove_file(&path).map_err(|e| LitError::io(format!("Remove error: {}", e)))?;
102 Ok(())
103 } else {
104 Err(LitError::general(format!("Peer not found: {}", did)))
105 }
106}
107
108pub fn list_peers(repo_root: &Path) -> Result<Vec<PeerInfo>, LitError> {
110 let dir = peers_dir(repo_root);
111 if !dir.exists() {
112 return Ok(Vec::new());
113 }
114
115 let mut peers = Vec::new();
116 for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
117 let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
118 if entry.path().extension().is_some_and(|e| e == "json") {
119 if let Ok(json) = fs::read_to_string(entry.path()) {
120 if let Ok(peer) = serde_json::from_str::<PeerInfo>(&json) {
121 peers.push(peer);
122 }
123 }
124 }
125 }
126 Ok(peers)
127}
128
129pub fn get_peer(repo_root: &Path, did: &str) -> Result<PeerInfo, LitError> {
131 let safe_name: String = did
132 .chars()
133 .map(|c| if c.is_alphanumeric() { c } else { '_' })
134 .collect();
135 let path = peers_dir(repo_root).join(format!("{}.json", safe_name));
136 if !path.exists() {
137 return Err(LitError::general(format!("Peer not found: {}", did)));
138 }
139 let json = fs::read_to_string(&path).map_err(|e| LitError::io(format!("IO: {}", e)))?;
140 serde_json::from_str(&json).map_err(|e| LitError::general(format!("Parse error: {}", e)))
141}
142
143pub fn update_peer_sync(repo_root: &Path, did: &str, head: &str) -> Result<(), LitError> {
145 let mut peer = get_peer(repo_root, did)?;
146 peer.last_known_head = Some(head.to_string());
147 peer.last_sync = Some(chrono::Utc::now().to_rfc3339());
148 peer.reachable = true;
149 add_peer(repo_root, &peer)
150}
151
152pub fn generate_want_list(repo_root: &Path) -> Result<Vec<String>, LitError> {
154 let refs_dir = repo_root.join(".lit").join("refs").join("remotes");
156 if !refs_dir.exists() {
157 return Ok(Vec::new());
158 }
159
160 let mut wants = Vec::new();
161 for entry in fs::read_dir(&refs_dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
162 let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
163 if let Ok(hash) = fs::read_to_string(entry.path()) {
164 let hash = hash.trim().to_string();
165 let obj_path = repo_root
167 .join(".lit")
168 .join("objects")
169 .join(&hash[..2])
170 .join(&hash[2..]);
171 if !obj_path.exists() && !hash.is_empty() {
172 wants.push(hash);
173 }
174 }
175 }
176 Ok(wants)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use std::path::PathBuf;
183 use std::sync::atomic::{AtomicU32, Ordering};
184
185 static COUNTER: AtomicU32 = AtomicU32::new(0);
186
187 fn tmp_dir() -> PathBuf {
193 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
194 let dir = std::env::temp_dir().join(format!("lit_fed_test_{}_{}", std::process::id(), n));
195 let _ = fs::remove_dir_all(&dir);
196 fs::create_dir_all(&dir).unwrap();
197 dir
198 }
199
200 #[test]
201 fn test_add_and_list_peer() {
202 let dir = tmp_dir();
203 let peer = PeerInfo {
204 did: "did:lit:peer1".to_string(),
205 alias: Some("Alice".to_string()),
206 endpoint: "https://alice.example.com:8443".to_string(),
207 public_key_hex: "abcdef1234567890".to_string(),
208 last_known_head: None,
209 last_sync: None,
210 reachable: false,
211 added: chrono::Utc::now().to_rfc3339(),
212 };
213 add_peer(&dir, &peer).unwrap();
214
215 let peers = list_peers(&dir).unwrap();
216 assert_eq!(peers.len(), 1);
217 assert_eq!(peers[0].did, "did:lit:peer1");
218
219 let _ = fs::remove_dir_all(&dir);
220 }
221
222 #[test]
223 fn test_content_id() {
224 let cid = ContentId::from_bytes(b"hello world");
225 assert_eq!(cid.algorithm, "sha3-256");
226 assert!(!cid.hash.is_empty());
227 assert!(cid.short().contains(".."));
228 }
229}