use std::fs; use anyhow::{anyhow, Result}; use crate::models::{DBState, Epic, Status, Story}; pub struct JiraDatabase { pub(crate) database: Box, } impl JiraDatabase { pub fn new(file_path: String) -> Self { Self { database: Box::new(JSONFileDatabase { file_path }), } } pub fn read_db(&self) -> Result { self.database.read_db() } pub fn create_epic(&self, epic: Epic) -> Result { let mut db = self.read_db()?; let id = db.last_item_id + 1; db.last_item_id = id; db.epics.insert(id, epic); self.database.write_db(&db)?; Ok(id) } pub fn create_story(&self, story: Story, epic_id: u32) -> Result { let mut db = self.read_db()?; let id = db.last_item_id + 1; db.last_item_id = id; db.stories.insert(id, story); db.epics .get_mut(&epic_id) .ok_or_else(|| anyhow!(format!("epic not found: {epic_id}")))? .stories .push(id); self.database.write_db(&db)?; Ok(id) } pub fn delete_epic(&self, epic_id: u32) -> Result<()> { let mut db = self.read_db()?; let epic = db .epics .get(&epic_id) .ok_or_else(|| anyhow!(format!("epic not found: {epic_id}")))?; db.stories.retain(|k, _| !epic.stories.contains(k)); db.epics.retain(|k, _| k != &epic_id); self.database.write_db(&db)?; Ok(()) } pub fn delete_story(&self, epic_id: u32, story_id: u32) -> Result<()> { let mut db = self.read_db()?; let epic = db .epics .get_mut(&epic_id) .ok_or_else(|| anyhow!(format!("epic not found: {epic_id}")))?; if !epic.stories.contains(&story_id) { return Err(anyhow!(format!( "story {story_id} not found in epic {epic_id}" ))); } epic.stories.retain(|k| k != &story_id); db.stories.retain(|k, _| k != &story_id); self.database.write_db(&db)?; Ok(()) } pub fn update_epic_status(&self, epic_id: u32, status: Status) -> Result<()> { let mut db = self.read_db()?; db.epics .get_mut(&epic_id) .ok_or_else(|| anyhow!(format!("epic not found: {epic_id}")))? .status = status; self.database.write_db(&db)?; Ok(()) } pub fn update_story_status(&self, story_id: u32, status: Status) -> Result<()> { let mut db = self.read_db()?; db.stories .get_mut(&story_id) .ok_or_else(|| anyhow!(format!("story not found: {story_id}")))? .status = status; self.database.write_db(&db)?; Ok(()) } } pub trait Database { fn read_db(&self) -> Result; fn write_db(&self, db_state: &DBState) -> Result<()>; } struct JSONFileDatabase { pub file_path: String, } impl Database for JSONFileDatabase { fn read_db(&self) -> Result { let contents = fs::read_to_string(&self.file_path)?; let db: DBState = serde_json::from_str(&contents)?; Ok(db) } fn write_db(&self, db_state: &DBState) -> Result<()> { let state = serde_json::to_vec_pretty(&db_state)?; Ok(fs::write(&self.file_path, state)?) } } pub mod test_utils { use std::{cell::RefCell, collections::HashMap}; use super::*; pub struct MockDB { last_written_state: RefCell, } impl MockDB { pub fn new() -> Self { Self { last_written_state: RefCell::new(DBState { last_item_id: 0, epics: HashMap::new(), stories: HashMap::new(), }), } } } impl Database for MockDB { fn read_db(&self) -> Result { // TODO: fix this error by deriving the appropriate traits for Story let state = self.last_written_state.borrow().clone(); Ok(state) } fn write_db(&self, db_state: &DBState) -> Result<()> { let latest_state = &self.last_written_state; // TODO: fix this error by deriving the appropriate traits for DBState *latest_state.borrow_mut() = db_state.clone(); Ok(()) } } } #[cfg(test)] mod tests { use super::test_utils::MockDB; use super::*; #[test] fn create_epic_should_work() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); // TODO: fix this error by deriving the appropriate traits for Epic let result = db.create_epic(epic.clone()); assert_eq!(result.is_ok(), true); let id = result.unwrap(); let db_state = db.read_db().unwrap(); let expected_id = 1; assert_eq!(id, expected_id); assert_eq!(db_state.last_item_id, expected_id); assert_eq!(db_state.epics.get(&id), Some(&epic)); } #[test] fn create_story_should_error_if_invalid_epic_id() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let story = Story::new("".to_owned(), "".to_owned()); let non_existent_epic_id = 999; let result = db.create_story(story, non_existent_epic_id); assert_eq!(result.is_err(), true); } #[test] fn create_story_should_work() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); let story = Story::new("".to_owned(), "".to_owned()); let result = db.create_epic(epic); assert_eq!(result.is_ok(), true); let epic_id = result.unwrap(); // TODO: fix this error by deriving the appropriate traits for Story let result = db.create_story(story.clone(), epic_id); assert_eq!(result.is_ok(), true); let id = result.unwrap(); let db_state = db.read_db().unwrap(); let expected_id = 2; assert_eq!(id, expected_id); assert_eq!(db_state.last_item_id, expected_id); assert_eq!( db_state.epics.get(&epic_id).unwrap().stories.contains(&id), true ); assert_eq!(db_state.stories.get(&id), Some(&story)); } #[test] fn delete_epic_should_error_if_invalid_epic_id() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let non_existent_epic_id = 999; let result = db.delete_epic(non_existent_epic_id); assert_eq!(result.is_err(), true); } #[test] fn delete_epic_should_work() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); let story = Story::new("".to_owned(), "".to_owned()); let result = db.create_epic(epic); assert_eq!(result.is_ok(), true); let epic_id = result.unwrap(); let result = db.create_story(story, epic_id); assert_eq!(result.is_ok(), true); let story_id = result.unwrap(); let result = db.delete_epic(epic_id); assert_eq!(result.is_ok(), true); let db_state = db.read_db().unwrap(); let expected_last_id = 2; assert_eq!(db_state.last_item_id, expected_last_id); assert_eq!(db_state.epics.get(&epic_id), None); assert_eq!(db_state.stories.get(&story_id), None); } #[test] fn delete_story_should_error_if_invalid_epic_id() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); let story = Story::new("".to_owned(), "".to_owned()); let result = db.create_epic(epic); assert_eq!(result.is_ok(), true); let epic_id = result.unwrap(); let result = db.create_story(story, epic_id); assert_eq!(result.is_ok(), true); let story_id = result.unwrap(); let non_existent_epic_id = 999; let result = db.delete_story(non_existent_epic_id, story_id); assert_eq!(result.is_err(), true); } #[test] fn delete_story_should_error_if_story_not_found_in_epic() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); let story = Story::new("".to_owned(), "".to_owned()); let result = db.create_epic(epic); assert_eq!(result.is_ok(), true); let epic_id = result.unwrap(); let result = db.create_story(story, epic_id); assert_eq!(result.is_ok(), true); let non_existent_story_id = 999; let result = db.delete_story(epic_id, non_existent_story_id); assert_eq!(result.is_err(), true); } #[test] fn delete_story_should_work() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); let story = Story::new("".to_owned(), "".to_owned()); let result = db.create_epic(epic); assert_eq!(result.is_ok(), true); let epic_id = result.unwrap(); let result = db.create_story(story, epic_id); assert_eq!(result.is_ok(), true); let story_id = result.unwrap(); let result = db.delete_story(epic_id, story_id); assert_eq!(result.is_ok(), true); let db_state = db.read_db().unwrap(); let expected_last_id = 2; assert_eq!(db_state.last_item_id, expected_last_id); assert_eq!( db_state .epics .get(&epic_id) .unwrap() .stories .contains(&story_id), false ); assert_eq!(db_state.stories.get(&story_id), None); } #[test] fn update_epic_status_should_error_if_invalid_epic_id() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let non_existent_epic_id = 999; let result = db.update_epic_status(non_existent_epic_id, Status::Closed); assert_eq!(result.is_err(), true); } #[test] fn update_epic_status_should_work() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); let result = db.create_epic(epic); assert_eq!(result.is_ok(), true); let epic_id = result.unwrap(); let result = db.update_epic_status(epic_id, Status::Closed); assert_eq!(result.is_ok(), true); let db_state = db.read_db().unwrap(); assert_eq!(db_state.epics.get(&epic_id).unwrap().status, Status::Closed); } #[test] fn update_story_status_should_error_if_invalid_story_id() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let non_existent_story_id = 999; let result = db.update_story_status(non_existent_story_id, Status::Closed); assert_eq!(result.is_err(), true); } #[test] fn update_story_status_should_work() { let db = JiraDatabase { database: Box::new(MockDB::new()), }; let epic = Epic::new("".to_owned(), "".to_owned()); let story = Story::new("".to_owned(), "".to_owned()); let result = db.create_epic(epic); let epic_id = result.unwrap(); let result = db.create_story(story, epic_id); let story_id = result.unwrap(); let result = db.update_story_status(story_id, Status::Closed); assert_eq!(result.is_ok(), true); let db_state = db.read_db().unwrap(); assert_eq!( db_state.stories.get(&story_id).unwrap().status, Status::Closed ); } mod database { use std::collections::HashMap; use std::io::Write; use super::*; #[test] fn read_db_should_fail_with_invalid_path() { let db = JSONFileDatabase { file_path: "INVALID_PATH".to_owned(), }; assert_eq!(db.read_db().is_err(), true); } #[test] fn read_db_should_fail_with_invalid_json() { let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); let file_contents = r#"{ "last_item_id": 0 epics: {} stories {} }"#; write!(tmpfile, "{}", file_contents).unwrap(); let db = JSONFileDatabase { file_path: tmpfile .path() .to_str() .expect("failed to convert tmpfile path to str") .to_string(), }; let result = db.read_db(); assert_eq!(result.is_err(), true); } #[test] fn read_db_should_parse_json_file() { let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); let file_contents = r#"{ "last_item_id": 0, "epics": {}, "stories": {} }"#; write!(tmpfile, "{}", file_contents).unwrap(); let db = JSONFileDatabase { file_path: tmpfile .path() .to_str() .expect("failed to convert tmpfile path to str") .to_string(), }; let result = db.read_db(); assert_eq!(result.is_ok(), true); } #[test] fn write_db_should_work() { let mut tmpfile = tempfile::NamedTempFile::new().unwrap(); let file_contents = r#"{ "last_item_id": 0, "epics": {}, "stories": {} }"#; write!(tmpfile, "{}", file_contents).unwrap(); let db = JSONFileDatabase { file_path: tmpfile .path() .to_str() .expect("failed to convert tmpfile path to str") .to_string(), }; let story = Story { name: "epic 1".to_owned(), description: "epic 1".to_owned(), status: Status::Open, }; let epic = Epic { name: "epic 1".to_owned(), description: "epic 1".to_owned(), status: Status::Open, stories: vec![2], }; let mut stories = HashMap::new(); stories.insert(2, story); let mut epics = HashMap::new(); epics.insert(1, epic); let state = DBState { last_item_id: 2, epics, stories, }; let write_result = db.write_db(&state); let read_result = db.read_db().unwrap(); assert_eq!(write_result.is_ok(), true); assert_eq!(read_result, state); } } }