lines/src/custom.rs
SW 498803fc55 feat(custom): escape variables so they expand on remote host
feat(ui): show command in global search results

chore: bump version to 0.1.19
2026-07-16 12:21:09 +02:00

182 lines
5.1 KiB
Rust

use crate::app::CustomState;
pub fn build_commands(state: &CustomState) -> Vec<String> {
let command = state.command.trim();
let pre_command = state.pre_command.trim();
if command.is_empty() && pre_command.is_empty() {
return Vec::new();
}
let selected = state.selected_lines();
if selected.is_empty() {
return Vec::new();
}
let prefix = if pre_command.is_empty() {
String::new()
} else {
format!("{} ", pre_command)
};
selected
.iter()
.map(|line| {
let line = line.trim();
let built = if command.is_empty() {
line.to_string()
} else if command.contains("{}") {
escape_dollars(&command.replace("{}", line))
} else {
let escaped = escape_dollars(command).replace('"', "\\\"");
format!("{} \"{}\"", line, escaped)
};
format!("{}{}", prefix, built)
})
.collect()
}
/// Escape `$` as `\$` unless it is already escaped, so that variables are
/// expanded by the remote shell rather than the local one when the built
/// command is passed to `ssh`.
fn escape_dollars(command: &str) -> String {
let mut result = String::with_capacity(command.len());
let mut prev_backslash = false;
for c in command.chars() {
if c == '$' && !prev_backslash {
result.push('\\');
}
result.push(c);
prev_backslash = c == '\\' && !prev_backslash;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{CustomLine, CustomState};
fn make_state(lines: Vec<&str>, selected: Vec<bool>, command: &str, pre_command: &str) -> CustomState {
let mut list_state = ratatui::widgets::ListState::default();
list_state.select(Some(0));
CustomState {
lines: lines
.into_iter()
.map(|c| CustomLine {
section: "test".to_string(),
command: c.to_string(),
})
.collect(),
selected,
cursor: 0,
focus: crate::app::CustomFocus::Lines,
command: command.to_string(),
pre_command: pre_command.to_string(),
list_state,
}
}
#[test]
fn test_build_commands_append() {
let state = make_state(
vec!["ssh root@ip1", "ssh root@ip2", "ssh root@ip3"],
vec![false, true, true],
"lsblk",
"",
);
assert_eq!(
build_commands(&state),
vec!["ssh root@ip2 \"lsblk\"", "ssh root@ip3 \"lsblk\""]
);
}
#[test]
fn test_build_commands_placeholder() {
let state = make_state(vec!["root@ip1", "root@ip2"], vec![true, true], "ssh {} lsblk", "");
assert_eq!(
build_commands(&state),
vec!["ssh root@ip1 lsblk", "ssh root@ip2 lsblk"]
);
}
#[test]
fn test_build_commands_empty_selection() {
let state = make_state(vec!["ssh root@ip1"], vec![false], "lsblk", "");
assert!(build_commands(&state).is_empty());
}
#[test]
fn test_build_commands_pre_command() {
let state = make_state(
vec!["ssh root@ip1", "ssh root@ip2"],
vec![true, true],
"ping",
"lview",
);
assert_eq!(
build_commands(&state),
vec![
"lview ssh root@ip1 \"ping\"",
"lview ssh root@ip2 \"ping\""
]
);
}
#[test]
fn test_build_commands_pre_command_placeholder() {
let state = make_state(
vec!["root@ip1", "root@ip2"],
vec![true, true],
"ssh {} lsblk",
"lview",
);
assert_eq!(
build_commands(&state),
vec!["lview ssh root@ip1 lsblk", "lview ssh root@ip2 lsblk"]
);
}
#[test]
fn test_build_commands_pre_command_only() {
let state = make_state(vec!["ssh root@ip1", "ssh root@ip2"], vec![false, true], "", "lview");
assert_eq!(build_commands(&state), vec!["lview ssh root@ip2"]);
}
#[test]
fn test_build_commands_escapes_dollar_variables() {
let state = make_state(
vec!["ssh root@ip1"],
vec![true],
"echo $HOME && echo $USER",
"",
);
assert_eq!(
build_commands(&state),
vec!["ssh root@ip1 \"echo \\$HOME && echo \\$USER\""]
);
}
#[test]
fn test_build_commands_preserves_already_escaped_dollars() {
let state = make_state(
vec!["ssh root@ip1"],
vec![true],
"echo \\$HOME",
"",
);
assert_eq!(
build_commands(&state),
vec!["ssh root@ip1 \"echo \\$HOME\""]
);
}
#[test]
fn test_build_commands_placeholder_escapes_dollars() {
let state = make_state(vec!["root@ip1"], vec![true], "ssh {} echo $HOSTNAME", "");
assert_eq!(
build_commands(&state),
vec!["ssh root@ip1 echo \\$HOSTNAME"]
);
}
}