feat: adds main loop

fix: some logic bugs
This commit is contained in:
itsscb 2024-08-12 22:26:24 +02:00
parent 786ab80474
commit 6f8b865e33
9 changed files with 136 additions and 37 deletions

View File

@ -9,6 +9,7 @@ serde = {version = "1.0.204", features = ["derive"]}
serde_json = "1.0.122" serde_json = "1.0.122"
ellipse = "0.2.0" ellipse = "0.2.0"
itertools = "0.13.0" itertools = "0.13.0"
clearscreen = "3.0.0"
[dev-dependencies] [dev-dependencies]
tempfile = "3.11.0" tempfile = "3.11.0"

5
db.json Normal file
View File

@ -0,0 +1,5 @@
{
"last_item_id": 3,
"epics": {},
"stories": {}
}

View File

@ -1,4 +1,5 @@
use std::fs; use std::fs::{self, OpenOptions};
use std::path::Path;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
@ -9,10 +10,23 @@ pub struct JiraDatabase {
} }
impl JiraDatabase { impl JiraDatabase {
pub fn new(file_path: String) -> Self { pub fn new(file_path: &str) -> Result<Self> {
Self { let path = file_path.to_owned();
database: Box::new(JSONFileDatabase { file_path }), 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<DBState> { pub fn read_db(&self) -> Result<DBState> {
@ -138,6 +152,7 @@ pub mod test_utils {
} }
impl MockDB { impl MockDB {
#[allow(dead_code)]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
last_written_state: RefCell::new(DBState { last_written_state: RefCell::new(DBState {

View File

@ -5,5 +5,9 @@ pub fn get_user_input() -> String {
io::stdin().read_line(&mut user_input).unwrap(); io::stdin().read_line(&mut user_input).unwrap();
user_input user_input.trim().to_owned()
} }
pub fn wait_for_key_press() {
io::stdin().read_line(&mut String::new()).unwrap();
}

View File

@ -1,9 +1,59 @@
mod db; use std::rc::Rc;
mod io_utils;
mod models; mod models;
mod navigator;
mod db;
use anyhow::Result;
use db::*;
mod ui; mod ui;
fn main() { mod io_utils;
println!("Welcome scrumtask-cli!"); 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);
}
}
}
} }

View File

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt::Display}; use std::{collections::HashMap, fmt::Display};
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq, Clone)]
pub enum Action { pub enum Action {
NavigateToEpicDetail { epic_id: u32 }, NavigateToEpicDetail { epic_id: u32 },
NavigateToStoryDetail { epic_id: u32, story_id: u32 }, NavigateToStoryDetail { epic_id: u32, story_id: u32 },
@ -15,7 +15,7 @@ pub enum Action {
Exit, Exit,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Status { pub enum Status {
Open, Open,
InProgress, 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 struct Epic {
pub name: String, pub name: String,
pub description: 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 struct Story {
pub name: String, pub name: String,
pub description: String, pub description: String,
@ -76,3 +76,13 @@ pub struct DBState {
pub epics: HashMap<u32, Epic>, pub epics: HashMap<u32, Epic>,
pub stories: HashMap<u32, Story>, pub stories: HashMap<u32, Story>,
} }
impl DBState {
pub fn new() -> Self {
Self {
last_item_id: 0,
epics: HashMap::new(),
stories: HashMap::new(),
}
}
}

View File

@ -1,3 +1,4 @@
#[allow(unused_imports)]
use anyhow::{anyhow, Context, Ok, Result}; use anyhow::{anyhow, Context, Ok, Result};
use std::rc::Rc; use std::rc::Rc;
@ -45,7 +46,9 @@ impl Navigator {
} }
Action::NavigateToPreviousPage => { Action::NavigateToPreviousPage => {
// remove the last page from the pages vector // remove the last page from the pages vector
let _ = self.pages.pop(); if !self.pages.is_empty() {
self.pages.pop();
}
} }
Action::CreateEpic => { Action::CreateEpic => {
// prompt the user to create a new epic and persist it in the database // prompt the user to create a new epic and persist it in the database
@ -61,9 +64,12 @@ impl Navigator {
} }
Action::DeleteEpic { epic_id } => { Action::DeleteEpic { epic_id } => {
// prompt the user to delete the epic and persist it in the database // prompt the user to delete the epic and persist it in the database
self.db if (self.prompts.delete_epic)() {
.delete_epic(epic_id) self.db
.with_context(|| format!("failed to delete epic: {epic_id}"))?; .delete_epic(epic_id)
.with_context(|| format!("failed to delete epic: {epic_id}"))?;
self.pages.pop();
}
} }
Action::CreateStory { epic_id } => { Action::CreateStory { epic_id } => {
// prompt the user to create a new story and persist it in the database // prompt the user to create a new story and persist it in the database
@ -73,9 +79,12 @@ impl Navigator {
} }
Action::UpdateStoryStatus { story_id } => { Action::UpdateStoryStatus { story_id } => {
// prompt the user to update status and persist it in the database // prompt the user to update status and persist it in the database
let status = (self.prompts.update_status)() if let Some(status) = (self.prompts.update_status)() {
.with_context(|| format!("invalid status: {story_id}"))?; let s = status.clone();
self.db.update_story_status(story_id, status)?; self.db
.update_story_status(story_id, status)
.with_context(|| format!("invalid status: {s}"))?;
}
} }
Action::DeleteStory { epic_id, story_id } => { Action::DeleteStory { epic_id, story_id } => {
// prompt the user to delete the story and persist it in the database // prompt the user to delete the story and persist it in the database
@ -83,6 +92,7 @@ impl Navigator {
self.db self.db
.delete_story(epic_id, story_id) .delete_story(epic_id, story_id)
.with_context(|| format!("failed to delete story: {story_id}"))?; .with_context(|| format!("failed to delete story: {story_id}"))?;
self.pages.pop();
} }
} }
Action::Exit => { Action::Exit => {
@ -95,11 +105,11 @@ impl Navigator {
} }
// Private functions used for testing // Private functions used for testing
#[allow(dead_code)]
fn get_page_count(&self) -> usize { fn get_page_count(&self) -> usize {
self.pages.len() self.pages.len()
} }
#[allow(dead_code)]
fn set_prompts(&mut self, prompts: Prompts) { fn set_prompts(&mut self, prompts: Prompts) {
self.prompts = prompts; self.prompts = prompts;
} }

View File

@ -14,6 +14,7 @@ use page_helpers::*;
pub trait Page { pub trait Page {
fn draw_page(&self) -> Result<()>; fn draw_page(&self) -> Result<()>;
fn handle_input(&self, input: &str) -> Result<Option<Action>>; fn handle_input(&self, input: &str) -> Result<Option<Action>>;
#[allow(dead_code)]
fn as_any(&self) -> &dyn Any; fn as_any(&self) -> &dyn Any;
} }
@ -31,11 +32,16 @@ impl Page for HomePage {
println!("----------------------------- EPICS -----------------------------"); println!("----------------------------- EPICS -----------------------------");
println!(" id | name | status "); println!(" id | name | status ");
self.db.read_db()?.epics.iter().for_each(|(id, e)| { self.db
print!("{}| ", get_column_string(format!("{id}").as_str(), 12)); .read_db()?
print!("{}| ", get_column_string(&e.name, 33)); .epics
print!("{}", get_column_string(&e.status.to_string(), 17)); .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!();
println!(); println!();
@ -95,7 +101,7 @@ impl Page for EpicDetail {
let stories = &db_state.stories; 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(format!("{id}").as_str(), 12));
print!("{}| ", get_column_string(&e.name, 33)); print!("{}| ", get_column_string(&e.name, 33));
print!("{}", get_column_string(&e.status.to_string(), 17)); print!("{}", get_column_string(&e.status.to_string(), 17));

View File

@ -46,7 +46,7 @@ fn create_story_prompt() -> Story {
fn delete_epic_prompt() -> bool { 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]:"; 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}"); println!("{DELIMITER}");
print!("{QUESTION}"); println!("{QUESTION}");
let decision = matches!(get_user_input().as_str(), "y" | "Y"); let decision = matches!(get_user_input().as_str(), "y" | "Y");
println!(); println!();
decision decision
@ -55,7 +55,7 @@ fn delete_epic_prompt() -> bool {
fn delete_story_prompt() -> bool { fn delete_story_prompt() -> bool {
static QUESTION: &str = "Are you sure you want to delete this story? [Y/n]:"; static QUESTION: &str = "Are you sure you want to delete this story? [Y/n]:";
println!("{DELIMITER}"); println!("{DELIMITER}");
print!("{QUESTION}"); println!("{QUESTION}");
let decision = matches!(get_user_input().as_str(), "y" | "Y"); let decision = matches!(get_user_input().as_str(), "y" | "Y");
println!(); println!();
decision decision
@ -64,14 +64,12 @@ fn delete_story_prompt() -> bool {
fn update_status_prompt() -> Option<Status> { fn update_status_prompt() -> Option<Status> {
static QUESTION: &str = "New Status (1 - OPEN, 2 - IN-PROGRESS, 3 - RESOLVED, 4 - CLOSED):"; static QUESTION: &str = "New Status (1 - OPEN, 2 - IN-PROGRESS, 3 - RESOLVED, 4 - CLOSED):";
println!("{DELIMITER}"); println!("{DELIMITER}");
print!("{QUESTION}"); println!("{QUESTION}");
let decision = match get_user_input().as_str() { match get_user_input().as_str() {
"1" => Some(Status::Open), "1" => Some(Status::Open),
"2" => Some(Status::InProgress), "2" => Some(Status::InProgress),
"3" => Some(Status::Resolved), "3" => Some(Status::Resolved),
"4" => Some(Status::Closed), "4" => Some(Status::Closed),
_ => None, _ => None,
}; }
println!();
decision
} }