Skip to main content

kindly_guard_server/shield/
cli.rs

1// Copyright 2025 Kindly Software Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//! CLI Shield Integration - Always-present security status display
15
16use crossterm::terminal;
17use serde::{Deserialize, Serialize};
18use std::io::{self, Write};
19use std::sync::atomic::{AtomicBool, Ordering};
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22
23use crate::scanner::ScannerStats;
24use crate::shield::Shield;
25
26/// Compact shield display for CLI integration
27pub struct CliShield {
28    shield: Arc<Shield>,
29    format: DisplayFormat,
30    last_update: Instant,
31    update_interval: Duration,
32    enabled: AtomicBool,
33    /// For shell integration - tracks if we're in a command
34    in_command: AtomicBool,
35}
36
37/// Display format options for the shield
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum DisplayFormat {
40    /// Compact single-line format for prompts
41    Compact,
42    /// Status bar format for tmux/screen
43    StatusBar,
44    /// Inline format that preserves cursor position
45    Inline,
46    /// Minimal format with just icon and status
47    Minimal,
48}
49
50/// Shield status for external consumption
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ShieldStatus {
53    pub active: bool,
54    pub threats_blocked: u64,
55    pub uptime_seconds: u64,
56    pub last_threat: Option<String>,
57    pub scanner_stats: ScannerStats,
58}
59
60impl CliShield {
61    pub fn new(shield: Arc<Shield>, format: DisplayFormat) -> Self {
62        Self {
63            shield,
64            format,
65            last_update: Instant::now(),
66            update_interval: Duration::from_millis(1000),
67            enabled: AtomicBool::new(true),
68            in_command: AtomicBool::new(false),
69        }
70    }
71
72    /// Get the current shield status
73    pub fn status(&self) -> ShieldStatus {
74        let stats = self.shield.stats();
75        let uptime = self.shield.start_time().elapsed().as_secs();
76
77        ShieldStatus {
78            active: self.shield.is_active(),
79            threats_blocked: stats.threats_blocked,
80            uptime_seconds: uptime,
81            last_threat: self.shield.last_threat_type(),
82            scanner_stats: self.shield.scanner_stats(),
83        }
84    }
85
86    /// Render the shield display to a string
87    pub fn render(&self) -> String {
88        if !self.enabled.load(Ordering::Relaxed) {
89            return String::new();
90        }
91
92        let status = self.status();
93
94        match self.format {
95            DisplayFormat::Compact => self.render_compact(&status),
96            DisplayFormat::StatusBar => self.render_status_bar(&status),
97            DisplayFormat::Inline => self.render_inline(&status),
98            DisplayFormat::Minimal => self.render_minimal(&status),
99        }
100    }
101
102    /// Render compact format for shell prompts
103    fn render_compact(&self, status: &ShieldStatus) -> String {
104        let shield_icon = if status.active { "🛡️" } else { "🔓" };
105        let status_icon = if status.active { "✓" } else { "✗" };
106        let threat_count = status.threats_blocked;
107        let uptime = format_duration(status.uptime_seconds);
108
109        if threat_count > 0 {
110            format!(
111                "[{shield_icon} KindlyGuard: {status_icon} Protected | ⚡ {threat_count} blocked | ⏱ {uptime}]"
112            )
113        } else {
114            format!("[{shield_icon} KindlyGuard: {status_icon} Protected | ⏱ {uptime}]")
115        }
116    }
117
118    /// Render status bar format for tmux/screen
119    fn render_status_bar(&self, status: &ShieldStatus) -> String {
120        let shield_icon = if status.active { "🛡️" } else { "🔓" };
121        let threats = status.threats_blocked;
122
123        if let Some(last_threat) = &status.last_threat {
124            format!("{shield_icon} {last_threat} ⚡{threats}")
125        } else {
126            format!("{shield_icon} Safe ⚡{threats}")
127        }
128    }
129
130    /// Render inline format that preserves cursor
131    fn render_inline(&self, status: &ShieldStatus) -> String {
132        let shield_icon = if status.active { "🛡️" } else { "🔓" };
133        let status_text = if status.active { "ON" } else { "OFF" };
134
135        format!("\r{shield_icon} {status_text}")
136    }
137
138    /// Render minimal format
139    fn render_minimal(&self, status: &ShieldStatus) -> String {
140        if status.active {
141            if status.threats_blocked > 0 {
142                format!("🛡️⚡{}", status.threats_blocked)
143            } else {
144                "🛡️".to_string()
145            }
146        } else {
147            "🔓".to_string()
148        }
149    }
150
151    /// Update the display if needed
152    pub fn update(&mut self) -> io::Result<()> {
153        if !self.should_update() {
154            return Ok(());
155        }
156
157        let display = self.render();
158        if !display.is_empty() {
159            self.write_display(&display)?;
160        }
161
162        self.last_update = Instant::now();
163        Ok(())
164    }
165
166    /// Check if display should be updated
167    fn should_update(&self) -> bool {
168        self.enabled.load(Ordering::Relaxed)
169            && self.last_update.elapsed() >= self.update_interval
170            && !self.in_command.load(Ordering::Relaxed)
171    }
172
173    /// Write display to terminal
174    fn write_display(&self, display: &str) -> io::Result<()> {
175        let mut stdout = io::stdout();
176
177        if self.format == DisplayFormat::Inline {
178            // Save cursor position, write at top-right, restore
179            write!(stdout, "\x1b7")?; // Save cursor
180            write!(
181                stdout,
182                "\x1b[1;{}H",
183                terminal::size()?.0.saturating_sub(display.len() as u16)
184            )?;
185            write!(stdout, "{display}")?;
186            write!(stdout, "\x1b8")?; // Restore cursor
187            stdout.flush()?;
188        } else {
189            // For other formats, just write to stdout
190            write!(stdout, "{display}")?;
191            stdout.flush()?;
192        }
193
194        Ok(())
195    }
196
197    /// Enable/disable the shield display
198    pub fn set_enabled(&self, enabled: bool) {
199        self.enabled.store(enabled, Ordering::Relaxed);
200    }
201
202    /// Set whether we're currently in a command (for shell integration)
203    pub fn set_in_command(&self, in_command: bool) {
204        self.in_command.store(in_command, Ordering::Relaxed);
205    }
206
207    /// Get shell initialization script
208    pub fn shell_init_script(shell: &str) -> String {
209        match shell {
210            "bash" => include_str!("../../scripts/shell-init.bash").to_string(),
211            "zsh" => include_str!("../../scripts/shell-init.zsh").to_string(),
212            "fish" => include_str!("../../scripts/shell-init.fish").to_string(),
213            _ => String::new(),
214        }
215    }
216}
217
218/// Format duration for display
219fn format_duration(seconds: u64) -> String {
220    let hours = seconds / 3600;
221    let minutes = (seconds % 3600) / 60;
222
223    if hours > 0 {
224        format!("{hours}h{minutes}m")
225    } else if minutes > 0 {
226        format!("{minutes}m")
227    } else {
228        format!("{seconds}s")
229    }
230}
231
232/// Shell hook commands for integration
233pub mod hooks {
234
235    /// Pre-command hook (called before each command)
236    pub const fn pre_command_hook() -> &'static str {
237        r"
238        if command -v kindly-guard >/dev/null 2>&1; then
239            kindly-guard shield pre-command
240        fi
241        "
242    }
243
244    /// Post-command hook (called after each command)
245    pub const fn post_command_hook() -> &'static str {
246        r"
247        if command -v kindly-guard >/dev/null 2>&1; then
248            kindly-guard shield post-command
249        fi
250        "
251    }
252
253    /// Prompt command for bash/zsh
254    pub const fn prompt_command() -> &'static str {
255        r#"
256        if command -v kindly-guard >/dev/null 2>&1; then
257            KINDLY_GUARD_STATUS="$(kindly-guard shield status --format=compact)"
258            if [ -n "$KINDLY_GUARD_STATUS" ]; then
259                echo -e "\033[1;34m$KINDLY_GUARD_STATUS\033[0m"
260            fi
261        fi
262        "#
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_display_formats() {
272        let shield = Arc::new(Shield::new());
273        let cli_shield = CliShield::new(shield.clone(), DisplayFormat::Compact);
274
275        let display = cli_shield.render();
276        assert!(display.contains("KindlyGuard"));
277        assert!(display.contains("Protected"));
278    }
279
280    #[test]
281    fn test_status_serialization() {
282        let shield = Arc::new(Shield::new());
283        let cli_shield = CliShield::new(shield, DisplayFormat::Minimal);
284
285        let status = cli_shield.status();
286        let json =
287            serde_json::to_string(&status).expect("Shield status should always be serializable");
288        assert!(json.contains("active"));
289        assert!(json.contains("threats_blocked"));
290    }
291}