• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kdash-rs / kdash / 25971629251

16 May 2026 07:54PM UTC coverage: 74.684% (-0.007%) from 74.691%
25971629251

push

github

deepu105
release: v1.1.2

11057 of 14805 relevant lines covered (74.68%)

133.55 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.54
/src/event/events.rs
1
//  adapted from tui-rs/examples/crossterm_demo.rs
2
use std::{
3
  env,
4
  path::PathBuf,
5
  sync::mpsc,
6
  thread,
7
  time::{Duration, Instant},
8
};
9

10
use crossterm::event::{self, Event as CEvent, KeyEvent, MouseEvent};
11
use log::{error, info, warn};
12
use notify::{RecursiveMode, Watcher};
13

14
#[derive(Debug, Clone, Copy)]
15
/// Configuration for event handling.
16
pub struct EventConfig {
17
  /// The tick rate at which the application will sent an tick event.
18
  pub tick_rate: Duration,
19
}
20

21
impl Default for EventConfig {
22
  fn default() -> EventConfig {
1✔
23
    EventConfig {
1✔
24
      tick_rate: Duration::from_millis(250),
1✔
25
    }
1✔
26
  }
1✔
27
}
28

29
/// An occurred event.
30
pub enum Event<I, J> {
31
  /// An input event occurred.
32
  Input(I),
33
  MouseInput(J),
34
  /// An tick event occurred.
35
  Tick,
36
  /// The kubeconfig file changed on disk.
37
  KubeConfigChange,
38
}
39

40
/// A small event handler that wrap crossterm input and tick event. Each event
41
/// type is handled in its own thread and returned to a common `Receiver`
42
pub struct Events {
43
  rx: mpsc::Receiver<Event<KeyEvent, MouseEvent>>,
44
  // Need to be kept around to prevent disposing the sender side.
45
  _tx: mpsc::Sender<Event<KeyEvent, MouseEvent>>,
46
}
47

48
impl Events {
49
  /// Constructs an new instance of `Events` with the default config.
50
  pub fn new(tick_rate: u64) -> Events {
2✔
51
    Events::with_config(EventConfig {
2✔
52
      tick_rate: Duration::from_millis(tick_rate),
2✔
53
    })
2✔
54
  }
2✔
55

56
  /// Constructs an new instance of `Events` from given config.
57
  pub fn with_config(config: EventConfig) -> Events {
2✔
58
    let (tx, rx) = mpsc::channel();
2✔
59

60
    let tick_rate = config.tick_rate;
2✔
61

62
    let event_tx = tx.clone();
2✔
63
    thread::spawn(move || {
2✔
64
      let mut last_tick = Instant::now();
2✔
65
      loop {
66
        let timeout = tick_rate
157,296✔
67
          .checked_sub(last_tick.elapsed())
157,296✔
68
          .unwrap_or_else(|| Duration::from_secs(0));
157,296✔
69
        // poll for tick rate duration, if no event, sent tick event.
70
        match event::poll(timeout) {
157,296✔
71
          Ok(true) => match event::read() {
×
72
            Ok(CEvent::Key(key_event)) => handle_key_event(&event_tx, key_event),
×
73
            Ok(CEvent::Mouse(mouse_event)) => {
×
74
              if event_tx.send(Event::MouseInput(mouse_event)).is_err() {
×
75
                break; // receiver dropped, app is shutting down
×
76
              }
×
77
            }
78
            Ok(_) => {}
×
79
            Err(e) => {
×
80
              error!("Failed to read terminal event: {:?}", e);
×
81
            }
82
          },
83
          Ok(false) => {} // no event available, fall through to tick
×
84
          Err(e) => {
157,296✔
85
            error!("Failed to poll terminal events: {:?}", e);
157,296✔
86
          }
87
        }
88
        if last_tick.elapsed() >= tick_rate {
157,296✔
89
          if event_tx.send(Event::Tick).is_err() {
2✔
90
            break; // receiver dropped, app is shutting down
2✔
91
          }
×
92
          last_tick = Instant::now();
×
93
        }
157,294✔
94
      }
95
    });
2✔
96

97
    // Start kubeconfig file watcher for live sync (#315)
98
    start_kubeconfig_watcher(tx.clone());
2✔
99

100
    Events { rx, _tx: tx }
2✔
101
  }
2✔
102

103
  /// Attempts to read an event.
104
  /// This function will block the current thread.
105
  pub fn next(&self) -> Result<Event<KeyEvent, MouseEvent>, mpsc::RecvError> {
2✔
106
    self.rx.recv()
2✔
107
  }
2✔
108

109
  /// Attempts to read an event without blocking.
110
  /// Returns `None` if no event is currently available or if the channel has
111
  /// been disconnected.
112
  pub fn try_next(&self) -> Option<Event<KeyEvent, MouseEvent>> {
1✔
113
    self.rx.try_recv().ok()
1✔
114
  }
1✔
115
}
116

