Skip to main content

scope/domain/
address_book.rs

1//! Address book domain logic.
2//!
3//! Core data types (`AddressBook`, `WatchedAddress`) and business logic
4//! (load, save, CRUD, label resolution) shared by the CLI and web layers.
5
6use crate::config::Config;
7use crate::error::{Result, ScopeError};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12/// A watched address in the address book.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WatchedAddress {
15    /// The blockchain address.
16    pub address: String,
17
18    /// Human-readable label.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub label: Option<String>,
21
22    /// Blockchain network.
23    pub chain: String,
24
25    /// Tags for categorization.
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub tags: Vec<String>,
28
29    /// When the address was added (Unix timestamp).
30    pub added_at: u64,
31}
32
33/// AddressBook data storage.
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct AddressBook {
36    /// All watched addresses.
37    pub addresses: Vec<WatchedAddress>,
38}
39
40/// AddressBook summary report.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AddressBookSummary {
43    /// Total number of addresses.
44    pub address_count: usize,
45
46    /// Balances by chain.
47    pub balances_by_chain: HashMap<String, ChainBalance>,
48
49    /// Total address book value in USD (if available).
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub total_usd: Option<f64>,
52
53    /// Individual address summaries.
54    pub addresses: Vec<AddressSummary>,
55}
56
57/// Balance summary for a chain.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ChainBalance {
60    /// Native token balance.
61    pub native_balance: String,
62
63    /// Native token symbol.
64    pub symbol: String,
65
66    /// USD value (if available).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub usd: Option<f64>,
69}
70
71/// Summary for a single address.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct AddressSummary {
74    /// The address.
75    pub address: String,
76
77    /// Label (if any).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub label: Option<String>,
80
81    /// Chain.
82    pub chain: String,
83
84    /// Native balance.
85    pub balance: String,
86
87    /// USD value (if available).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub usd: Option<f64>,
90
91    /// Token balances (for chains that support SPL/ERC20 tokens).
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub tokens: Vec<TokenSummary>,
94}
95
96/// Summary for a token balance.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct TokenSummary {
99    /// Token contract/mint address.
100    pub contract_address: String,
101    /// Token balance (human-readable).
102    pub balance: String,
103    /// Token decimals.
104    pub decimals: u8,
105    /// Token symbol (if known).
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub symbol: Option<String>,
108}
109
110impl AddressBook {
111    /// Loads the address book from the data directory.
112    pub fn load(data_dir: &std::path::Path) -> Result<Self> {
113        let path = data_dir.join("address_book.yaml");
114
115        if !path.exists() {
116            return Ok(Self::default());
117        }
118
119        let contents = std::fs::read_to_string(&path)?;
120        let address_book: AddressBook = serde_saphyr::from_str(&contents)
121            .map_err(|e| ScopeError::Config(crate::error::ConfigError::Parse { source: e }))?;
122
123        Ok(address_book)
124    }
125
126    /// Saves the address book to the data directory.
127    pub fn save(&self, data_dir: &PathBuf) -> Result<()> {
128        std::fs::create_dir_all(data_dir)?;
129
130        let path = data_dir.join("address_book.yaml");
131        let contents = serde_saphyr::to_string(self)
132            .map_err(|e| ScopeError::Export(format!("Failed to serialize address book: {}", e)))?;
133
134        std::fs::write(&path, contents)?;
135        Ok(())
136    }
137
138    /// Adds an address to the address book.
139    pub fn add_address(&mut self, watched: WatchedAddress) -> Result<()> {
140        // Check for duplicates
141        if self
142            .addresses
143            .iter()
144            .any(|a| a.address.to_lowercase() == watched.address.to_lowercase())
145        {
146            return Err(ScopeError::InvalidInput(format!(
147                "Address already in address book: {}",
148                watched.address
149            )));
150        }
151
152        self.addresses.push(watched);
153        Ok(())
154    }
155
156    /// Removes an address from the address book.
157    pub fn remove_address(&mut self, address: &str) -> Result<bool> {
158        let original_len = self.addresses.len();
159        self.addresses
160            .retain(|a| a.address.to_lowercase() != address.to_lowercase());
161
162        Ok(self.addresses.len() < original_len)
163    }
164
165    /// Finds an address in the address book by address string.
166    pub fn find_address(&self, address: &str) -> Option<&WatchedAddress> {
167        self.addresses
168            .iter()
169            .find(|a| a.address.to_lowercase() == address.to_lowercase())
170    }
171
172    /// Finds an address in the address book by its label (case-insensitive).
173    ///
174    /// Returns the first matching entry. Labels are compared after
175    /// lowercasing and trimming whitespace.
176    pub fn find_by_label(&self, label: &str) -> Option<&WatchedAddress> {
177        let needle = label.trim().to_lowercase();
178        self.addresses.iter().find(|a| {
179            a.label
180                .as_ref()
181                .is_some_and(|l| l.trim().to_lowercase() == needle)
182        })
183    }
184}
185
186/// Resolves a user-supplied input string against the address book.
187///
188/// **Label lookup (requires `@` prefix):** If the input starts with `@`, the remainder
189/// is looked up as a label. Example: `@main-wallet` resolves to the address with
190/// label "main-wallet". This convention distinguishes label lookups from raw addresses.
191///
192/// **Address match (no `@`):** If the input does not start with `@`, only direct
193/// address matching is attempted (to inject chain info from the address book).
194/// Raw addresses and token identifiers are not treated as labels.
195///
196/// Returns `Ok(Some((address, chain)))` when resolved, `Ok(None)` when no `@` prefix
197/// and no address match, or `Err` when the `@` prefix was used but the label wasn't found.
198pub fn resolve_address_book_input(
199    input: &str,
200    config: &Config,
201) -> crate::error::Result<Option<(String, String)>> {
202    let data_dir = config.data_dir();
203    let address_book = match AddressBook::load(&data_dir) {
204        Ok(ab) => ab,
205        Err(_) => return Ok(None),
206    };
207
208    // If input starts with @, strip it and look up remainder as label
209    if let Some(label) = input.strip_prefix('@') {
210        if let Some(watched) = address_book.find_by_label(label) {
211            let label_display = watched.label.as_deref().unwrap_or(label);
212            crate::notice!(
213                "  Using '{}' → {} ({})",
214                label_display,
215                watched.address,
216                watched.chain
217            );
218            return Ok(Some((watched.address.clone(), watched.chain.clone())));
219        }
220        // List available labels to help the user
221        let available: Vec<String> = address_book
222            .addresses
223            .iter()
224            .filter_map(|a| a.label.clone())
225            .collect();
226        let suggestion = if available.is_empty() {
227            "Your address book is empty. Add entries with `scope address-book add`.".to_string()
228        } else {
229            format!(
230                "Available labels: {}",
231                available
232                    .iter()
233                    .map(|l| format!("@{}", l))
234                    .collect::<Vec<_>>()
235                    .join(", ")
236            )
237        };
238        return Err(crate::error::ScopeError::NotFound(format!(
239            "No address book entry matching '@{}'.\n      {}",
240            label, suggestion
241        )));
242    }
243
244    // No @ prefix: only try address match (inject chain info from address book)
245    if let Some(watched) = address_book.find_address(input) {
246        if let Some(ref label) = watched.label {
247            tracing::debug!(
248                "Address book match by address for '{}' ({})",
249                label,
250                watched.chain
251            );
252        }
253        return Ok(Some((watched.address.clone(), watched.chain.clone())));
254    }
255
256    Ok(None)
257}
258
259// ============================================================================
260// Unit Tests
261// ============================================================================
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use tempfile::TempDir;
267
268    fn create_test_address_book() -> AddressBook {
269        AddressBook {
270            addresses: vec![
271                WatchedAddress {
272                    address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
273                    label: Some("Main Wallet".to_string()),
274                    chain: "ethereum".to_string(),
275                    tags: vec!["personal".to_string()],
276                    added_at: 1700000000,
277                },
278                WatchedAddress {
279                    address: "0xABCdef1234567890abcdef1234567890ABCDEF12".to_string(),
280                    label: None,
281                    chain: "polygon".to_string(),
282                    tags: vec![],
283                    added_at: 1700000001,
284                },
285            ],
286        }
287    }
288
289    #[test]
290    fn test_address_book_default() {
291        let address_book = AddressBook::default();
292        assert!(address_book.addresses.is_empty());
293    }
294
295    #[test]
296    fn test_address_book_add_address() {
297        let mut address_book = AddressBook::default();
298
299        let watched = WatchedAddress {
300            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
301            label: Some("Test".to_string()),
302            chain: "ethereum".to_string(),
303            tags: vec![],
304            added_at: 0,
305        };
306
307        let result = address_book.add_address(watched);
308        assert!(result.is_ok());
309        assert_eq!(address_book.addresses.len(), 1);
310    }
311
312    #[test]
313    fn test_address_book_add_duplicate_fails() {
314        let mut address_book = AddressBook::default();
315
316        let watched1 = WatchedAddress {
317            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
318            label: Some("First".to_string()),
319            chain: "ethereum".to_string(),
320            tags: vec![],
321            added_at: 0,
322        };
323
324        let watched2 = WatchedAddress {
325            address: "0x742d35CC6634C0532925A3b844Bc9e7595f1b3c2".to_string(), // Same address, different case
326            label: Some("Second".to_string()),
327            chain: "ethereum".to_string(),
328            tags: vec![],
329            added_at: 0,
330        };
331
332        address_book.add_address(watched1).unwrap();
333        let result = address_book.add_address(watched2);
334
335        assert!(result.is_err());
336        assert!(
337            result
338                .unwrap_err()
339                .to_string()
340                .contains("already in address book")
341        );
342    }
343
344    #[test]
345    fn test_address_book_remove_address() {
346        let mut address_book = create_test_address_book();
347        let original_len = address_book.addresses.len();
348
349        let removed = address_book
350            .remove_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2")
351            .unwrap();
352
353        assert!(removed);
354        assert_eq!(address_book.addresses.len(), original_len - 1);
355    }
356
357    #[test]
358    fn test_address_book_remove_nonexistent() {
359        let mut address_book = create_test_address_book();
360        let original_len = address_book.addresses.len();
361
362        let removed = address_book.remove_address("0xnonexistent").unwrap();
363
364        assert!(!removed);
365        assert_eq!(address_book.addresses.len(), original_len);
366    }
367
368    #[test]
369    fn test_address_book_find_address() {
370        let address_book = create_test_address_book();
371
372        let found = address_book.find_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
373        assert!(found.is_some());
374        assert_eq!(found.unwrap().label, Some("Main Wallet".to_string()));
375
376        let not_found = address_book.find_address("0xnonexistent");
377        assert!(not_found.is_none());
378    }
379
380    #[test]
381    fn test_address_book_find_address_case_insensitive() {
382        let address_book = create_test_address_book();
383
384        let found = address_book.find_address("0x742D35CC6634C0532925A3B844BC9E7595F1B3C2");
385        assert!(found.is_some());
386    }
387
388    #[test]
389    fn test_address_book_save_and_load() {
390        let temp_dir = TempDir::new().unwrap();
391        let data_dir = temp_dir.path().to_path_buf();
392
393        let address_book = create_test_address_book();
394        address_book.save(&data_dir).unwrap();
395
396        let loaded = AddressBook::load(&data_dir).unwrap();
397        assert_eq!(loaded.addresses.len(), address_book.addresses.len());
398        assert_eq!(
399            loaded.addresses[0].address,
400            address_book.addresses[0].address
401        );
402    }
403
404    #[test]
405    fn test_address_book_load_nonexistent_returns_default() {
406        let temp_dir = TempDir::new().unwrap();
407        let data_dir = temp_dir.path().to_path_buf();
408
409        let address_book = AddressBook::load(&data_dir).unwrap();
410        assert!(address_book.addresses.is_empty());
411    }
412
413    #[test]
414    fn test_watched_address_serialization() {
415        let watched = WatchedAddress {
416            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
417            label: Some("Test".to_string()),
418            chain: "ethereum".to_string(),
419            tags: vec!["tag1".to_string(), "tag2".to_string()],
420            added_at: 1700000000,
421        };
422
423        let json = serde_json::to_string(&watched).unwrap();
424        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
425        assert!(json.contains("Test"));
426        assert!(json.contains("tag1"));
427
428        let deserialized: WatchedAddress = serde_json::from_str(&json).unwrap();
429        assert_eq!(deserialized.address, watched.address);
430        assert_eq!(deserialized.tags.len(), 2);
431    }
432
433    #[test]
434    fn test_address_book_summary_serialization() {
435        let summary = AddressBookSummary {
436            address_count: 2,
437            balances_by_chain: HashMap::new(),
438            total_usd: Some(10000.0),
439            addresses: vec![AddressSummary {
440                address: "0x123".to_string(),
441                label: Some("Test".to_string()),
442                chain: "ethereum".to_string(),
443                balance: "1.5".to_string(),
444                usd: Some(5000.0),
445                tokens: vec![],
446            }],
447        };
448
449        let json = serde_json::to_string(&summary).unwrap();
450        assert!(json.contains("10000"));
451        assert!(json.contains("0x123"));
452    }
453
454    #[test]
455    fn test_chain_balance_serialization() {
456        let balance = ChainBalance {
457            native_balance: "10.5".to_string(),
458            symbol: "ETH".to_string(),
459            usd: Some(35000.0),
460        };
461
462        let json = serde_json::to_string(&balance).unwrap();
463        assert!(json.contains("10.5"));
464        assert!(json.contains("ETH"));
465        assert!(json.contains("35000"));
466    }
467}