1use std::{
2 fs::File,
3 io,
4 io::{prelude::*, BufReader, Read},
5 os::unix::fs::PermissionsExt,
6 path::Path,
7 process::{Command, Stdio},
8};
9
10use super::types::CommandType;
11
12const SOURCE_EXTENSION: &str = "source";
13const SOURCED_SCRIPT_PREFIX: &str = ".";
14const SINGLE_QUOTE_ESCAPE_SEQUENCE: &str = r"'\''";
15
16pub fn is_tome_script(path: &Path) -> bool {
18 let filename = path
19 .file_name()
20 .unwrap_or_default()
21 .to_str()
22 .unwrap_or_default();
23 if filename.starts_with('.') {
24 return false;
25 }
26 if is_source_file(filename) {
27 return true;
28 }
29 is_executable(path)
30}
31
32pub fn is_source_file(filename: &str) -> bool {
33 Path::new(filename)
34 .extension()
35 .map_or(false, |ext| ext == SOURCE_EXTENSION)
36}
37
38fn is_executable(path: &Path) -> bool {
39 path.metadata()
40 .map(|m| m.permissions().mode() & 0o111 != 0)
41 .unwrap_or(false)
42}
43
44pub fn strip_source_suffix(name: &str) -> &str {
45 name.strip_suffix(".source").unwrap_or(name)
46}
47
48pub fn resolve_source_path(path: &Path) -> Option<std::path::PathBuf> {
51 if path.is_file() {
52 return Some(path.to_path_buf());
53 }
54 let source_path = path.with_extension(SOURCE_EXTENSION);
55 if source_path.is_file() {
56 return Some(source_path);
57 }
58 None
59}
60
61pub struct Script {
66 pub help_string: String,
67 pub path: String,
71 pub should_source: bool,
74 pub should_complete: bool,
77 pub summary_string: String,
80}
81
82impl Script {
83 pub fn load(path: &str) -> io::Result<Script> {
84 let file = Box::new(File::open(path)?) as Box<dyn Read>;
85 Ok(Script::load_from_buffer(path.to_owned(), file))
86 }
87 pub fn load_from_buffer(path: String, body: Box<dyn Read>) -> Script {
88 let mut buffer = BufReader::new(body);
89 let mut should_complete = false;
90 let should_source = is_source_file(&path);
91 let mut help_string = String::new();
92 let mut summary_string = String::new();
93 let mut line = String::new();
94 let mut consuming_help = false;
95 loop {
96 line.clear();
97 match buffer.read_line(&mut line) {
98 Ok(bytes_read) => {
99 if bytes_read == 0 {
100 break;
101 }
102 }
103 Err(_) => break,
104 }
105 if consuming_help {
106 if line.starts_with("# END HELP") {
107 consuming_help = false;
108 } else if let Some(rest) = line.strip_prefix("# ") {
109 help_string.push_str(rest);
112 }
113 } else if line.starts_with("# COMPLETE") {
114 should_complete = true;
115 } else if line.starts_with("# START HELP") {
116 consuming_help = true;
117 } else if line.starts_with("# SUMMARY: ") {
118 summary_string.push_str(&line[11..(line.len() - 1)]);
120 } else if !line.starts_with("#!") {
121 break;
125 }
126 }
127 Script {
128 help_string,
129 path,
130 should_complete,
131 should_source,
132 summary_string,
133 }
134 }
135
136 pub fn get_execution_body(
139 &self,
140 command_type: CommandType,
141 shell: &str,
142 args: &[&String],
143 ) -> Result<String, String> {
144 match command_type {
145 CommandType::Completion => {
146 if !self.should_complete {
147 return Ok(String::new());
148 }
149 let mut command = match self.should_source {
153 true => Command::new(shell),
154 false => Command::new(self.path.clone()),
155 };
156 if self.should_source {
157 command.arg(self.path.clone());
158 }
159 command.arg("--complete");
160 let command_output = command.args(args).stdout(Stdio::piped()).output();
161 match command_output {
162 Ok(output) => match String::from_utf8(output.stdout) {
163 Err(error) => Err(format!(
164 "unable to parse completion results as a utf8 string: {}",
165 error
166 )),
167 Ok(result) => Ok(result),
168 },
169 Err(result) => Err(format!("completion called failed: {}", result)),
172 }
173 }
174 CommandType::Execute => {
175 let prefix: &[&str] = if self.should_source {
176 &[SOURCED_SCRIPT_PREFIX, &self.path]
177 } else {
178 &[&self.path]
179 };
180 let escaped_command_string: Vec<String> = prefix
181 .iter()
182 .copied()
183 .chain(args.iter().map(|arg| arg.as_str()))
184 .map(quote_for_shell)
185 .collect();
186 let mut final_command = escaped_command_string.join(" ");
187 if self.should_source
190 && args.is_empty()
191 && (shell.ends_with("bash") || shell.ends_with("zsh"))
192 {
193 final_command = format!("set --; {}", final_command);
194 }
195 Ok(final_command)
196 }
197 }
198 }
199}
200
201pub fn quote_for_shell(arg: &str) -> String {
202 format!("'{}'", arg.replace('\'', SINGLE_QUOTE_ESCAPE_SEQUENCE))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn test_quote_for_shell_plain() {
211 assert_eq!(quote_for_shell("hello"), "'hello'");
212 }
213
214 #[test]
215 fn test_quote_for_shell_with_single_quote() {
216 assert_eq!(
217 quote_for_shell("I'm the developer"),
218 r"'I'\''m the developer'"
219 );
220 }
221
222 #[test]
223 fn test_quote_for_shell_with_surrounding_single_quotes() {
224 assert_eq!(quote_for_shell("'hello'"), r"''\''hello'\'''");
225 }
226
227 #[test]
228 fn test_quote_for_shell_isolated_single_quote() {
229 assert_eq!(quote_for_shell("'"), r"''\'''");
230 }
231
232 #[test]
233 fn test_quote_for_shell_with_double_quotes() {
234 assert_eq!(quote_for_shell("\"hello\""), "'\"hello\"'");
235 }
236
237 #[test]
238 fn test_quote_for_shell_empty() {
239 assert_eq!(quote_for_shell(""), "''");
240 }
241
242 #[test]
243 fn test_quote_for_shell_special_characters() {
244 assert_eq!(
245 quote_for_shell("$PATH `rm -rf` \\ \""),
246 "'$PATH `rm -rf` \\ \"'"
247 );
248 }
249
250 #[test]
251 fn test_get_execution_body_escapes_single_quotes() {
252 let script = Script {
253 help_string: String::new(),
254 path: String::from("/path/to/quotes"),
255 should_complete: false,
256 should_source: false,
257 summary_string: String::new(),
258 };
259 let arg1 = String::from("13");
260 let arg2 = String::from("I'm the developer");
261 let arg3 = String::from("'hello'");
262 let result =
263 script.get_execution_body(CommandType::Execute, "bash", &[&arg1, &arg2, &arg3]);
264 assert_eq!(
265 result,
266 Ok(String::from(
267 r#"'/path/to/quotes' '13' 'I'\''m the developer' ''\''hello'\'''"#
268 ))
269 );
270 }
271
272 #[test]
273 fn test_get_execution_body_sourced_escapes_single_quotes() {
274 let script = Script {
275 help_string: String::new(),
276 path: String::from("/path/to/quotes.source"),
277 should_complete: false,
278 should_source: true,
279 summary_string: String::new(),
280 };
281 let arg1 = String::from("I'm the developer");
282 let result = script.get_execution_body(CommandType::Execute, "bash", &[&arg1]);
283 assert_eq!(
284 result,
285 Ok(String::from(
286 r#"'.' '/path/to/quotes.source' 'I'\''m the developer'"#
287 ))
288 );
289 }
290}