use std::{
fs::File,
io,
io::{prelude::*, BufReader, Read},
os::unix::fs::PermissionsExt,
path::Path,
process::{Command, Stdio},
};
use super::types::CommandType;
const SOURCE_EXTENSION: &str = "source";
const SOURCED_SCRIPT_PREFIX: &str = ".";
const SINGLE_QUOTE_ESCAPE_SEQUENCE: &str = r"'\''";
pub fn is_tome_script(path: &Path) -> bool {
let filename = path
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
if filename.starts_with('.') {
return false;
}
if is_source_file(filename) {
return true;
}
is_executable(path)
}
pub fn is_source_file(filename: &str) -> bool {
Path::new(filename)
.extension()
.map_or(false, |ext| ext == SOURCE_EXTENSION)
}
fn is_executable(path: &Path) -> bool {
path.metadata()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
pub fn strip_source_suffix(name: &str) -> &str {
name.strip_suffix(".source").unwrap_or(name)
}
pub fn resolve_source_path(path: &Path) -> Option<std::path::PathBuf> {
if path.is_file() {
return Some(path.to_path_buf());
}
let source_path = path.with_extension(SOURCE_EXTENSION);
if source_path.is_file() {
return Some(source_path);
}
None
}
pub struct Script {
pub help_string: String,
pub path: String,
pub should_source: bool,
pub should_complete: bool,
pub summary_string: String,
}
impl Script {
pub fn load(path: &str) -> io::Result<Script> {
let file = Box::new(File::open(path)?) as Box<dyn Read>;
Ok(Script::load_from_buffer(path.to_owned(), file))
}
pub fn load_from_buffer(path: String, body: Box<dyn Read>) -> Script {
let mut buffer = BufReader::new(body);
let mut should_complete = false;
let should_source = is_source_file(&path);
let mut help_string = String::new();
let mut summary_string = String::new();
let mut line = String::new();
let mut consuming_help = false;
loop {
line.clear();
match buffer.read_line(&mut line) {
Ok(bytes_read) => {
if bytes_read == 0 {
break;
}
}
Err(_) => break,
}
if consuming_help {
if line.starts_with("# END HELP") {
consuming_help = false;
} else if let Some(rest) = line.strip_prefix("# ") {
help_string.push_str(rest);
}
} else if line.starts_with("# COMPLETE") {
should_complete = true;
} else if line.starts_with("# START HELP") {
consuming_help = true;
} else if line.starts_with("# SUMMARY: ") {
summary_string.push_str(&line[11..(line.len() - 1)]);
} else if !line.starts_with("#!") {
break;
}
}
Script {
help_string,
path,
should_complete,
should_source,
summary_string,
}
}
pub fn get_execution_body(
&self,
command_type: CommandType,
shell: &str,
args: &[&String],
) -> Result<String, String> {
match command_type {
CommandType::Completion => {
if !self.should_complete {
return Ok(String::new());
}
let mut command = match self.should_source {
true => Command::new(shell),
false => Command::new(self.path.clone()),
};
if self.should_source {
command.arg(self.path.clone());
}
command.arg("--complete");
let command_output = command.args(args).stdout(Stdio::piped()).output();
match command_output {
Ok(output) => match String::from_utf8(output.stdout) {
Err(error) => Err(format!(
"unable to parse completion results as a utf8 string: {}",
error
)),
Ok(result) => Ok(result),
},
Err(result) => Err(format!("completion called failed: {}", result)),
}
}
CommandType::Execute => {
let prefix: &[&str] = if self.should_source {
&[SOURCED_SCRIPT_PREFIX, &self.path]
} else {
&[&self.path]
};
let escaped_command_string: Vec<String> = prefix
.iter()
.copied()
.chain(args.iter().map(|arg| arg.as_str()))
.map(quote_for_shell)
.collect();
let mut final_command = escaped_command_string.join(" ");
if self.should_source
&& args.is_empty()
&& (shell.ends_with("bash") || shell.ends_with("zsh"))
{
final_command = format!("set --; {}", final_command);
}
Ok(final_command)
}
}
}
}
pub fn quote_for_shell(arg: &str) -> String {
format!("'{}'", arg.replace('\'', SINGLE_QUOTE_ESCAPE_SEQUENCE))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quote_for_shell_plain() {
assert_eq!(quote_for_shell("hello"), "'hello'");
}
#[test]
fn test_quote_for_shell_with_single_quote() {
assert_eq!(
quote_for_shell("I'm the developer"),
r"'I'\''m the developer'"
);
}
#[test]
fn test_quote_for_shell_with_surrounding_single_quotes() {
assert_eq!(quote_for_shell("'hello'"), r"''\''hello'\'''");
}
#[test]
fn test_quote_for_shell_isolated_single_quote() {
assert_eq!(quote_for_shell("'"), r"''\'''");
}
#[test]
fn test_quote_for_shell_with_double_quotes() {
assert_eq!(quote_for_shell("\"hello\""), "'\"hello\"'");
}
#[test]
fn test_quote_for_shell_empty() {
assert_eq!(quote_for_shell(""), "''");
}
#[test]
fn test_quote_for_shell_special_characters() {
assert_eq!(
quote_for_shell("$PATH `rm -rf` \\ \""),
"'$PATH `rm -rf` \\ \"'"
);
}
#[test]
fn test_get_execution_body_escapes_single_quotes() {
let script = Script {
help_string: String::new(),
path: String::from("/path/to/quotes"),
should_complete: false,
should_source: false,
summary_string: String::new(),
};
let arg1 = String::from("13");
let arg2 = String::from("I'm the developer");
let arg3 = String::from("'hello'");
let result =
script.get_execution_body(CommandType::Execute, "bash", &[&arg1, &arg2, &arg3]);
assert_eq!(
result,
Ok(String::from(
r#"'/path/to/quotes' '13' 'I'\''m the developer' ''\''hello'\'''"#
))
);
}
#[test]
fn test_get_execution_body_sourced_escapes_single_quotes() {
let script = Script {
help_string: String::new(),
path: String::from("/path/to/quotes.source"),
should_complete: false,
should_source: true,
summary_string: String::new(),
};
let arg1 = String::from("I'm the developer");
let result = script.get_execution_body(CommandType::Execute, "bash", &[&arg1]);
assert_eq!(
result,
Ok(String::from(
r#"'.' '/path/to/quotes.source' 'I'\''m the developer'"#
))
);
}
}