michaelitindi / tailcall

High Performance GraphQL Runtime
https://tailcall.run
Apache License 2.0
0 stars 0 forks source link

Add a --watch feature #1

Open michaelitindi opened 1 month ago

michaelitindi commented 1 month ago

Use rs-notify library to identify if the source files have changed and restart the server when it does.

Example Usage

tailcall start ./jp.graphql --watch Technical Requirements

Restart should happen automatically when the main config or any of the linked file changes. There should be no leaks in restarts and should happen seamlessly. Should not crash the server if there is a validation error.

codeautopilot[bot] commented 1 month ago

Potential solution

To add a --watch feature to the tailcall CLI, we need to integrate the rs-notify library to monitor file changes and restart the server automatically when changes are detected. This involves updating the start_command function to include file-watching capabilities, modifying the start command to accept the --watch flag, adding the rs-notify dependency to the Cargo.toml file, and ensuring proper error handling for file-watching errors.

How to implement

Step 1: Add rs-notify Dependency

Add the rs-notify crate to your Cargo.toml file to enable file-watching capabilities.

Cargo.toml

[dependencies]
rs-notify = "5.0.0"

Step 2: Update start_command Function

Modify the start_command function in src/cli/tc/start.rs to include file-watching capabilities using rs-notify.

src/cli/tc/start.rs

use anyhow::Result;
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use std::sync::mpsc::channel;
use std::time::Duration;
use std::thread;

use super::helpers::log_endpoint_set;
use crate::cli::fmt::Fmt;
use crate::cli::server::Server;
use crate::core::config::reader::ConfigReader;

pub(super) async fn start_command(
    file_paths: Vec<String>,
    config_reader: &ConfigReader,
    watch: bool,
) -> Result<()> {
    if watch {
        let (tx, rx) = channel();
        let mut watcher = watcher(tx, Duration::from_secs(2))?;

        for path in &file_paths {
            watcher.watch(path, RecursiveMode::Recursive)?;
        }

        loop {
            match rx.recv() {
                Ok(DebouncedEvent::Write(_)) | Ok(DebouncedEvent::Create(_)) | Ok(DebouncedEvent::Remove(_)) => {
                    if let Err(e) = restart_server(&file_paths, config_reader).await {
                        eprintln!("Error restarting server: {:?}", e);
                    }
                },
                Err(e) => eprintln!("Watch error: {:?}", e),
                _ => (),
            }
        }
    } else {
        restart_server(&file_paths, config_reader).await?;
    }
    Ok(())
}

async fn restart_server(file_paths: &Vec<String>, config_reader: &ConfigReader) -> Result<()> {
    let config_module = config_reader.read_all(file_paths).await?;
    log_endpoint_set(&config_module.extensions().endpoint_set);
    Fmt::log_n_plus_one(false, config_module.config());
    let server = Server::new(config_module);
    server.fork_start().await?;
    Ok(())
}

Step 3: Update start Command to Accept --watch Flag

Modify the Start variant of the Command enum in src/cli/command.rs to include an optional --watch flag.

src/cli/command.rs

use clap::{Parser, Subcommand};
use strum_macros::Display;
use tailcall_version::VERSION;

use crate::core::config;

const ABOUT: &str = r"
   __        _ __           ____
  / /_____ _(_) /________ _/ / /
 / __/ __ `/ / / ___/ __ `/ / /
/ /_/ /_/ / / / /__/ /_/ / / /
\__/\__,_/_/_/\___/\__,_/_/_/";

#[derive(Parser)]
#[command(name = "tailcall", author, version = VERSION.as_str(), about, long_about = Some(ABOUT))]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Display)]
pub enum Command {
    /// Starts the GraphQL server on the configured port
    Start {
        /// Path for the configuration files or http(s) link to config files
        /// separated by spaces if more than one
        #[arg(required = true)]
        file_paths: Vec<String>,

        /// Watch for file changes and restart the server automatically
        #[arg(short, long)]
        watch: bool,
    },

    /// Validate a composition spec
    Check {
        /// Path for the configuration files separated by spaces if more than
        /// one
        #[arg(required = true)]
        file_paths: Vec<String>,

        /// N plus one queries
        #[arg(short, long)]
        n_plus_one_queries: bool,

        /// Display schema
        #[arg(short, long)]
        schema: bool,

        /// Prints the input config in the provided format
        #[clap(short, long)]
        format: Option<config::Source>,
    },

    /// Initialize a new project
    Init {
        // default is current directory
        #[arg(default_value = ".")]
        folder_path: String,
    },

    /// Generates a Tailcall Configuration from one or more source files.
    Gen {
        /// Path of the configuration file
        #[arg(required = true)]
        file_path: String,
    },
}

Step 4: Handle File-Watching Errors

Ensure that any errors arising from the rs-notify library are properly captured and converted into CLIError instances in src/cli/error.rs.

src/cli/error.rs

use std::fmt::{Debug, Display};

use colored::Colorize;
use derive_setters::Setters;
use thiserror::Error;
use notify::Error as NotifyError; // Import notify error

use crate::core::valid::ValidationError;

#[derive(Debug, Error, Setters, PartialEq, Clone)]
pub struct CLIError {
    is_root: bool,
    #[setters(skip)]
    color: bool,
    message: String,
    #[setters(strip_option)]
    description: Option<String>,
    trace: Vec<String>,

    #[setters(skip)]
    caused_by: Vec<CLIError>,
}

impl CLIError {
    pub fn new(message: &str) -> Self {
        CLIError {
            is_root: true,
            color: false,
            message: message.to_string(),
            description: Default::default(),
            trace: Default::default(),
            caused_by: Default::default(),
        }
    }

    pub fn caused_by(mut self, error: Vec<CLIError>) -> Self {
        self.caused_by = error;

        for error in self.caused_by.iter_mut() {
            error.is_root = false;
        }

        self
    }

    fn colored<'a>(&'a self, str: &'a str, color: colored::Color) -> String {
        if self.color {
            str.color(color).to_string()
        } else {
            str.to_string()
        }
    }

    fn dimmed<'a>(&'a self, str: &'a str) -> String {
        if self.color {
            str.dimmed().to_string()
        } else {
            str.to_string()
        }
    }

    pub fn color(mut self, color: bool) -> Self {
        self.color = color;
        for inner in self.caused_by.iter_mut() {
            inner.color = color;
        }
        self
    }
}

fn margin(str: &str, margin: usize) -> String {
    let mut result = String::new();
    for line in str.split_inclusive('\n') {
        result.push_str(&format!("{}{}", " ".repeat(margin), line));
    }
    result
}

fn bullet(str: &str) -> String {
    let mut chars = margin(str, 2).chars().collect::<Vec<char>>();
    chars[0] = '•';
    chars[1] = ' ';
    chars.into_iter().collect::<String>()
}

