diff --git a/zino-cli/Cargo.toml b/zino-cli/Cargo.toml index 7ccb7af2..4d553f4c 100644 --- a/zino-cli/Cargo.toml +++ b/zino-cli/Cargo.toml @@ -18,11 +18,13 @@ name = "zli" path = "src/main.rs" [dependencies] +env_logger = "0.11.5" git2 = "0.19.0" include_dir = "0.7.4" log = "0.4.22" taplo = "0.13.0" toml_edit = "0.22.20" +regex = "1.10.6" walkdir = "2.5.0" [dependencies.axum] diff --git a/zino-cli/README.md b/zino-cli/README.md index a9c86b82..1ea1dca8 100644 --- a/zino-cli/README.md +++ b/zino-cli/README.md @@ -9,3 +9,32 @@ CLI tools for [`zino`]. [`zino`]: https://github.com/zino-rs/zino + +## Features +- **Project Initialization**: Quickly set up new `zino` projects. +- **Dependency Management**: Manage your project dependencies with ease. + +## Installation +```sh +cargo install zino-cli +``` + +## Usage + +### Create a new project +```sh +zli new +``` +options: +- `--template `: Use a custom template for the project. + +### Init project in current directory +```sh +zli init +``` +options: +- `--template `: Use a custom template for the project. +- `--project-name `: Name of the project (current_dir by default). + +### Manage dependencies +run `zli serve` and access http://localhost:6080/zino-config.html in your browser. \ No newline at end of file diff --git a/zino-cli/public/404.html b/zino-cli/public/404.html index ac294fe8..6bbc8fa0 100644 --- a/zino-cli/public/404.html +++ b/zino-cli/public/404.html @@ -8,22 +8,35 @@ font-family: Arial, sans-serif; text-align: center; padding-top: 50px; + background-color: #f8f9fa; + color: #343a40; + } + h1 { + font-size: 3em; + margin-bottom: 20px; } .message { margin: 20px; + font-size: 1.2em; } a { color: #007BFF; text-decoration: none; + padding: 10px 20px; + border: 1px solid #007BFF; + border-radius: 5px; + transition: background-color 0.3s, color 0.3s; } a:hover { - text-decoration: underline; + background-color: #007BFF; + color: #fff; + text-decoration: none; }

404 Page Not Found

Sorry, the page you are looking for is not found.

-

Access this page to set the configuration for your project.

+

Access zino-config page to set the configuration for your project.

