krabby_cli/
project.rs

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    /// Defines the hook and return the command or commands.
71    /// It returns an `Option` so we can return an `Ok` if the hook is set to None.
72    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 from_string(s: &str) -> Self {
133    //     toml::from_str::<Project>(s).expect("Failed to parse project string")
134    // }
135
136    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            // So we can rewrite the whole file
156            .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 project.validate_hook() {}
205            // p.validate_hook(p.hook())?
206            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}