impl Display for CLIError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let default_padding = 2;

        let message_color = if self.is_root {
            colored::Color::Yellow
        } else {
            colored::Color::White
        };

        f.write_str(self.colored(&self.message, message_color).as_str())?;

        if let Some(description) = &self.description {
            f.write_str(&self.colored(": ", message_color))?;
            f.write_str(&self.colored(description.to_string().as_str(), colored::Color::White))?;
        }

        if !self.trace.is_empty() {
            let mut buf = String::new();
            buf.push_str(" [at ");
            let len = self.trace.len();
            for (i, trace) in self.trace.iter().enumerate() {
                buf.push_str(&trace.to_string());
                if i < len - 1 {
                    buf.push('.');
                }
            }
            buf.push(']');
            f.write_str(&self.colored(&buf, colored::Color::Cyan))?;
        }

        if !self.caused_by.is_empty() {
            f.write_str("\n")?;
            f.write_str(self.dimmed("Caused by:").as_str())?;
            f.write_str("\n")?;
            for (i, error) in self.caused_by.iter().enumerate() {
                let message = &error.to_string();

                f.write_str(&margin(bullet(message.as_str()).as_str(), default_padding))?;

                if i < self.caused_by.len() - 1 {
                    f.write_str("\n")?;
                }
            }
        }

        Ok(())
    }
}

impl From<hyper::Error> for CLIError {
    fn from(error: hyper::Error) -> Self {
        // TODO: add type-safety to CLIError conversion
        let cli_error = CLIError::new("Server Failed");
        let message = error.to_string();
        if message.to_lowercase().contains("os error 48") {
            cli_error
                .description("The port is already in use".to_string())
                .caused_by(vec![CLIError::new(message.as_str())])
        } else {
            cli_error.description(message)
        }
    }
}

impl From<rustls::Error> for CLIError {
    fn from(error: rustls::Error) -> Self {
        let cli_error = CLIError::new("Failed to create TLS Acceptor");
        let message = error.to_string();

        cli_error.description(message)
    }
}

impl From<anyhow::Error> for CLIError {
    fn from(error: anyhow::Error) -> Self {
        // Convert other errors to CLIError
        let cli_error = match error.downcast::<CLIError>() {
            Ok(cli_error) => cli_error,
            Err(error) => {
                // Convert other errors to CLIError
                let cli_error = match error.downcast::<ValidationError<String>>() {
                    Ok(validation_error) => CLIError::from(validation_error),
                    Err(error) => {
                        let sources = error
                            .source()
                            .map(|error| vec![CLIError::new(error.to_string().as_str())])
                            .unwrap_or_default();

                        CLIError::new(&error.to_string()).caused_by(sources)
                    }
                };
                cli_error
            }
        };
        cli_error
    }
}

impl From<std::io::Error> for CLIError {
    fn from(error: std::io::Error) -> Self {
        let cli_error = CLIError::new("IO Error");
        let message = error.to_string();

        cli_error.description(message)
    }
}

impl<'a> From<ValidationError<&'a str>> for CLIError {
    fn from(error: ValidationError<&'a str>) -> Self {
        CLIError::new("Invalid Configuration").caused_by(
            error
                .as_vec()
                .iter()
                .map(|cause| {
                    let mut err =
                        CLIError::new(cause.message).trace(Vec::from(cause.trace.clone()));
                    if let Some(description) = cause.description {
                        err = err.description(description.to_owned());
                    }
                    err
                })
                .collect(),
        )
    }
}

impl From<ValidationError<String>> for CLIError {
    fn from(error: ValidationError<String>) -> Self {
        CLIError::new("Invalid Configuration").caused_by(
            error
                .as_vec()
                .iter()
                .map(|cause| {
                    CLIError::new(cause.message.as_str()).trace(Vec::from(cause.trace.clone()))
                })
                .collect(),
        )
    }
}

impl From<Box<dyn std::error::Error>> for CLIError {
    fn from(value: Box<dyn std::error::Error>) -> Self {
        CLIError::new(value.to_string().as_str())
    }
}

// New implementation for notify::Error
impl From<NotifyError> for CLIError {
    fn from(error: NotifyError) -> Self {
        let cli_error = CLIError::new("File Watching Error");
        let message = error.to_string();

        cli_error.description(message)
    }
}

#[cfg(test)]
mod tests {

    use pretty_assertions::assert_eq;
    use stripmargin::StripMargin;

    use super::*;
    use crate::core::valid::Cause;

    #[test]
    fn test_no_newline() {
        let input = "Hello";
        let expected = "    Hello";
        assert_eq!(margin(input, 4), expected);
    }

    #[test]
    fn test_with_newline() {
        let input = "Hello\nWorld";
        let expected = "    Hello\n    World";
        assert_eq!(margin(input, 4), expected);
    }

    #[test]
    fn test_empty_string() {
        let input = "";
        let expected = "";
        assert_eq!(margin(input, 4), expected);
    }

    #[test]
    fn test_zero_margin() {
        let input = "Hello";
        let expected = "Hello";
        assert_eq!(margin(input, 0), expected);
    }

    #[test]
    fn test_zero_margin_with_newline() {
        let input = "Hello\nWorld";
        let expected = "Hello\nWorld";
        assert_eq!(margin(input, 0), expected);
    }