\ No newline at end of file diff --git a/zino-cli/public/zino-config.css b/zino-cli/public/zino-config.css new file mode 100644 index 00000000..d279e32a --- /dev/null +++ b/zino-cli/public/zino-config.css @@ -0,0 +1,184 @@ +body, html { + margin: 0; + padding: 0; + font-family: Arial, sans-serif; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.top-bar { + background-color: #007bff; + color: white; + padding: 20px; + text-align: center; + height: 18%; +} + +.top-bar h1 { + margin-top: 1%; + font-size: 32px; +} + +.main-content { + display: flex; + flex: 1; + overflow: hidden; +} + +input[type="text"] { + padding: 10px; + margin-bottom: 20px; + border: 1px solid #ccc; + border-radius: 4px; + width: 61.8%; + box-sizing: border-box; + font-size: 18px; +} + +input[type="text"]:focus { + border-color: #007bff; + outline: none; +} + +.cargoTomlBlock { + display: flex; + flex-direction: column; + width: 30.9%; +} + +.cargoTomlDescription { + display: flex; + align-items: center; + justify-content: center; + height: 10%; + background-color: #f0f0f0; + color: #333; + font-size: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 5px; +} + +.textarea-container { + display: flex; + height: 100%; + width: 100%; + position: relative; + overflow: hidden; +} + +.textarea-container .lineNumber { + white-space: pre; + display: flex; + flex-direction: column; + position: absolute; + left: 0; + width: 20px; + height: 100%; + padding: 2px; + text-align: center; + color: #007bff; + font-family: monospace; + font-size: 14px; + line-height: 1.5; + pointer-events: none; +} + +.cargoTomlTextArea { + height: 100%; + width: 100%; + font-family: Consolas, monospace; + white-space: pre; + background-color: #f3f4f6; + color: #333; + padding-left: 30px; + line-height: 1.5; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + border: none; + border-radius: 8px; + box-sizing: border-box; + overflow-x: auto; + overflow-y: scroll; + font-size: 14px; +} + +#config-form-block { + display: flex; + flex-direction: column; + padding: 20px; + width: 38.2%; + height: auto; + overflow-y: auto; +} + +.config-form { + display: flex; + flex-direction: column; + padding: 20px; + border-top: #007bff 2px solid; +} + +.config-form form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.config-form .form-description { + font-size: 32px; + color: #007bff; + margin-bottom: 20px; +} + +.config-form button:hover { + background-color: #0056b3; +} + +.closet { + margin-bottom: 10px; +} + +.closet .option-title { + font-size: 24px; + color: black; + margin-bottom: 8px; +} + +.option-group { + font-size: 24px; + color: #8d8f91; + border-color: #0056b3; + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-left: 15%; +} + +.option-group div { + cursor: pointer; + border-bottom: 2px solid grey; +} + +.option-group div.checked { + font-size: 25px; + color: #14161a; + border-color: #0056b3; + border-bottom: 2px solid #007bff; +} + +#save-config { + padding: 10px 15px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + font-size: 18px; + cursor: pointer; + width: 61.8%; + margin-left: 19.1%; +} + +.hidden { + display: none; +} \ No newline at end of file diff --git a/zino-cli/public/zino-config.html b/zino-cli/public/zino-config.html index f80e1909..155b3018 100644 --- a/zino-cli/public/zino-config.html +++ b/zino-cli/public/zino-config.html @@ -3,192 +3,7 @@ new-project - +
@@ -318,6 +133,30 @@

current project: None

+
+
validator
+ +
+
validator
+
validator-credit-card
+
validator-email
+
validator-phone-number
+
validator-regex
+
all
+
+
+ +
+
view
+ +
+
view
+
view-minijinja
+
view-tera
+
all
+
+
+
core-features
@@ -332,10 +171,8 @@

current project: None

sentry
tls-native
tls-rustls
-
validator
tracing-log
http02
-
view
all
@@ -356,233 +193,6 @@

current project: None

- + \ No newline at end of file diff --git a/zino-cli/public/zino-config.js b/zino-cli/public/zino-config.js new file mode 100644 index 00000000..ea63630c --- /dev/null +++ b/zino-cli/public/zino-config.js @@ -0,0 +1,239 @@ +// fetch the current directory and Cargo.toml content +const currentDirInput = document.getElementById('currentDir'); + +currentDirInput.addEventListener('blur', updateCurrentDir); + +currentDirInput.addEventListener('keyup', async function (event) { + if (event.key === 'Enter') { + event.preventDefault(); // 防止默认的回车行为(如提交表单) + await updateCurrentDir(); + } +}); + +async function updateCurrentDir() { + const currentDir = currentDirInput.value; + + try { + const response = await fetch(`/update_current_dir/${encodeURIComponent(currentDir)}`, { + method: 'POST', + headers: { + 'Content-Type': 'text/plain', + }, + }); + if (!response.ok) { + throw new Error(await response.text()); + } + } catch (err) { + console.error('Failed to update directory:', err); + await fetchCurrentDir(); + } + + await fetchCargoToml() + await fetchFeatures() +} + +// ask the server to change the current directory +async function fetchCurrentDir() { + try { + const response = await fetch('/current_dir'); + if (!response.ok) { + throw new Error((await response.json()).data); + } + document.getElementById('currentDir').value = (await response.json()).data; + } catch (error) { + console.error('Failed to fetch current directory:', error); + } +} + +// get the content of current_dir/Cargo.toml +async function fetchCargoToml() { + try { + const response = await fetch('/get_current_cargo_toml'); + if (!response.ok) { + throw new Error((await response.json()).data); + } + const content = (await response.json()).data; + document.getElementById('currentCargoTomlTextArea').value = content; + + const packageNameLine = content.split('\n').find(line => line.startsWith('name =')); + const projectName = packageNameLine ? packageNameLine.split('=')[1].trim().replace(/"/g, '') : 'Not Found'; + document.getElementById("project_name").textContent = `current project: ${projectName}`; + updateLineNumbers(document.getElementById('currentCargoTomlTextArea')); + } catch (error) { + console.error('Failed to fetch Cargo.toml:', error); + document.getElementById('currentCargoTomlDescription').value = 'Failed to fetch Cargo.toml, make sure you entered a valid project directory'; + } +} + + +// init the options of each group +async function fetchFeatures() { + try { + const response = fetch('/get_current_features'); + let features = await (await response).json(); + document.querySelectorAll('.checked').forEach(option => { + option.classList.replace('checked', 'unchecked') + }) + for (let feature of features.data.zino_feature) { + Array.from(document.querySelectorAll('#zino-config-form [data-feature]')).filter(option => option.getAttribute('data-feature') === feature).forEach(option => { + option.click() + }) + } + for (let feature of features.data.core_feature) { + Array.from(document.querySelectorAll('#core-config-form [data-feature]')).filter(option => option.getAttribute('data-feature') === feature).forEach(option => { + option.click() + }) + } + } catch (error) { + console.error('Failed to init options:', error); + } +} + +function checkedOptions() { + let option_groups = {}; + document.querySelectorAll('.closet').forEach(closet => { + const groupName = closet.querySelector('.option-title').textContent; + option_groups[groupName] = []; + closet.querySelectorAll('.checked').forEach(option => { + if (!option.classList.contains('all-options')) { + option_groups[groupName].push(option.getAttribute('data-feature')); + } else { + let all_flag = option.getAttribute('data-feature') + if (all_flag != null) { + option_groups[groupName] = [option.getAttribute('data-feature')] + } + } + }); + }); + let option = { + zino_feature: option_groups['Framework'] + .concat(option_groups['zino-features']) + .sort(), + core_feature: option_groups['core-features'] + .concat(option_groups['Database']) + .concat(option_groups['Accessor']) + .concat(option_groups['Connector']) + .concat(option_groups['locale']) + .concat(option_groups['validator']) + .concat(option_groups['view']) + .sort() + } + return option; +} + +async function generateCargoToml() { + const res = await fetch('/generate_cargo_toml', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(checkedOptions()) + }); + + + document.querySelector('#aimCargoTomlTextArea').value = (await res.json()).data; +} + + +function change_option_state() { + this.classList.toggle('unchecked'); + this.classList.toggle('checked'); + let self_checked = this.classList.contains('checked'); + + let group = this.parentElement; + + if (group.classList.contains('exclusive')) { + [...group.querySelectorAll('.checked')].filter(o => o !== this).forEach(option => { + option.classList.toggle('checked') + option.classList.toggle('unchecked') + }) + } + + if (this.classList.contains('all-options')) { + if (self_checked) { + group.querySelectorAll('.unchecked').forEach(option => { + option.classList.replace('unchecked', 'checked') + }) + } else { + group.querySelectorAll('.checked').forEach(option => { + option.classList.replace('checked', 'unchecked') + }) + } + } else { + if (self_checked) { + if (group.querySelectorAll('.unchecked').length === 1) { + group.querySelectorAll('.all-options').forEach(option => { + option.classList.replace('unchecked', 'checked') + }) + } + } else { + group.querySelectorAll('.all-options').forEach(option => { + option.classList.replace('checked', 'unchecked') + }) + } + } + + const ormOption = document.querySelector('#zino-config-form [data-feature="orm"]'); + const ormForm = document.getElementById('orm-form'); + if (ormOption && ormOption.classList.contains('checked')) { + ormForm.classList.remove('hidden'); + } else { + ormForm.classList.add('hidden'); + ormForm.querySelectorAll('.option-group div').forEach(option => { + option.classList.replace('checked', 'unchecked'); + }); + } + + generateCargoToml(); +} + +document.querySelectorAll('.unchecked').forEach(option => { + option.addEventListener('click', change_option_state); +}); + + +function updateLineNumbers(textArea) { + const lineCount = textArea.value.split('\n').length; + let lineNumberHtml = ''; + for (let i = 1; i <= lineCount; i++) { + lineNumberHtml += `${i}\n`; + } + + const lineNumberElement = textArea.previousElementSibling; + lineNumberElement.textContent = lineNumberHtml; +} + +document.querySelectorAll('.cargoTomlTextArea').forEach(textArea => { + textArea.addEventListener('input', function () { + updateLineNumbers(this); + }); + textArea.addEventListener('scroll', function () { + this.previousElementSibling.style.marginTop = `-${this.scrollTop}px`; + }); +}); + +// save the generated Cargo.toml +document.getElementById('save-config').addEventListener('click', async () => { + const aimCargoToml = document.getElementById('aimCargoTomlTextArea').value; + try { + const response = await fetch('/save_cargo_toml', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(aimCargoToml), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + await fetchCargoToml(); + } catch (error) { + console.error('Failed to save Cargo.toml:', error); + } +}); + +window.onload = async () => { + await fetchCurrentDir(); + await fetchCargoToml(); + await fetchFeatures(); +}; \ No newline at end of file diff --git a/zino-cli/src/cli/init.rs b/zino-cli/src/cli/init.rs index 55f6e172..94cdf4a4 100644 --- a/zino-cli/src/cli/init.rs +++ b/zino-cli/src/cli/init.rs @@ -1,5 +1,6 @@ use crate::cli::{ - clean_template_dir, process_template, DEFAULT_TEMPLATE_URL, TEMPORARY_TEMPLATE_PATH, + check_package_name_validation, clean_template_dir, clone_and_process_template, + DEFAULT_TEMPLATE_URL, TEMPORARY_TEMPLATE_PATH, }; use clap::Parser; use std::{env, path::Path}; @@ -26,31 +27,35 @@ impl Init { let init_res = self.init_with_template(); // must clean the temporary template directory after the initialization clean_template_dir(TEMPORARY_TEMPLATE_PATH); - match init_res { - Ok(_) => { - log::info!("project initialized successfully"); - Ok(()) - } - Err(e) => Err(e), - } + init_res.map(|_| { + log::info!("project initialized successfully"); + }) } /// Initializes the project with the template. fn init_with_template(&self) -> Result<(), Error> { let current_dir = env::current_dir()? .file_name() - .expect("fail to get the current directory name") - .to_str() - .expect("fail to convert the directory name to string") - .to_string(); + .and_then(|name| name.to_str()) + .map(|s| s.to_string()) + .ok_or_else(|| Error::new("fail to get or convert the current directory name"))?; let project_name = match &self.project_name { - Some(project_name) => project_name, - None => ¤t_dir, - }; - let template_url = match self.template { - Some(ref template) => template.as_ref(), - None => DEFAULT_TEMPLATE_URL, + Some(project_name) => { + check_package_name_validation(project_name)?; + project_name + } + None => { + check_package_name_validation(¤t_dir).map_err(|_| { + Error::new(format!( + "current directory's name:{} is not a valid Rust package name,\ + try to specify the project name with `--project-name`", + ¤t_dir + )) + })?; + ¤t_dir + } }; - process_template(template_url, "", project_name) + let template_url = self.template.as_deref().unwrap_or(DEFAULT_TEMPLATE_URL); + clone_and_process_template(template_url, "", project_name) } } diff --git a/zino-cli/src/cli/mod.rs b/zino-cli/src/cli/mod.rs index 7d82203d..7f6bce7c 100644 --- a/zino-cli/src/cli/mod.rs +++ b/zino-cli/src/cli/mod.rs @@ -2,6 +2,7 @@ use clap::Parser; use git2::Repository; +use regex::Regex; use std::fs; use std::fs::remove_dir_all; use std::path::Path; @@ -43,7 +44,7 @@ pub enum Subcommands { Init(init::Init), /// Create a new project. New(new::New), - /// Start the server. + /// Start the server at localhost:6080/zino-config.html. Serve(serve::Serve), } @@ -55,7 +56,7 @@ pub(crate) static DEFAULT_TEMPLATE_URL: &str = "https://github.com/zino-rs/zino-template-default.git"; /// Clones the template repository, do replacements, and create the project. -pub(crate) fn process_template( +pub(crate) fn clone_and_process_template( template_url: &str, target_path_prefix: &str, project_name: &str, @@ -75,7 +76,9 @@ pub(crate) fn process_template( template_file_path .strip_prefix(TEMPORARY_TEMPLATE_PATH)? .to_str() - .unwrap() + .ok_or_else(|| Error::new( + "fail to convert the template file path to string" + ))? ); fs::create_dir_all(Path::new(&target_path).parent().unwrap())?; @@ -97,7 +100,14 @@ fn is_ignored(entry: &DirEntry) -> bool { /// Clean the temporary template directory. fn clean_template_dir(path: &str) { - if let Err(err) = remove_dir_all(path) { - log::error!("fail to remove the temporary template directory: {}", err) - } + let _ = remove_dir_all(path); +} + +/// Check name validity. +pub(crate) fn check_package_name_validation(name: &str) -> Result<(), Error> { + Regex::new(r"^[a-zA-Z][a-zA-Z0-9_-]*$") + .map_err(|e| Error::new(e.to_string()))? + .is_match(name) + .then_some(()) + .ok_or_else(|| Error::new(format!("invalid package name: `{}`", name))) } diff --git a/zino-cli/src/cli/new.rs b/zino-cli/src/cli/new.rs index 717b0d1b..7d137263 100644 --- a/zino-cli/src/cli/new.rs +++ b/zino-cli/src/cli/new.rs @@ -1,5 +1,6 @@ use crate::cli::{ - clean_template_dir, process_template, DEFAULT_TEMPLATE_URL, TEMPORARY_TEMPLATE_PATH, + check_package_name_validation, clean_template_dir, clone_and_process_template, + DEFAULT_TEMPLATE_URL, TEMPORARY_TEMPLATE_PATH, }; use clap::Parser; use std::{fs, path::Path}; @@ -23,20 +24,21 @@ impl New { let new_res = self.new_with_template(); // must clean the temporary template directory after the initialization clean_template_dir(TEMPORARY_TEMPLATE_PATH); - match new_res { - Ok(_) => { + new_res + .map(|_| { log::info!("project `{}` created successfully", self.project_name); - Ok(()) - } - Err(err) => { - if !project_dir_already_exists { + }) + .map_err(|err| { + if !project_dir_already_exists && Path::new("./Cargo.toml").is_dir() { if let Err(err) = fs::remove_dir_all(&self.project_name) { - log::warn!("fail to remove project directory: {err}"); + log::warn!( + "fail to remove project directory:{}, {err}", + self.project_name + ); } } - Err(err) - } - } + err + }) } /// Checks if the project directory already exists and if it's empty. @@ -54,11 +56,9 @@ impl New { /// Creates a new project with the template. fn new_with_template(&self) -> Result<(), Error> { - let template_url = match self.template { - Some(ref template) => template.as_ref(), - None => DEFAULT_TEMPLATE_URL, - }; - let project_root = &format!("/{}", self.project_name); - process_template(template_url, project_root, &self.project_name) + let template_url = self.template.as_deref().unwrap_or(DEFAULT_TEMPLATE_URL); + check_package_name_validation(&self.project_name)?; + let project_root = &format!("/{}", &self.project_name); + clone_and_process_template(template_url, project_root, &self.project_name) } } diff --git a/zino-cli/src/cli/serve.rs b/zino-cli/src/cli/serve.rs index 5f6f9683..ef4146f9 100644 --- a/zino-cli/src/cli/serve.rs +++ b/zino-cli/src/cli/serve.rs @@ -1,6 +1,4 @@ use axum::{ - extract::Path, - response::{Html, IntoResponse}, routing::{get, post}, Router, }; @@ -43,78 +41,82 @@ impl Serve { } /// Returns the content of `Cargo.toml` file in the current directory. -async fn get_current_cargo_toml() -> impl IntoResponse { +async fn get_current_cargo_toml(req: zino::Request) -> zino::Result { + let mut res = zino::Response::default().context(&req); fs::read_to_string("./Cargo.toml") - .map(|content| content.into_response()) - .unwrap_or_else(|err| { - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - format!("fail to read 'Cargo.toml' file: {err}"), - ) - .into_response() + .map(|content| { + res.set_content_type("application/json"); + res.set_data(&content); + }) + .map_err(|err| { + res.set_code(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + res.set_data(&err.to_string()); }) + .ok(); + Ok(res.into()) } /// Returns the HTML page. -async fn get_page(Path(file_name): Path) -> impl IntoResponse { - match RESOURCE.get_file(&file_name) { - Some(file) => { +async fn get_page(req: zino::Request) -> zino::Result { + let mut res = zino::Response::default(); + let file_name: String = req.parse_param("file_name").unwrap_or_default(); + RESOURCE + .get_file(&file_name) + .map(|file| { let content = file.contents_utf8().unwrap_or_default(); - Html(content).into_response() - } - None => RESOURCE - .get_file("404.html") - .map(|not_found_page| { - let not_found_page_content = not_found_page.contents_utf8().unwrap_or_default(); - ( - axum::http::StatusCode::NOT_FOUND, - Html(not_found_page_content), - ) - .into_response() + res.set_content_type(match file_name.split('.').last().unwrap_or("html") { + "html" => "text/html", + "css" => "text/css", + "js" => "application/javascript", + _ => "text/plain", + }); + res.set_data(&content); + }) + .or_else(|| { + RESOURCE.get_file("404.html").map(|not_found_page| { + let not_found_page_content = not_found_page + .contents_utf8() + .unwrap_or("404.html not found, include_dir is corrupted. Try reinstall zli"); + res.set_content_type("text/html"); + res.set_data(¬_found_page_content); }) - .unwrap_or_else(|| { - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "404.html not found, include_dir is corrupted. Try reinstall zli", - ) - .into_response() - }), - } + }); + Ok(res.into()) } /// Returns the current directory. -async fn get_current_dir() -> impl IntoResponse { +async fn get_current_dir(req: zino::Request) -> zino::Result { + let mut res = zino::Response::default().context(&req); env::current_dir() .map(|current_dir| { - current_dir - .to_str() - .unwrap_or("fail to convert current_path to utf-8 string") - .to_string() - .into_response() + res.set_code(axum::http::StatusCode::OK); + res.set_data( + ¤t_dir + .to_str() + .unwrap_or("fail to convert current_path to utf-8 string"), + ); }) .unwrap_or_else(|err| { - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - format!("fail to get current_dir: {err}"), - ) - .into_response() - }) + res.set_code(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + res.set_data(&format!("fail to get current_dir: {}", err)); + }); + Ok(res.into()) } /// Updates current directory. -async fn update_current_dir(Path(path): Path) -> impl IntoResponse { +async fn update_current_dir(req: zino::Request) -> zino::Result { + let mut res = zino::Response::default().context(&req); + let path: String = req.parse_param("path").unwrap_or_default(); env::set_current_dir(&path) .map(|_| { - log::info!("directory updated to: {}", path); - axum::http::StatusCode::OK.into_response() + res.set_code(axum::http::StatusCode::OK); + res.set_data(&format!("directory updated to: {}", path)); }) .unwrap_or_else(|err| { - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - format!("fail to update current_dir: {err}"), - ) - .into_response() - }) + res.set_code(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + res.set_data(&format!("fail to update current_dir: {}", err)); + }); + Ok(res.into()) } /// Features struct. diff --git a/zino-cli/src/main.rs b/zino-cli/src/main.rs index fea84ea6..431d7f38 100644 --- a/zino-cli/src/main.rs +++ b/zino-cli/src/main.rs @@ -1,10 +1,19 @@ use clap::Parser; +use std::env; use zino_cli::{Cli, Subcommands::*}; fn main() { + env::set_var("RUST_LOG", "info"); + let result = match Cli::parse().action() { - Init(opts) => opts.run(), - New(opts) => opts.run(), + Init(opts) => { + env_logger::init(); + opts.run() + } + New(opts) => { + env_logger::init(); + opts.run() + } Serve(opts) => opts.run(), }; if let Err(err) = result {