117
/// Resolve the kubeconfig file paths that should be watched.
118
fn kubeconfig_watch_paths() -> Vec<PathBuf> {
5✔
119
  if let Some(value) = env::var_os("KUBECONFIG") {
5✔
120
    let paths: Vec<PathBuf> = env::split_paths(&value)
2✔
121
      .filter(|p| !p.as_os_str().is_empty())
6✔
122
      .collect();
2✔
123
    if !paths.is_empty() {
2✔
124
      return paths;
2✔
125
    }
×
126
  }
3✔
127
  // Fall back to default kubeconfig location
128
  if let Some(home) = env::var_os("HOME").or_else(|| env::var_os("USERPROFILE")) {
3✔
129
    vec![PathBuf::from(home).join(".kube").join("config")]
3✔
130
  } else {
131
    vec![]
×
132
  }
133
}
5✔
134

135
/// Start a file watcher thread for kubeconfig files. Sends `Event::KubeConfigChange`
136
/// on the provided channel when any watched file is modified.
137
fn start_kubeconfig_watcher(tx: mpsc::Sender<Event<KeyEvent, MouseEvent>>) {
2✔
138
  let paths = kubeconfig_watch_paths();
2✔
139
  if paths.is_empty() {
2✔
140
    info!("No kubeconfig paths to watch");
×
141
    return;
×
142
  }
2✔
143

144
  thread::spawn(move || {
2✔
145
    let (notify_tx, notify_rx) = mpsc::channel();
2✔
146
    let mut watcher = match notify::recommended_watcher(move |res| {
2✔
147
      let _ = notify_tx.send(res);
×
148
    }) {
×
149
      Ok(w) => w,
2✔
150
      Err(e) => {
×
151
        warn!("Failed to create kubeconfig file watcher: {}", e);
×
152
        return;
×
153
      }
154
    };
155

156
    // Collect the canonical file names we care about, and watch their
157
    // parent directories instead of the files themselves. This handles
158
    // atomic saves (tmp + rename) that tools like kubectl perform, which
159
    // would otherwise invalidate a direct file watch.
160
    let mut watched_dirs = std::collections::HashSet::new();
2✔
161
    let mut target_filenames = std::collections::HashSet::new();
2✔
162
    for path in &paths {
2✔
163
      if let Some(filename) = path.file_name() {
2✔
164
        target_filenames.insert(filename.to_os_string());
2✔
165
      }
2✔
166
      let dir = if let Some(parent) = path.parent() {
2✔
167
        if parent.exists() {
2✔
168
          parent.to_path_buf()
×
169
        } else {
170
          continue;
2✔
171
        }
172
      } else {
173
        continue;
×
174
      };
175
      if watched_dirs.insert(dir.clone()) {
×
176
        if let Err(e) = watcher.watch(&dir, RecursiveMode::NonRecursive) {
×
177
          warn!("Failed to watch {:?}: {}", dir, e);
×
178
        } else {
179
          info!("Watching kubeconfig directory: {:?}", dir);
×
180
        }
181
      }
×
182
    }
183

184
    // Debounce: ignore rapid successive events (editors do multiple writes)
185
    let debounce = Duration::from_secs(2);
2✔
186
    let mut last_sent = Instant::now() - debounce;
2✔
187

188
    for res in notify_rx {
2✔
189
      match res {
×
190
        Ok(event) => {
×
191
          // Only react to events that touch our target kubeconfig files
192
          let dominated = event
×
193
            .paths
×
194
            .iter()
×
195
            .any(|p| p.file_name().is_some_and(|f| target_filenames.contains(f)));
×
196
          if !dominated {
×
197
            continue;
×
198
          }
×
199
          if last_sent.elapsed() >= debounce {
×
200
            info!("Kubeconfig file change detected: {:?}", event.kind);
×
201
            if tx.send(Event::KubeConfigChange).is_err() {
×
202
              break; // receiver dropped, app is shutting down
×
203
            }
×
204
            last_sent = Instant::now();
×
205
          }
×
206
        }
207
        Err(e) => {
×
208
          warn!("Kubeconfig watcher error: {:?}", e);
×
209
        }
210
      }
211
    }
212
  });
2✔
213
}
2✔
214

