diff --git a/Cargo.toml b/Cargo.toml index bf94617..d9dde97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ serde = {version = "1.0.204", features = ["derive"]} serde_json = "1.0.122" ellipse = "0.2.0" itertools = "0.13.0" +clearscreen = "3.0.0" [dev-dependencies] tempfile = "3.11.0" diff --git a/db.json b/db.json new file mode 100644 index 0000000..8ea7d50 --- /dev/null +++ b/db.json @@ -0,0 +1,5 @@ +{ + "last_item_id": 3, + "epics": {}, + "stories": {} +} \ No newline at end of file diff --git a/src/db.rs b/src/db.rs index c6a51c6..6eb3cc6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,5 @@ -use std::fs; +use std::fs::{self, OpenOptions}; +use std::path::Path; use anyhow::{anyhow, Result}; @@ -9,10 +10,23 @@ pub struct JiraDatabase { } impl JiraDatabase { - pub fn new(file_path: String) -> Self { - Self { - database: Box::new(JSONFileDatabase { file_path }), + pub fn new(file_path: &str) -> Result { + let path = file_path.to_owned(); + let db = Self { + database: Box::new(JSONFileDatabase { file_path: path }), + }; + + if !Path::new(file_path).exists() { + match OpenOptions::new().create(true).write(true).open(file_path) { + Err(e) => return Err(anyhow!("failed to open/create database file: {e}")), + Ok(_) => { + db.database.write_db(&DBState::new())?; + } + } + // .with_context(|| format!("failed to create epic"))?; } + + Ok(db) } pub fn read_db(&self) -> Result { @@ -138,6 +152,7 @@ pub mod test_utils { } impl MockDB { + #[allow(dead_code)] pub fn new() -> Self { Self { last_written_state: RefCell::new(DBState { diff --git a/src/io_utils.rs b/src/io_utils.rs index bef7884..28ba4c2 100644 --- a/src/io_utils.rs +++ b/src/io_utils.rs @@ -5,5 +5,9 @@ pub fn get_user_input() -> String { io::stdin().read_line(&mut user_input).unwrap(); - user_input -} \ No newline at end of file + user_input.trim().to_owned() +} + +pub fn wait_for_key_press() { + io::stdin().read_line(&mut String::new()).unwrap(); +} diff --git a/src/main.rs b/src/main.rs index 872eadd..103e904 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,59 @@ -mod db; -mod io_utils; +use std::rc::Rc; + mod models; -mod navigator; + +mod db; +use anyhow::Result; +use db::*; + mod ui; -fn main() { - println!("Welcome scrumtask-cli!"); +mod io_utils; +use io_utils::*; + +mod navigator; +use navigator::*; + +fn main() -> Result<()> { + // TODO: create database and navigator + let db = Rc::new(JiraDatabase::new("./db.json")?); + let mut nav = Navigator::new(db); + + loop { + // clearscreen::clear().unwrap(); + + // 1. get current page from navigator. If there is no current page exit the loop. + let page = match nav.get_current_page() { + Some(p) => p, + None => { + break Ok(()); + } + }; + // 2. render page + if let Err(e) = page.draw_page() { + eprintln!("failed to render page: {e}"); + wait_for_key_press(); + break Err(e); + } + // 3. get user input + let input = io_utils::get_user_input(); + // 4. pass input to page's input handler + let action = match page.handle_input(&input.trim()) { + Err(e) => { + eprintln!("failed to handle input '{input}': {e}"); + wait_for_key_press(); + break Err(e); + } + Ok(a) => a, + }; + // 5. if the page's input handler returns an action let the navigator process the action + if let Some(a) = action { + let action = a.clone(); + if let Err(e) = nav.handle_action(a) { + eprintln!("failed to handle action '{action:?}': {e}"); + wait_for_key_press(); + break Err(e); + } + } + } } diff --git a/src/models.rs b/src/models.rs index 87bf27f..2ad6938 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt::Display}; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub enum Action { NavigateToEpicDetail { epic_id: u32 }, NavigateToStoryDetail { epic_id: u32, story_id: u32 }, @@ -15,7 +15,7 @@ pub enum Action { Exit, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum Status { Open, InProgress, @@ -34,7 +34,7 @@ impl Display for Status { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct Epic { pub name: String, pub description: String, @@ -53,7 +53,7 @@ impl Epic { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct Story { pub name: String, pub description: String, @@ -76,3 +76,13 @@ pub struct DBState { pub epics: HashMap, pub stories: HashMap, } + +impl DBState { + pub fn new() -> Self { + Self { + last_item_id: 0, + epics: HashMap::new(), + stories: HashMap::new(), + } + } +} diff --git a/src/navigator.rs b/src/navigator.rs index 3302cee..a199eae 100644 --- a/src/navigator.rs +++ b/src/navigator.rs @@ -1,3 +1,4 @@ +#[allow(unused_imports)] use anyhow::{anyhow, Context, Ok, Result}; use std::rc::Rc; @@ -45,7 +46,9 @@ impl Navigator { } Action::NavigateToPreviousPage => { // remove the last page from the pages vector - let _ = self.pages.pop(); + if !self.pages.is_empty() { + self.pages.pop(); + } } Action::CreateEpic => { // prompt the user to create a new epic and persist it in the database @@ -61,9 +64,12 @@ impl Navigator { } Action::DeleteEpic { epic_id } => { // prompt the user to delete the epic and persist it in the database - self.db - .delete_epic(epic_id) - .with_context(|| format!("failed to delete epic: {epic_id}"))?; + if (self.prompts.delete_epic)() { + self.db + .delete_epic(epic_id) + .with_context(|| format!("failed to delete epic: {epic_id}"))?; + self.pages.pop(); + } } Action::CreateStory { epic_id } => { // prompt the user to create a new story and persist it in the database @@ -73,9 +79,12 @@ impl Navigator { } Action::UpdateStoryStatus { story_id } => { // prompt the user to update status and persist it in the database - let status = (self.prompts.update_status)() - .with_context(|| format!("invalid status: {story_id}"))?; - self.db.update_story_status(story_id, status)?; + if let Some(status) = (self.prompts.update_status)() { + let s = status.clone(); + self.db + .update_story_status(story_id, status) + .with_context(|| format!("invalid status: {s}"))?; + } } Action::DeleteStory { epic_id, story_id } => { // prompt the user to delete the story and persist it in the database @@ -83,6 +92,7 @@ impl Navigator { self.db .delete_story(epic_id, story_id) .with_context(|| format!("failed to delete story: {story_id}"))?; + self.pages.pop(); } } Action::Exit => { @@ -95,11 +105,11 @@ impl Navigator { } // Private functions used for testing - + #[allow(dead_code)] fn get_page_count(&self) -> usize { self.pages.len() } - + #[allow(dead_code)] fn set_prompts(&mut self, prompts: Prompts) { self.prompts = prompts; } diff --git a/src/ui/pages/mod.rs b/src/ui/pages/mod.rs index 27572cd..e69427c 100644 --- a/src/ui/pages/mod.rs +++ b/src/ui/pages/mod.rs @@ -14,6 +14,7 @@ use page_helpers::*; pub trait Page { fn draw_page(&self) -> Result<()>; fn handle_input(&self, input: &str) -> Result>; + #[allow(dead_code)] fn as_any(&self) -> &dyn Any; } @@ -31,11 +32,16 @@ impl Page for HomePage { println!("----------------------------- EPICS -----------------------------"); println!(" id | name | status "); - self.db.read_db()?.epics.iter().for_each(|(id, e)| { - print!("{}| ", get_column_string(format!("{id}").as_str(), 12)); - print!("{}| ", get_column_string(&e.name, 33)); - print!("{}", get_column_string(&e.status.to_string(), 17)); - }); + self.db + .read_db()? + .epics + .iter() + .sorted() + .for_each(|(id, e)| { + print!("{}| ", get_column_string(format!("{id}").as_str(), 12)); + print!("{}| ", get_column_string(&e.name, 33)); + print!("{}", get_column_string(&e.status.to_string(), 17)); + }); println!(); println!(); @@ -95,7 +101,7 @@ impl Page for EpicDetail { let stories = &db_state.stories; - for (id, e) in stories { + for (id, e) in stories.iter().sorted() { print!("{}| ", get_column_string(format!("{id}").as_str(), 12)); print!("{}| ", get_column_string(&e.name, 33)); print!("{}", get_column_string(&e.status.to_string(), 17)); diff --git a/src/ui/prompts.rs b/src/ui/prompts.rs index 2eabb5a..c1f8c68 100644 --- a/src/ui/prompts.rs +++ b/src/ui/prompts.rs @@ -46,7 +46,7 @@ fn create_story_prompt() -> Story { fn delete_epic_prompt() -> bool { static QUESTION: &str = "Are you sure you want to delete this epic? All stories in this epic will also be deleted [Y/n]:"; println!("{DELIMITER}"); - print!("{QUESTION}"); + println!("{QUESTION}"); let decision = matches!(get_user_input().as_str(), "y" | "Y"); println!(); decision @@ -55,7 +55,7 @@ fn delete_epic_prompt() -> bool { fn delete_story_prompt() -> bool { static QUESTION: &str = "Are you sure you want to delete this story? [Y/n]:"; println!("{DELIMITER}"); - print!("{QUESTION}"); + println!("{QUESTION}"); let decision = matches!(get_user_input().as_str(), "y" | "Y"); println!(); decision @@ -64,14 +64,12 @@ fn delete_story_prompt() -> bool { fn update_status_prompt() -> Option { static QUESTION: &str = "New Status (1 - OPEN, 2 - IN-PROGRESS, 3 - RESOLVED, 4 - CLOSED):"; println!("{DELIMITER}"); - print!("{QUESTION}"); - let decision = match get_user_input().as_str() { + println!("{QUESTION}"); + match get_user_input().as_str() { "1" => Some(Status::Open), "2" => Some(Status::InProgress), "3" => Some(Status::Resolved), "4" => Some(Status::Closed), _ => None, - }; - println!(); - decision + } }