diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 901773d..c2979d7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,6 +4,16 @@ - Add edits should be applied synced to the external storage (Gist) in the same order they were applied to the file - No excessive synchronizations: as few requests to the external storage as possible +## Architecture v0.0.7 + +### Extension + +- LSP server's path watcher store notifies LSP client with an error message if a file sync has failed or there is an internal file watcher error. These messages are displayed by Zed as popups. + +### CLI tool + +- We don't try to load credentials from the Zed settings file anymore because it's creates an inconvenient user flow. The only option that CLI tool provides is the interactive input of credentials. + ## Architecture v0.0.6 ### CLI tool diff --git a/ROADMAP.md b/ROADMAP.md index 53dafa0..8c1e5d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,36 +10,33 @@ - [x] Forbid the usage of unwrap and expect for Option, Result - [x] Use serde_json from zed_extension_api, not directly - [x] To support multiple sync providers, implement a Client trait and use it for all sync operations. Move it to the "common" crate. -- [~] **Add unit and integration tests** -- [ ] Test all error-returning code paths and ensure that all error conditions are either properly logged and/or reported back to Zed in form or a JSON-RPC error response -- [ ] Revamp README before publishing the extension to make it easier to consume and start using the extension immediately -- [ ] Add secrecy crate and use it for all usages of the Github token +- [~] **Add unit tests for all important business logic types** +- [ ] Prepare README for the initial public release (see Documentation) ## LSP server +- [x] Report a sync error in a visible way (crash the server? know how to report an LSP error inside Zed?) +- [x] To support multiple sync providers, implement a SyncClient trait and use it for all sync operations. Move it to the "common" crate. - [ ] Manually save a settings file on its open (before adding to the watch list) to handle the case when the LSP server is restarted after the initialization_options are changes in settings.json file. +- [ ] Test all error-returning code paths and ensure that all error conditions are either properly logged and/or reported back to Zed in form or a JSON-RPC error response - [ ] Ensure that restarting or shutting down the LSP server doesn't prevent the last coming updates from getting synced; otherwise, mitigate that -- [ ] Report a sync error in a visible way (crash the server? know how to report an LSP error inside Zed?) +- [ ] Backup installed themes to the gist automatically - [ ] Create a true integration test when a server is spawned, it's fed with JSON-RPC messages, and Github API URL is mocked via env var to point to a local mock server as another process - [ ] After implementing naive changes persistence (sync files as FS events come), seek the ways to improve it (e.g. queuing events) – [ ] (experimental) Rewrite the LSP server to use structured async concurrency, thread-per-code async runtime, and get rid of Arc's around every data structure -- [ ] Add secrecy crate and use it for all usages of the Github token -- [ ] To support multiple sync providers, implement a SyncClient trait and use it for all sync operations. Move it to the "common" crate. -- [ ] Report a sync error in a visible way (crash the server? know how to report an LSP error inside Zed?) -- [ ] Backup installed themes to the gist automatically - [ ] Use type-state pattern to guarantee that one cannot watch/unwatch a path using non-started WatcherSet or PathStore +- [ ] Add secrecy crate and use it for all usages of the Github token ### CLI tool +- [ ] Add an option to create a new gist on the fly, copy settings to it and start using it from now on - [ ] Handle errors more beautifully, introduce the dedicated Error type if needed - [ ] Log output through tracing subscriber and/or add coloring of various levels of output messages – [ ] Add cross-platform colored plain chars for CLI output instead of colored circle emojis - [ ] Refactor to get rid of the InteractiveIO trait in favor of BufRead + Write type (trait), see -- [ ] Add an option to create a new gist on the fly, copy settings to it and start using it from now on ## CI -- [ ] Enable ["cancel-in-progress"](https://share.google/Vk4zJKCbkerc5BAfC) for CI builds - [x] Add matrix to compile for Windows on ARM64 - [x] Speed up the build if possible (caching, Docker images, etc.) - [ ] ~~Extract "compile" as a separate local Github action~~ No need for that, because "compile" is used only for release. diff --git a/common/src/sync/client.rs b/common/src/sync/client.rs index 6f62985..5a40687 100644 --- a/common/src/sync/client.rs +++ b/common/src/sync/client.rs @@ -30,7 +30,7 @@ pub trait Client: Send + Sync { } #[derive(Error, Debug)] -#[error("Error processing file {file_name}: {error}")] +#[error("Error syncing file {file_name}: {error}")] pub struct FileError { file_name: String, error: Error, diff --git a/common/src/sync/local_file_data.rs b/common/src/sync/local_file_data.rs index 338e8f4..0097e66 100644 --- a/common/src/sync/local_file_data.rs +++ b/common/src/sync/local_file_data.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use anyhow::{Result, anyhow}; +#[derive(Debug, PartialEq, Eq)] pub struct LocalFileData { pub path: PathBuf, pub filename: String, diff --git a/lsp/src/app_state.rs b/lsp/src/app_state.rs index 3045d5e..978a907 100644 --- a/lsp/src/app_state.rs +++ b/lsp/src/app_state.rs @@ -2,7 +2,11 @@ use std::sync::Arc; use anyhow::Result; use common::sync::GithubClient; +#[cfg(not(test))] +use tower_lsp::Client as LspClient; +#[cfg(test)] +use crate::mocks::MockLspClient as LspClient; use crate::watching::PathStore; #[derive(Debug)] @@ -11,9 +15,9 @@ pub struct AppState { } impl AppState { - pub fn new(gist_id: String, github_token: String) -> Result { + pub fn new(gist_id: String, github_token: String, lsp_client: Arc) -> Result { let sync_client = Arc::new(GithubClient::new(gist_id, github_token)?); - let watched_paths = PathStore::new(sync_client)?; + let watched_paths = PathStore::new(sync_client, lsp_client)?; Ok(Self { watched_paths }) } diff --git a/lsp/src/backend.rs b/lsp/src/backend.rs index 595f869..5e29c07 100644 --- a/lsp/src/backend.rs +++ b/lsp/src/backend.rs @@ -1,12 +1,14 @@ use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use anyhow::Result; use common::config::Config; +#[cfg(not(test))] +use tower_lsp::Client as LspClient; use tower_lsp::jsonrpc::Result as LspResult; use tower_lsp::lsp_types::{DidCloseTextDocumentParams, DidOpenTextDocumentParams}; use tower_lsp::{ - Client, LanguageServer, + LanguageServer, lsp_types::{ InitializeParams, InitializeResult, InitializedParams, ServerCapabilities, ServerInfo, TextDocumentSyncCapability, TextDocumentSyncOptions, WorkspaceServerCapabilities, @@ -16,6 +18,8 @@ use tracing::{debug, error, info, instrument}; use zed_extension_api::serde_json::from_value; use crate::app_state::AppState; +#[cfg(test)] +use crate::mocks::MockLspClient as LspClient; use crate::watching::{ZedConfigFilePath, ZedConfigPathError}; const CARGO_PKG_NAME: &str = env!("CARGO_PKG_NAME"); @@ -27,12 +31,14 @@ pub struct Backend { // Mutex is needed for interior mutability over a shared reference // because LanguageServer trait methods accept &self (not &mut self). app_state: OnceLock>, + lsp_client: LspClient, } impl Backend { - pub fn new(_client: Client) -> Self { + pub fn new(lsp_client: LspClient) -> Self { Self { app_state: OnceLock::new(), + lsp_client, } } @@ -85,11 +91,15 @@ impl LanguageServer for Backend { tower_lsp::jsonrpc::Error::internal_error() })?; - let app_state = AppState::new(config.gist_id().into(), config.github_token().into()) - .map_err(|err| { - error!("Failed to build the app state: {}", err); - tower_lsp::jsonrpc::Error::internal_error() - })?; + let app_state = AppState::new( + config.gist_id().into(), + config.github_token().into(), + Arc::new(self.lsp_client.clone()), + ) + .map_err(|err| { + error!("Failed to build the app state: {}", err); + tower_lsp::jsonrpc::Error::internal_error() + })?; #[allow(clippy::expect_used)] self.app_state diff --git a/lsp/src/main.rs b/lsp/src/main.rs index 9bd5804..936861e 100644 --- a/lsp/src/main.rs +++ b/lsp/src/main.rs @@ -1,11 +1,15 @@ +#[cfg(not(test))] use tower_lsp::{LspService, Server}; use tracing::info; +#[cfg(not(test))] use crate::backend::Backend; mod app_state; mod backend; mod logger; +#[cfg(test)] +mod mocks; mod watching; #[cfg(test)] @@ -20,13 +24,15 @@ async fn main() { env!("CARGO_PKG_VERSION") ); - let stdin = tokio::io::stdin(); - let stdout = tokio::io::stdout(); - + #[cfg(not(test))] // to avoid "type mismatch in function arguments" error in LspService::new let (service, socket) = LspService::new(Backend::new); info!("LSP service created, starting server"); - Server::new(stdin, stdout, socket).serve(service).await; + + #[cfg(not(test))] + Server::new(tokio::io::stdin(), tokio::io::stdout(), socket) + .serve(service) + .await; info!("Zed Settings Sync LSP server stopped"); } diff --git a/lsp/src/mocks.rs b/lsp/src/mocks.rs new file mode 100644 index 0000000..b218da5 --- /dev/null +++ b/lsp/src/mocks.rs @@ -0,0 +1,22 @@ +use std::fmt; + +use mockall::mock; +use tower_lsp::lsp_types::MessageType; + +mock! { + pub LspClient { + pub fn show_message(&self, msg_type: MessageType, message: String) -> impl Future + Send + Sync; + } + + impl fmt::Debug for LspClient { + fn fmt<'a>(&self, f: &mut std::fmt::Formatter<'a>) -> std::fmt::Result { + f.debug_struct("LspClient").finish() + } + } + + impl Clone for LspClient { + fn clone(&self) -> Self { + Self::default() + } + } +} diff --git a/lsp/src/watching/path_store.rs b/lsp/src/watching/path_store.rs index ce8ff42..a175c0b 100644 --- a/lsp/src/watching/path_store.rs +++ b/lsp/src/watching/path_store.rs @@ -2,12 +2,17 @@ use std::{path::PathBuf, pin::Pin, sync::Arc}; use anyhow::Result; use anyhow::{Context, anyhow}; -use common::sync::{Client, LocalFileData}; +use common::sync::{Client as SyncClient, LocalFileData}; use mockall_double::double; use notify::{Event, EventKind, event::ModifyKind}; use tokio::fs; +#[cfg(not(test))] +use tower_lsp::Client as LspClient; +use tower_lsp::lsp_types::MessageType; use tracing::{debug, error}; +#[cfg(test)] +use crate::mocks::MockLspClient as LspClient; #[double] use crate::watching::WatchedSet; @@ -17,9 +22,11 @@ pub struct PathStore { } impl PathStore { - pub fn new(client: Arc) -> Result { + pub fn new(sync_client: Arc, lsp_client: Arc) -> Result { let event_handler = Box::new(move |event| { - let client_clone = Arc::clone(&client); + let sync_client_clone = Arc::clone(&sync_client); + let lsp_client_clone = Arc::clone(&lsp_client); + Box::pin(async move { match process_event(&event).await { Ok(data) => { @@ -27,11 +34,22 @@ impl PathStore { return; }; - if let Err(err) = client_clone.sync_file(data).await { - error!("{}", err); + if let Err(err) = sync_client_clone.sync_file(data).await { + error!("Could not sync file: {err}"); + lsp_client_clone + .show_message(MessageType::ERROR, err.to_string()) + .await; } } - Err(err) => error!("Could not process file event: {err}"), + Err(err) => { + error!("Could not process file event: {err}"); + lsp_client_clone + .show_message( + MessageType::ERROR, + "File watcher internal error, check LSP server logs".to_owned(), + ) + .await; + } } }) as Pin + Send>> }); @@ -79,29 +97,46 @@ async fn process_event(event: &Event) -> Result> { mod tests { #![allow(clippy::unwrap_used)] - use assert_fs::{TempDir, prelude::*}; - use common::sync::MockGithubClient; - use mockall::{Sequence, predicate}; + use assert_fs::{NamedTempFile, TempDir, prelude::*}; + use common::sync::{Error, FileError, MockGithubClient}; + use mockall::predicate; + use notify::event::{AccessKind, DataChange}; use paste::paste; + use tokio::runtime::Runtime; use super::*; - use crate::watching::MockWatchedSet; + use crate::{ + mocks::MockLspClient, + watching::{EventHandler, MockWatchedSet}, + }; - #[tokio::test] - async fn test_successful_creation() { + #[test] + fn test_successful_creation() { let ctx = MockWatchedSet::new_context(); ctx.expect().returning(|_| Ok(MockWatchedSet::default())); - assert!(PathStore::new(Arc::new(MockGithubClient::default())).is_ok()); + assert!( + PathStore::new( + Arc::new(MockGithubClient::default()), + Arc::new(MockLspClient::default()) + ) + .is_ok() + ); } - #[tokio::test] - async fn test_unsuccessful_creation_when_watched_set_creation_failed() { + #[test] + fn test_unsuccessful_creation_when_watched_set_creation_failed() { let ctx = MockWatchedSet::new_context(); ctx.expect() .returning(|_| Err(anyhow!("Failed to create watched set"))); - assert!(PathStore::new(Arc::new(MockGithubClient::default())).is_err()); + assert!( + PathStore::new( + Arc::new(MockGithubClient::default()), + Arc::new(MockLspClient::default()) + ) + .is_err() + ); } macro_rules! setup_watched_set_mock { @@ -109,7 +144,7 @@ mod tests { paste! { let ctx = MockWatchedSet::new_context(); ctx.expect().returning(move |_| { - let mut seq = Sequence::new(); + let mut seq = mockall::Sequence::new(); let mut mock_watched_set = MockWatchedSet::default(); mock_watched_set .expect_start_watcher() @@ -117,7 +152,7 @@ mod tests { .returning(|| ()); mock_watched_set .[]() - .with(predicate::eq($path)) + .with(mockall::predicate::eq($path)) .in_sequence(&mut seq) .returning(|_| Ok(())); @@ -129,7 +164,7 @@ mod tests { paste! { let ctx = MockWatchedSet::new_context(); ctx.expect().returning(move |_| { - let mut seq = Sequence::new(); + let mut seq = mockall::Sequence::new(); let mut mock_watched_set = MockWatchedSet::default(); mock_watched_set .expect_start_watcher() @@ -137,7 +172,7 @@ mod tests { .returning(|| ()); mock_watched_set .[]() - .with(predicate::eq($path)) + .with(mockall::predicate::eq($path)) .in_sequence(&mut seq) .returning(|_| Err(anyhow!($err_msg))); @@ -147,8 +182,8 @@ mod tests { }; } - #[tokio::test] - async fn test_successful_watch_path() -> Result<()> { + #[test] + fn test_successful_watch_path() -> Result<()> { let dir = TempDir::new()?; dir.child("foobar").touch()?; let path = dir.path().to_path_buf(); @@ -156,15 +191,18 @@ mod tests { setup_watched_set_mock!(watch, path.clone()); - let mut store = PathStore::new(Arc::new(MockGithubClient::default()))?; + let mut store = PathStore::new( + Arc::new(MockGithubClient::default()), + Arc::new(MockLspClient::default()), + )?; store.start_watcher(); store.watch(path_clone)?; Ok(()) } - #[tokio::test] - async fn test_unsuccessful_watch_path() -> Result<()> { + #[test] + fn test_unsuccessful_watch_path() -> Result<()> { let dir = TempDir::new()?; dir.child("foobar").touch()?; let path = dir.path().to_path_buf(); @@ -172,7 +210,10 @@ mod tests { setup_watched_set_mock!(watch, path.clone(), "Path already being watched"); - let mut store = PathStore::new(Arc::new(MockGithubClient::default()))?; + let mut store = PathStore::new( + Arc::new(MockGithubClient::default()), + Arc::new(MockLspClient::default()), + )?; store.start_watcher(); assert_eq!( @@ -183,8 +224,8 @@ mod tests { Ok(()) } - #[tokio::test] - async fn test_successful_unwatch_path() -> Result<()> { + #[test] + fn test_successful_unwatch_path() -> Result<()> { let dir = TempDir::new()?; dir.child("foobar").touch()?; let path = dir.path().to_path_buf(); @@ -192,15 +233,18 @@ mod tests { setup_watched_set_mock!(unwatch, path.clone()); - let mut store = PathStore::new(Arc::new(MockGithubClient::default()))?; + let mut store = PathStore::new( + Arc::new(MockGithubClient::default()), + Arc::new(MockLspClient::default()), + )?; store.start_watcher(); store.unwatch(&path_clone)?; Ok(()) } - #[tokio::test] - async fn test_unsuccessful_unwatch_path() -> Result<()> { + #[test] + fn test_unsuccessful_unwatch_path() -> Result<()> { let dir = TempDir::new()?; dir.child("foobar").touch()?; let path = dir.path().to_path_buf(); @@ -208,7 +252,10 @@ mod tests { setup_watched_set_mock!(unwatch, path.clone(), "Path was not watched"); - let mut store = PathStore::new(Arc::new(MockGithubClient::default()))?; + let mut store = PathStore::new( + Arc::new(MockGithubClient::default()), + Arc::new(MockLspClient::default()), + )?; store.start_watcher(); assert_eq!( @@ -219,35 +266,177 @@ mod tests { Ok(()) } - /* - - Integration tests, here or just for WatchedSet (TODO) - - - events handling - - test create file does not trigger event handler - - create a store with the MockGithubClient passed - - start watcher - - add a new path to watch (assert_fs::TempDir), maybe with an already existing file - - create a new file in that dir - - ensure event was not triggered (MockGithubClient) - - test delete file does not trigger event handler - - create a store with the MockGithubClient passed - - start watcher - - add a new path to watch (assert_fs::TempDir), with an already existing file - - delete the file - - ensure event was not triggered (MockGithubClient) - - test modify file data triggers event handler - - create a store with the MockGithubClient passed - - start watcher - - add a new path to watch (assert_fs::TempDir), with an already existing file - - modify the file data - - ensure event was triggered (MockGithubClient) - - test modify file data outside of watched paths does not trigger event handler - - create a store with the MockGithubClient passed - - start watcher - - add a new path to watch (assert_fs::TempDir) - - create another assert_fs::TempDir with an existing file - - modify that file data - - ensure event was not triggered (MockGithubClient) - */ + #[test] + fn test_non_modify_event_handling() -> Result<()> { + let ctx = MockWatchedSet::new_context(); + ctx.expect().returning(move |event_handler: EventHandler| { + let event = Event::new(EventKind::Access(AccessKind::Read)); + + let rt = Runtime::new()?; + rt.block_on(async { + event_handler(event).await; + }); + + let mock_watched_set = MockWatchedSet::default(); + Ok(mock_watched_set) + }); + + let mut mock_sync_client = MockGithubClient::default(); + mock_sync_client.expect_sync_file().never(); + + PathStore::new( + Arc::new(mock_sync_client), + Arc::new(MockLspClient::default()), + )?; + + Ok(()) + } + + #[test] + fn test_modify_event_handling_without_modified_path() -> Result<()> { + let ctx = MockWatchedSet::new_context(); + ctx.expect().returning(move |event_handler: EventHandler| { + let event = Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Any))); + + let rt = Runtime::new()?; + rt.block_on(async { + event_handler(event).await; + }); + + let mock_watched_set = MockWatchedSet::default(); + Ok(mock_watched_set) + }); + + let mut mock_sync_client = MockGithubClient::default(); + mock_sync_client.expect_sync_file().never(); + + let mut mock_lsp_client = MockLspClient::default(); + mock_lsp_client + .expect_show_message() + .with( + predicate::eq(MessageType::ERROR), + predicate::eq("File watcher internal error, check LSP server logs".to_owned()), + ) + .return_once(|_msg_type, _msg| Box::pin(async {})); + + PathStore::new(Arc::new(mock_sync_client), Arc::new(mock_lsp_client))?; + + Ok(()) + } + + #[test] + fn test_modify_event_handling_with_file_read_error() -> Result<()> { + let ctx = MockWatchedSet::new_context(); + ctx.expect().returning(move |event_handler: EventHandler| { + let mut event = Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Any))); + event = event.add_path(PathBuf::from("non-existent-file")); + + let rt = Runtime::new()?; + rt.block_on(async { + event_handler(event).await; + }); + + let mock_watched_set = MockWatchedSet::default(); + Ok(mock_watched_set) + }); + + let mut mock_sync_client = MockGithubClient::default(); + mock_sync_client.expect_sync_file().never(); + + let mut mock_lsp_client = MockLspClient::default(); + mock_lsp_client + .expect_show_message() + .with( + predicate::eq(MessageType::ERROR), + predicate::eq("File watcher internal error, check LSP server logs".to_owned()), + ) + .return_once(|_msg_type, _msg| Box::pin(async {})); + + PathStore::new(Arc::new(mock_sync_client), Arc::new(mock_lsp_client))?; + + Ok(()) + } + + #[test] + fn test_non_modify_event_handling_with_sync_success() -> Result<()> { + let temp_file = NamedTempFile::new("settings.json")?; + temp_file.write_str(r#"{ "hello": "kitty" }"#)?; + let temp_file_path = temp_file.path().to_path_buf(); + + let ctx = MockWatchedSet::new_context(); + ctx.expect().returning(move |event_handler: EventHandler| { + let mut event = Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))); + event = event.add_path(temp_file.path().to_path_buf()); + + let rt = Runtime::new()?; + rt.block_on(async { + event_handler(event).await; + }); + + let mock_watched_set = MockWatchedSet::default(); + Ok(mock_watched_set) + }); + + let file_data = LocalFileData::new(temp_file_path, r#"{ "hello": "kitty" }"#.into())?; + + let mut mock_sync_client = MockGithubClient::default(); + mock_sync_client + .expect_sync_file() + .with(predicate::eq(file_data)) + .return_once(|_| Ok(())); + + let mut mock_lsp_client = MockLspClient::default(); + mock_lsp_client.expect_show_message().never(); + + PathStore::new(Arc::new(mock_sync_client), Arc::new(mock_lsp_client))?; + + Ok(()) + } + + #[test] + fn test_non_modify_event_handling_with_sync_failure() -> Result<()> { + let temp_file = NamedTempFile::new("settings.json")?; + temp_file.write_str(r#"{ "hello": "kitty" }"#)?; + let temp_file_path = temp_file.path().to_path_buf(); + + let ctx = MockWatchedSet::new_context(); + ctx.expect().returning(move |event_handler: EventHandler| { + let mut event = Event::new(EventKind::Modify(ModifyKind::Data(DataChange::Content))); + event = event.add_path(temp_file.path().to_path_buf()); + + let rt = Runtime::new()?; + rt.block_on(async { + event_handler(event).await; + }); + + let mock_watched_set = MockWatchedSet::default(); + Ok(mock_watched_set) + }); + + let file_data = LocalFileData::new(temp_file_path, r#"{ "hello": "kitty" }"#.into())?; + + let mut mock_sync_client = MockGithubClient::default(); + mock_sync_client + .expect_sync_file() + .with(predicate::eq(file_data)) + .return_once(|_| { + Err(FileError::from_error( + "settings.json", + Error::UnhandledInternal("Sync error".into()), + )) + }); + + let mut mock_lsp_client = MockLspClient::default(); + mock_lsp_client + .expect_show_message() + .with( + predicate::eq(MessageType::ERROR), + predicate::eq("Error syncing file settings.json: Unhandled internal error from underlying client library: Sync error".to_owned()), + ) + .return_once(|_msg_type, _msg| Box::pin(async {})); + + PathStore::new(Arc::new(mock_sync_client), Arc::new(mock_lsp_client))?; + + Ok(()) + } } diff --git a/lsp/src/watching/watched_set.rs b/lsp/src/watching/watched_set.rs index 07346e4..5d607ad 100644 --- a/lsp/src/watching/watched_set.rs +++ b/lsp/src/watching/watched_set.rs @@ -63,4 +63,35 @@ impl WatchedSet { } #[cfg(test)] -mod tests {} +mod tests { + /* + Tests TODO + + - events handling + - test create file does not trigger event handler + - create a store with the MockGithubClient passed + - start watcher + - add a new path to watch (assert_fs::TempDir), maybe with an already existing file + - create a new file in that dir + - ensure event was not triggered (MockGithubClient) + - test delete file does not trigger event handler + - create a store with the MockGithubClient passed + - start watcher + - add a new path to watch (assert_fs::TempDir), with an already existing file + - delete the file + - ensure event was not triggered (MockGithubClient) + - test modify file data triggers event handler + - create a store with the MockGithubClient passed + - start watcher + - add a new path to watch (assert_fs::TempDir), with an already existing file + - modify the file data + - ensure event was triggered (MockGithubClient) + - test modify file data outside of watched paths does not trigger event handler + - create a store with the MockGithubClient passed + - start watcher + - add a new path to watch (assert_fs::TempDir) + - create another assert_fs::TempDir with an existing file + - modify that file data + - ensure event was not triggered (MockGithubClient) + */ +}