    #[test]
    fn test_title() {
        let error = CLIError::new("Server could not be started");
        let expected = r"Server could not be started".strip_margin();
        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_description() {
        let error = CLIError::new("Server could not be started")
            .description("The port is already in use".to_string());
        let expected = r"|Server could not be started: The port is already in use".strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_description_trace() {
        let error = CLIError::new("Server could not be started")
            .description("The port is already in use".to_string())
            .trace(vec!["@server".into(), "port".into()]);

        let expected =
            r"|Server could not be started: The port is already in use [at @server.port]"
                .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_trace_caused_by() {
        let error = CLIError::new("Configuration Error").caused_by(vec![CLIError::new(
            "Base URL needs to be specified",
        )
        .trace(vec![
            "User".into(),
            "posts".into(),
            "@http".into(),
            "baseURL".into(),
        ])]);

        let expected = r"|Configuration Error
                     |Caused by:
                     |  • Base URL needs to be specified [at User.posts.@http.baseURL]"
            .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_trace_multiple_caused_by() {
        let error = CLIError::new("Configuration Error").caused_by(vec![
            CLIError::new("Base URL needs to be specified").trace(vec![
                "User".into(),
                "posts".into(),
                "@http".into(),
                "baseURL".into(),
            ]),
            CLIError::new("Base URL needs to be specified").trace(vec![
                "Post".into(),
                "users".into(),
                "@http".into(),
                "baseURL".into(),
            ]),
            CLIError::new("Base URL needs to be specified")
                .description("Set `baseURL` in @http or @server directives".into())
                .trace(vec![
                    "Query".into(),
                    "users".into(),
                    "@http".into(),
                    "baseURL".into(),
                ]),
            CLIError::new("Base URL needs to be specified").trace(vec![
                "Query".into(),
                "posts".into(),
                "@http".into(),
                "baseURL".into(),
            ]),
        ]);

        let expected = r"|Configuration Error
                     |Caused by:
                     |  • Base URL needs to be specified [at User.posts.@http.baseURL]
                     |  • Base URL needs to be specified [at Post.users.@http.baseURL]
                     |  • Base URL needs to be specified: Set `baseURL` in @http or @server directives [at Query.users.@http.baseURL]
                     |  • Base URL needs to be specified [at Query.posts.@http.baseURL]"
            .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_from_validation() {
        let cause = Cause::new("Base URL needs to be specified")
            .description("Set `baseURL` in @http or @server directives")
            .trace(vec!["Query", "users", "@http", "baseURL"]);
        let valid = ValidationError::from(cause);
        let error = CLIError::from(valid);
        let expected = r"|Invalid Configuration
                     |Caused by:
                     |  • Base URL needs to be specified: Set `baseURL` in @http or @server directives [at Query.users.@http.baseURL]"
            .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_cli_error_identity() {
        let cli_error = CLIError::new("Server could not be started")
            .description("The port is already in use".to_string())
            .trace(vec!["@server".into(), "port".into()]);
        let anyhow_error: anyhow::Error = cli_error.clone().into();

        let actual = CLIError::from(anyhow_error);
        let expected = cli_error;

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_validation_error_identity() {
        let validation_error = ValidationError::from(
            Cause::new("Test Error".to_string()).trace(vec!["Query".to_string()]),
        );
        let anyhow_error: anyhow::Error = validation_error.clone().into();

        let actual = CLIError::from(anyhow_error);
        let expected = CLIError::from(validation_error);

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_generic_error() {
        let anyhow_error = anyhow::anyhow!("Some error msg");

        let actual: CLIError = CLIError::from(anyhow_error);
        let expected = CLIError::new("Some error msg");

        assert_eq!(actual, expected);
    }
}

Summary

  1. **

Click here to create a Pull Request with the proposed solution

Files used for this task:

Changes on src/cli/tc/start.rs To integrate the `rs-notify` library into the `start_command` function in `src/cli/tc/start.rs`, follow these steps: 1. **Add `rs-notify` to the dependencies**: This will be done in the `Cargo.toml` file, as specified in the plan. 2. **Update the `start_command` function**: Modify the function to include file-watching capabilities using `rs-notify`. When a change is detected, the server should be restarted. Here is a concrete proposal for updating the `start_command` function: ### Updated `src/cli/tc/start.rs` ```rust use anyhow::Result; use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher}; use std::sync::mpsc::channel; use std::time::Duration; use std::thread; use super::helpers::log_endpoint_set; use crate::cli::fmt::Fmt; use crate::cli::server::Server; use crate::core::config::reader::ConfigReader; pub(super) async fn start_command( file_paths: Vec, config_reader: &ConfigReader, watch: bool, ) -> Result<()> { if watch { let (tx, rx) = channel(); let mut watcher = watcher(tx, Duration::from_secs(2))?; for path in &file_paths { watcher.watch(path, RecursiveMode::Recursive)?; } loop { match rx.recv() { Ok(DebouncedEvent::Write(_)) | Ok(DebouncedEvent::Create(_)) | Ok(DebouncedEvent::Remove(_)) => { if let Err(e) = restart_server(&file_paths, config_reader).await { eprintln!("Error restarting server: {:?}", e); } }, Err(e) => eprintln!("Watch error: {:?}", e), _ => (), } } } else { restart_server(&file_paths, config_reader).await?; } Ok(()) } async fn restart_server(file_paths: &Vec, config_reader: &ConfigReader) -> Result<()> { let config_module = config_reader.read_all(file_paths).await?; log_endpoint_set(&config_module.extensions().endpoint_set); Fmt::log_n_plus_one(false, config_module.config()); let server = Server::new(config_module); server.fork_start().await?; Ok(()) } ``` ### Explanation: 1. **Channel and Watcher Setup**: A channel is created to receive file change events. The `watcher` is set up with a debounce duration of 2 seconds to avoid multiple triggers for the same event. 2. **Watching Files**: Each file path in `file_paths` is added to the watcher. 3. **Event Loop**: The loop waits for file change events. When a change is detected (write, create, or remove events), the `restart_server` function is called to restart the server. 4. **Restart Server Function**: The `restart_server` function encapsulates the logic to read the configuration and start the server. This function is called both initially (if not watching) and on each file change event. 5. **Error Handling**: Errors during the restart process are printed to the standard error output. This approach ensures that the server restarts seamlessly upon file changes, adhering to the requirements specified in the ticket.
Changes on src/cli/command.rs To update the `start` command to accept the `--watch` flag, you need to modify the `Start` variant of the `Command` enum to include an optional `--watch` flag. Here's how you can do it: 1. Add a new field to the `Start` variant to capture the `--watch` flag. 2. Update the `arg` macro to include the new flag. Here's the updated code for `src/cli/command.rs`: ```rust use clap::{Parser, Subcommand}; use strum_macros::Display; use tailcall_version::VERSION; use crate::core::config; const ABOUT: &str = r" __ _ __ ____ / /_____ _(_) /________ _/ / / / __/ __ `/ / / ___/ __ `/ / / / /_/ /_/ / / / /__/ /_/ / / / \__/\__,_/_/_/\___/\__,_/_/_/"; #[derive(Parser)] #[command(name = "tailcall", author, version = VERSION.as_str(), about, long_about = Some(ABOUT))] pub struct Cli { #[command(subcommand)] pub command: Command, } #[derive(Subcommand, Display)] pub enum Command { /// Starts the GraphQL server on the configured port Start { /// Path for the configuration files or http(s) link to config files /// separated by spaces if more than one #[arg(required = true)] file_paths: Vec, /// Watch for file changes and restart the server automatically #[arg(short, long)] watch: bool, }, /// Validate a composition spec Check { /// Path for the configuration files separated by spaces if more than /// one #[arg(required = true)] file_paths: Vec, /// N plus one queries #[arg(short, long)] n_plus_one_queries: bool, /// Display schema #[arg(short, long)] schema: bool, /// Prints the input config in the provided format #[clap(short, long)] format: Option, }, /// Initialize a new project Init { // default is current directory #[arg(default_value = ".")] folder_path: String, }, /// Generates a Tailcall Configuration from one or more source files. Gen { /// Path of the configuration file #[arg(required = true)] file_path: String, }, } ``` ### Explanation: - Added a new field `watch` of type `bool` to the `Start` variant. - Used the `#[arg(short, long)]` attribute to specify that the `--watch` flag can be used in both short (`-w`) and long (`--watch`) forms. This change will allow the `start` command to accept the `--watch` flag, which can then be used in the implementation to trigger the file-watching functionality.
Changes on Cargo.toml To address the task of adding a `--watch` feature using the `rs-notify` library, the first step is to add `rs-notify` to the dependencies section in the `Cargo.toml` file. Here’s how you can do it: 1. **Add `rs-notify` Dependency:** - Open the `Cargo.toml` file. - Add the `rs-notify` crate to the `[dependencies]` section. Here is the updated `Cargo.toml` file with the `rs-notify` dependency added: ```toml [package] name = "tailcall" version = "0.1.0" edition = "2021" [[bin]] name = "tailcall" path = "src/main.rs" [workspace.dependencies] anyhow = "1.0.82" async-graphql = { version = "7.0.3" } futures-util = { version = "0.3.30" } indexmap = "2.2.6" insta = { version = "1.38.0", features = ["json"] } tokio = { version = "1.37.0", features = ["rt", "time"] } reqwest = { version = "0.11", features = [ "json", "rustls-tls", ], default-features = false } tracing = "0.1.40" lazy_static = "1.4.0" serde_json = { version = "1.0.116", features = ["preserve_order"] } serde = { version = "1.0.200", features = ["derive"] } derive_more = "0.99.18" thiserror = "1.0.59" url = { version = "2.5.0", features = ["serde"] } [dependencies] # dependencies specific to CLI must have optional = true and the dep should be added to default feature. # one needs to add default feature tag if it is something IO related or might conflict with WASM mimalloc = { version = "0.1.41", default-features = false, optional = true } http-cache-reqwest = { version = "0.13.0", features = [ "manager-moka", ], default-features = false, optional = true } moka = { version = "0.12.7", default-features = false, features = [ "future", ], optional = true } hyper-rustls = { version = "0.25.0", optional = true } rustls = { version = "0.23.5", optional = true, features = [ "std", ], default-features = false } rustls-pki-types = "1.5.0" inquire = { version = "0.7.5", optional = true } opentelemetry-otlp = { version = "0.16.0", features = [ "trace", "logs", "metrics", # required to make grpc requests "tls-roots", ], optional = true } opentelemetry-system-metrics = { version = "0.2.0", optional = true } tailcall-http-cache = { path = "tailcall-http-cache", optional = true } tailcall-version = { path = "./tailcall-version", optional = true } genai = { git = "https://github.com/laststylebender14/rust-genai.git", rev = "63a542ce20132503c520f4e07108e0d768f243c3", optional = true } rs-notify = "5.0.0" # Add this line # dependencies safe for wasm: rustls-pemfile = { version = "1.0.4" } schemars = { version = "0.8.17", features = ["derive"] } hyper = { version = "0.14.28", features = ["server"], default-features = false } tokio = { workspace = true } anyhow = { workspace = true } reqwest = { workspace = true } derive_setters = "0.1.6" derive-getters = "0.5.0" thiserror = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } serde_qs = "0.13" serde_yaml = "0.9.34" serde_urlencoded = "0.7.1" url = { workspace = true } indexmap = { workspace = true } once_cell = "1.19.0" clap = { version = "4.5.4", features = ["derive"] } colored = "2.1.0" regex = "1.10.4" reqwest-middleware = "0.2.5" async-trait = "0.1.80" serde_path_to_error = "0.1.16" cache_control = "0.2.0" nom = "7.1.3" exitcode = "1.1.2" resource = "0.5.0" stripmargin = "0.1.1" num_cpus = "1.16.0" fnv = "1.0.7" futures-channel = { version = "0.3.30" } futures-timer = { version = "3.0.3", features = ["wasm-bindgen"] } futures-util = { workspace = true } lru = { version = "0.12.3" } async-std = { version = "1.12.0", features = [ "wasm-bindgen-futures", "unstable", ] } ttl_cache = "0.5.1" protox = "0.7.0" protox-parse = "0.7.0" prost-reflect = { version = "0.14.0", features = ["serde"] } prost = "0.13.1" update-informer = { version = "1.1.0", default-features = false, features = [ "github", "reqwest", ], optional = true } lazy_static = { workspace = true } which = { version = "6.0.1", optional = true } async-recursion = "1.1.1" rquickjs = { "version" = "0.5.1", optional = true, features = ["macro"] } strum_macros = "0.26.4" # TODO: disable some levels with features? tracing = { workspace = true } tracing-subscriber = { version = "0.3.18", features = [ "default", "fmt", "env-filter", ] } tracing-opentelemetry = "0.24.0" getrandom = { version = "0.2.14", features = ["js"] } prometheus = "0.13.4" tonic = { version = "0.11.0", default-features = false } opentelemetry-semantic-conventions = "0.15.0" opentelemetry = { version = "0.23.0", features = ["trace", "logs", "metrics"] } opentelemetry_sdk = { version = "0.23.0", features = [ "trace", "logs", "metrics", ] } opentelemetry-http = "0.12.0" opentelemetry-stdout = { version = "0.4.0", features = [ "trace", "logs", "metrics", ] } opentelemetry-appender-tracing = { version = "0.4.0" } opentelemetry-prometheus = "0.16.0" phonenumber = "0.3.4" chrono = "0.4.38" async-graphql-extension-apollo-tracing = { version = "3.2.15" } headers = "0.3.9" # previous version until hyper is updated to 1+ mime = "0.3.17" htpasswd-verify = { version = "0.3.0", git = "https://github.com/twistedfall/htpasswd-verify", rev = "ff14703083cbd639f7d05622b398926f3e718d61" } # fork version that is wasm compatible jsonwebtoken = "9.3.0" async-graphql-value = "7.0.3" async-graphql = { workspace = true, features = [ "dynamic-schema", "dataloader", "apollo_tracing", "opentelemetry", ] } dotenvy = "0.15.7" convert_case = "0.6.0" rand = "0.8.5" tailcall-macros = { path = "tailcall-macros" } tailcall-tracker = { path = "tailcall-tracker", optional = true } tailcall-typedefs-common = { path = "./tailcall-typedefs-common" } tonic-types = "0.12.1" datatest-stable = "0.2.9" tokio-test = "0.4.4" base64 = "0.22.1" tailcall-hasher = { path = "tailcall-hasher" } serde_json_borrow = "0.5.0" pluralizer = "0.4.0" path-clean = "=1.0.1" pathdiff = "0.2.1" num = "0.4.3" indenter = "0.3.3" derive_more = { workspace = true } enum_dispatch = "0.3.13" strum = "0.26.2" [dev-dependencies] tailcall-prettier = { path = "tailcall-prettier" } criterion = "0.5.1" httpmock = "0.7.0" pretty_assertions = "1.4.0" stripmargin = "0.1.1" markdown = "1.0.0-alpha.17" insta = { workspace = true } tempfile = "3.10.1" temp-env = "0.3.6" maplit = "1.0.2" tailcall-fixtures = { path = "./tailcall-fixtures" } http-cache-semantics = { version = "1.0.1", default-features = false, features = [ "with_serde", "reqwest", ] } cacache = { version = "13.0.0", default-features = false, features = [ "tokio-runtime", "mmap", ] } test-log = { version = "0.2.16", default-features = false, features = [ "color", "trace", ] } flate2 = "1.0.30" bincode = "1.3.3" [features] # Feature Flag to enable V8. # V8 currently is not support on all platforms so, we control it via this feature flag. js = ["dep:rquickjs"] # Feature Flag to core CLI features. # This is created to control what we expose for WASM. # Will be deprecated once we move CLI to it's own crate and WASM builds won't depend on it. cli = [ "tokio/fs", "tokio/rt-multi-thread", "dep:mimalloc", "dep:http-cache-reqwest", "dep:moka", "dep:hyper-rustls", "dep:rustls", "dep:inquire", "dep:which", "dep:update-informer", "opentelemetry_sdk/testing", "opentelemetry_sdk/rt-tokio", "dep:opentelemetry-otlp", "dep:opentelemetry-system-metrics", "dep:tailcall-tracker", "dep:tailcall-http-cache", "dep:tailcall-version", "dep:genai", ] # Feature flag to enable all default features. # This is used by default locally while developing and on CI. # We generally want to interface via CLI and have V8 enabled, while running tests. default = ["cli", "js"] # Feature flag to force JIT engine inside integration tests force_jit = [] [workspace] members = [ ".", "tailcall-typedefs-common", "tailcall-typedefs", "tailcall-aws-lambda", "tailcall-cloudflare", "tailcall-macros", "tailcall-prettier", "tailcall-fixtures", "tailcall-upstream-grpc", "tailcall-tracker", "tailcall-wasm", "tailcall-hasher", "tailcall-http-cache", "tailcall-version", ] # Boost execution_spec snapshot diffing performance [profile.dev.package] insta.opt-level = 3 similar.opt-level = 3 [profile.release] opt-level = 3 codegen-units = 1 panic = 'abort' lto = 'fat' debug = false incremental = false overflow-checks = false [profile.release.package.tailcall-cloudflare] strip = true codegen-units = 1 opt-level = 'z' [[bench]] name = "tailcall_benches" harness = false [[test]] name = "execution_spec" harness = false [[test]] name = "cli_spec" path = "tests/cli/gen.rs" harness = false [[test]] name = "json_to_config_spec" path = "src/core/generator/tests/json_to_config_spec.rs" harness = false ``` This change adds the `rs-notify` crate to your project, which will be used to watch for file changes and trigger server restarts. Next steps involve modifying the other relevant files to integrate this functionality, but those changes will be handled separately.
Changes on src/cli/error.rs To address the task of adding error handling for the file-watching functionality in the `src/cli/error.rs` file, we need to ensure that any errors arising from the `rs-notify` library are properly captured and converted into `CLIError` instances. This will allow the rest of the application to handle these errors gracefully. Here's a concrete plan to achieve this: 1. **Define a new error variant for file-watching errors**: We need to add a new variant to the `CLIError` struct to handle errors specifically related to file watching. 2. **Implement `From` trait for `notify::Error`**: This will allow us to convert errors from the `rs-notify` library into our `CLIError` type. Let's implement these changes step-by-step: ### Step 1: Define a new error variant for file-watching errors Add a new method to the `CLIError` struct to handle file-watching errors. ### Step 2: Implement `From` trait for `notify::Error` Implement the `From` trait for `notify::Error` to convert it into `CLIError`. Here's the updated code for `src/cli/error.rs`: ```rust use std::fmt::{Debug, Display}; use colored::Colorize; use derive_setters::Setters; use thiserror::Error; use notify::Error as NotifyError; // Import notify error use crate::core::valid::ValidationError; #[derive(Debug, Error, Setters, PartialEq, Clone)] pub struct CLIError { is_root: bool, #[setters(skip)] color: bool, message: String, #[setters(strip_option)] description: Option, trace: Vec, #[setters(skip)] caused_by: Vec, } impl CLIError { pub fn new(message: &str) -> Self { CLIError { is_root: true, color: false, message: message.to_string(), description: Default::default(), trace: Default::default(), caused_by: Default::default(), } } pub fn caused_by(mut self, error: Vec) -> Self { self.caused_by = error; for error in self.caused_by.iter_mut() { error.is_root = false; } self } fn colored<'a>(&'a self, str: &'a str, color: colored::Color) -> String { if self.color { str.color(color).to_string() } else { str.to_string() } } fn dimmed<'a>(&'a self, str: &'a str) -> String { if self.color { str.dimmed().to_string() } else { str.to_string() } } pub fn color(mut self, color: bool) -> Self { self.color = color; for inner in self.caused_by.iter_mut() { inner.color = color; } self } } fn margin(str: &str, margin: usize) -> String { let mut result = String::new(); for line in str.split_inclusive('\n') { result.push_str(&format!("{}{}", " ".repeat(margin), line)); } result } fn bullet(str: &str) -> String { let mut chars = margin(str, 2).chars().collect::>(); chars[0] = '•'; chars[1] = ' '; chars.into_iter().collect::() } impl Display for CLIError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let default_padding = 2; let message_color = if self.is_root { colored::Color::Yellow } else { colored::Color::White }; f.write_str(self.colored(&self.message, message_color).as_str())?; if let Some(description) = &self.description { f.write_str(&self.colored(": ", message_color))?; f.write_str(&self.colored(description.to_string().as_str(), colored::Color::White))?; } if !self.trace.is_empty() { let mut buf = String::new(); buf.push_str(" [at "); let len = self.trace.len(); for (i, trace) in self.trace.iter().enumerate() { buf.push_str(&trace.to_string()); if i < len - 1 { buf.push('.'); } } buf.push(']'); f.write_str(&self.colored(&buf, colored::Color::Cyan))?; } if !self.caused_by.is_empty() { f.write_str("\n")?; f.write_str(self.dimmed("Caused by:").as_str())?; f.write_str("\n")?; for (i, error) in self.caused_by.iter().enumerate() { let message = &error.to_string(); f.write_str(&margin(bullet(message.as_str()).as_str(), default_padding))?; if i < self.caused_by.len() - 1 { f.write_str("\n")?; } } } Ok(()) } } impl From for CLIError { fn from(error: hyper::Error) -> Self { // TODO: add type-safety to CLIError conversion let cli_error = CLIError::new("Server Failed"); let message = error.to_string(); if message.to_lowercase().contains("os error 48") { cli_error .description("The port is already in use".to_string()) .caused_by(vec![CLIError::new(message.as_str())]) } else { cli_error.description(message) } } } impl From for CLIError { fn from(error: rustls::Error) -> Self { let cli_error = CLIError::new("Failed to create TLS Acceptor"); let message = error.to_string(); cli_error.description(message) } } impl From for CLIError { fn from(error: anyhow::Error) -> Self { // Convert other errors to CLIError let cli_error = match error.downcast::() { Ok(cli_error) => cli_error, Err(error) => { // Convert other errors to CLIError let cli_error = match error.downcast::>() { Ok(validation_error) => CLIError::from(validation_error), Err(error) => { let sources = error .source() .map(|error| vec![CLIError::new(error.to_string().as_str())]) .unwrap_or_default(); CLIError::new(&error.to_string()).caused_by(sources) } }; cli_error } }; cli_error } } impl From for CLIError { fn from(error: std::io::Error) -> Self { let cli_error = CLIError::new("IO Error"); let message = error.to_string(); cli_error.description(message) } } impl<'a> From> for CLIError { fn from(error: ValidationError<&'a str>) -> Self { CLIError::new("Invalid Configuration").caused_by( error .as_vec() .iter() .map(|cause| { let mut err = CLIError::new(cause.message).trace(Vec::from(cause.trace.clone())); if let Some(description) = cause.description { err = err.description(description.to_owned()); } err }) .collect(), ) } } impl From> for CLIError { fn from(error: ValidationError) -> Self { CLIError::new("Invalid Configuration").caused_by( error .as_vec() .iter() .map(|cause| { CLIError::new(cause.message.as_str()).trace(Vec::from(cause.trace.clone())) }) .collect(), ) } } impl From> for CLIError { fn from(value: Box) -> Self { CLIError::new(value.to_string().as_str()) } } // New implementation for notify::Error impl From for CLIError { fn from(error: NotifyError) -> Self { let cli_error = CLIError::new("File Watching Error"); let message = error.to_string(); cli_error.description(message) } } #[cfg(test)] mod tests { use pretty_assertions::assert_eq; use stripmargin::StripMargin; use super::*; use crate::core::valid::Cause; #[test] fn test_no_newline() { let input = "Hello"; let expected = " Hello"; assert_eq!(margin(input, 4), expected); } #[test] fn test_with_newline() { let input = "Hello\nWorld"; let expected = " Hello\n World"; assert_eq!(margin(input, 4), expected); } #[test] fn test_empty_string() { let input = ""; let expected = ""; assert_eq!(margin(input, 4), expected); } #[test] fn test_zero_margin() { let input = "Hello"; let expected = "Hello"; assert_eq!(margin(input, 0), expected); } #[test] fn test_zero_margin_with_newline() { let input = "Hello\nWorld"; let expected = "Hello\nWorld"; assert_eq!(margin(input, 0), expected); } #[test] fn test_title() { let error = CLIError::new("Server could not be started"); let expected = r"Server could not be started".strip_margin(); assert_eq!(error.to_string(), expected); } #[test] fn test_title_description() { let error = CLIError::new("Server could not be started") .description("The port is already in use".to_string()); let expected = r"|Server could not be started: The port is already in use".strip_margin(); assert_eq!(error.to_string(), expected); } #[test] fn test_title_description_trace() { let error = CLIError::new("Server could not be started") .description("The port is already in use".to_string()) .trace(vec!["@server".into(), "port".into()]); let expected = r"|Server could not be started: The port is already in use [at @server.port]" .strip_margin(); assert_eq!(error.to_string(), expected); } #[test] fn test_title_trace_caused_by() { let error = CLIError::new("Configuration Error").caused_by(vec![CLIError::new( "Base URL needs to be specified", ) .trace(vec![ "User".into(), "posts".into(), "@http".into(), "baseURL".into(), ])]); let expected = r"|Configuration Error |Caused by: | • Base URL needs to be specified [at User.posts.@http.baseURL]" .strip_margin(); assert_eq!(error.to_string(), expected); } #[test] fn test_title_trace_multiple_caused_by() { let error = CLIError::new("Configuration Error").caused_by(vec![ CLIError::new("Base URL needs to be specified").trace(vec![ "User".into(), "posts".into(), "@http".into(), "baseURL".into(), ]), CLIError::new("Base URL needs to be specified").trace(vec![ "Post".into(), "users".into(), "@http".into(), "baseURL".into(), ]), CLIError::new("Base URL needs to be specified") .description("Set `baseURL` in @http or @server directives".into()) .trace(vec![ "Query".into(), "users".into(), "@http".into(), "baseURL".into(), ]), CLIError::new("Base URL needs to be specified").trace(vec![ "Query".into(), "posts".into(), "@http".into(), "baseURL".into(), ]), ]); let expected = r"|Configuration Error |Caused by: | • Base URL needs to be specified [at User.posts.@http.baseURL] | • Base URL needs to be specified [at Post.users.@http.baseURL] | • Base URL needs to be specified: Set `baseURL` in @http or @server directives [at Query.users.@http.baseURL] | • Base URL needs to be specified [at Query.posts.@http.baseURL]" .strip_margin(); assert_eq!(error.to_string(), expected); } #[test] fn test_from_validation() { let cause = Cause::new("Base URL needs to be specified") .description("Set `baseURL` in @http or @server directives") .trace(vec!["Query", "users", "@http", "baseURL"]); let valid = ValidationError::from(cause); let error = CLIError::from(valid); let expected = r"|Invalid Configuration |Caused by: | • Base URL needs to be specified: Set `baseURL` in @http or @server directives [at Query.users.@http.baseURL]" .strip_margin(); assert_eq!(error.to_string(), expected); } #[test] fn test_cli_error_identity() { let cli_error = CLIError::new("Server could not be started") .description("The port is already in use".to_string()) .trace(vec!["@server".into(), "port".into()]); let anyhow_error: anyhow::Error = cli_error.clone().into(); let actual = CLIError::from(anyhow_error); let expected = cli_error; assert_eq!(actual, expected); } #[test] fn test_validation_error_identity() { let validation_error = ValidationError::from( Cause::new("Test Error".to_string()).trace(vec!["Query".to_string()]), ); let anyhow_error: anyhow::Error = validation_error.clone().into(); let actual = CLIError::from(anyhow_error); let expected = CLIError::from(validation_error); assert_eq!(actual, expected); } #[test] fn test_generic_error() { let anyhow_error = anyhow::anyhow!("Some error msg"); let actual: CLIError = CLIError::from(anyhow_error); let expected = CLIError::new("Some error msg"); assert_eq!(actual, expected); } } ``` ### Summary 1. **Added `notify::Error` import**. 2. **Implemented `From` for `CLIError`** to convert file-watching errors into `CLIError`. This ensures that any errors from the `rs-notify` library are properly handled and converted into a format that the rest of the application can manage.
michaelitindi commented 1 month ago

@codeautopilot show the parts of the code that I should change

codeautopilot[bot] commented 1 month ago

@codeautopilot show the parts of the code that I should change

Certainly! Here are the specific parts of the code that you need to change:

1. Add rs-notify Dependency

Add the rs-notify crate to your Cargo.toml file:

[dependencies]
rs-notify = "5.0.0"

2. Update start_command Function

Modify the start_command function in src/cli/tc/start.rs to include file-watching capabilities using rs-notify:

use anyhow::Result;
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use std::sync::mpsc::channel;
use std::time::Duration;
use std::thread;

use super::helpers::log_endpoint_set;
use crate::cli::fmt::Fmt;
use crate::cli::server::Server;
use crate::core::config::reader::ConfigReader;

pub(super) async fn start_command(
    file_paths: Vec<String>,
    config_reader: &ConfigReader,
    watch: bool,
) -> Result<()> {
    if watch {
        let (tx, rx) = channel();
        let mut watcher = watcher(tx, Duration::from_secs(2))?;

        for path in &file_paths {
            watcher.watch(path, RecursiveMode::Recursive)?;
        }

        loop {
            match rx.recv() {
                Ok(DebouncedEvent::Write(_)) | Ok(DebouncedEvent::Create(_)) | Ok(DebouncedEvent::Remove(_)) => {
                    if let Err(e) = restart_server(&file_paths, config_reader).await {
                        eprintln!("Error restarting server: {:?}", e);
                    }
                },
                Err(e) => eprintln!("Watch error: {:?}", e),
                _ => (),
            }
        }
    } else {
        restart_server(&file_paths, config_reader).await?;
    }
    Ok(())
}

async fn restart_server(file_paths: &Vec<String>, config_reader: &ConfigReader) -> Result<()> {
    let config_module = config_reader.read_all(file_paths).await?;
    log_endpoint_set(&config_module.extensions().endpoint_set);
    Fmt::log_n_plus_one(false, config_module.config());
    let server = Server::new(config_module);
    server.fork_start().await?;
    Ok(())
}

3. Update start Command to Accept --watch Flag

Modify the Start variant of the Command enum in src/cli/command.rs to include an optional --watch flag:

use clap::{Parser, Subcommand};
use strum_macros::Display;
use tailcall_version::VERSION;

use crate::core::config;

const ABOUT: &str = r"
   __        _ __           ____
  / /_____ _(_) /________ _/ / /
 / __/ __ `/ / / ___/ __ `/ / /
/ /_/ /_/ / / / /__/ /_/ / / /
\__/\__,_/_/_/\___/\__,_/_/_/";

#[derive(Parser)]
#[command(name = "tailcall", author, version = VERSION.as_str(), about, long_about = Some(ABOUT))]
pub struct Cli {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Display)]
pub enum Command {
    /// Starts the GraphQL server on the configured port
    Start {
        /// Path for the configuration files or http(s) link to config files
        /// separated by spaces if more than one
        #[arg(required = true)]
        file_paths: Vec<String>,

        /// Watch for file changes and restart the server automatically
        #[arg(short, long)]
        watch: bool,
    },

    /// Validate a composition spec
    Check {
        /// Path for the configuration files separated by spaces if more than
        /// one
        #[arg(required = true)]
        file_paths: Vec<String>,

        /// N plus one queries
        #[arg(short, long)]
        n_plus_one_queries: bool,

        /// Display schema
        #[arg(short, long)]
        schema: bool,

        /// Prints the input config in the provided format
        #[clap(short, long)]
        format: Option<config::Source>,
    },

    /// Initialize a new project
    Init {
        // default is current directory
        #[arg(default_value = ".")]
        folder_path: String,
    },

    /// Generates a Tailcall Configuration from one or more source files.
    Gen {
        /// Path of the configuration file
        #[arg(required = true)]
        file_path: String,
    },
}

4. Handle File-Watching Errors

Ensure that any errors arising from the rs-notify library are properly captured and converted into CLIError instances in src/cli/error.rs:

use std::fmt::{Debug, Display};

use colored::Colorize;
use derive_setters::Setters;
use thiserror::Error;
use notify::Error as NotifyError; // Import notify error

use crate::core::valid::ValidationError;

#[derive(Debug, Error, Setters, PartialEq, Clone)]
pub struct CLIError {
    is_root: bool,
    #[setters(skip)]
    color: bool,
    message: String,
    #[setters(strip_option)]
    description: Option<String>,
    trace: Vec<String>,

    #[setters(skip)]
    caused_by: Vec<CLIError>,
}

impl CLIError {
    pub fn new(message: &str) -> Self {
        CLIError {
            is_root: true,
            color: false,
            message: message.to_string(),
            description: Default::default(),
            trace: Default::default(),
            caused_by: Default::default(),
        }
    }

    pub fn caused_by(mut self, error: Vec<CLIError>) -> Self {
        self.caused_by = error;

        for error in self.caused_by.iter_mut() {
            error.is_root = false;
        }

        self
    }

    fn colored<'a>(&'a self, str: &'a str, color: colored::Color) -> String {
        if self.color {
            str.color(color).to_string()
        } else {
            str.to_string()
        }
    }

    fn dimmed<'a>(&'a self, str: &'a str) -> String {
        if self.color {
            str.dimmed().to_string()
        } else {
            str.to_string()
        }
    }

    pub fn color(mut self, color: bool) -> Self {
        self.color = color;
        for inner in self.caused_by.iter_mut() {
            inner.color = color;
        }
        self
    }
}

fn margin(str: &str, margin: usize) -> String {
    let mut result = String::new();
    for line in str.split_inclusive('\n') {
        result.push_str(&format!("{}{}", " ".repeat(margin), line));
    }
    result
}

fn bullet(str: &str) -> String {
    let mut chars = margin(str, 2).chars().collect::<Vec<char>>();
    chars[0] = '•';
    chars[1] = ' ';
    chars.into_iter().collect::<String>()
}

impl Display for CLIError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let default_padding = 2;

        let message_color = if self.is_root {
            colored::Color::Yellow
        } else {
            colored::Color::White
        };

        f.write_str(self.colored(&self.message, message_color).as_str())?;

        if let Some(description) = &self.description {
            f.write_str(&self.colored(": ", message_color))?;
            f.write_str(&self.colored(description.to_string().as_str(), colored::Color::White))?;
        }

        if !self.trace.is_empty() {
            let mut buf = String::new();
            buf.push_str(" [at ");
            let len = self.trace.len();
            for (i, trace) in self.trace.iter().enumerate() {
                buf.push_str(&trace.to_string());
                if i < len - 1 {
                    buf.push('.');
                }
            }
            buf.push(']');
            f.write_str(&self.colored(&buf, colored::Color::Cyan))?;
        }

        if !self.caused_by.is_empty() {
            f.write_str("\n")?;
            f.write_str(self.dimmed("Caused by:").as_str())?;
            f.write_str("\n")?;
            for (i, error) in self.caused_by.iter().enumerate() {
                let message = &error.to_string();

                f.write_str(&margin(bullet(message.as_str()).as_str(), default_padding))?;

                if i < self.caused_by.len() - 1 {
                    f.write_str("\n")?;
                }
            }
        }

        Ok(())
    }
}

impl From<hyper::Error> for CLIError {
    fn from(error: hyper::Error) -> Self {
        // TODO: add type-safety to CLIError conversion
        let cli_error = CLIError::new("Server Failed");
        let message = error.to_string();
        if message.to_lowercase().contains("os error 48") {
            cli_error
                .description("The port is already in use".to_string())
                .caused_by(vec![CLIError::new(message.as_str())])
        } else {
            cli_error.description(message)
        }
    }
}

impl From<rustls::Error> for CLIError {
    fn from(error: rustls::Error) -> Self {
        let cli_error = CLIError::new("Failed to create TLS Acceptor");
        let message = error.to_string();

        cli_error.description(message)
    }
}

impl From<anyhow::Error> for CLIError {
    fn from(error: anyhow::Error) -> Self {
        // Convert other errors to CLIError
        let cli_error = match error.downcast::<CLIError>() {
            Ok(cli_error) => cli_error,
            Err(error) => {
                // Convert other errors to CLIError
                let cli_error = match error.downcast::<ValidationError<String>>() {
                    Ok(validation_error) => CLIError::from(validation_error),
                    Err(error) => {
                        let sources = error
                            .source()
                            .map(|error| vec![CLIError::new(error.to_string().as_str())])
                            .unwrap_or_default();

                        CLIError::new(&error.to_string()).caused_by(sources)
                    }
                };
                cli_error
            }
        };
        cli_error
    }
}

impl From<std::io::Error> for CLIError {
    fn from(error: std::io::Error) -> Self {
        let cli_error = CLIError::new("IO Error");
        let message = error.to_string();

        cli_error.description(message)
    }
}

impl<'a> From<ValidationError<&'a str>> for CLIError {
    fn from(error: ValidationError<&'a str>) -> Self {
        CLIError::new("Invalid Configuration").caused_by(
            error
                .as_vec()
                .iter()
                .map(|cause| {
                    let mut err =
                        CLIError::new(cause.message).trace(Vec::from(cause.trace.clone()));
                    if let Some(description) = cause.description {
                        err = err.description(description.to_owned());
                    }
                    err
                })
                .collect(),
        )
    }
}

impl From<ValidationError<String>> for CLIError {
    fn from(error: ValidationError<String>) -> Self {
        CLIError::new("Invalid Configuration").caused_by(
            error
                .as_vec()
                .iter()
                .map(|cause| {
                    CLIError::new(cause.message.as_str()).trace(Vec::from(cause.trace.clone()))
                })
                .collect(),
        )
    }
}

impl From<Box<dyn std::error::Error>> for CLIError {
    fn from(value: Box<dyn std::error::Error>) -> Self {
        CLIError::new(value.to_string().as_str())
    }
}

// New implementation for notify::Error
impl From<NotifyError> for CLIError {
    fn from(error: NotifyError) -> Self {
        let cli_error = CLIError::new("File Watching Error");
        let message = error.to_string();

        cli_error.description(message)
    }
}

#[cfg(test)]
mod tests {

    use pretty_assertions::assert_eq;
    use stripmargin::StripMargin;

    use super::*;
    use crate::core::valid::Cause;

    #[test]
    fn test_no_newline() {
        let input = "Hello";
        let expected = "    Hello";
        assert_eq!(margin(input, 4), expected);
    }

    #[test]
    fn test_with_newline() {
        let input = "Hello\nWorld";
        let expected = "    Hello\n    World";
        assert_eq!(margin(input, 4), expected);
    }

    #[test]
    fn test_empty_string() {
        let input = "";
        let expected = "";
        assert_eq!(margin(input, 4), expected);
    }

    #[test]
    fn test_zero_margin() {
        let input = "Hello";
        let expected = "Hello";
        assert_eq!(margin(input, 0), expected);
    }

    #[test]
    fn test_zero_margin_with_newline() {
        let input = "Hello\nWorld";
        let expected = "Hello\nWorld";
        assert_eq!(margin(input, 0), expected);
    }

    #[test]
    fn test_title() {
        let error = CLIError::new("Server could not be started");
        let expected = r"Server could not be started".strip_margin();
        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_description() {
        let error = CLIError::new("Server could not be started")
            .description("The port is already in use".to_string());
        let expected = r"|Server could not be started: The port is already in use".strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_description_trace() {
        let error = CLIError::new("Server could not be started")
            .description("The port is already in use".to_string())
            .trace(vec!["@server".into(), "port".into()]);

        let expected =
            r"|Server could not be started: The port is already in use [at @server.port]"
                .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_trace_caused_by() {
        let error = CLIError::new("Configuration Error").caused_by(vec![CLIError::new(
            "Base URL needs to be specified",
        )
        .trace(vec![
            "User".into(),
            "posts".into(),
            "@http".into(),
            "baseURL".into(),
        ])]);

        let expected = r"|Configuration Error
                     |Caused by:
                     |  • Base URL needs to be specified [at User.posts.@http.baseURL]"
            .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_title_trace_multiple_caused_by() {
        let error = CLIError::new("Configuration Error").caused_by(vec![
            CLIError::new("Base URL needs to be specified").trace(vec![
                "User".into(),
                "posts".into(),
                "@http".into(),
                "baseURL".into(),
            ]),
            CLIError::new("Base URL needs to be specified").trace(vec![
                "Post".into(),
                "users".into(),
                "@http".into(),
                "baseURL".into(),
            ]),
            CLIError::new("Base URL needs to be specified")
                .description("Set `baseURL` in @http or @server directives".into())
                .trace(vec![
                    "Query".into(),
                    "users".into(),
                    "@http".into(),
                    "baseURL".into(),
                ]),
            CLIError::new("Base URL needs to be specified").trace(vec![
                "Query".into(),
                "posts".into(),
                "@http".into(),
                "baseURL".into(),
            ]),
        ]);

        let expected = r"|Configuration Error
                     |Caused by:
                     |  • Base URL needs to be specified [at User.posts.@http.baseURL]
                     |  • Base URL needs to be specified [at Post.users.@http.baseURL]
                     |  • Base URL needs to be specified: Set `baseURL` in @http or @server directives [at Query.users.@http.baseURL]
                     |  • Base URL needs to be specified [at Query.posts.@http.baseURL]"
            .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_from_validation() {
        let cause = Cause::new("Base URL needs to be specified")
            .description("Set `baseURL` in @http or @server directives")
            .trace(vec!["Query", "users", "@http", "baseURL"]);
        let valid = ValidationError::from(cause);
        let error = CLIError::from(valid);
        let expected = r"|Invalid Configuration
                     |Caused by:
                     |  • Base URL needs to be specified: Set `baseURL` in @http or @server directives [at Query.users.@http.baseURL]"
            .strip_margin();

        assert_eq!(error.to_string(), expected);
    }

    #[test]
    fn test_cli_error_identity() {
        let cli_error = CLIError::new("Server could not be started")
            .description("The port is already in use".to_string())
            .trace(vec!["@server".into(), "port".into()]);
        let anyhow_error: anyhow::Error = cli_error.clone().into();

        let actual = CLIError::from(anyhow_error);
        let expected = cli_error;

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_validation_error_identity() {
        let validation_error = ValidationError::from(
            Cause::new("Test Error".to_string()).trace(vec!["Query".to_string()]),
        );
        let anyhow_error: anyhow::Error = validation_error.clone().into();

        let actual = CLIError::from(anyhow_error);
        let expected = CLIError::from(validation_error);

        assert_eq!(actual, expected);
    }

    #[test]
    fn test_generic_error() {
        let anyhow_error = anyhow::anyhow!("Some error msg");

        let actual: CLIError = CLIError::from(anyhow_error);
        let expected = CLIError::new("Some error msg");

        assert_eq!(actual, expected);
    }
}

These changes will add the --watch feature to your application, allowing it to monitor file changes and restart the server automatically.