1use crate::{
2 hook::ProjectHook,
3 script::{Command, Script, ScriptName},
4};
5use anyhow::{anyhow, Error};
6use indexmap::IndexMap;
7use owo_colors::OwoColorize;
8use serde::{Deserialize, Serialize};
9use std::{
10 fmt::{self, Display},
11 fs::OpenOptions,
12 io::{Read, Write},
13 path::PathBuf,
14 str::FromStr,
15};
16
17#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
18pub struct Project {
19 pub name: ProjectName,
20 #[serde(skip)]
21 pub path: Option<PathBuf>,
22 pub hook: Option<ProjectHook>,
23 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
24 pub scripts: IndexMap<ScriptName, Script>,
25}
26
27impl Project {
28 pub fn new(name: ProjectName, path: Option<PathBuf>) -> Self {
29 Project {
30 name,
31 path,
32 scripts: IndexMap::new(),
33 hook: None,
34 }
35 }
36
37 pub fn populate_names(&mut self) -> Result<(), anyhow::Error> {
38 for (name, script) in &mut self.scripts {
39 script.set_name(name.clone())
40 }
41 Ok(())
42 }
43
44 pub fn scripts(&self) -> &IndexMap<ScriptName, Script> {
45 &self.scripts
46 }
47 pub fn get_script(&self, script_name: &ScriptName) -> Option<&Script> {
48 self.scripts.get(script_name)
49 }
50
51 pub fn remove_script(&mut self, script_name: ScriptName) -> Result<(), anyhow::Error> {
52 match self.scripts.shift_remove(&script_name) {
53 Some(_s) => Ok(()),
54 None => Err(anyhow!("{} was not found.", script_name.to_string().bold())),
55 }
56 }
57
58 pub fn add_script(&mut self, name: ScriptName, script: Script) -> Result<(), anyhow::Error> {
59 if self.scripts.contains_key(&name) {
60 return Err(anyhow!("Script already exists: {}", &name.bold()));
61 }
62 let _ = &self.scripts.insert(name, script);
63 Ok(())
64 }
65
66 pub fn hook(&self) -> &Option<ProjectHook> {
67 &self.hook
68 }
69
70 pub fn set_hook(&mut self, hook: Option<ProjectHook>) -> Result<Option<String>, Error> {
73 match self.validate_hook(&hook) {
74 Ok(_) => {
75 self.hook = hook;
76 match self.get_hook_cmd() {
77 Some(cmd) => Ok(Some(cmd)),
78 None => Ok(None),
79 }
80 }
81 Err(e) => Err(e),
82 }
83 }
84
85 pub fn get_hook_cmd(&self) -> Option<String> {
86 match self.validate_hook(self.hook()) {
87 Ok(_) => match self.hook() {
88 Some(ProjectHook::Simple(cmd)) => Some(cmd.to_string()),
89 Some(ProjectHook::ScriptArray(hooks)) => {
90 let hooks_cmd = hooks
91 .iter()
92 .map(|s| {
93 self.get_script(s)
94 .unwrap_or_else(|| {
95 panic!("Hook {} does not match any script", s.bold());
96 })
97 .to_string()
98 })
99 .collect::<Vec<String>>()
100 .join("; ");
101 Some(hooks_cmd)
102 }
103 None => None,
104 },
105 Err(e) => {
106 panic!("Failed to get hook command.\n{}", e);
107 }
108 }
109 }
110
111 pub fn validate_hook(&self, hook: &Option<ProjectHook>) -> Result<(), Error> {
112 match hook {
113 Some(ProjectHook::ScriptArray(hooks)) => {
114 for h in hooks {
115 if !self
116 .scripts
117 .contains_key(&ScriptName::parse(h.to_string().clone()))
118 {
119 return Err(anyhow!("{} is not a valid hook", h.bold()));
120 }
121 }
122 Ok(())
123 }
124 Some(ProjectHook::Simple(hook)) => {
125 Command::parse(hook.to_string());
126 Ok(())
127 }
128 None => Ok(()),
129 }
130 }
131
132 pub fn save(&self) {
137 self.write().unwrap_or_else(|_| {
138 panic!(
139 "Failed to save project file at {}.",
140 self.path
141 .clone()
142 .unwrap()
143 .into_os_string()
144 .into_string()
145 .unwrap()
146 );
147 });
148 }
149
150 pub fn write(&self) -> Result<(), Error> {
151 let mut f = std::fs::OpenOptions::new()
152 .read(true)
153 .write(true)
154 .create(true)
155 .truncate(true)
157 .open(self.path.clone().expect("Path was not set properly"))?;
158 f.write_all(self.to_string().as_bytes())?;
159 Ok(())
160 }
161
162 fn set_path(&mut self, path: PathBuf) {
163 self.path = Some(path);
164 }
165
166 pub fn from_file(path: PathBuf) -> Result<Self, Error> {
167 let mut file = OpenOptions::new()
168 .read(true)
169 .write(true)
170 .open(path.clone())?;
171 let mut contents = String::new();
172 file.read_to_string(&mut contents)?;
173 if contents.is_empty() {
174 let project_name = ProjectName::parse(
175 std::env::current_dir()
176 .expect("Failed to check current directory.")
177 .file_name()
178 .expect("Failed to get the current directory")
179 .to_str()
180 .expect("Failed to convert directory to str")
181 .to_string(),
182 );
183
184 file.write_all(
185 Self::new(project_name, Some(path.clone()))
186 .to_string()
187 .as_bytes(),
188 )?;
189 }
190 let mut project = Self::from_str(&contents)
191 .unwrap_or_else(|e| panic!("Failed to read project string.\n{}", e));
192 project.set_path(path);
193 Ok(project)
194 }
195}
196
197impl FromStr for Project {
198 type Err = Error;
199
200 fn from_str(s: &str) -> Result<Self, Self::Err> {
201 let project: Result<Self, toml::de::Error> = toml::from_str(s);
202 if let Ok(mut p) = project {
203 let _ = p.populate_names();
204 match p.validate_hook(p.hook()) {
207 Ok(_) => {
208 return Ok(p);
209 }
210 Err(e) => {
211 return Err(anyhow!("Krabby failed to validate project hook.\n{}", e));
212 }
213 }
214 }
215 Err(anyhow!("Krabby couldn't parse project from string:\n{}", s))
216 }
217}
218
219impl ToString for Project {
220 fn to_string(&self) -> String {
221 toml::to_string(self).unwrap()
222 }
223}
224
225#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)]
226#[serde(transparent)]
227pub struct ProjectName(String);
228impl ProjectName {
229 pub fn parse(s: String) -> Self {
230 let is_empty = s.trim().is_empty();
231
232 let is_too_long = s.len() > 20;
233
234 let forbidden_characters = ['/', '(', ')', '"', '\'', '<', '>', '\\', '{', '}'];
235 let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g));
236
237 if is_empty || is_too_long || contains_forbidden_characters {
238 panic!("{} is not a valid script name", s);
239 }
240 Self(s)
241 }
242}
243
244impl Display for ProjectName {
245 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
246 write!(f, "{}", self.0)
247 }
248}
249
250#[cfg(test)]
251mod test {
252 use std::panic;
253
254 use crate::script::Command;
255
256 use super::*;
257
258 #[test]
259 fn project_initializes_with_no_script() {
260 let project = Project::new(ProjectName::parse("validname".into()), None);
261 assert!(project.scripts.is_empty());
262 assert!(project.hook().is_none());
263 }
264
265 #[test]
266 fn empty_project_file_parsed_successfully() {
267 let project = Project::new(ProjectName::parse("project".into()), None);
268 let project_from_str: Project = toml::from_str(
269 r#"
270 name = "project"
271 "#,
272 )
273 .unwrap();
274 assert_eq!(project, project_from_str)
275 }
276
277 #[test]
278 fn project_simple_hook_file_parsed_successfully() {
279 let mut project = Project::new(ProjectName::parse("project".into()), None);
280 project
281 .set_hook(Some(ProjectHook::Simple(
282 "echo \"Welcome to Krabby!\"".to_string(),
283 )))
284 .unwrap();
285 let project_from_str: Project = toml::from_str(
286 r#"
287 name = "project"
288 hook = "echo \"Welcome to Krabby!\""
289 "#,
290 )
291 .unwrap();
292 assert_eq!(project, project_from_str)
293 }
294
295 #[test]
296 fn project_list_hook_file_parsed_successfully() {
297 let mut project = Project::new(ProjectName::parse("project".into()), None);
298 let script_name = ScriptName::parse("hello".into());
299 let script = Script::new(script_name.clone(), Command::parse("echo hello".into()));
300 project.add_script(script_name, script).unwrap();
301 project
302 .set_hook(Some(ProjectHook::ScriptArray(vec![ScriptName::parse(
303 "hello".into(),
304 )])))
305 .unwrap();
306 let project_from_str: Project = toml::from_str(
307 r#"
308 name = "project"
309 hook = [ "hello" ]
310
311 [scripts]
312 hello = "echo hello"
313 "#,
314 )
315 .unwrap();
316 dbg!(&project_from_str);
317 assert_eq!(project, project_from_str)
318 }
319
320 #[test]
321 #[should_panic]
322 fn project_fails_to_add_invalid_hook_successfully() {
323 let mut project = Project::new(ProjectName::parse("project".into()), None);
324 let script_name = ScriptName::parse("hello".into());
325 project
326 .set_hook(Some(ProjectHook::ScriptArray(vec![script_name])))
327 .unwrap();
328 }
329
330 #[test]
331 #[should_panic]
332 fn project_fails_to_parse_invalid_hook() {
333 Project::from_str(
334 r#"
335 name = "project"
336 hook = [ "hello" ]
337 "#,
338 )
339 .unwrap();
340 }
341
342 #[test]
343 fn project_file_with_scripts_is_parsed_successfully() {
344 let mut project = Project::new(ProjectName::parse("project".into()), None);
345
346 let name = ScriptName::parse("run".into());
347 let script = Script::new(
348 name.clone(),
349 Command::parse(r#"echo 'Krabby says hi'"#.into()),
350 );
351 project.add_script(name.clone(), script).unwrap();
352 let project_from_str: Project = Project::from_str(
353 r#"
354 name = "project"
355
356 [scripts]
357 run = "echo 'Krabby says hi'"
358 "#,
359 )
360 .unwrap();
361 assert_eq!(project, project_from_str)
362 }
363
364 #[test]
365 fn remove_existing_script_from_project() {
366 let database_str = r#"
367name = "krabby"
368
369[scripts]
370hello = "echo \"hello\""
371world = "echo \"world\""
372 "#;
373 let (_, project_path) = create_random_project_file_from_str(database_str);
374 let mut project = Project::from_file(project_path.clone()).unwrap();
375 project.save();
376 project
377 .remove_script(ScriptName::parse("hello".into()))
378 .unwrap();
379 project.save();
380
381 let single_script_project_str = r#"name = "krabby"
382
383[scripts]
384world = "echo \"world\""
385"#;
386 assert_eq!(project.to_string(), single_script_project_str);
387
388 project
389 .remove_script(ScriptName::parse("world".into()))
390 .unwrap();
391 project.save();
392
393 let empty_project_str = r#"name = "krabby"
394"#;
395
396 assert_eq!(project.to_string(), empty_project_str);
397 remove_file(&project_path);
398 }
399
400 #[test]
401 #[should_panic]
402 fn project_name_cannot_be_empty() {
403 ProjectName::parse("".into());
404 }
405
406 #[test]
407 #[should_panic]
408 fn project_name_cannot_be_blank() {
409 ProjectName::parse(" ".into());
410 }
411
412 #[test]
413 #[should_panic]
414 fn project_name_cannot_be_over_20_characters() {
415 ProjectName::parse("iamlongerthan20characters".into());
416 }
417
418 #[test]
419 fn project_name_cannot_contain_forbidden_characters() {
420 let cases = [
421 ("i/am/not/allowed".to_string(), "cannot contain slashes"),
422 ("i(cantbeallowed".to_string(), "cannot contain parenthesis"),
423 ("neither)shouldi".to_string(), "cannot contain parenthesis"),
424 ("i\\shouldpanic".to_string(), "cannot contain backslashes"),
425 (
426 "why\"notallowed".to_string(),
427 "cannot contain double quotes",
428 ),
429 ("shouldnot'be".to_string(), "cannot contain single quotes"),
430 ("<antthisbegood".to_string(), "cannot contain lt sign"),
431 ("cantthisbegoo>".to_string(), "cannot contain gt sign"),
432 ("cantcauseof{".to_string(), "cannot contain bracket"),
433 ("cantcauseof}".to_string(), "cannot contain bracket"),
434 ];
435 for (case, msg) in cases {
436 let result = panic::catch_unwind(|| ProjectName::parse(case.clone()));
437 assert!(
438 result.is_err(),
439 "{} should be a project script name: {}",
440 case,
441 msg,
442 );
443 }
444 }
445
446 use rand::distributions::Alphanumeric;
447 use rand::{thread_rng, Rng};
448 use std::fs;
449
450 fn create_random_project_file() -> (ProjectName, PathBuf) {
451 let mut path = std::env::temp_dir();
452 let rand_string: String = thread_rng()
453 .sample_iter(&Alphanumeric)
454 .take(30)
455 .map(char::from)
456 .collect();
457 let project_name = ProjectName::parse(rand_string.clone());
458 let project_file_name = format!("{}-krabby.toml", rand_string.clone());
459 path.push(project_file_name);
460
461 Project::new(project_name.clone(), Some(path.clone()));
462
463 (project_name, path)
464 }
465
466 fn create_random_project_file_from_str(contents: &str) -> (ProjectName, PathBuf) {
467 let mut path = std::env::temp_dir();
468 let rand_string: String = thread_rng()
469 .sample_iter(&Alphanumeric)
470 .take(10)
471 .map(char::from)
472 .collect();
473 let project_name = ProjectName::parse(rand_string.clone());
474 let project_file_name = format!("{}-krabby.toml", project_name.clone());
475 path.push(project_file_name);
476
477 let mut project = Project::from_str(contents).unwrap();
478 project.set_path(path.clone());
479 project.save();
480
481 (project_name, path)
482 }
483
484 fn remove_file(path: &PathBuf) {
485 fs::remove_file(path)
486 .unwrap_or_else(|_| panic!("Failed to remove {}", path.to_string_lossy()))
487 }
488}