summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore3
-rw-r--r--Cargo.lock29
-rw-r--r--locales/help_docs/en.yml111
-rw-r--r--locales/help_docs/zh-CN.yml173
-rw-r--r--src/bin/jv.rs524
-rw-r--r--src/bin/jvv.rs119
-rw-r--r--src/utils.rs2
-rw-r--r--src/utils/input.rs105
-rw-r--r--src/utils/socket_addr_helper.rs184
9 files changed, 1141 insertions, 109 deletions
diff --git a/.gitignore b/.gitignore
index 1dc8293..405606d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@
# All temp directory
**/.temp/
+
+# Target directory
+/target/
diff --git a/Cargo.lock b/Cargo.lock
index ccc1187..6fb294c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -844,6 +844,7 @@ dependencies = [
name = "just_enough_vcs"
version = "0.0.0"
dependencies = [
+ "action_system",
"cfg_file",
"string_proc",
"tcp_connection",
@@ -1218,7 +1219,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.16",
"libredox",
- "thiserror 2.0.17",
+ "thiserror",
]
[[package]]
@@ -1649,38 +1650,18 @@ dependencies = [
"rsa",
"serde",
"serde_json",
- "thiserror 1.0.69",
+ "thiserror",
"tokio",
"uuid",
]
[[package]]
name = "thiserror"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
-dependencies = [
- "thiserror-impl 1.0.69",
-]
-
-[[package]]
-name = "thiserror"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
- "thiserror-impl 2.0.17",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
+ "thiserror-impl",
]
[[package]]
@@ -1866,7 +1847,7 @@ dependencies = [
"serde_json",
"string_proc",
"tcp_connection",
- "thiserror 1.0.69",
+ "thiserror",
"tokio",
"vcs_data",
]
diff --git a/locales/help_docs/en.yml b/locales/help_docs/en.yml
index 6092873..5a12f8f 100644
--- a/locales/help_docs/en.yml
+++ b/locales/help_docs/en.yml
@@ -1,3 +1,7 @@
+common:
+ confirm: |
+ Confirm to perform the above operation? (Y/n):
+
jvv:
help: |
**JustEnoughVCS Upstream Vault Commands**
@@ -56,12 +60,12 @@ jvv:
service: |
**Service Management**
**Usage**:
- jvv service listen - Start the vault server in the current vault to accept client connections
- You can use --no-log to disable log output
+ jvv service listen - Start the server in the current vault
+ You can use --port to set the listening port,
+ use --no-log to disable log output
fail:
jvcs: "JustEnoughVCS Error: %{err}"
-
no_vault_here: No vault found here
tokio:
@@ -80,9 +84,7 @@ jvv:
If you wish to use this directory as a vault, please use jvv init --help to view related help
vault_init_failed: Failed to initialize vault!
-
member_ids_failed: Failed to get member IDs!
-
ref_sheet_not_found: Reference sheet `ref` not found, but it should exist!
create:
@@ -95,6 +97,11 @@ jvv:
register: Failed to create member! Please check if a member with the same name already exists
list: Failed to get member ID!
+ service:
+ listen_done: |
+ Server forced to close due to error!
+ Error message: %{error}
+
success:
here:
info: |
@@ -119,10 +126,11 @@ jvv:
footer: |
**Among them, %{num} members have registered PubKeys.**
- status_key_registered: (PubKey Exists)
+ status_key_registered: (Registered)
service:
- listen: Listening for client connections in vault `%{path}` ...
+ listen_start: Listening for client connections in vault `%{path}` ...
+ listen_done: Server shutdown!
jv:
help: |
@@ -147,7 +155,7 @@ jv:
jv account as <ACCOUNT_NAME> - Switch current account
jv account add <ACCOUNT_NAME> - Add an account to this computer
jv account remove <ACCOUNT_NAME> - Delete this account
- jv account mvkey <ACCOUNT_NAME> <PRIVATE_KEY_FILE> - Move private key to specified account
+ jv account movekey <ACCOUNT_NAME> <PRIVATE_KEY_FILE> - Move private key to specified account
**Information Synchronization**:
jv update - Download latest information from upstream workspace
@@ -183,7 +191,7 @@ jv:
jv account as <ACCOUNT_NAME> - Switch current account
jv account add <ACCOUNT_NAME> - Add an account to this computer
jv account remove <ACCOUNT_NAME> - Delete this account
- jv account mvkey <ACCOUNT_NAME> <PRIVATE_KEY_FILE> - Move private key to specified account
+ jv account movekey <ACCOUNT_NAME> <PRIVATE_KEY_FILE> - Move private key to specified account
Accounts are identity identifiers on the local computer, each account can be associated with different private keys.
You need to set up accounts before interacting with upstream vaults.
@@ -328,3 +336,88 @@ jv:
jv docs collaboration -e | nano
Built-in documentation includes JustEnoughVCS usage guides, collaboration paradigms, and best practices.
+
+ confirm:
+ direct: |
+ Do you want to direct the current local workspace to the upstream vault %{upstream}?
+
+ fail:
+ path_not_found: |
+ The directory `%{path}` cannot be found!
+
+ parse:
+ parser_failed: |
+ Incorrect command input!
+ Please use `jv -h` to view help
+
+ str_to_sockaddr: |
+ Error: %{err}
+ Cannot recognize *`%{str}`* as a valid address, please check your input!
+
+ from_just_version_control: |
+ **Error**: `%{err}` (This error is provided by JustEnoughVCS)
+
+ account:
+ no_user_dir: Cannot find user directory!
+ add: Failed to add account `%{account}`, please check if the account already exists.
+ remove: Failed to remove account `%{account}`, please check if the account exists.
+ list: Failed to get account list!
+ not_found: Cannot find account `%{account}`!
+
+ init_create_dir_not_empty: |
+ The current directory is not empty!
+ If you are certain you want to create here, please use --force to force execution!
+
+ create: Failed to create local workspace!
+ init: Failed to create workspace here!
+ get_current_dir: Failed to get current directory!
+
+ workspace_not_found: |
+ Local workspace not found! Please use this command within a local workspace
+ If you wish to use this directory as a local workspace, please use jv init
+
+ read_cfg: Failed to read local workspace configuration file!
+ write_cfg: Failed to write changes to local workspace configuration file!
+
+ create_socket: Failed to create TCP socket!
+ connection_failed: Failed to connect to target server!
+
+ unstain: |
+ The current workspace is not stained, no need to unstain
+
+ warn:
+ unstain: |
+ This operation will disconnect the current workspace from the upstream vault `%{upstream}`
+ If you reconnect to a vault with a mismatched identifier, it will cause serious problems, please operate with caution!
+
+ success:
+ account:
+ as: Successfully switched this workspace's account to `%{account}`
+ add: Successfully added account `%{account}`!
+ remove: Successfully removed account `%{account}`!
+ list:
+ header: |
+ **There are %{num} account(s) on this computer:**
+
+ status_has_key: (Registered)
+ move_key: Successfully moved the private key to the account directory!
+ create: Successfully created local workspace!
+ init: Successfully created workspace here!
+ unstain: |
+ Successfully unstained!
+ The current workspace no longer belongs to any upstream vault, please direct to a new upstream vault before working
+ **Tip**: Use `jv direct <upstream_vault_address>` to redirect to a new upstream vault
+
+ result:
+ common:
+ authroize_failed: |
+ Authentication failed: %{err}!
+
+ direct:
+ directed_and_stained: |
+ Successfully directed to upstream vault `%{upstream}`!
+ Workspace has been **stained**, ready to start working!
+
+ already_stained: |
+ Current workspace is already stained and cannot be directed to other upstream vaults with different identifiers
+ Please use `jv unstain` to remove the stain first
diff --git a/locales/help_docs/zh-CN.yml b/locales/help_docs/zh-CN.yml
index 2025cbe..4d3212e 100644
--- a/locales/help_docs/zh-CN.yml
+++ b/locales/help_docs/zh-CN.yml
@@ -1,3 +1,7 @@
+common:
+ confirm: |
+ 是否执行上述操作?(Y/n):
+
jvv:
help: |
**JustEnoughVCS 上游库命令**
@@ -55,11 +59,10 @@ jvv:
**服务设置相关**
**用法**:
jvv service listen - 在当前库中启动服务器,以接受客户端连接
- 您可以使用 --no-log 来禁用日志输出
+ 您可以使用 --port 来设定监听的端口,使用 --no-log 禁用日志输出
fail:
jvcs: JustEnoughVCS 错误:%{err}
-
no_vault_here: 此处并没有找到库文件
tokio:
@@ -78,21 +81,24 @@ jvv:
若您希望将该目录作为库,请使用 jvv init --help 查看相关帮助
vault_init_failed: 初始化库失败!
-
member_ids_failed: 获得成员 ID 失败!
-
ref_sheet_not_found: 未找到参照表 `ref`,该参照表理应存在!
create:
- not_empty: 禁止的操作!指定的目录已经存在。
+ not_empty: 禁止的操作!指定的目录已经存在
init:
- not_empty: 禁止的操作!该目录不为空。
+ not_empty: 禁止的操作!该目录不为空
member:
register: 创建成员失败!请检查是否存在同名的成员
list: 获取成员 ID 失败!
+ service:
+ listen_done: |
+ 服务端被错误强制关闭!
+ 错误信息:%{error}
+
success:
here:
info: |
@@ -102,7 +108,6 @@ jvv:
**提示**:若要启动服务端,请运行 jvv service listen
create: 成功在 `%{name}` 创建库!
-
init: 在此处初始化库成功!
member:
@@ -118,12 +123,13 @@ jvv:
status_key_registered: (公钥存在)
service:
- listen: 正在库 `%{path}` 监听来自客户端的连接 ...
+ listen_start: 正在库 `%{path}` 监听来自客户端的连接 ...
+ listen_done: 服务端运行结束!
jv:
help: |
**JustEnoughVCS 本地工作区命令**
- 该程序将连接至上游库,用以同步、提交本地工作区文件的变化,以供协同创作。
+ 该程序将连接至上游库,用以同步、提交本地工作区文件的变化,以供协同创作
**常用别名**:
jv u 下载最新信息,jv t 追踪文件,jv mv -a 自动移动文件,jv in/out 导入或导出文件
@@ -143,7 +149,7 @@ jv:
jv account as <账户名称> - 切换当前账户
jv account add <账户名称> - 为当前计算机添加账户
jv account remove <账户名称> - 删除该账户
- jv account mvkey <账户名称> <私钥文件> - 移动私钥到指定账户
+ jv account movekey <账户名称> <私钥文件> - 移动私钥到指定账户
**信息同步**:
jv update - 从上游工作区下载最新的信息
@@ -179,10 +185,10 @@ jv:
jv account as <账户名称> - 切换当前账户
jv account add <账户名称> - 为当前计算机添加账户
jv account remove <账户名称> - 删除该账户
- jv account mvkey <账户名称> <私钥文件> - 移动私钥到指定账户
+ jv account movekey <账户名称> <私钥文件> - 移动私钥到指定账户
- 账户是本地计算机上的身份标识,每个账户可以关联不同的私钥。
- 您需要先设置账户才能与上游库进行交互。
+ 账户是本地计算机上的身份标识,每个账户可以关联不同的私钥
+ 您需要先设置账户才能与上游库进行交互
sheet: |
@@ -192,8 +198,8 @@ jv:
jv sheet use <表名称> - 使用指定的表开始当前工作(不存在则自动创建)
jv sheet exit - 退出当前工作
- 表是 JustEnoughVCS 中的核心概念,每个表代表一个独立的文件集合。
- 您可以在不同的表之间切换工作,或者将文件从一个表导出到另一个表。
+ 表是 JustEnoughVCS 中的核心概念,每个表代表一个独立的文件集合
+ 您可以在不同的表之间切换工作,或者将文件从一个表导出到另一个表
create: |
@@ -201,18 +207,18 @@ jv:
**用法**:jv create <工作区名称>
**例如**:jv create my_workspace
- 上述操作会在当前目录创建名为 my_workspace 的目录,并在其中初始化工作区。
+ 上述操作会在当前目录创建名为 my_workspace 的目录,并在其中初始化工作区
- 工作区是您本地的工作环境,用于与上游库进行文件同步和版本控制。
+ 工作区是您本地的工作环境,用于与上游库进行文件同步和版本控制
init: |
**在此目录创建工作区**
**用法**:jv init
- 该命令会在当前所在的目录创建工作区,工作区名称由当前所在目录名称决定。
+ 该命令会在当前所在的目录创建工作区,工作区名称由当前所在目录名称决定
- 如果当前目录不为空,该操作将会失败。请确保在空目录中执行此命令。
+ 如果当前目录不为空,该操作将会失败,请确保在空目录中执行此命令
here: |
@@ -224,7 +230,7 @@ jv:
- 文件当前的持有人
- 文件最新版本的提交信息
- 这是了解当前工作区目录状态的快速方式。
+ 这是了解当前工作区目录状态的快速方式
track: |
@@ -232,10 +238,10 @@ jv:
**用法**:jv track <文件路径>
**例如**:jv track src/main.rs
- 第一次追踪文件时,会创建并上传 “第一版本”,然后自动持有该文件的编辑权。
- 后续追踪同一文件时,会更新文件的新版本。
+ 第一次追踪文件时,会创建并上传 “第一版本”,然后自动持有该文件的编辑权
+ 后续追踪同一文件时,会更新文件的新版本
- 追踪文件是版本控制的基础操作,确保您的更改能够同步到上游库。
+ 追踪文件是版本控制的基础操作,确保您的更改能够同步到上游库
hold: |
@@ -243,10 +249,10 @@ jv:
**用法**:jv hold <文件路径>
**例如**:jv hold src/lib.rs
- 当您需要编辑某个文件时,必须先持有该文件的编辑权。
- 持有文件后,其他协作者将无法同时编辑该文件,避免冲突。
+ 当您需要编辑某个文件时,必须先持有该文件的编辑权
+ 持有文件后,其他协作者将无法同时编辑该文件,避免冲突
- 编辑完成后,请记得追踪文件以保存更改。
+ 编辑完成后,请记得追踪文件以保存更改
throw: |
@@ -254,10 +260,10 @@ jv:
**用法**:jv throw <文件路径>
**例如**:jv throw src/config.rs
- 当您不再需要编辑某个文件时,可以丢弃该文件的编辑权。
- 丢弃后,其他协作者就可以持有并编辑该文件。
+ 当您不再需要编辑某个文件时,可以丢弃该文件的编辑权
+ 丢弃后,其他协作者就可以持有并编辑该文件
- 如果您对文件进行了更改但尚未追踪,丢弃操作会丢失这些更改。
+ 如果您对文件进行了更改但尚未追踪,丢弃操作会丢失这些更改
move: |
@@ -271,7 +277,7 @@ jv:
jv move src/old_dir/file.rs src/new_dir/file.rs
jv move auto
- 安全移动操作会保持文件的版本历史,而自动移动会检测并处理所有重命名。
+ 安全移动操作会保持文件的版本历史,而自动移动会检测并处理所有重命名
export: |
@@ -279,9 +285,9 @@ jv:
**用法**:jv export <文件> <目标表> -m <描述> -n <文件包名称>
**例如**:jv export data.csv analytics -m "导出分析数据" -n "analysis_data"
- 该操作会将指定的文件打包并发送到目标表的导入区。
+ 该操作会将指定的文件打包并发送到目标表的导入区
- 其他协作者可以在目标表中使用 jv import 命令来导入这些文件。
+ 其他协作者可以在目标表中使用 jv import 命令来导入这些文件
import: |
@@ -294,7 +300,7 @@ jv:
jv import Player_Import - 来自导入区定义的名称
jv import ref@Data/Player.csv - 来自参照表的路径
- 导入操作会将文件从其他表或导入区复制到当前工作区。
+ 导入操作会将文件从其他表或导入区复制到当前工作区
direct: |
@@ -302,28 +308,28 @@ jv:
**用法**:jv direct <上游库地址>
**例如**:jv direct your_vault.org
- 该操作会将当前工作区连接到指定的上游库,并为工作区添加染色标识。
+ 该操作会将当前工作区连接到指定的上游库,并为工作区添加染色标识
- 染色后,该工作区将只能与指定标识的库进行交互,确保数据一致性。
+ 染色后,该工作区将只能与指定标识的库进行交互,确保数据一致性
unstain: |
**为工作区祛色**
**用法**:jv unstain
- **危险操作**:该操作会移除工作区的染色标识,此后该工作区将与上游库断开连接。
+ **危险操作**:该操作会移除工作区的染色标识,此后该工作区将与上游库断开连接
- 祛色后,工作区将不再与任何特定库关联,可以重新连接到其他库。
- 但请注意,这可能会导致数据同步问题,请谨慎使用。
+ 祛色后,工作区将不再与任何特定库关联,可以重新连接到其他库
+ 但请注意,这可能会导致数据同步问题,请谨慎使用
update: |
**从上游库下载最新的信息**
**用法**:jv update
- 该操作会从上游库同步最新的文件状态、表信息和成员信息。
+ 该操作会从上游库同步最新的文件状态、表信息和成员信息
- 建议在开始工作前先执行更新操作,确保您拥有最新的工作环境。
+ 建议在开始工作前先执行更新操作,确保您拥有最新的工作环境
docs: |
@@ -337,4 +343,89 @@ jv:
jv docs get-started
jv docs collaboration -e | nano
- 内建文档包含 JustEnoughVCS 的使用指南、协作范式和最佳实践。
+ 内建文档包含 JustEnoughVCS 的使用指南、协作范式和最佳实践
+
+ confirm:
+ direct: |
+ 是否需要将当前本地工作区定向到上游库 %{upstream}?
+
+ fail:
+ path_not_found: |
+ 您给出的目录 `%{path}` 无法找到!
+
+ parse:
+ parser_failed: |
+ 错误的命令输入!
+ 请使用 `jv -h` 查看帮助
+
+ str_to_sockaddr: |
+ 错误:%{err}
+ 无法将 *`%{str}`* 识别为有效地址,请检查您的输入!
+
+ from_just_version_control: |
+ **错误**:`%{err}`(该错误由 JustEnoughVCS 提供)
+
+ account:
+ no_user_dir: 无法找到用户目录!
+ add: 添加账户 `%{account}` 失败,请检查账户是否已存在。
+ remove: 删除账户 `%{account}` 失败,请检查账户是否存在。
+ list: 获取账户列表失败!
+ move_key: 将该私钥移动至账户目录失败!
+ not_found: 无法找到账户 `%{account}`!
+
+ init_create_dir_not_empty: |
+ 当前目录并不是空的!
+ 若您确实确定在此处创建,请使用 --force 来强制执行!
+
+ create: 创建本地工作区失败!
+ init: 在此处创建本地工作区失败!
+ get_current_dir: 无法获得当前目录!
+
+ workspace_not_found: |
+ 无法找到本地工作区!请在本地工作区内使用该命令
+ 若您希望将该目录作为本地工作区,请使用 jv init
+
+ read_cfg: 无法读取本地工作区的配置文件!
+ write_cfg: 无法将修改写入本地工作区的配置文件!
+
+ create_socket: 无法创建 TCP 套接字!
+ connection_failed: 无法连接至目标服务器!
+
+ unstain: |
+ 当前工作区并未被染色,无需祛色
+
+ warn:
+ unstain: |
+ 此操作将会断开当前工作区与上游库 `%{upstream}` 的关联
+ 若重新连接至标识不匹配的库,会导致严重的问题,请谨慎操作!
+
+ success:
+ account:
+ as: 成功将此工作区的账户切换至 `%{account}`
+ add: 成功添加账户 `%{account}`!
+ remove: 成功删除账户 `%{account}`!
+ list:
+ header: |
+ **当前计算机上有 %{num} 个账户:**
+
+ status_has_key: (已注册私钥)
+ move_key: 成功将该私钥移动至账户目录!
+ create: 成功创建本地工作区!
+ init: 成功在此处创建工作区!
+ unstain: |
+ 成功祛色!
+ 当前工作区不再属于任何上游库,请工作前定向至新的上游库
+ **提示**:使用 `jv direct <上游库地址>` 重新定向至新的上游库
+
+ result:
+ common:
+ authroize_failed: 身份认证失败:%{err}!
+
+ direct:
+ directed_and_stained: |
+ 成功定向到上游库 `%{upstream}`!
+ 工作区已被 **染色**,现可开始工作!
+
+ already_stained: |
+ 当前工作区已被染色,无法定向其他不同标识的上游库
+ 请先使用 jv unstain 祛色
diff --git a/src/bin/jv.rs b/src/bin/jv.rs
index 9530203..95176b5 100644
--- a/src/bin/jv.rs
+++ b/src/bin/jv.rs
@@ -1,6 +1,30 @@
+use std::{env::current_dir, net::SocketAddr, path::PathBuf, process::exit};
+
+use just_enough_vcs::{
+ system::action_system::action::ActionContext,
+ utils::{cfg_file::config::ConfigFile, tcp_connection::instance::ConnectionInstance},
+ vcs::{
+ actions::local_actions::SetUpstreamVaultActionResult,
+ constants::PORT,
+ current::current_local_path,
+ data::{
+ local::{LocalWorkspace, config::LocalConfig},
+ member::Member,
+ user::UserDirectory,
+ },
+ },
+};
+
use clap::{Parser, Subcommand, arg, command};
-use just_enough_vcs_cli::utils::{lang_selector::current_locales, md_colored::md};
+use just_enough_vcs::{
+ utils::tcp_connection::error::TcpTargetError,
+ vcs::{actions::local_actions::proc_set_upstream_vault_action, registry::client_registry},
+};
+use just_enough_vcs_cli::utils::{
+ input::confirm_hint_or, lang_selector::current_locales, md_colored::md, socket_addr_helper,
+};
use rust_i18n::{set_locale, t};
+use tokio::{fs, net::TcpSocket};
// Import i18n files
rust_i18n::i18n!("locales", fallback = "en");
@@ -20,9 +44,12 @@ struct JustEnoughVcsWorkspace {
#[derive(Subcommand, Debug)]
enum JustEnoughVcsWorkspaceCommand {
+ #[command(alias = "--help", alias = "-h")]
+ Help,
+
// Member management
/// Manage your local accounts
- #[command(subcommand)]
+ #[command(subcommand, alias = "acc")]
Account(AccountManage),
/// Create an empty workspace
@@ -84,6 +111,25 @@ enum AccountManage {
/// Show help information
#[command(alias = "--help", alias = "-h")]
Help,
+
+ /// Register a member to this computer
+ #[command(alias = "+")]
+ Add(AccountAddArgs),
+
+ /// Remove a account from this computer
+ #[command(alias = "rm", alias = "-")]
+ Remove(AccountRemoveArgs),
+
+ /// List all accounts in this computer
+ #[command(alias = "ls")]
+ List(AccountListArgs),
+
+ /// Set current local workspace account
+ As(SetLocalWorkspaceAccountArgs),
+
+ /// Move private key file to account
+ #[command(alias = "mvkey", alias = "mvk")]
+ MoveKey(MoveKeyToAccountArgs),
}
#[derive(Subcommand, Debug)]
@@ -98,6 +144,13 @@ struct CreateWorkspaceArgs {
/// Show help information
#[arg(short, long)]
help: bool,
+
+ /// Workspace directory path
+ path: PathBuf,
+
+ /// Force create, ignore files in the directory
+ #[arg(short, long)]
+ force: bool,
}
#[derive(Parser, Debug)]
@@ -105,6 +158,10 @@ struct InitWorkspaceArgs {
/// Show help information
#[arg(short, long)]
help: bool,
+
+ /// Force create, ignore files in the directory
+ #[arg(short, long)]
+ force: bool,
}
#[derive(Parser, Debug)]
@@ -115,6 +172,56 @@ struct HereArgs {
}
#[derive(Parser, Debug)]
+struct AccountAddArgs {
+ /// Show help information
+ #[arg(short, long)]
+ help: bool,
+
+ /// Account name
+ account_name: String,
+}
+
+#[derive(Parser, Debug)]
+struct AccountRemoveArgs {
+ /// Show help information
+ #[arg(short, long)]
+ help: bool,
+
+ /// Account name
+ account_name: String,
+}
+
+#[derive(Parser, Debug)]
+struct AccountListArgs {
+ /// Show help information
+ #[arg(short, long)]
+ help: bool,
+}
+
+#[derive(Parser, Debug)]
+struct SetLocalWorkspaceAccountArgs {
+ /// Show help information
+ #[arg(short, long)]
+ help: bool,
+
+ /// Account name
+ account_name: String,
+}
+
+#[derive(Parser, Debug)]
+struct MoveKeyToAccountArgs {
+ /// Show help information
+ #[arg(short, long)]
+ help: bool,
+
+ /// Account name
+ account_name: String,
+
+ /// Private key file path
+ key_path: PathBuf,
+}
+
+#[derive(Parser, Debug)]
struct TrackFileArgs {
/// Show help information
#[arg(short, long)]
@@ -161,6 +268,13 @@ struct DirectArgs {
/// Show help information
#[arg(short, long)]
help: bool,
+
+ /// Upstream vault address
+ upstream: String,
+
+ /// Whether to skip confirmation
+ #[arg(short = 'C', long)]
+ confirm: bool,
}
#[derive(Parser, Debug)]
@@ -168,6 +282,10 @@ struct UnstainArgs {
/// Show help information
#[arg(short, long)]
help: bool,
+
+ /// Whether to skip confirmation
+ #[arg(short = 'C', long)]
+ confirm: bool,
}
#[derive(Parser, Debug)]
@@ -187,33 +305,85 @@ async fn main() {
colored::control::set_virtual_terminal(true).unwrap();
let Ok(parser) = JustEnoughVcsWorkspace::try_parse() else {
- println!("{}", md(t!("jv.help")));
+ println!("{}", md(t!("jv.fail.parse.parser_failed")));
return;
};
match parser.command {
- JustEnoughVcsWorkspaceCommand::Account(account_manage) => match account_manage {
- AccountManage::Help => {
- println!("{}", md(t!("jv.account")));
+ JustEnoughVcsWorkspaceCommand::Help => {
+ println!("{}", md(t!("jv.help")));
+ }
+
+ JustEnoughVcsWorkspaceCommand::Account(account_manage) => {
+ let user_dir = match UserDirectory::current_doc_dir() {
+ Some(dir) => dir,
+ None => {
+ eprintln!("{}", t!("jv.fail.account.no_user_dir"));
+ return;
+ }
+ };
+
+ match account_manage {
+ AccountManage::Help => {
+ println!("{}", md(t!("jv.account")));
+ }
+ AccountManage::Add(account_add_args) => {
+ if account_add_args.help {
+ println!("{}", md(t!("jv.account")));
+ return;
+ }
+ jv_account_add(user_dir, account_add_args).await;
+ }
+ AccountManage::Remove(account_remove_args) => {
+ if account_remove_args.help {
+ println!("{}", md(t!("jv.account")));
+ return;
+ }
+ jv_account_remove(user_dir, account_remove_args).await;
+ }
+ AccountManage::List(account_list_args) => {
+ if account_list_args.help {
+ println!("{}", md(t!("jv.account")));
+ return;
+ }
+ jv_account_list(user_dir, account_list_args).await;
+ }
+ AccountManage::As(set_local_workspace_account_args) => {
+ if set_local_workspace_account_args.help {
+ println!("{}", md(t!("jv.account")));
+ return;
+ }
+ jv_account_as(user_dir, set_local_workspace_account_args).await;
+ }
+ AccountManage::MoveKey(move_key_to_account_args) => {
+ if move_key_to_account_args.help {
+ println!("{}", md(t!("jv.account")));
+ return;
+ }
+ jv_account_move_key(user_dir, move_key_to_account_args).await;
+ }
}
- },
+ }
JustEnoughVcsWorkspaceCommand::Create(create_workspace_args) => {
if create_workspace_args.help {
println!("{}", md(t!("jv.create")));
return;
}
+ jv_create(create_workspace_args).await;
}
JustEnoughVcsWorkspaceCommand::Init(init_workspace_args) => {
if init_workspace_args.help {
println!("{}", md(t!("jv.init")));
return;
}
+ jv_init(init_workspace_args).await;
}
JustEnoughVcsWorkspaceCommand::Here(here_args) => {
if here_args.help {
println!("{}", md(t!("jv.here")));
return;
}
+ jv_here(here_args).await;
}
JustEnoughVcsWorkspaceCommand::Sheet(sheet_manage) => match sheet_manage {
SheetManage::Help => {
@@ -226,54 +396,394 @@ async fn main() {
println!("{}", md(t!("jv.track")));
return;
}
+ jv_track(track_file_args).await;
}
JustEnoughVcsWorkspaceCommand::Hold(hold_file_args) => {
if hold_file_args.help {
println!("{}", md(t!("jv.hold")));
return;
}
+ jv_hold(hold_file_args).await;
}
JustEnoughVcsWorkspaceCommand::Throw(throw_file_args) => {
if throw_file_args.help {
println!("{}", md(t!("jv.throw")));
return;
}
+ jv_throw(throw_file_args).await;
}
JustEnoughVcsWorkspaceCommand::Move(move_file_args) => {
if move_file_args.help {
println!("{}", md(t!("jv.move")));
return;
}
+ jv_move(move_file_args).await;
}
JustEnoughVcsWorkspaceCommand::Export(export_file_args) => {
if export_file_args.help {
println!("{}", md(t!("jv.export")));
return;
}
+ jv_export(export_file_args).await;
}
JustEnoughVcsWorkspaceCommand::Import(import_file_args) => {
if import_file_args.help {
println!("{}", md(t!("jv.import")));
return;
}
+ jv_import(import_file_args).await;
}
JustEnoughVcsWorkspaceCommand::Direct(direct_args) => {
if direct_args.help {
println!("{}", md(t!("jv.direct")));
return;
}
+ jv_direct(direct_args).await;
}
JustEnoughVcsWorkspaceCommand::Unstain(unstain_args) => {
if unstain_args.help {
println!("{}", md(t!("jv.unstain")));
return;
}
+ jv_unstain(unstain_args).await;
}
JustEnoughVcsWorkspaceCommand::Docs(docs_args) => {
if docs_args.help {
println!("{}", md(t!("jv.docs")));
return;
}
+ jv_docs(docs_args).await;
+ }
+ }
+}
+
+async fn jv_create(args: CreateWorkspaceArgs) {
+ let path = args.path;
+
+ if !args.force && path.exists() && !is_directory_empty(&path).await {
+ eprintln!("{}", t!("jv.fail.init_create_dir_not_empty").trim());
+ return;
+ }
+
+ match LocalWorkspace::setup_local_workspace(path).await {
+ Ok(_) => {
+ println!("{}", t!("jv.success.create"));
+ }
+ Err(e) => {
+ eprintln!("{}", t!("jv.fail.create", error = e.to_string()));
+ }
+ }
+}
+
+async fn jv_init(args: InitWorkspaceArgs) {
+ let path = match current_dir() {
+ Ok(path) => path,
+ Err(e) => {
+ eprintln!("{}", t!("jv.fail.get_current_dir", error = e.to_string()));
+ return;
+ }
+ };
+
+ if !args.force && path.exists() && !is_directory_empty(&path).await {
+ eprintln!("{}", t!("jv.fail.init_create_dir_not_empty").trim());
+ return;
+ }
+
+ match LocalWorkspace::setup_local_workspace(path).await {
+ Ok(_) => {
+ println!("{}", t!("jv.success.init"));
+ }
+ Err(e) => {
+ eprintln!("{}", t!("jv.fail.init", error = e.to_string()));
+ }
+ }
+}
+
+async fn is_directory_empty(path: &PathBuf) -> bool {
+ match fs::read_dir(path).await {
+ Ok(mut entries) => entries.next_entry().await.unwrap().is_none(),
+ Err(_) => false,
+ }
+}
+
+async fn jv_here(_args: HereArgs) {
+ todo!()
+}
+
+async fn jv_track(_args: TrackFileArgs) {
+ todo!()
+}
+
+async fn jv_hold(_args: HoldFileArgs) {
+ todo!()
+}
+
+async fn jv_throw(_args: ThrowFileArgs) {
+ todo!()
+}
+
+async fn jv_move(_args: MoveFileArgs) {
+ todo!()
+}
+
+async fn jv_export(_args: ExportFileArgs) {
+ todo!()
+}
+
+async fn jv_import(_args: ImportFileArgs) {
+ todo!()
+}
+
+async fn jv_account_add(user_dir: UserDirectory, args: AccountAddArgs) {
+ let member = Member::new(args.account_name.clone());
+
+ match user_dir.register_account(member).await {
+ Ok(_) => {
+ println!(
+ "{}",
+ t!("jv.success.account.add", account = args.account_name)
+ );
+ }
+ Err(_) => {
+ eprintln!("{}", t!("jv.fail.account.add", account = args.account_name));
+ }
+ }
+}
+
+async fn jv_account_remove(user_dir: UserDirectory, args: AccountRemoveArgs) {
+ match user_dir.remove_account(&args.account_name) {
+ Ok(_) => {
+ println!(
+ "{}",
+ t!("jv.success.account.remove", account = args.account_name)
+ );
+ }
+ Err(_) => {
+ eprintln!(
+ "{}",
+ t!("jv.fail.account.remove", account = args.account_name)
+ );
+ }
+ }
+}
+
+async fn jv_account_list(user_dir: UserDirectory, _args: AccountListArgs) {
+ match user_dir.account_ids() {
+ Ok(account_ids) => {
+ println!(
+ "{}",
+ md(t!(
+ "jv.success.account.list.header",
+ num = account_ids.len()
+ ))
+ );
+
+ let mut i = 0;
+ for account_id in account_ids {
+ println!("{}. {} {}", i + 1, &account_id, {
+ if user_dir.has_private_key(&account_id) {
+ t!("jv.success.account.list.status_has_key")
+ } else {
+ std::borrow::Cow::Borrowed("")
+ }
+ });
+ i += 1;
+ }
+ }
+ Err(_) => {
+ eprintln!("{}", t!("jv.fail.account.list"));
}
}
}
+
+async fn jv_account_as(user_dir: UserDirectory, args: SetLocalWorkspaceAccountArgs) {
+ // Account exist
+ let Ok(member) = user_dir.account(&args.account_name).await else {
+ eprintln!(
+ "{}",
+ t!("jv.fail.account.not_found", account = args.account_name)
+ );
+ return;
+ };
+
+ let Some(_local_dir) = current_local_path() else {
+ eprintln!("{}", t!("jv.fail.workspace_not_found").trim());
+ return;
+ };
+
+ let Ok(mut local_cfg) = LocalConfig::read().await else {
+ eprintln!("{}", md(t!("jv.fail.read_cfg")));
+ return;
+ };
+
+ local_cfg.set_current_account(member.id());
+
+ let Ok(_) = LocalConfig::write(&local_cfg).await else {
+ eprintln!("{}", t!("jv.fail.write_cfg").trim());
+ return;
+ };
+
+ println!(
+ "{}",
+ t!("jv.success.account.as", account = member.id()).trim()
+ );
+}
+
+async fn jv_account_move_key(user_dir: UserDirectory, args: MoveKeyToAccountArgs) {
+ // Key file exist
+ if !args.key_path.exists() {
+ eprintln!(
+ "{}",
+ t!("jv.fail.path_not_found", path = args.key_path.display())
+ );
+ return;
+ }
+
+ // Account exist
+ let Ok(_member) = user_dir.account(&args.account_name).await else {
+ eprintln!(
+ "{}",
+ t!("jv.fail.account.not_found", account = args.account_name)
+ );
+ return;
+ };
+
+ // Rename key file
+ match fs::rename(
+ args.key_path,
+ user_dir.account_private_key_path(&args.account_name),
+ )
+ .await
+ {
+ Ok(_) => println!("{}", t!("jv.success.account.move_key")),
+ Err(_) => eprintln!("{}", t!("jv.fail.account.move_key")),
+ }
+}
+
+async fn jv_direct(args: DirectArgs) {
+ if !args.confirm {
+ println!(
+ "{}",
+ t!("jv.confirm.direct", upstream = args.upstream).trim()
+ );
+ confirm_hint_or(t!("common.confirm"), || exit(1)).await;
+ }
+
+ let pool = client_registry::client_action_pool();
+ let upstream = match socket_addr_helper::get_socket_addr(&args.upstream, PORT).await {
+ Ok(result) => result,
+ Err(e) => {
+ eprintln!(
+ "{}",
+ md(t!(
+ "jv.fail.parse.str_to_sockaddr",
+ str = &args.upstream.trim(),
+ err = e
+ ))
+ );
+ return;
+ }
+ };
+
+ let Some(instance) = connect(upstream).await else {
+ // Since connect() function already printed error messages, we only handle the return here
+ return;
+ };
+
+ let ctx = ActionContext::local().insert_instance(instance);
+
+ match proc_set_upstream_vault_action(&pool, ctx, upstream).await {
+ Err(e) => handle_err(e),
+ Ok(result) => match result {
+ SetUpstreamVaultActionResult::DirectedAndStained => {
+ println!(
+ "{}",
+ md(t!(
+ "jv.result.direct.directed_and_stained",
+ upstream = upstream
+ ))
+ )
+ }
+ SetUpstreamVaultActionResult::AlreadyStained => {
+ eprintln!("{}", md(t!("jv.result.direct.already_stained")))
+ }
+ SetUpstreamVaultActionResult::AuthorizeFailed(e) => {
+ println!(
+ "{}",
+ md(t!("jv.result.direct.directed_and_stained", err = e))
+ )
+ }
+ },
+ };
+}
+
+async fn jv_unstain(args: UnstainArgs) {
+ let Some(_local_dir) = current_local_path() else {
+ eprintln!("{}", t!("jv.fail.workspace_not_found").trim());
+ return;
+ };
+
+ let Ok(mut local_cfg) = LocalConfig::read().await else {
+ eprintln!("{}", md(t!("jv.fail.read_cfg")));
+ return;
+ };
+
+ if !local_cfg.stained() {
+ eprintln!("{}", md(t!("jv.fail.unstain")));
+ return;
+ }
+
+ if !args.confirm {
+ println!(
+ "{}",
+ md(t!("jv.warn.unstain", upstream = local_cfg.vault_addr()))
+ );
+ confirm_hint_or(t!("common.confirm"), || exit(1)).await;
+ }
+
+ local_cfg.unstain();
+
+ let Ok(_) = LocalConfig::write(&local_cfg).await else {
+ eprintln!("{}", t!("jv.fail.write_cfg").trim());
+ return;
+ };
+
+ println!("{}", md(t!("jv.success.unstain")));
+}
+
+async fn jv_docs(_args: DocsArgs) {
+ todo!()
+}
+
+pub fn handle_err(err: TcpTargetError) {
+ eprintln!("{}", md(t!("jv.fail.from_just_version_control", err = err)))
+}
+
+async fn connect(upstream: SocketAddr) -> Option<ConnectionInstance> {
+ // Create Socket
+ let socket = if upstream.is_ipv4() {
+ match TcpSocket::new_v4() {
+ Ok(socket) => socket,
+ Err(_) => {
+ eprintln!("{}", t!("jv.fail.create_socket").trim());
+ return None;
+ }
+ }
+ } else {
+ match TcpSocket::new_v6() {
+ Ok(socket) => socket,
+ Err(_) => {
+ eprintln!("{}", t!("jv.fail.create_socket").trim());
+ return None;
+ }
+ }
+ };
+
+ // Connect
+ let Ok(stream) = socket.connect(upstream).await else {
+ eprintln!("{}", t!("jv.fail.connection_failed").trim());
+ return None;
+ };
+
+ Some(ConnectionInstance::from(stream))
+}
diff --git a/src/bin/jvv.rs b/src/bin/jvv.rs
index 50dc3b3..8480029 100644
--- a/src/bin/jvv.rs
+++ b/src/bin/jvv.rs
@@ -19,7 +19,7 @@ use just_enough_vcs::{
use just_enough_vcs_cli::utils::{
build_env_logger::build_env_logger, lang_selector::current_locales, md_colored::md,
};
-use log::info;
+use log::{error, info};
use rust_i18n::{set_locale, t};
use tokio::fs::{self};
@@ -41,32 +41,42 @@ struct JustEnoughVcsVault {
#[derive(Subcommand, Debug)]
enum JustEnoughVcsVaultCommand {
/// Get vault info in the current directory
+ #[command(alias = "-H")]
Here(HereArgs),
/// Create a new directory and initialize a vault
+ #[command(alias = "-c")]
Create(CreateVaultArgs),
/// Create a vault in the current directory
+ #[command(alias = "-i")]
Init(InitVaultArgs),
/// Member manage
- #[command(subcommand)]
+ #[command(subcommand, alias = "-m")]
Member(MemberManage),
/// Manage service
#[command(subcommand)]
Service(ServiceManage),
+
+ // Short commands
+ #[command(alias = "-l", alias = "listen")]
+ ServiceListen(ListenArgs),
}
#[derive(Subcommand, Debug)]
enum MemberManage {
/// Register a member to the vault
+ #[command(alias = "+")]
Register(MemberRegisterArgs),
/// Remove a member from the vault
+ #[command(alias = "-")]
Remove(MemberRemoveArgs),
/// List all members in the vault
+ #[command(alias = "ls")]
List(MemberListArgs),
/// Show help information
@@ -144,6 +154,10 @@ struct ListenArgs {
/// Disable logging
#[arg(short, long)]
no_log: bool,
+
+ /// Custom port
+ #[arg(short, long)]
+ port: Option<u16>,
}
#[tokio::main]
@@ -153,7 +167,10 @@ async fn main() {
// Init colored
#[cfg(windows)]
- colored::control::set_virtual_terminal(true).unwrap();
+ if let Err(err) = colored::control::set_virtual_terminal(true) {
+ eprintln!("{}", t!("jvv.fail.colored_control", err = err.to_string()));
+ return;
+ }
let Ok(parser) = JustEnoughVcsVault::try_parse() else {
println!("{}", md(t!("jvv.help")));
@@ -183,9 +200,13 @@ async fn main() {
jvv_init(init_vault_args).await;
}
JustEnoughVcsVaultCommand::Member(member_manage) => {
- let vault_cfg = VaultConfig::read()
- .await
- .unwrap_or_else(|_| panic!("{}", t!("jvv.fail.no_vault_here").trim().to_string()));
+ let vault_cfg = match VaultConfig::read().await {
+ Ok(cfg) => cfg,
+ Err(_) => {
+ eprintln!("{}", t!("jvv.fail.no_vault_here").trim());
+ return;
+ }
+ };
let vault = match Vault::init_current_dir(vault_cfg) {
Some(vault) => vault,
@@ -239,6 +260,14 @@ async fn main() {
return;
}
},
+ // Short commands
+ JustEnoughVcsVaultCommand::ServiceListen(listen_args) => {
+ if listen_args.help {
+ println!("{}", md(t!("jvv.service")));
+ return;
+ }
+ jvv_service_listen(listen_args).await;
+ }
}
}
@@ -282,11 +311,11 @@ async fn jvv_here(_args: HereArgs) {
if let Ok(mut entries) = fs::read_dir(&virtual_file_root).await {
while let Ok(Some(entry)) = entries.next_entry().await {
- if let Ok(metadata) = entry.metadata().await {
- if metadata.is_file() {
- num_vf += 1;
- total_size += metadata.len();
- }
+ if let Ok(metadata) = entry.metadata().await
+ && metadata.is_file()
+ {
+ num_vf += 1;
+ total_size += metadata.len();
}
}
}
@@ -358,19 +387,28 @@ async fn jvv_here(_args: HereArgs) {
}
async fn jvv_init(_args: InitVaultArgs) {
- let current_dir = std::env::current_dir()
- .unwrap_or_else(|_| panic!("{}", t!("jvv.fail.std.current_dir").trim().to_string()));
- if current_dir.read_dir().unwrap().next().is_some() {
+ let current_dir = match std::env::current_dir() {
+ Ok(dir) => dir,
+ Err(_) => {
+ eprintln!("{}", t!("jvv.fail.std.current_dir").trim());
+ return;
+ }
+ };
+ if let Ok(mut entries) = current_dir.read_dir()
+ && entries.next().is_some()
+ {
eprintln!("{}", t!("jvv.fail.init.not_empty"));
return;
}
// Setup vault
- let vault_name = current_dir
- .file_name()
- .unwrap_or_else(|| panic!("{}", t!("jvv.fail.std.current_dir_name").trim().to_string()))
- .to_string_lossy()
- .to_string();
+ let vault_name = match current_dir.file_name() {
+ Some(name) => name.to_string_lossy().to_string(),
+ None => {
+ eprintln!("{}", t!("jvv.fail.std.current_dir_name").trim());
+ return;
+ }
+ };
let vault_name = pascal_case!(vault_name);
if let Err(err) = Vault::setup_vault(current_dir.clone()).await {
@@ -401,8 +439,13 @@ async fn jvv_init(_args: InitVaultArgs) {
}
async fn jvv_create(args: CreateVaultArgs) {
- let current_dir = std::env::current_dir()
- .unwrap_or_else(|_| panic!("{}", t!("jvv.fail.std.current_dir").trim().to_string()));
+ let current_dir = match std::env::current_dir() {
+ Ok(dir) => dir,
+ Err(_) => {
+ eprintln!("{}", t!("jvv.fail.std.current_dir").trim());
+ return;
+ }
+ };
let target_dir = current_dir.join(args.vault_name.clone());
// Create directory
@@ -417,7 +460,9 @@ async fn jvv_create(args: CreateVaultArgs) {
return;
}
- if target_dir.read_dir().unwrap().next().is_some() {
+ if let Ok(mut entries) = target_dir.read_dir()
+ && entries.next().is_some()
+ {
eprintln!("{}", t!("jvv.fail.create.not_empty"));
return;
}
@@ -490,9 +535,13 @@ async fn jvv_member_remove(vault: Vault, args: MemberRemoveArgs) {
async fn jvv_member_list(vault: Vault, _args: MemberListArgs) {
// Get id list
- let ids = vault
- .member_ids()
- .unwrap_or_else(|_| panic!("{}", t!("jvv.fail.member.list").trim().to_string()));
+ let ids = match vault.member_ids() {
+ Ok(ids) => ids,
+ Err(_) => {
+ eprintln!("{}", t!("jvv.fail.member.list").trim());
+ return;
+ }
+ };
// Print header
println!(
@@ -559,11 +608,25 @@ async fn jvv_service_listen(args: ListenArgs) {
info!(
"{}",
t!(
- "jvv.success.service.listen",
- path = current_vault.file_name().unwrap().display()
+ "jvv.success.service.listen_start",
+ path = match current_vault.file_name() {
+ Some(name) => name.to_string_lossy(),
+ None => std::borrow::Cow::Borrowed("unknown"),
+ }
)
)
}
- let _ = server_entry(current_vault).await;
+ let port = args.port.unwrap_or_default();
+ match server_entry(current_vault, port).await {
+ Ok(_) => {
+ info!("{}", t!("jvv.success.service.listen_done").trim());
+ }
+ Err(e) => {
+ error!(
+ "{}",
+ t!("jvv.fail.service.listen_done", error = e.to_string()).trim()
+ );
+ }
+ }
}
diff --git a/src/utils.rs b/src/utils.rs
index 87cc1b4..dfa6926 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -1,3 +1,5 @@
pub mod build_env_logger;
+pub mod input;
pub mod lang_selector;
pub mod md_colored;
+pub mod socket_addr_helper;
diff --git a/src/utils/input.rs b/src/utils/input.rs
new file mode 100644
index 0000000..a728c77
--- /dev/null
+++ b/src/utils/input.rs
@@ -0,0 +1,105 @@
+use tokio::{fs, process::Command};
+
+/// Confirm the current operation
+/// Waits for user input of 'y' or 'n'
+pub async fn confirm_hint(text: impl Into<String>) -> bool {
+ use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};
+
+ let prompt = text.into().trim().to_string();
+
+ let mut stdout = io::stdout();
+ let mut stdin = BufReader::new(io::stdin());
+
+ stdout
+ .write_all(prompt.as_bytes())
+ .await
+ .expect("Failed to write prompt");
+ stdout.flush().await.expect("Failed to flush stdout");
+
+ let mut input = String::new();
+ stdin
+ .read_line(&mut input)
+ .await
+ .expect("Failed to read input");
+
+ input.trim().eq_ignore_ascii_case("y")
+}
+
+/// Confirm the current operation, or execute a closure if rejected
+/// Waits for user input of 'y' or 'n'
+/// If 'n' is entered, executes the provided closure and returns false
+pub async fn confirm_hint_or<F>(text: impl Into<String>, on_reject: F) -> bool
+where
+ F: FnOnce(),
+{
+ let confirmed = confirm_hint(text).await;
+ if !confirmed {
+ on_reject();
+ }
+ confirmed
+}
+
+/// Confirm the current operation, and execute a closure if confirmed
+/// Waits for user input of 'y' or 'n'
+/// If 'y' is entered, executes the provided closure and returns true
+pub async fn confirm_hint_then<F>(text: impl Into<String>, on_confirm: F) -> bool
+where
+ F: FnOnce(),
+{
+ let confirmed = confirm_hint(text).await;
+ if confirmed {
+ on_confirm();
+ }
+ confirmed
+}
+
+/// Input text using the system editor
+/// Opens the system editor (from EDITOR environment variable) with default text in a cache file,
+/// then reads back the modified content after the editor closes, removing comment lines
+pub async fn input_with_editor(
+ default_text: impl AsRef<str>,
+ cache_file: impl AsRef<std::path::Path>,
+ comment_char: impl AsRef<str>,
+) -> Result<String, std::io::Error> {
+ let cache_path = cache_file.as_ref();
+ let default_content = default_text.as_ref();
+ let comment_prefix = comment_char.as_ref();
+
+ // Write default text to cache file
+ fs::write(cache_path, default_content).await?;
+
+ // Get editor from environment variable
+ let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
+
+ // Open editor with cache file
+ let status = Command::new(editor).arg(cache_path).status().await?;
+
+ if !status.success() {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::Other,
+ "Editor exited with non-zero status",
+ ));
+ }
+
+ // Read the modified content
+ let content = fs::read_to_string(cache_path).await?;
+
+ // Remove comment lines and trim
+ let processed_content: String = content
+ .lines()
+ .filter_map(|line| {
+ let trimmed = line.trim();
+ if trimmed.starts_with(comment_prefix) {
+ None
+ } else {
+ Some(line)
+ }
+ })
+ .collect::<Vec<&str>>()
+ .join("\n");
+
+ // Delete the cache file
+ let _ = fs::remove_file(cache_path).await;
+
+ Ok(processed_content)
+}
diff --git a/src/utils/socket_addr_helper.rs b/src/utils/socket_addr_helper.rs
new file mode 100644
index 0000000..fd7b346
--- /dev/null
+++ b/src/utils/socket_addr_helper.rs
@@ -0,0 +1,184 @@
+use std::net::SocketAddr;
+use tokio::net::lookup_host;
+
+/// Helper function to parse a string into a SocketAddr with optional default port
+pub async fn get_socket_addr(
+ address_str: impl AsRef<str>,
+ default_port: u16,
+) -> Result<SocketAddr, std::io::Error> {
+ let address = address_str.as_ref().trim();
+
+ // Check if the address contains a port
+ if let Some((host, port_str)) = parse_host_and_port(address) {
+ let port = port_str.parse::<u16>().map_err(|e| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ format!("Invalid port number '{}': {}", port_str, e),
+ )
+ })?;
+
+ return resolve_to_socket_addr(host, port).await;
+ }
+
+ // No port specified, use default port
+ resolve_to_socket_addr(address, default_port).await
+}
+
+/// Parse host and port from address string
+fn parse_host_and_port(address: &str) -> Option<(&str, &str)> {
+ if address.starts_with('[')
+ && let Some(close_bracket) = address.find(']')
+ && close_bracket + 1 < address.len() && address.as_bytes()[close_bracket + 1] == b':' {
+ let host = &address[1..close_bracket];
+ let port = &address[close_bracket + 2..];
+ return Some((host, port));
+ }
+
+ // Handle IPv4 addresses and hostnames with ports
+ if let Some(colon_pos) = address.rfind(':') {
+ // Check if this is not part of an IPv6 address without brackets
+ if !address.contains('[') && !address.contains(']') {
+ let host = &address[..colon_pos];
+ let port = &address[colon_pos + 1..];
+
+ // Basic validation to avoid false positives
+ if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
+ return Some((host, port));
+ }
+ }
+ }
+
+ None
+}
+
+/// Resolve host to SocketAddr, handling both IP addresses and domain names
+async fn resolve_to_socket_addr(host: &str, port: u16) -> Result<SocketAddr, std::io::Error> {
+ // First try to parse as IP address (IPv4 or IPv6)
+ if let Ok(ip_addr) = host.parse() {
+ return Ok(SocketAddr::new(ip_addr, port));
+ }
+
+ // If it's not a valid IP address, treat it as a domain name and perform DNS lookup
+ let lookup_addr = format!("{}:{}", host, port);
+ let mut addrs = lookup_host(&lookup_addr).await?;
+
+ if let Some(addr) = addrs.next() {
+ Ok(addr)
+ } else {
+ Err(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ format!("Could not resolve host '{}'", host),
+ ))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_ipv4_with_port() {
+ let result = get_socket_addr("127.0.0.1:8080", 80).await;
+ assert!(result.is_ok());
+ let addr = result.unwrap();
+ assert_eq!(addr.port(), 8080);
+ assert_eq!(addr.ip().to_string(), "127.0.0.1");
+ }
+
+ #[tokio::test]
+ async fn test_ipv4_without_port() {
+ let result = get_socket_addr("192.168.1.1", 443).await;
+ assert!(result.is_ok());
+ let addr = result.unwrap();
+ assert_eq!(addr.port(), 443);
+ assert_eq!(addr.ip().to_string(), "192.168.1.1");
+ }
+
+ #[tokio::test]
+ async fn test_ipv6_with_port() {
+ let result = get_socket_addr("[::1]:8080", 80).await;
+ assert!(result.is_ok());
+ let addr = result.unwrap();
+ assert_eq!(addr.port(), 8080);
+ assert_eq!(addr.ip().to_string(), "::1");
+ }
+
+ #[tokio::test]
+ async fn test_ipv6_without_port() {
+ let result = get_socket_addr("[::1]", 443).await;
+ assert!(result.is_ok());
+ let addr = result.unwrap();
+ assert_eq!(addr.port(), 443);
+ assert_eq!(addr.ip().to_string(), "::1");
+ }
+
+ #[tokio::test]
+ async fn test_invalid_port() {
+ let result = get_socket_addr("127.0.0.1:99999", 80).await;
+ assert!(result.is_err());
+ }
+
+ #[tokio::test]
+ async fn test_empty_string() {
+ let result = get_socket_addr("", 80).await;
+ assert!(result.is_err());
+ }
+
+ #[tokio::test]
+ async fn test_whitespace_trimming() {
+ let result = get_socket_addr(" 127.0.0.1:8080 ", 80).await;
+ assert!(result.is_ok());
+ let addr = result.unwrap();
+ assert_eq!(addr.port(), 8080);
+ }
+
+ #[tokio::test]
+ async fn test_domain_name_with_port() {
+ // This test will only pass if localhost resolves
+ let result = get_socket_addr("localhost:8080", 80).await;
+ if result.is_ok() {
+ let addr = result.unwrap();
+ assert_eq!(addr.port(), 8080);
+ // localhost should resolve to 127.0.0.1 or ::1
+ assert!(addr.ip().is_loopback());
+ }
+ }
+
+ #[tokio::test]
+ async fn test_domain_name_without_port() {
+ // This test will only pass if localhost resolves
+ let result = get_socket_addr("localhost", 443).await;
+ if result.is_ok() {
+ let addr = result.unwrap();
+ assert_eq!(addr.port(), 443);
+ // localhost should resolve to 127.0.0.1 or ::1
+ assert!(addr.ip().is_loopback());
+ }
+ }
+
+ #[tokio::test]
+ async fn test_parse_host_and_port() {
+ // IPv4 with port
+ assert_eq!(
+ parse_host_and_port("192.168.1.1:8080"),
+ Some(("192.168.1.1", "8080"))
+ );
+
+ // IPv6 with port
+ assert_eq!(parse_host_and_port("[::1]:8080"), Some(("::1", "8080")));
+
+ // Hostname with port
+ assert_eq!(
+ parse_host_and_port("example.com:443"),
+ Some(("example.com", "443"))
+ );
+
+ // No port
+ assert_eq!(parse_host_and_port("192.168.1.1"), None);
+ assert_eq!(parse_host_and_port("example.com"), None);
+
+ // Invalid cases
+ assert_eq!(parse_host_and_port(":"), None);
+ assert_eq!(parse_host_and_port("192.168.1.1:"), None);
+ }
+}