Skip to main content

lanflare_cli/
tui.rs

1//! CLI terminal output & pretty-printing utilities.
2
3use lanflare_core::discovery::registry::DiscoveredPeer;
4use lanflare_proto::discovery::{DeviceType, ProtocolType};
5
6/// Returns an icon representing the device type or OS model.
7pub fn get_device_icon(device_type: Option<DeviceType>, model: Option<&str>) -> &'static str {
8    // Check model string for OS hints first
9    if let Some(m) = model {
10        let m_lower = m.to_ascii_lowercase();
11        if m_lower.contains("windows") {
12            return "🪟 ";
13        } else if m_lower.contains("mac") || m_lower.contains("darwin") || m_lower.contains("apple") {
14            return "🍎";
15        } else if m_lower.contains("linux") || m_lower.contains("ubuntu") || m_lower.contains("debian") || m_lower.contains("fedora") || m_lower.contains("arch") {
16            return "🐧";
17        } else if m_lower.contains("android") {
18            return "🤖";
19        } else if m_lower.contains("iphone") || m_lower.contains("ipad") || m_lower.contains("ios") {
20            return "📱";
21        }
22    }
23
24    match device_type {
25        Some(DeviceType::Mobile) => "📱",
26        Some(DeviceType::Desktop) => "💻",
27        Some(DeviceType::Web) => "🌐",
28        Some(DeviceType::Headless) => "⚡",
29        Some(DeviceType::Server) => "🖥️ ",
30        None => "💻",
31    }
32}
33
34/// Formats protocol type for display with HTTPS/HTTP indicators.
35pub fn format_protocol(protocol: ProtocolType) -> &'static str {
36    match protocol {
37        ProtocolType::Http => "HTTP (plain)",
38        ProtocolType::Https => "HTTPS (mTLS)",
39    }
40}
41
42/// Shortens a 64-char hex fingerprint for compact display, e.g. "A1B2C3D4...E5F6"
43pub fn shorten_fingerprint(fp: &str) -> String {
44    if fp.len() <= 16 {
45        fp.to_string()
46    } else {
47        format!("{}...{}", &fp[..8], &fp[fp.len() - 6..])
48    }
49}
50
51/// Renders a formatted text table of discovered peers.
52pub fn render_peer_table(peers: &[DiscoveredPeer]) -> String {
53    if peers.is_empty() {
54        return "No peers discovered on local network.\n".to_string();
55    }
56
57    let mut out = String::new();
58    out.push_str("\nDiscovered Peers:\n");
59    out.push_str(&format!(
60        "{:<3} {:<22} {:<18} {:<14} {:<16} {:<16}\n",
61        "OS", "ALIAS", "PRIMARY IP", "PORT", "PROTOCOL", "FINGERPRINT"
62    ));
63    out.push_str(&format!(
64        "{:-<3} {:-<22} {:-<18} {:-<14} {:-<16} {:-<16}\n",
65        "", "", "", "", "", ""
66    ));
67
68    for peer in peers {
69        let icon = get_device_icon(peer.device_type, peer.device_model.as_deref());
70        let alias = if peer.alias.len() > 20 {
71            format!("{}…", &peer.alias[..19])
72        } else {
73            peer.alias.clone()
74        };
75        let ip_str = peer
76            .primary_ip()
77            .map(|ip| ip.to_string())
78            .unwrap_or_else(|| "-".to_string());
79        let port_str = peer.port.to_string();
80        let proto_str = match peer.protocol {
81            ProtocolType::Http => "HTTP",
82            ProtocolType::Https => "HTTPS (TLS)",
83        };
84        let fp_str = shorten_fingerprint(&peer.fingerprint);
85
86        out.push_str(&format!(
87            "{:<3} {:<22} {:<18} {:<14} {:<16} {:<16}\n",
88            icon, alias, ip_str, port_str, proto_str, fp_str
89        ));
90    }
91
92    out
93}
94
95use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
96use std::collections::HashMap;
97use lanflare_core::client::transfer::TransferProgress;
98
99/// Manages multiple progress bars for a file transfer session.
100pub struct TransferProgressTracker {
101    mp: MultiProgress,
102    overall_pb: ProgressBar,
103    file_pbs: HashMap<String, ProgressBar>,
104    spinner_style: ProgressStyle,
105    bar_style: ProgressStyle,
106    is_interactive: bool,
107}
108
109impl Default for TransferProgressTracker {
110    fn default() -> Self {
111        Self::new(true)
112    }
113}
114
115impl TransferProgressTracker {
116    /// Creates a new progress tracker.
117    ///
118    /// If `is_interactive` is false, progress bars will be hidden/silent
119    /// (useful for headless/scripting environments or unit tests).
120    pub fn new(is_interactive: bool) -> Self {
121        let mp = MultiProgress::new();
122
123        let bar_style = ProgressStyle::default_bar()
124            .template("{prefix:>18} [{elapsed_precise}] [{bar:30.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})")
125            .unwrap_or_else(|_| ProgressStyle::default_bar())
126            .progress_chars("━╾─");
127
128        let spinner_style = ProgressStyle::default_spinner()
129            .template("{prefix:>18} {spinner:.green} {msg}")
130            .unwrap_or_else(|_| ProgressStyle::default_spinner());
131
132        let overall_pb = if is_interactive {
133            mp.add(ProgressBar::new(0))
134        } else {
135            ProgressBar::hidden()
136        };
137        overall_pb.set_style(bar_style.clone());
138        overall_pb.set_prefix("Overall Progress");
139
140        Self {
141            mp,
142            overall_pb,
143            file_pbs: HashMap::new(),
144            spinner_style,
145            bar_style,
146            is_interactive,
147        }
148    }
149
150    /// Handles an incoming [`TransferProgress`] event and updates progress bars accordingly.
151    pub fn handle_event(&mut self, event: &TransferProgress) {
152        match event {
153            TransferProgress::Started { total_bytes, accepted_files, .. } => {
154                self.overall_pb.set_length(*total_bytes);
155                self.overall_pb.set_position(0);
156                self.overall_pb.set_message(format!("Uploading {} file(s)", accepted_files.len()));
157            }
158            TransferProgress::FileProgress {
159                file_id,
160                file_name,
161                file_bytes_sent,
162                file_total_bytes,
163                session_bytes_sent,
164                ..
165            } => {
166                self.overall_pb.set_position(*session_bytes_sent);
167
168                let pb = self.file_pbs.entry(file_id.clone()).or_insert_with(|| {
169                    let pb = if self.is_interactive {
170                        self.mp.add(ProgressBar::new(*file_total_bytes))
171                    } else {
172                        ProgressBar::hidden()
173                    };
174                    pb.set_style(self.bar_style.clone());
175                    let prefix = if file_name.len() > 18 {
176                        format!("…{}", &file_name[file_name.len() - 17..])
177                    } else {
178                        file_name.clone()
179                    };
180                    pb.set_prefix(prefix);
181                    pb
182                });
183
184                pb.set_position(*file_bytes_sent);
185            }
186            TransferProgress::FileFinished { file_id, file_name, .. } => {
187                if let Some(pb) = self.file_pbs.get(file_id) {
188                    pb.finish_with_message("Done ✓");
189                } else if self.is_interactive {
190                    let pb = self.mp.add(ProgressBar::new(0));
191                    pb.set_style(self.spinner_style.clone());
192                    pb.set_prefix(file_name.clone());
193                    pb.finish_with_message("Done ✓");
194                }
195            }
196            TransferProgress::FileFailed { file_id, error, .. } => {
197                if let Some(pb) = self.file_pbs.get(file_id) {
198                    pb.abandon_with_message(format!("Failed: {error}"));
199                }
200            }
201            TransferProgress::SessionCompleted { total_files, total_bytes, duration, .. } => {
202                self.overall_pb.set_position(*total_bytes);
203                self.overall_pb.finish_with_message(format!(
204                    "Completed {} file(s) in {:.1}s ✓",
205                    total_files,
206                    duration.as_secs_f64()
207                ));
208            }
209            TransferProgress::SessionCancelled { reason, .. } => {
210                self.overall_pb.abandon_with_message(format!("Cancelled: {reason}"));
211            }
212        }
213    }
214
215    /// Access the underlying MultiProgress if needed.
216    pub fn multi_progress(&self) -> &MultiProgress {
217        &self.mp
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use lanflare_core::discovery::registry::PeerEndpoint;
225    use lanflare_core::discovery::DiscoverySource;
226    use std::collections::{HashMap, HashSet};
227    use std::net::{IpAddr, Ipv4Addr};
228
229    #[test]
230    fn test_device_icon_resolution() {
231        assert_eq!(get_device_icon(Some(DeviceType::Desktop), Some("Linux 6.8")), "🐧");
232        assert_eq!(get_device_icon(Some(DeviceType::Desktop), Some("Windows 11")), "🪟 ");
233        assert_eq!(get_device_icon(Some(DeviceType::Desktop), Some("macOS Sonoma")), "🍎");
234        assert_eq!(get_device_icon(Some(DeviceType::Mobile), Some("Pixel 8 Android")), "🤖");
235        assert_eq!(get_device_icon(Some(DeviceType::Mobile), None), "📱");
236        assert_eq!(get_device_icon(Some(DeviceType::Server), None), "🖥️ ");
237        assert_eq!(get_device_icon(None, None), "💻");
238    }
239
240    #[test]
241    fn test_shorten_fingerprint() {
242        let fp = "ABCD1234EF567890ABCD1234EF567890ABCD1234EF567890ABCD1234EF567890";
243        assert_eq!(shorten_fingerprint(fp), "ABCD1234...567890");
244        assert_eq!(shorten_fingerprint("SHORT"), "SHORT");
245    }
246
247    #[test]
248    fn test_render_peer_table_empty() {
249        let out = render_peer_table(&[]);
250        assert!(out.contains("No peers discovered"));
251    }
252
253    #[test]
254    fn test_render_peer_table_with_peers() {
255        let mut sources = HashSet::new();
256        sources.insert(DiscoverySource::Multicast);
257
258        let peer = DiscoveredPeer {
259            fingerprint: "11223344556677889900AABBCCDDEEFF11223344556677889900AABBCCDDEEFF".to_string(),
260            alias: "Alice's Laptop".to_string(),
261            version: "2.0".to_string(),
262            device_model: Some("Linux 6.8".to_string()),
263            device_type: Some(DeviceType::Desktop),
264            port: 53317,
265            protocol: ProtocolType::Https,
266            download: false,
267            endpoints: vec![PeerEndpoint {
268                ip: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)),
269                scope_id: None,
270                sources: sources.clone(),
271                first_seen_ms: 1000,
272                last_seen_ms: 1000,
273            }],
274            discovery_sources: sources,
275            first_seen_ms: 1000,
276            last_seen_ms: 1000,
277            metadata: HashMap::new(),
278        };
279
280        let table = render_peer_table(&[peer]);
281        assert!(table.contains("Alice's Laptop"));
282        assert!(table.contains("192.168.1.100"));
283        assert!(table.contains("53317"));
284        assert!(table.contains("HTTPS (TLS)"));
285        assert!(table.contains("11223344...DDEEFF"));
286    }
287
288    #[test]
289    fn test_transfer_progress_tracker_lifecycle() {
290        let mut tracker = TransferProgressTracker::new(false);
291        tracker.handle_event(&TransferProgress::Started {
292            session_id: "sess-1".to_string(),
293            accepted_files: vec!["file-1".to_string()],
294            total_bytes: 1000,
295        });
296
297        tracker.handle_event(&TransferProgress::FileProgress {
298            session_id: "sess-1".to_string(),
299            file_id: "file-1".to_string(),
300            file_name: "test.txt".to_string(),
301            file_bytes_sent: 500,
302            file_total_bytes: 1000,
303            session_bytes_sent: 500,
304            session_total_bytes: 1000,
305            bytes_per_second: 5000,
306            eta: Some(std::time::Duration::from_secs(1)),
307        });
308
309        tracker.handle_event(&TransferProgress::FileFinished {
310            session_id: "sess-1".to_string(),
311            file_id: "file-1".to_string(),
312            file_name: "test.txt".to_string(),
313        });
314
315        tracker.handle_event(&TransferProgress::SessionCompleted {
316            session_id: "sess-1".to_string(),
317            total_files: 1,
318            total_bytes: 1000,
319            duration: std::time::Duration::from_millis(200),
320        });
321    }
322
323    #[test]
324    fn test_transfer_progress_tracker_error_and_cancellation() {
325        let mut tracker = TransferProgressTracker::new(false);
326        tracker.handle_event(&TransferProgress::FileFailed {
327            session_id: "sess-2".to_string(),
328            file_id: "file-2".to_string(),
329            file_name: "fail.bin".to_string(),
330            error: "connection reset".to_string(),
331        });
332
333        tracker.handle_event(&TransferProgress::SessionCancelled {
334            session_id: "sess-2".to_string(),
335            reason: "user aborted".to_string(),
336        });
337    }
338}