) {
self.frames.push(ite);
}
@@ -33,11 +35,7 @@ pub trait CsvImporter {
fn load(&mut self, reader: R) -> anyhow::Result>;
}
-pub fn write_csv(
- recording: &Recording,
- exporter: &E,
- writer: W,
-) -> anyhow::Result<()>
+pub fn write_csv(recording: &Recording, exporter: &E, writer: W) -> anyhow::Result<()>
where
E: CsvExporter,
W: std::io::Write,
diff --git a/src/serial_core/serial.rs b/src/serial_core/serial.rs
index 4b9a90e..bb302fc 100644
--- a/src/serial_core/serial.rs
+++ b/src/serial_core/serial.rs
@@ -155,8 +155,6 @@ where
});
let mut buffer = [0u8; 1024];
- let mut prune_interval = time::interval(Duration::from_millis(450));
- prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
tokio::select! {
@@ -193,17 +191,21 @@ where
r.on_rx_frame(&frame);
}
- let decode_res = handler
+ let _decoded_values = handler
.on_frame(&frame)
.await?
.map(|vals| vals.into_iter().map(Into::into).collect::>());
- let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
- record.push(RecordedFrame{
- timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() },
+ let mut record = recording
+ .lock()
+ .map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
+ record.push(RecordedFrame {
+ timing: FrameTiming {
+ pts_ms: None,
+ dts_ms: frame.dts_ms(),
+ },
frame: frame.clone(),
});
-
}
}
}
diff --git a/src/serial_core/utils.rs b/src/serial_core/utils.rs
index f5b2542..15eff52 100644
--- a/src/serial_core/utils.rs
+++ b/src/serial_core/utils.rs
@@ -1,6 +1,9 @@
-
use std::time::Instant;
+use tokio_serial::available_ports;
+
+use crate::serial_core::error::SerialError;
+
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
(n as u16).to_be_bytes()
}
@@ -33,6 +36,16 @@ pub fn elapsed_millis(start_at: Instant) -> u64 {
start_at.elapsed().as_millis() as u64
}
+pub fn serial_enum() -> Result, SerialError> {
+ let ports = available_ports()
+ .map_err(|_| SerialError::ScanError)?
+ .into_iter()
+ .map(|info| info.port_name)
+ .collect();
+
+ Ok(ports)
+}
+
#[cfg(test)]
mod test {
use anyhow::Ok;
@@ -41,7 +54,9 @@ mod test {
#[test]
fn test_crc8_itu() -> anyhow::Result<()> {
- let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00];
+ let req_vec = vec![
+ 0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
+ ];
let checksum = calc_crc8_itu(req_vec.as_slice());
assert_eq!(checksum, 0x7A);
@@ -50,10 +65,12 @@ mod test {
#[test]
fn test_crc8_smbus() -> anyhow::Result<()> {
- let req_vec = vec![0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00];
+ let req_vec = vec![
+ 0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
+ ];
let checksum = calc_crc8_smbus(req_vec.as_slice());
assert_eq!(checksum, 0x2F);
Ok(())
}
-}
\ No newline at end of file
+}
diff --git a/src/tui.rs b/src/tui.rs
new file mode 100644
index 0000000..d55c249
--- /dev/null
+++ b/src/tui.rs
@@ -0,0 +1,706 @@
+use std::{fs, sync::Arc, time::Duration};
+
+use anyhow::{Result, anyhow};
+use crossterm::event::{self, Event};
+use ratatui::{
+ DefaultTerminal, Frame,
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::Line,
+ widgets::{Block, Borders, Paragraph, Wrap},
+};
+use ratatui_textarea::{CursorMove, DataCursor, Input, Key, TextArea};
+
+use crate::{
+ app::{SerialConnectionState, shutdown_all_sessions},
+ cmd,
+ serial_core::utils::serial_enum,
+};
+
+const APP_POLL_INTERVAL_MS: u64 = 250;
+const MAX_COMMAND_LINES: usize = 512;
+const COMMAND_INPUT_TITLE: &str = "Command Input [Enter=run Tab=complete]";
+const COMPLETION_PREVIEW_LIMIT: usize = 4;
+const COMMAND_COMPLETIONS: &[&str] = &[
+ "/help", "/scan", "/status", "/open", "/close", "/export", "/set", "/echo", "/exit", "/quit",
+];
+const SETTING_COMPLETIONS: &[&str] = &["export"];
+
+pub async fn run() -> Result<()> {
+ let serial_state = Arc::new(SerialConnectionState::new());
+ let mut terminal = ratatui::init();
+ let run_result = {
+ let mut app = TuiApp::new(Arc::clone(&serial_state));
+ app.run(&mut terminal).await
+ };
+ let shutdown_result = shutdown_all_sessions(&serial_state).await;
+ ratatui::restore();
+
+ run_result?;
+ shutdown_result.map_err(|err| anyhow!("failed to close active serial sessions: {err}"))?;
+ Ok(())
+}
+
+struct TuiApp {
+ should_quit: bool,
+ command_output: Vec,
+ command_input: TextArea<'static>,
+ serial_state: Arc,
+ completion_hint: Option,
+ completion_cycle: Option,
+}
+
+impl TuiApp {
+ fn new(serial_state: Arc) -> Self {
+ let mut app = Self {
+ should_quit: false,
+ command_output: Vec::new(),
+ command_input: new_command_input(),
+ serial_state,
+ completion_hint: None,
+ completion_cycle: None,
+ };
+
+ app.push_command_lines([
+ "JE-Skin CLI TUI".to_string(),
+ "Streaming serial text has been disabled to keep the terminal responsive.".to_string(),
+ "Use /scan, /open , /status, /export , /set export , /close , /close, /exit.".to_string(),
+ "The right pane now shows active collectors only.".to_string(),
+ "Press Tab to autocomplete commands and paths.".to_string(),
+ ]);
+ app.refresh_input_block();
+
+ app
+ }
+
+ async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
+ while !self.should_quit {
+ terminal.draw(|frame| self.draw(frame))?;
+
+ if event::poll(Duration::from_millis(APP_POLL_INTERVAL_MS))? {
+ match event::read()? {
+ Event::Key(key_event) => self.handle_key(Input::from(key_event)).await?,
+ Event::Resize(_, _) => {}
+ _ => {}
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ async fn handle_key(&mut self, input: Input) -> Result<()> {
+ match input {
+ Input { key: Key::Esc, .. }
+ | Input {
+ key: Key::Char('c'),
+ ctrl: true,
+ ..
+ } => {
+ self.should_quit = true;
+ }
+ Input {
+ key: Key::Enter, ..
+ } => {
+ self.invalidate_completion();
+ self.submit_command().await?;
+ }
+ Input { key: Key::Tab, .. } => {
+ self.complete_input();
+ }
+ Input { key: Key::Null, .. } => {}
+ other => {
+ self.invalidate_completion();
+ self.command_input.input(other);
+ }
+ }
+
+ Ok(())
+ }
+
+ async fn submit_command(&mut self) -> Result<()> {
+ let raw_command = self.command_input.lines().join("\n");
+ let command = raw_command.trim().to_string();
+ self.command_input = new_command_input();
+
+ if command.is_empty() {
+ return Ok(());
+ }
+
+ self.push_command_line(format!("> {command}"));
+ let response = cmd::execute_input(&command, Arc::clone(&self.serial_state)).await;
+ self.push_command_lines(response.lines);
+ self.should_quit |= response.should_exit;
+
+ Ok(())
+ }
+
+ fn complete_input(&mut self) {
+ if self.apply_cycle_completion() {
+ return;
+ }
+
+ let line = self.current_input_line();
+ let DataCursor(_, cursor_col) = self.command_input.cursor();
+ let Some(request) = build_completion_request(&line, cursor_col) else {
+ self.set_completion_hint(Some("No completion available".to_string()));
+ return;
+ };
+
+ let candidates = self.completion_candidates(&request);
+ if candidates.is_empty() {
+ self.set_completion_hint(Some("No completion matches".to_string()));
+ return;
+ }
+
+ if candidates.len() == 1 {
+ let replacement = finalize_unique_completion(&request, &candidates[0], &line);
+ self.replace_input_range(request.start, request.end, &replacement);
+ self.invalidate_completion();
+ return;
+ }
+
+ let common_prefix = longest_common_prefix(&candidates);
+ let common_prefix_len = common_prefix.chars().count();
+ let token_len = request.token.chars().count();
+ let preview = completion_preview(&candidates);
+
+ if common_prefix_len > token_len {
+ self.replace_input_range(request.start, request.end, &common_prefix);
+ self.completion_cycle = Some(CompletionCycle::new(
+ request.start,
+ request.start + common_prefix_len,
+ candidates,
+ ));
+ self.set_completion_hint(Some(format!("Matches: {preview}")));
+ return;
+ }
+
+ self.completion_cycle = Some(CompletionCycle::new(request.start, request.end, candidates));
+ self.set_completion_hint(Some(format!("Tab again to cycle: {preview}")));
+ }
+
+ fn apply_cycle_completion(&mut self) -> bool {
+ let Some(mut cycle) = self.completion_cycle.take() else {
+ return false;
+ };
+
+ if cycle.candidates.is_empty() {
+ return false;
+ }
+
+ let candidate_index = cycle.next_index % cycle.candidates.len();
+ let candidate = cycle.candidates[candidate_index].clone();
+ let new_end = cycle.start + candidate.chars().count();
+ self.replace_input_range(cycle.start, cycle.end, &candidate);
+ cycle.end = new_end;
+ cycle.next_index = candidate_index + 1;
+ let preview = completion_preview(&cycle.candidates);
+ self.completion_cycle = Some(cycle);
+ self.set_completion_hint(Some(format!("Cycling: {preview}")));
+ true
+ }
+
+ fn completion_candidates(&self, request: &CompletionRequest) -> Vec {
+ let mut candidates = match &request.kind {
+ CompletionKind::Command => command_completion_candidates(&request.token),
+ CompletionKind::Setting => setting_completion_candidates(&request.token),
+ CompletionKind::SerialPort { command_name } => {
+ serial_port_completion_candidates(command_name, &request.token, &self.serial_state)
+ }
+ CompletionKind::FileSystemPath => filesystem_path_candidates(&request.token),
+ };
+
+ candidates.sort();
+ candidates.dedup();
+ candidates
+ }
+
+ fn push_command_line(&mut self, line: impl Into) {
+ self.command_output.push(line.into());
+ trim_lines(&mut self.command_output, MAX_COMMAND_LINES);
+ }
+
+ fn push_command_lines(&mut self, lines: I)
+ where
+ I: IntoIterator- ,
+ {
+ for line in lines {
+ self.push_command_line(line);
+ }
+ }
+
+ fn draw(&self, frame: &mut Frame) {
+ let columns = Layout::default()
+ .direction(Direction::Horizontal)
+ .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
+ .split(frame.area());
+
+ let left = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Min(6), Constraint::Length(3)])
+ .split(columns[0]);
+
+ let left_title = Line::styled(
+ "Command Console",
+ Style::default()
+ .fg(Color::Cyan)
+ .add_modifier(Modifier::BOLD),
+ );
+ let left_output = Paragraph::new(render_lines(
+ &self.command_output,
+ "No command output yet. Type /help.",
+ ))
+ .block(Block::default().borders(Borders::ALL).title(left_title))
+ .wrap(Wrap { trim: false })
+ .scroll((scroll_offset(left[0], self.command_output.len()), 0));
+ frame.render_widget(left_output, left[0]);
+
+ frame.render_widget(&self.command_input, left[1]);
+
+ let collector_lines = self
+ .serial_state
+ .collector_lines()
+ .unwrap_or_else(|err| vec![format!("Read collector status failed: {err}")]);
+ let collector_count = self
+ .serial_state
+ .active_ports()
+ .map(|ports| ports.len())
+ .unwrap_or(0);
+ let right_title = Line::styled(
+ format!("Collectors [{collector_count}]"),
+ Style::default()
+ .fg(Color::Green)
+ .add_modifier(Modifier::BOLD),
+ );
+ let right_output = Paragraph::new(render_lines(
+ &collector_lines,
+ "No active serial collectors.",
+ ))
+ .block(Block::default().borders(Borders::ALL).title(right_title))
+ .wrap(Wrap { trim: false })
+ .scroll((scroll_offset(columns[1], collector_lines.len()), 0));
+ frame.render_widget(right_output, columns[1]);
+ }
+
+ fn invalidate_completion(&mut self) {
+ self.completion_hint = None;
+ self.completion_cycle = None;
+ self.refresh_input_block();
+ }
+
+ fn set_completion_hint(&mut self, hint: Option) {
+ self.completion_hint = hint;
+ self.refresh_input_block();
+ }
+
+ fn refresh_input_block(&mut self) {
+ let title = match self.completion_hint.as_deref() {
+ Some(hint) => format!("{COMMAND_INPUT_TITLE} | {hint}"),
+ None => COMMAND_INPUT_TITLE.to_string(),
+ };
+ self.command_input
+ .set_block(Block::default().borders(Borders::ALL).title(title));
+ }
+
+ fn current_input_line(&self) -> String {
+ self.command_input
+ .lines()
+ .first()
+ .cloned()
+ .unwrap_or_default()
+ }
+
+ fn replace_input_range(&mut self, start: usize, end: usize, replacement: &str) {
+ let line = self.current_input_line();
+ let start_byte = char_to_byte_index(&line, start);
+ let end_byte = char_to_byte_index(&line, end);
+ let new_line = format!(
+ "{}{}{}",
+ &line[..start_byte],
+ replacement,
+ &line[end_byte..]
+ );
+ let cursor_col = start + replacement.chars().count();
+
+ self.command_input.clear();
+ self.command_input.insert_str(&new_line);
+ self.command_input.move_cursor(CursorMove::Jump(
+ 0,
+ cursor_col.min(u16::MAX as usize) as u16,
+ ));
+ self.refresh_input_block();
+ }
+}
+
+fn new_command_input() -> TextArea<'static> {
+ let mut input = TextArea::default();
+ input.set_cursor_line_style(Style::default());
+ input.set_style(Style::default().fg(Color::White));
+ input.set_placeholder_text(
+ "/scan | /open /dev/ttyUSB0 | /export /dev/ttyUSB0 | /set export ./exports",
+ );
+ input
+}
+
+#[derive(Debug, Clone)]
+struct CompletionCycle {
+ start: usize,
+ end: usize,
+ candidates: Vec,
+ next_index: usize,
+}
+
+impl CompletionCycle {
+ fn new(start: usize, end: usize, candidates: Vec) -> Self {
+ Self {
+ start,
+ end,
+ candidates,
+ next_index: 0,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum CompletionKind {
+ Command,
+ Setting,
+ SerialPort { command_name: String },
+ FileSystemPath,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct CompletionRequest {
+ kind: CompletionKind,
+ start: usize,
+ end: usize,
+ token: String,
+}
+
+fn build_completion_request(line: &str, cursor_col: usize) -> Option {
+ if !line.starts_with('/') {
+ return None;
+ }
+
+ let char_len = line.chars().count();
+ let cursor_col = cursor_col.min(char_len);
+ let (start, end) = token_bounds(line, cursor_col);
+ let token = slice_by_chars(line, start, end).to_string();
+
+ if start == 0 {
+ return Some(CompletionRequest {
+ kind: CompletionKind::Command,
+ start,
+ end,
+ token,
+ });
+ }
+
+ let command_name = line.split_whitespace().next()?.to_string();
+ let token_prefix = &line[..char_to_byte_index(line, start)];
+ let token_position = token_prefix.split_whitespace().count();
+
+ if command_name == "/set" {
+ if token_position == 1 {
+ return Some(CompletionRequest {
+ kind: CompletionKind::Setting,
+ start,
+ end,
+ token,
+ });
+ }
+
+ let mut parts = line.split_whitespace();
+ let _ = parts.next();
+ let setting_name = parts.next().unwrap_or_default();
+ if setting_name == "export" {
+ return Some(CompletionRequest {
+ kind: CompletionKind::FileSystemPath,
+ start,
+ end,
+ token,
+ });
+ }
+ }
+
+ if matches!(command_name.as_str(), "/open" | "/close" | "/export") {
+ return Some(CompletionRequest {
+ kind: CompletionKind::SerialPort { command_name },
+ start,
+ end,
+ token,
+ });
+ }
+
+ None
+}
+
+fn command_completion_candidates(prefix: &str) -> Vec {
+ COMMAND_COMPLETIONS
+ .iter()
+ .filter(|command| command.starts_with(prefix))
+ .map(|command| (*command).to_string())
+ .collect()
+}
+
+fn setting_completion_candidates(prefix: &str) -> Vec {
+ SETTING_COMPLETIONS
+ .iter()
+ .filter(|setting| setting.starts_with(prefix))
+ .map(|setting| (*setting).to_string())
+ .collect()
+}
+
+fn serial_port_completion_candidates(
+ command_name: &str,
+ prefix: &str,
+ state: &SerialConnectionState,
+) -> Vec {
+ match command_name {
+ "/open" => {
+ let mut candidates = serial_enum()
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|port| port.starts_with(prefix))
+ .collect::>();
+ candidates.extend(filesystem_path_candidates(prefix));
+ candidates
+ }
+ "/close" => state
+ .active_ports()
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|port| port.starts_with(prefix))
+ .collect(),
+ "/export" => state
+ .exportable_ports()
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|port| port.starts_with(prefix))
+ .collect(),
+ _ => Vec::new(),
+ }
+}
+
+fn filesystem_path_candidates(prefix: &str) -> Vec {
+ if prefix.is_empty() {
+ return Vec::new();
+ }
+
+ let (search_dir, dir_prefix, name_prefix, separator) = filesystem_search_parts(prefix);
+ let entries = match fs::read_dir(&search_dir) {
+ Ok(entries) => entries,
+ Err(_) => return Vec::new(),
+ };
+
+ let mut candidates = Vec::new();
+ for entry in entries.flatten() {
+ let file_name = entry.file_name();
+ let file_name = file_name.to_string_lossy();
+ if !file_name.starts_with(name_prefix) {
+ continue;
+ }
+
+ let mut candidate = format!("{dir_prefix}{file_name}");
+ if entry.path().is_dir() {
+ candidate.push(separator);
+ }
+ candidates.push(candidate);
+ }
+
+ candidates.sort();
+ candidates
+}
+
+fn filesystem_search_parts(prefix: &str) -> (String, String, &str, char) {
+ let separator = if prefix.contains('\\') && !prefix.contains('/') {
+ '\\'
+ } else {
+ '/'
+ };
+
+ match prefix.rfind(['/', '\\']) {
+ Some(index) => {
+ let dir_prefix = &prefix[..=index];
+ let search_dir = if dir_prefix.is_empty() {
+ ".".to_string()
+ } else {
+ dir_prefix.to_string()
+ };
+ let name_prefix = &prefix[index + 1..];
+ (search_dir, dir_prefix.to_string(), name_prefix, separator)
+ }
+ None => (".".to_string(), String::new(), prefix, separator),
+ }
+}
+
+fn finalize_unique_completion(request: &CompletionRequest, candidate: &str, line: &str) -> String {
+ match &request.kind {
+ CompletionKind::Command => {
+ if command_takes_argument(candidate) && request.end == line.chars().count() {
+ format!("{candidate} ")
+ } else {
+ candidate.to_string()
+ }
+ }
+ CompletionKind::Setting => {
+ if request.end == line.chars().count() {
+ format!("{candidate} ")
+ } else {
+ candidate.to_string()
+ }
+ }
+ CompletionKind::SerialPort { .. } => candidate.to_string(),
+ CompletionKind::FileSystemPath => {
+ if candidate.ends_with('/') || request.end < line.chars().count() {
+ candidate.to_string()
+ } else {
+ format!("{candidate} ")
+ }
+ }
+ }
+}
+
+fn command_takes_argument(command: &str) -> bool {
+ matches!(command, "/open" | "/close" | "/export" | "/set" | "/echo")
+}
+
+fn completion_preview(candidates: &[String]) -> String {
+ let preview = candidates
+ .iter()
+ .take(COMPLETION_PREVIEW_LIMIT)
+ .cloned()
+ .collect::>()
+ .join(", ");
+ if candidates.len() > COMPLETION_PREVIEW_LIMIT {
+ format!("{preview}, ...")
+ } else {
+ preview
+ }
+}
+
+fn longest_common_prefix(items: &[String]) -> String {
+ let Some(first) = items.first() else {
+ return String::new();
+ };
+
+ let mut prefix = first.clone();
+ while !prefix.is_empty() {
+ if items.iter().all(|item| item.starts_with(&prefix)) {
+ return prefix;
+ }
+ prefix.pop();
+ }
+
+ String::new()
+}
+
+fn token_bounds(line: &str, cursor_col: usize) -> (usize, usize) {
+ let chars = line.chars().collect::>();
+ let char_len = chars.len();
+ let cursor = cursor_col.min(char_len);
+
+ let mut start = cursor;
+ while start > 0 && !chars[start - 1].is_whitespace() {
+ start -= 1;
+ }
+
+ let mut end = cursor;
+ while end < char_len && !chars[end].is_whitespace() {
+ end += 1;
+ }
+
+ (start, end)
+}
+
+fn slice_by_chars(text: &str, start: usize, end: usize) -> &str {
+ let start_byte = char_to_byte_index(text, start);
+ let end_byte = char_to_byte_index(text, end);
+ &text[start_byte..end_byte]
+}
+
+fn char_to_byte_index(text: &str, char_index: usize) -> usize {
+ text.char_indices()
+ .nth(char_index)
+ .map(|(byte_index, _)| byte_index)
+ .unwrap_or(text.len())
+}
+
+fn trim_lines(lines: &mut Vec, max_lines: usize) {
+ if lines.len() > max_lines {
+ let overflow = lines.len() - max_lines;
+ lines.drain(0..overflow);
+ }
+}
+
+fn render_lines(lines: &[String], empty_hint: &str) -> String {
+ if lines.is_empty() {
+ empty_hint.to_string()
+ } else {
+ lines.join("\n")
+ }
+}
+
+fn scroll_offset(area: Rect, line_count: usize) -> u16 {
+ let visible_lines = area.height.saturating_sub(2) as usize;
+ line_count.saturating_sub(visible_lines) as u16
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn build_command_completion_request() {
+ let request = build_completion_request("/op", 3).unwrap();
+ assert_eq!(request.kind, CompletionKind::Command);
+ assert_eq!(request.token, "/op");
+ assert_eq!((request.start, request.end), (0, 3));
+ }
+
+ #[test]
+ fn build_open_path_completion_request() {
+ let line = "/open /dev/ttyU";
+ let request = build_completion_request(line, line.chars().count()).unwrap();
+ assert_eq!(
+ request.kind,
+ CompletionKind::SerialPort {
+ command_name: "/open".to_string()
+ }
+ );
+ assert_eq!(request.token, "/dev/ttyU");
+ }
+
+ #[test]
+ fn build_setting_completion_request() {
+ let line = "/set exp";
+ let request = build_completion_request(line, line.chars().count()).unwrap();
+ assert_eq!(request.kind, CompletionKind::Setting);
+ assert_eq!(request.token, "exp");
+ }
+
+ #[test]
+ fn build_set_export_path_request() {
+ let line = "/set export ./out";
+ let request = build_completion_request(line, line.chars().count()).unwrap();
+ assert_eq!(request.kind, CompletionKind::FileSystemPath);
+ assert_eq!(request.token, "./out");
+ }
+
+ #[test]
+ fn longest_common_prefix_for_paths() {
+ let prefix = longest_common_prefix(&[
+ "/dev/ttyUSB0".to_string(),
+ "/dev/ttyUSB1".to_string(),
+ "/dev/ttyUSB2".to_string(),
+ ]);
+ assert_eq!(prefix, "/dev/ttyUSB");
+ }
+
+ #[test]
+ fn command_completion_candidates_match_prefix() {
+ let candidates = command_completion_candidates("/st");
+ assert_eq!(candidates, vec!["/status".to_string()]);
+ }
+}