215
#[cfg(target_os = "windows")]
216
fn handle_key_event(event_tx: &mpsc::Sender<Event<KeyEvent, MouseEvent>>, key_event: KeyEvent) {
217
  if key_event.kind == event::KeyEventKind::Press {
218
    let _ = event_tx.send(Event::Input(key_event));
219
  }
220
}
221

222
#[cfg(not(target_os = "windows"))]
223
fn handle_key_event(event_tx: &mpsc::Sender<Event<KeyEvent, MouseEvent>>, key_event: KeyEvent) {
1✔
224
  let _ = event_tx.send(Event::Input(key_event));
1✔
225
}
1✔
226

227
#[cfg(test)]
228
mod tests {
229
  use std::sync::{LazyLock, Mutex};
230

231
  use super::*;
232

233
  static KUBECONFIG_ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
3✔
234

235
  #[test]
236
  fn test_events_produces_tick() {
1✔
237
    // Events should produce at least one Tick within a reasonable time
238
    let events = Events::new(50); // 50ms tick rate
1✔
239
    match events.next() {
1✔
240
      Ok(Event::Tick) => {}             // expected
1✔
241
      Ok(Event::Input(_)) => {}         // possible if terminal sends something
×
242
      Ok(Event::MouseInput(_)) => {}    // possible
×
243
      Ok(Event::KubeConfigChange) => {} // possible if kubeconfig watcher fires
×
244
      Err(e) => panic!("Events::next() returned error: {:?}", e),
×
245
    }
246
  }
1✔
247

248
  #[test]
249
  fn test_try_next_returns_none_when_empty() {
1✔
250
    // try_next should return None when there are no pending events
251
    let (tx, rx) = mpsc::channel();
1✔
252
    let events = Events { rx, _tx: tx };
1✔
253
    assert!(events.try_next().is_none());
1✔
254
  }
1✔
255

256
  #[test]
257
  fn test_events_receiver_drop_stops_sender() {
1✔
258
    // Create events, then drop the Events struct — the sender thread should exit gracefully
259
    let events = Events::new(50);
1✔
260
    // Get one event to ensure the thread is running
261
    let _ = events.next();
1✔
262
    // Drop events — the thread should detect the receiver is gone and break
263
    drop(events);
1✔
264
    // If this test completes without hanging, the thread exited properly
265
  }
1✔
266

267
  #[test]
268
  fn test_handle_key_event_send_failure() {
1✔
269
    // When the receiver is dropped, handle_key_event should not panic
270
    let (tx, rx) = mpsc::channel();
1✔
271
    drop(rx); // drop receiver immediately
1✔
272
    let key_event = KeyEvent::new(
1✔
273
      crossterm::event::KeyCode::Char('a'),
1✔
274
      crossterm::event::KeyModifiers::NONE,
275
    );
276
    // This should not panic — uses `let _ = send()`
277
    handle_key_event(&tx, key_event);
1✔
278
  }
1✔
279

280
  #[test]
281
  fn test_event_config_default() {
1✔
282
    let config = EventConfig::default();
1✔
283
    assert_eq!(config.tick_rate, std::time::Duration::from_millis(250));
1✔
284
  }
1✔
285

286
  #[test]
287
  fn test_kubeconfig_watch_paths_default() {
1✔
288
    let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap();
1✔
289
    // When KUBECONFIG is not set, should return ~/.kube/config
290
    let original = env::var_os("KUBECONFIG");
1✔
291
    env::remove_var("KUBECONFIG");
1✔
292

293
    let paths = kubeconfig_watch_paths();
1✔
294

295
    // Restore
296
    if let Some(val) = original {
1✔
297
      env::set_var("KUBECONFIG", val);
×
298
    }
1✔
299

300
    assert_eq!(paths.len(), 1);
1✔
301
    assert!(paths[0].ends_with(".kube/config"));
1✔
302
  }
1✔
303

304
  #[test]
305
  fn test_kubeconfig_watch_paths_from_env() {
1✔
306
    let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap();
1✔
307
    let original = env::var_os("KUBECONFIG");
1✔
308
    // Use env::join_paths for cross-platform separator (: on Unix, ; on Windows)
309
    let joined = env::join_paths(["/tmp/a", "/tmp/b"]).unwrap();
1✔
310
    env::set_var("KUBECONFIG", &joined);
1✔
311

312
    let paths = kubeconfig_watch_paths();
1✔
313

314
    // Restore
315
    match original {
1✔
316
      Some(val) => env::set_var("KUBECONFIG", val),
×
317
      None => env::remove_var("KUBECONFIG"),
1✔
318
    }
319

320
    assert_eq!(paths.len(), 2);
1✔
321
    assert_eq!(paths[0], PathBuf::from("/tmp/a"));
1✔
322
    assert_eq!(paths[1], PathBuf::from("/tmp/b"));
1✔
323
  }
1✔
324

325
  #[test]
326
  fn test_kubeconfig_watch_paths_ignores_empty_segments() {
1✔
327
    let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap();
1✔
328
    let original = env::var_os("KUBECONFIG");
1✔
329
    // Use env::join_paths and include empty segments
330
    let joined = env::join_paths(["/tmp/a", "", "/tmp/b", ""]).unwrap();
1✔
331
    env::set_var("KUBECONFIG", &joined);
1✔
332

333
    let paths = kubeconfig_watch_paths();
1✔
334

335
    match original {
1✔
336
      Some(val) => env::set_var("KUBECONFIG", val),
×
337
      None => env::remove_var("KUBECONFIG"),
1✔
338
    }
339

340
    assert_eq!(paths.len(), 2);
1✔
341
    assert_eq!(paths[0], PathBuf::from("/tmp/a"));
1✔
342
    assert_eq!(paths[1], PathBuf::from("/tmp/b"));
1✔
343
  }
1✔
344

345
  #[test]
346
  fn test_start_kubeconfig_watcher_sends_event_on_file_change() {
1✔
347
    use std::fs;
348

349
    let dir = env::temp_dir().join(format!("kdash-watcher-test-{}", std::process::id()));
1✔
350
    fs::create_dir_all(&dir).unwrap();
1✔
351
    let config_file = dir.join("config");
1✔
352
    fs::write(&config_file, "initial").unwrap();
1✔
353

354
    let (tx, rx) = mpsc::channel::<Event<KeyEvent, MouseEvent>>();
1✔
355

356
    // Manually set up a watcher on our test file
357
    let watch_tx = tx.clone();
1✔
358
    let config_path = config_file.clone();
1✔
359
    thread::spawn(move || {
1✔
360
      let (notify_tx, notify_rx) = mpsc::channel();
1✔
361
      let mut watcher = notify::recommended_watcher(move |res| {
3✔
362
        let _ = notify_tx.send(res);
3✔
363
      })
3✔
364
      .unwrap();
1✔
365
      watcher
1✔
366
        .watch(&config_path, RecursiveMode::NonRecursive)
1✔
367
        .unwrap();
1✔
368
      for res in notify_rx {
1✔
369
        if res.is_ok() {
1✔
370
          let _ = watch_tx.send(Event::KubeConfigChange);
1✔
371
          break; // one event is enough for the test
1✔
372
        }
×
373
      }
374
    });
1✔
375

376
    // Give the watcher time to start
377
    thread::sleep(Duration::from_millis(200));
1✔
378

379
    // Modify the file
380
    fs::write(&config_file, "modified").unwrap();
1✔
381

382
    // Should receive the event within a reasonable time
383
    match rx.recv_timeout(Duration::from_secs(5)) {
1✔
384
      Ok(Event::KubeConfigChange) => {} // expected
1✔
385
      other => panic!("Expected KubeConfigChange, got: {:?}", other.is_ok()),
×
386
    }
387

388
    fs::remove_dir_all(dir).unwrap();
1✔
389
  }
1✔
390
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc