krabby_cli/
database.rs

1use crate::{
2    messages::Message,
3    project::{Project, ProjectName},
4};
5use anyhow::{anyhow, Error};
6use indexmap::IndexMap;
7use owo_colors::OwoColorize;
8use serde::{Deserialize, Serialize};
9use std::{
10    fs::OpenOptions,
11    io::{Read, Write},
12    path::PathBuf,
13    str::FromStr,
14};
15
16#[derive(Debug, Eq, PartialEq, Serialize, Deserialize)]
17pub struct Database {
18    pub projects: IndexMap<ProjectName, PathBuf>,
19    #[serde(skip)]
20    pub path: Option<PathBuf>,
21}
22
23impl Database {
24    pub fn new(path: Option<PathBuf>) -> Self {
25        Self {
26            projects: IndexMap::new(),
27            path,
28        }
29    }
30
31    pub fn list_projects(&self) -> Vec<ProjectName> {
32        self.projects
33            .iter()
34            .map(|(project_name, _project_path)| project_name.clone())
35            .collect()
36    }
37
38    pub fn remove_project(&mut self, project_name: ProjectName) -> Result<(), Error> {
39        match self.projects.shift_remove(&project_name) {
40            Some(_p) => Ok(()),
41            None => Err(anyhow!(
42                "There was no project named {} on the database!",
43                project_name.bold()
44            )),
45        }
46    }
47
48    pub fn add_project(&mut self, project_name: ProjectName, path: PathBuf) -> Result<(), Error> {
49        // If we don't canonicalize, the path will be useless when we try to `cd` into it.
50        let path = std::fs::canonicalize(path).expect("Failed to canonicalize path");
51        if let Some(p) = self.projects.get(&project_name) {
52            return Err(anyhow!(
53                "{} is registered already at {}.",
54                project_name,
55                p.to_str().unwrap().bold()
56            ));
57        }
58        for (project_name, project_path) in self.projects.iter() {
59            if project_path == &path {
60                return Err(anyhow!(
61                    "The project at {} is already registered under the name {}.",
62                    project_path.to_str().unwrap().bold(),
63                    project_name.bold()
64                ));
65            }
66        }
67        self.projects.insert(project_name, path);
68        Ok(())
69    }
70
71    pub fn get_project_path(&self, project_name: &ProjectName) -> Option<&PathBuf> {
72        self.projects.get(project_name)
73    }
74
75    // Return Ok((cd_command, Some(hook_command))) in case of success
76    pub fn go_to_project(
77        &self,
78        project_name: &ProjectName,
79    ) -> Result<(String, Option<String>), Error> {
80        match self.projects.get(project_name) {
81            Some(project_path) => {
82                if !project_path.is_dir() {
83                    return Err(anyhow!("I dont know how to tell you this, but there was a problem with the registry.\nApparently {} directory {} or {}.", project_path.to_string_lossy().bold(), "is missing".red().bold(), "is not a directory".red().bold()));
84                }
85                println!("echo \"Krabby is taking you to {}!\";", project_name.bold());
86                let s = project_path.to_string_lossy();
87                let cd_cmd = format!("cd {}", s);
88                println!("{}", cd_cmd);
89                // Checks for `krabby.toml` project file to see if there are any hooks to run
90                if let Some(hook_cmd) = self.get_project_hook_cmd(project_name) {
91                    println!("echo \"Running hook:\n{}\"", hook_cmd.bold());
92                    println!("{}", hook_cmd);
93                    return Ok((cd_cmd, Some(hook_cmd)));
94                }
95                Ok((cd_cmd, None))
96            }
97            None => Err(anyhow!(
98                "{}",
99                Message::ProjectNotFound(project_name.clone())
100            )),
101        }
102    }
103
104    pub fn get_project_hook_cmd(&self, project_name: &ProjectName) -> Option<String> {
105        if let Ok(project) = self.get_project_file(project_name) {
106            return project.get_hook_cmd();
107        }
108        None
109    }
110
111    pub fn get_project(&self, project_name: &ProjectName) -> Option<&PathBuf> {
112        self.projects.get(project_name)
113    }
114
115    fn get_project_file(&self, project_name: &ProjectName) -> Result<Project, anyhow::Error> {
116        let project_file_path = match self.get_project(project_name) {
117            Some(path) => path.join("krabby.toml"),
118            None => return Err(anyhow!("There was no project {}", project_name)),
119        };
120        Project::from_file(project_file_path)
121    }
122
123    pub fn from_string(s: &str) -> Self {
124        toml::from_str(s).unwrap()
125    }
126
127    fn set_path(&mut self, path: PathBuf) {
128        self.path = Some(path);
129    }
130
131    pub fn save(&self) {
132        self.write().unwrap_or_else(|_| {
133            panic!(
134                "Failed to save database at {}",
135                self.path
136                    .clone()
137                    .unwrap()
138                    .into_os_string()
139                    .into_string()
140                    .unwrap()
141            );
142        });
143    }
144
145    pub fn write(&self) -> Result<(), Error> {
146        let mut f = std::fs::OpenOptions::new()
147            .read(true)
148            .write(true)
149            // So we can rewrite the whole file
150            .truncate(true)
151            .create(true)
152            .open(self.path.clone().expect("Path was not set properly"))?;
153        f.write_all(self.to_string().as_bytes())?;
154        Ok(())
155    }
156
157    pub fn from_file(path: PathBuf) -> Result<Self, Error> {
158        let mut file = OpenOptions::new()
159            .read(true)
160            .write(true)
161            .create(true)
162            .open(path.clone())?;
163        let mut contents = String::new();
164        file.read_to_string(&mut contents)?;
165        if contents.is_empty() {
166            file.write_all(Database::new(Some(path.clone())).to_string().as_bytes())?;
167        }
168        let mut db = Self::from_str(&contents)
169            .unwrap_or_else(|e| panic!("Failed to read database string.\n{}", e));
170        db.set_path(path);
171        Ok(db)
172    }
173}
174
175impl FromStr for Database {
176    type Err = Error;
177
178    fn from_str(s: &str) -> Result<Self, Self::Err> {
179        let database: Result<Self, toml::de::Error> = toml::from_str(s);
180        if let Ok(db) = database {
181            return Ok(db);
182        }
183        Err(anyhow!("Failed to parse Database from string:\n{}", s))
184    }
185}
186
187impl ToString for Database {
188    fn to_string(&self) -> String {
189        toml::to_string(self).unwrap()
190    }
191}
192
193impl Default for Database {
194    fn default() -> Self {
195        Self::new(None)
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn database_is_initialized_empty() {
205        let database = Database::new(None);
206        assert!(database.projects.is_empty())
207    }
208
209    #[test]
210    fn parse_database_successfully() {
211        let mut database = Database::new(None);
212        database
213            .add_project(ProjectName::parse("project".into()), "/".into())
214            .unwrap();
215
216        let database_str = r#"
217            [projects]
218            project = "/"
219        "#;
220        let database_from_str: Database = toml::from_str(database_str).unwrap();
221
222        assert_eq!(database, database_from_str)
223    }
224
225    #[test]
226    fn add_project_to_database_successfully() {
227        let mut database = Database::new(None);
228        let res = database.add_project(ProjectName::parse("project".into()), "/".into());
229        assert!(res.is_ok());
230        let project_list: Vec<ProjectName> = vec![ProjectName::parse("project".into())];
231        assert_eq!(database.list_projects(), project_list);
232    }
233
234    #[test]
235    fn remove_existing_project_from_database() {
236        let database_str = r#"
237            [projects]
238            project1 = "/tmp/project1"
239            project2 = "/tmp/project2"
240        "#;
241        let mut database = Database::from_string(database_str);
242        database.set_path("./krabby.db".into());
243        database.save();
244        database
245            .remove_project(ProjectName::parse("project2".into()))
246            .unwrap();
247        database.save();
248
249        let single_project_database_str = r#"[projects]
250project1 = "/tmp/project1"
251"#;
252        assert_eq!(database.to_string(), single_project_database_str);
253
254        database
255            .remove_project(ProjectName::parse("project1".into()))
256            .unwrap();
257        database.save();
258
259        let empty_database_str = r#"[projects]
260"#;
261
262        assert_eq!(database.to_string(), empty_database_str);
263    }
264
265    #[test]
266    #[should_panic]
267    fn remove_project_from_empty_database() {
268        let mut database = Database::new(None);
269        database
270            .remove_project(ProjectName::parse("project".into()))
271            .expect("failed to delete project")
272    }
273
274    use crate::project::{Project, ProjectName};
275    use rand::distributions::Alphanumeric;
276    use rand::{thread_rng, Rng};
277    use std::fs;
278
279    fn create_random_database_file(path: &str) -> PathBuf {
280        let mut path = PathBuf::from(path);
281        let rand_string: String = thread_rng()
282            .sample_iter(&Alphanumeric)
283            .take(30)
284            .map(char::from)
285            .collect();
286        let database_file_name = format!("{}-krabby.db", rand_string.clone());
287        path.push(database_file_name);
288
289        Database::new(Some(path.clone()));
290
291        path
292    }
293
294    fn create_random_project(path: &str) -> (ProjectName, PathBuf) {
295        let mut path = PathBuf::from(path);
296        let rand_string: String = thread_rng()
297            .sample_iter(&Alphanumeric)
298            .take(30)
299            .map(char::from)
300            .collect();
301        let project_name = ProjectName::parse(format!("{}-project", rand_string.clone()));
302        path.push(project_name.to_string());
303
304        fs::create_dir(path.clone())
305            .unwrap_or_else(|_| panic!("Failed to create {}", path.clone().to_string_lossy()));
306        Project::new(project_name.clone(), Some(path.clone()));
307
308        (project_name, path)
309    }
310
311    fn remove_file(path: &str) {
312        fs::remove_file(path).unwrap_or_else(|_| panic!("Failed to remove {}", path))
313    }
314    fn remove_dir(path: &str) {
315        fs::remove_dir(path).unwrap_or_else(|_| panic!("Failed to remove {}", path))
316    }
317}