Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "nomifun-shell"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-system.workspace = true
|
||||
nomifun-runtime.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
open.workspace = true
|
||||
which.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nomifun-db.workspace = true
|
||||
http-body-util.workspace = true
|
||||
sqlx.workspace = true
|
||||
tempfile.workspace = true
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
wiremock.workspace = true
|
||||
@@ -0,0 +1,221 @@
|
||||
use nomifun_common::AppError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ShellError {
|
||||
#[error("file not found: {0}")]
|
||||
FileNotFound(String),
|
||||
|
||||
#[error("directory not found: {0}")]
|
||||
DirectoryNotFound(String),
|
||||
|
||||
#[error("invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
#[error("invalid target: {0}")]
|
||||
InvalidTarget(String),
|
||||
|
||||
#[error("tool not installed: {0}")]
|
||||
ToolNotInstalled(String),
|
||||
|
||||
#[error("command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl From<ShellError> for AppError {
|
||||
fn from(err: ShellError) -> Self {
|
||||
match err {
|
||||
ShellError::FileNotFound(path) => AppError::BadRequest(format!("file not found: {path}")),
|
||||
ShellError::DirectoryNotFound(path) => AppError::BadRequest(format!("directory not found: {path}")),
|
||||
ShellError::InvalidUrl(msg) => AppError::BadRequest(format!("invalid URL: {msg}")),
|
||||
ShellError::InvalidTarget(msg) => AppError::BadRequest(format!("invalid target: {msg}")),
|
||||
ShellError::ToolNotInstalled(tool) => AppError::BadRequest(format!("tool not installed: {tool}")),
|
||||
ShellError::CommandFailed(msg) => AppError::Internal(format!("command failed: {msg}")),
|
||||
ShellError::Io(e) => AppError::Internal(format!("IO error: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SttError {
|
||||
#[error("STT is not enabled")]
|
||||
Disabled,
|
||||
|
||||
#[error("OpenAI STT is not configured: missing API key")]
|
||||
OpenaiNotConfigured,
|
||||
|
||||
#[error("Deepgram STT is not configured: missing API key")]
|
||||
DeepgramNotConfigured,
|
||||
|
||||
#[error("STT request failed: {0}")]
|
||||
RequestFailed(String),
|
||||
|
||||
#[error("STT unknown error: {0}")]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl SttError {
|
||||
pub fn error_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Disabled => "STT_DISABLED",
|
||||
Self::OpenaiNotConfigured => "STT_OPENAI_NOT_CONFIGURED",
|
||||
Self::DeepgramNotConfigured => "STT_DEEPGRAM_NOT_CONFIGURED",
|
||||
Self::RequestFailed(_) => "STT_REQUEST_FAILED",
|
||||
Self::Unknown(_) => "STT_UNKNOWN",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> u16 {
|
||||
match self {
|
||||
Self::Disabled | Self::OpenaiNotConfigured | Self::DeepgramNotConfigured => 400,
|
||||
Self::RequestFailed(_) => 502,
|
||||
Self::Unknown(_) => 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SttError> for AppError {
|
||||
fn from(err: SttError) -> Self {
|
||||
match &err {
|
||||
SttError::Disabled | SttError::OpenaiNotConfigured | SttError::DeepgramNotConfigured => {
|
||||
AppError::BadRequest(err.to_string())
|
||||
}
|
||||
SttError::RequestFailed(_) => AppError::BadGateway(err.to_string()),
|
||||
SttError::Unknown(_) => AppError::Internal(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_not_found_maps_to_bad_request() {
|
||||
let err: AppError = ShellError::FileNotFound("/tmp/missing.txt".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("/tmp/missing.txt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_not_found_maps_to_bad_request() {
|
||||
let err: AppError = ShellError::DirectoryNotFound("/tmp/nodir".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("/tmp/nodir")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_url_maps_to_bad_request() {
|
||||
let err: AppError = ShellError::InvalidUrl("not a url".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("not a url")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_not_installed_maps_to_bad_request() {
|
||||
let err: AppError = ShellError::ToolNotInstalled("vscode".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("vscode")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_failed_maps_to_internal() {
|
||||
let err: AppError = ShellError::CommandFailed("exit code 1".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(msg) if msg.contains("exit code 1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn io_error_maps_to_internal() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied");
|
||||
let err: AppError = ShellError::Io(io_err).into();
|
||||
assert!(matches!(err, AppError::Internal(msg) if msg.contains("permission denied")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_error_display_messages() {
|
||||
assert_eq!(
|
||||
ShellError::FileNotFound("/a.txt".into()).to_string(),
|
||||
"file not found: /a.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
ShellError::DirectoryNotFound("/dir".into()).to_string(),
|
||||
"directory not found: /dir"
|
||||
);
|
||||
assert_eq!(ShellError::InvalidUrl("bad".into()).to_string(), "invalid URL: bad");
|
||||
assert_eq!(
|
||||
ShellError::ToolNotInstalled("code".into()).to_string(),
|
||||
"tool not installed: code"
|
||||
);
|
||||
assert_eq!(
|
||||
ShellError::CommandFailed("oops".into()).to_string(),
|
||||
"command failed: oops"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_disabled_maps_to_bad_request() {
|
||||
let err: AppError = SttError::Disabled.into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("not enabled")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_openai_not_configured_maps_to_bad_request() {
|
||||
let err: AppError = SttError::OpenaiNotConfigured.into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("OpenAI")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_deepgram_not_configured_maps_to_bad_request() {
|
||||
let err: AppError = SttError::DeepgramNotConfigured.into();
|
||||
assert!(matches!(err, AppError::BadRequest(msg) if msg.contains("Deepgram")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_request_failed_maps_to_bad_gateway() {
|
||||
let err: AppError = SttError::RequestFailed("HTTP 401".into()).into();
|
||||
assert!(matches!(err, AppError::BadGateway(msg) if msg.contains("HTTP 401")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_unknown_maps_to_internal() {
|
||||
let err: AppError = SttError::Unknown("unexpected".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(msg) if msg.contains("unexpected")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_error_codes() {
|
||||
assert_eq!(SttError::Disabled.error_code(), "STT_DISABLED");
|
||||
assert_eq!(SttError::OpenaiNotConfigured.error_code(), "STT_OPENAI_NOT_CONFIGURED");
|
||||
assert_eq!(
|
||||
SttError::DeepgramNotConfigured.error_code(),
|
||||
"STT_DEEPGRAM_NOT_CONFIGURED"
|
||||
);
|
||||
assert_eq!(SttError::RequestFailed("x".into()).error_code(), "STT_REQUEST_FAILED");
|
||||
assert_eq!(SttError::Unknown("x".into()).error_code(), "STT_UNKNOWN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_status_codes() {
|
||||
assert_eq!(SttError::Disabled.status_code(), 400);
|
||||
assert_eq!(SttError::OpenaiNotConfigured.status_code(), 400);
|
||||
assert_eq!(SttError::DeepgramNotConfigured.status_code(), 400);
|
||||
assert_eq!(SttError::RequestFailed("x".into()).status_code(), 502);
|
||||
assert_eq!(SttError::Unknown("x".into()).status_code(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stt_error_display_messages() {
|
||||
assert_eq!(SttError::Disabled.to_string(), "STT is not enabled");
|
||||
assert_eq!(
|
||||
SttError::OpenaiNotConfigured.to_string(),
|
||||
"OpenAI STT is not configured: missing API key"
|
||||
);
|
||||
assert_eq!(
|
||||
SttError::DeepgramNotConfigured.to_string(),
|
||||
"Deepgram STT is not configured: missing API key"
|
||||
);
|
||||
assert_eq!(
|
||||
SttError::RequestFailed("timeout".into()).to_string(),
|
||||
"STT request failed: timeout"
|
||||
);
|
||||
assert_eq!(SttError::Unknown("oops".into()).to_string(), "STT unknown error: oops");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! OS shell integration: file/folder opener, tool detection, and speech-to-text.
|
||||
pub mod error;
|
||||
pub mod opener;
|
||||
pub mod routes;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod stt;
|
||||
pub(crate) mod stt_deepgram;
|
||||
pub(crate) mod stt_openai;
|
||||
|
||||
pub use error::{ShellError, SttError};
|
||||
pub use opener::{DefaultSystemOpener, ISystemOpener, NoopSystemOpener};
|
||||
pub use routes::shell_routes;
|
||||
pub use shell::ShellService;
|
||||
pub use state::ShellRouterState;
|
||||
pub use stt::SttService;
|
||||
@@ -0,0 +1,112 @@
|
||||
use nomifun_runtime::Builder as CmdBuilder;
|
||||
|
||||
use crate::error::ShellError;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait ISystemOpener: Send + Sync {
|
||||
fn open_detached(&self, target: &str) -> Result<(), ShellError>;
|
||||
/// Open `target` with a specific application (e.g. open a URL in a named
|
||||
/// browser). On Windows this is ShellExecute via the registered app, which
|
||||
/// avoids the `cmd /c start` window-title argument quirk.
|
||||
fn open_with_detached(&self, target: &str, app: &str) -> Result<(), ShellError>;
|
||||
async fn run_command(&self, program: &str, args: &[&str]) -> Result<(), ShellError>;
|
||||
fn is_tool_available(&self, tool_name: &str) -> bool;
|
||||
}
|
||||
|
||||
pub struct DefaultSystemOpener;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ISystemOpener for DefaultSystemOpener {
|
||||
fn open_detached(&self, target: &str) -> Result<(), ShellError> {
|
||||
open::that_detached(target).map_err(|e| ShellError::CommandFailed(format!("open: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_with_detached(&self, target: &str, app: &str) -> Result<(), ShellError> {
|
||||
open::with_detached(target, app)
|
||||
.map_err(|e| ShellError::CommandFailed(format!("open {target:?} with {app:?}: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_command(&self, program: &str, args: &[&str]) -> Result<(), ShellError> {
|
||||
let mut builder = CmdBuilder::clean_cli(program);
|
||||
builder
|
||||
.args(args)
|
||||
// Everything launched here is handed off to the user (a terminal
|
||||
// window, an editor) and must survive this app exiting — keep it
|
||||
// out of the force-kill safety nets (Windows cleanup job / Linux
|
||||
// PDEATHSIG), which propagate to descendants like the opened
|
||||
// window.
|
||||
.hand_off()
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
let output = builder
|
||||
.spawn()
|
||||
.map_err(|e| ShellError::CommandFailed(format!("{program}: {e}")))?;
|
||||
|
||||
let result = output
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|e| ShellError::CommandFailed(format!("{program}: {e}")))?;
|
||||
|
||||
if !result.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
tracing::warn!(program, ?args, %stderr, "command exited with non-zero status");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_tool_available(&self, tool_name: &str) -> bool {
|
||||
which::which(tool_name).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopSystemOpener;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ISystemOpener for NoopSystemOpener {
|
||||
fn open_detached(&self, _target: &str) -> Result<(), ShellError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_with_detached(&self, _target: &str, _app: &str) -> Result<(), ShellError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_command(&self, _program: &str, _args: &[&str]) -> Result<(), ShellError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_tool_available(&self, _tool_name: &str) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_opener_detects_nonexistent_tool() {
|
||||
let opener = DefaultSystemOpener;
|
||||
assert!(!opener.is_tool_available("__nonexistent_tool_xyz__"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_opener_open_detached_succeeds() {
|
||||
let opener = NoopSystemOpener;
|
||||
assert!(opener.open_detached("https://example.com").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn noop_opener_run_command_succeeds() {
|
||||
let opener = NoopSystemOpener;
|
||||
assert!(opener.run_command("fake-program", &["arg1"]).await.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_opener_is_tool_available_always_true() {
|
||||
let opener = NoopSystemOpener;
|
||||
assert!(opener.is_tool_available("__nonexistent__"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
use axum::extract::{Multipart, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
|
||||
use nomifun_api_types::{
|
||||
ApiResponse, CheckToolInstalledRequest, CheckToolInstalledResponse, OpenExternalRequest, OpenFileRequest,
|
||||
OpenFolderWithRequest, ShowItemInFolderRequest, SpeechToTextConfig,
|
||||
};
|
||||
use nomifun_common::AppError;
|
||||
|
||||
use crate::error::SttError;
|
||||
use crate::state::ShellRouterState;
|
||||
|
||||
pub fn shell_routes(state: ShellRouterState) -> Router {
|
||||
Router::new()
|
||||
.route("/api/shell/open-file", post(open_file))
|
||||
.route("/api/shell/show-item-in-folder", post(show_item_in_folder))
|
||||
.route("/api/shell/open-external", post(open_external))
|
||||
.route("/api/shell/check-tool-installed", post(check_tool_installed))
|
||||
.route("/api/shell/open-folder-with", post(open_folder_with))
|
||||
.route("/api/stt", post(speech_to_text))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn open_file(
|
||||
State(state): State<ShellRouterState>,
|
||||
body: Result<Json<OpenFileRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.shell_service.open_file(&req.file_path).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn show_item_in_folder(
|
||||
State(state): State<ShellRouterState>,
|
||||
body: Result<Json<ShowItemInFolderRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.shell_service.show_item_in_folder(&req.file_path).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn open_external(
|
||||
State(state): State<ShellRouterState>,
|
||||
body: Result<Json<OpenExternalRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.shell_service.open_external(&req.url).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
async fn check_tool_installed(
|
||||
State(state): State<ShellRouterState>,
|
||||
body: Result<Json<CheckToolInstalledRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<CheckToolInstalledResponse>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let installed = state.shell_service.check_tool_installed(req.tool).await;
|
||||
Ok(Json(ApiResponse::ok(CheckToolInstalledResponse { installed })))
|
||||
}
|
||||
|
||||
async fn open_folder_with(
|
||||
State(state): State<ShellRouterState>,
|
||||
body: Result<Json<OpenFolderWithRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError> {
|
||||
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
state.shell_service.open_folder_with(&req.folder_path, req.tool).await?;
|
||||
Ok(Json(ApiResponse::success()))
|
||||
}
|
||||
|
||||
struct SttMultipartFields {
|
||||
file_data: Vec<u8>,
|
||||
file_name: String,
|
||||
mime_type: String,
|
||||
language_hint: Option<String>,
|
||||
}
|
||||
|
||||
async fn extract_stt_multipart(mut multipart: Multipart) -> Result<SttMultipartFields, AppError> {
|
||||
let mut file_data: Option<Vec<u8>> = None;
|
||||
let mut file_name: Option<String> = None;
|
||||
let mut mime_type: Option<String> = None;
|
||||
let mut language_hint: Option<String> = None;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("multipart error: {e}")))?
|
||||
{
|
||||
let name = field.name().unwrap_or("").to_owned();
|
||||
match name.as_str() {
|
||||
"file" => {
|
||||
file_data = Some(
|
||||
field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("failed to read file: {e}")))?
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
"fileName" => {
|
||||
file_name = Some(
|
||||
field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("failed to read fileName: {e}")))?,
|
||||
);
|
||||
}
|
||||
"mimeType" => {
|
||||
mime_type = Some(
|
||||
field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("failed to read mimeType: {e}")))?,
|
||||
);
|
||||
}
|
||||
"languageHint" => {
|
||||
let text = field
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("failed to read languageHint: {e}")))?;
|
||||
if !text.is_empty() {
|
||||
language_hint = Some(text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let file_data = file_data.ok_or_else(|| AppError::BadRequest("missing 'file' field".to_owned()))?;
|
||||
let file_name = file_name.ok_or_else(|| AppError::BadRequest("missing 'fileName' field".to_owned()))?;
|
||||
let mime_type = mime_type.ok_or_else(|| AppError::BadRequest("missing 'mimeType' field".to_owned()))?;
|
||||
|
||||
Ok(SttMultipartFields {
|
||||
file_data,
|
||||
file_name,
|
||||
mime_type,
|
||||
language_hint,
|
||||
})
|
||||
}
|
||||
|
||||
async fn speech_to_text(
|
||||
State(state): State<ShellRouterState>,
|
||||
multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, Json<serde_json::Value>)> {
|
||||
let fields = extract_stt_multipart(multipart).await.map_err(|e| {
|
||||
let status = e.status_code();
|
||||
let body = serde_json::json!({
|
||||
"success": false,
|
||||
"error": e.to_string(),
|
||||
"code": e.error_code(),
|
||||
});
|
||||
(status, Json(body))
|
||||
})?;
|
||||
|
||||
let prefs = state
|
||||
.client_pref_service
|
||||
.get_preferences(Some(&["speechToText"]))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let status = e.status_code();
|
||||
let body = serde_json::json!({
|
||||
"success": false,
|
||||
"error": e.to_string(),
|
||||
"code": e.error_code(),
|
||||
});
|
||||
(status, Json(body))
|
||||
})?;
|
||||
|
||||
let config: SpeechToTextConfig = prefs
|
||||
.get("speechToText")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or(SpeechToTextConfig {
|
||||
enabled: false,
|
||||
provider: nomifun_api_types::SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: None,
|
||||
});
|
||||
|
||||
let result = state
|
||||
.stt_service
|
||||
.transcribe(
|
||||
fields.file_data,
|
||||
&fields.file_name,
|
||||
&fields.mime_type,
|
||||
fields.language_hint.as_deref(),
|
||||
&config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| stt_error_response(&e))?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"success": true,
|
||||
"data": result,
|
||||
});
|
||||
Ok((StatusCode::OK, Json(body)))
|
||||
}
|
||||
|
||||
fn stt_error_response(err: &SttError) -> (StatusCode, Json<serde_json::Value>) {
|
||||
let status = StatusCode::from_u16(err.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let body = serde_json::json!({
|
||||
"success": false,
|
||||
"error": err.to_string(),
|
||||
"code": err.error_code(),
|
||||
});
|
||||
(status, Json(body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use axum::http::Request;
|
||||
use http_body_util::BodyExt;
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn make_state() -> ShellRouterState {
|
||||
use crate::opener::NoopSystemOpener;
|
||||
use crate::shell::ShellService;
|
||||
use crate::stt::SttService;
|
||||
|
||||
let pool = sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap();
|
||||
let repo = Arc::new(nomifun_db::SqliteClientPreferenceRepository::new(pool));
|
||||
let client_pref_service = nomifun_system::ClientPrefService::new(repo);
|
||||
|
||||
ShellRouterState {
|
||||
shell_service: Arc::new(ShellService::new(Arc::new(NoopSystemOpener))),
|
||||
stt_service: Arc::new(SttService::new(reqwest::Client::new())),
|
||||
client_pref_service,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_router() -> Router {
|
||||
shell_routes(make_state())
|
||||
}
|
||||
|
||||
async fn body_json(resp: axum::response::Response) -> serde_json::Value {
|
||||
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_file_missing_body_returns_400() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/open-file")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_file_nonexistent_returns_400() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/open-file")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"filePath":"/nonexistent/file.txt"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_external_invalid_url_returns_400() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/open-external")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"url":"; rm -rf /"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_external_file_scheme_returns_400() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/open-external")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"url":"file:///etc/passwd"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_tool_terminal_returns_installed_true() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/check-tool-installed")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"tool":"terminal"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["installed"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_tool_explorer_returns_installed_true() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/check-tool-installed")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"tool":"explorer"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let json = body_json(resp).await;
|
||||
assert_eq!(json["success"], true);
|
||||
assert_eq!(json["data"]["installed"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_folder_with_nonexistent_dir_returns_400() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/open-folder-with")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"folderPath":"/nonexistent/dir","tool":"explorer"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn show_item_in_folder_nonexistent_returns_400() {
|
||||
let app = make_router();
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/shell/show-item-in-folder")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"filePath":"/nonexistent/path"}"#))
|
||||
.unwrap();
|
||||
let resp = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::ToolType;
|
||||
|
||||
use crate::error::ShellError;
|
||||
use crate::opener::ISystemOpener;
|
||||
|
||||
const ALLOWED_URL_SCHEMES: &[&str] = &["http", "https", "mailto"];
|
||||
|
||||
pub struct ShellService {
|
||||
opener: Arc<dyn ISystemOpener>,
|
||||
}
|
||||
|
||||
impl ShellService {
|
||||
pub fn new(opener: Arc<dyn ISystemOpener>) -> Self {
|
||||
Self { opener }
|
||||
}
|
||||
|
||||
pub async fn open_file(&self, file_path: &str) -> Result<(), ShellError> {
|
||||
let path = validate_file_exists(file_path)?;
|
||||
self.opener.open_detached(&path.to_string_lossy())
|
||||
}
|
||||
|
||||
pub async fn show_item_in_folder(&self, file_path: &str) -> Result<(), ShellError> {
|
||||
let path = validate_path_exists(file_path)?;
|
||||
if cfg!(target_os = "macos") {
|
||||
self.opener.run_command("open", &["-R", &path.to_string_lossy()]).await
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let parent = path.parent().unwrap_or(&path);
|
||||
self.opener.run_command("explorer", &[&parent.to_string_lossy()]).await
|
||||
} else {
|
||||
let parent = path.parent().unwrap_or(&path);
|
||||
self.opener.run_command("xdg-open", &[&parent.to_string_lossy()]).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_external(&self, url: &str) -> Result<(), ShellError> {
|
||||
validate_url(url)?;
|
||||
self.opener.open_detached(url)
|
||||
}
|
||||
|
||||
/// Launch a URL, file, folder, or application (by name or path) via the OS
|
||||
/// shell (ShellExecute on Windows). Unlike `open_external`/`open_file`, this
|
||||
/// accepts any target — app names like `msedge`, arbitrary paths — so an
|
||||
/// agent can reliably open browsers/apps WITHOUT the fragile `cmd /c start`
|
||||
/// window-title-argument quirk. `app` optionally launches the target with a
|
||||
/// specific application (e.g. open a URL in a named browser). The target is
|
||||
/// guarded against the empty / bare-path-separator inputs (e.g. `\\`) that
|
||||
/// otherwise surface a Windows "cannot find '\\'" ShellExecute dialog.
|
||||
pub async fn launch(&self, target: &str, app: Option<&str>) -> Result<(), ShellError> {
|
||||
validate_launch_target(target)?;
|
||||
match app {
|
||||
Some(app) => self.opener.open_with_detached(target, app),
|
||||
None => self.opener.open_detached(target),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_tool_installed(&self, tool: ToolType) -> bool {
|
||||
match tool {
|
||||
ToolType::Terminal | ToolType::Explorer => true,
|
||||
ToolType::Vscode => self.detect_vscode(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn open_folder_with(&self, folder_path: &str, tool: ToolType) -> Result<(), ShellError> {
|
||||
let path = validate_directory_exists(folder_path)?;
|
||||
match tool {
|
||||
ToolType::Vscode => self.open_folder_vscode(&path).await,
|
||||
ToolType::Terminal => self.open_folder_terminal(&path).await,
|
||||
ToolType::Explorer => self.open_folder_explorer(&path).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_vscode(&self) -> bool {
|
||||
if self.opener.is_tool_available("code") {
|
||||
return true;
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
let app_path = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code";
|
||||
return Path::new(app_path).exists();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn open_folder_vscode(&self, path: &Path) -> Result<(), ShellError> {
|
||||
if !self.detect_vscode() {
|
||||
return Err(ShellError::ToolNotInstalled("vscode".to_owned()));
|
||||
}
|
||||
self.opener.run_command("code", &[&path.to_string_lossy()]).await
|
||||
}
|
||||
|
||||
async fn open_folder_terminal(&self, path: &Path) -> Result<(), ShellError> {
|
||||
let path_str = path.to_string_lossy();
|
||||
if cfg!(target_os = "macos") {
|
||||
self.opener.run_command("open", &["-a", "Terminal", &path_str]).await
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// `start "" /D <dir> cmd`: the empty first argument is the window
|
||||
// title — without it, `start` treats a quoted path (any path with
|
||||
// spaces) as the title instead of the command. `/D` sets the
|
||||
// startup directory as a discrete argument, so no `cd /d` string
|
||||
// splicing is needed.
|
||||
self.opener
|
||||
.run_command("cmd", &["/c", "start", "", "/D", &path_str, "cmd"])
|
||||
.await
|
||||
} else {
|
||||
self.try_linux_terminal(&path_str).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_folder_explorer(&self, path: &Path) -> Result<(), ShellError> {
|
||||
let path_str = path.to_string_lossy();
|
||||
if cfg!(target_os = "macos") {
|
||||
self.opener.run_command("open", &[&path_str]).await
|
||||
} else if cfg!(target_os = "windows") {
|
||||
self.opener.run_command("explorer", &[&path_str]).await
|
||||
} else {
|
||||
self.opener.run_command("xdg-open", &[&path_str]).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn try_linux_terminal(&self, path: &str) -> Result<(), ShellError> {
|
||||
let terminals = [
|
||||
"gnome-terminal",
|
||||
"konsole",
|
||||
"xfce4-terminal",
|
||||
"x-terminal-emulator",
|
||||
"terminator",
|
||||
];
|
||||
for term in &terminals {
|
||||
if self.opener.is_tool_available(term) {
|
||||
let args: Vec<&str> = match *term {
|
||||
"gnome-terminal" => vec!["--working-directory", path],
|
||||
"konsole" => vec!["--workdir", path],
|
||||
_ => vec!["--working-directory", path],
|
||||
};
|
||||
return self.opener.run_command(term, &args).await;
|
||||
}
|
||||
}
|
||||
Err(ShellError::ToolNotInstalled("terminal emulator".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_file_exists(file_path: &str) -> Result<std::path::PathBuf, ShellError> {
|
||||
let path = Path::new(file_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|_| ShellError::FileNotFound(file_path.to_owned()))?;
|
||||
if !canonical.is_file() {
|
||||
return Err(ShellError::FileNotFound(file_path.to_owned()));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn validate_path_exists(file_path: &str) -> Result<std::path::PathBuf, ShellError> {
|
||||
let path = Path::new(file_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|_| ShellError::FileNotFound(file_path.to_owned()))?;
|
||||
if !canonical.exists() {
|
||||
return Err(ShellError::FileNotFound(file_path.to_owned()));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn validate_directory_exists(dir_path: &str) -> Result<std::path::PathBuf, ShellError> {
|
||||
let path = Path::new(dir_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.map_err(|_| ShellError::DirectoryNotFound(dir_path.to_owned()))?;
|
||||
if !canonical.is_dir() {
|
||||
return Err(ShellError::DirectoryNotFound(dir_path.to_owned()));
|
||||
}
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn validate_url(url: &str) -> Result<(), ShellError> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|_| ShellError::InvalidUrl(url.to_owned()))?;
|
||||
if !ALLOWED_URL_SCHEMES.contains(&parsed.scheme()) {
|
||||
return Err(ShellError::InvalidUrl(format!(
|
||||
"scheme '{}' is not allowed",
|
||||
parsed.scheme()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject launch targets the OS shell cannot meaningfully open and that surface
|
||||
/// a "Windows cannot find 'X'" ShellExecute dialog: an empty/whitespace target,
|
||||
/// or a string consisting ENTIRELY of path separators (`\` / `/`) — e.g. a bare
|
||||
/// UNC root `\\`. Real URLs, paths, and app names contain non-separator
|
||||
/// characters and pass.
|
||||
fn validate_launch_target(target: &str) -> Result<(), ShellError> {
|
||||
let trimmed = target.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ShellError::InvalidTarget(
|
||||
"empty target — provide a URL, file/folder path, or application name".to_owned(),
|
||||
));
|
||||
}
|
||||
// A target made up entirely of path separators (e.g. a bare UNC root `\\`)
|
||||
// is not something ShellExecute can open; passing it through surfaces the
|
||||
// "Windows cannot find '\\'" dialog. Reject it with a clear message.
|
||||
if trimmed.chars().all(|c| c == '\\' || c == '/') {
|
||||
return Err(ShellError::InvalidTarget(format!(
|
||||
"{target:?} is only path separators (e.g. a bare UNC root); provide a real URL, \
|
||||
path, or application name"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::opener::NoopSystemOpener;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn validate_file_exists_succeeds_for_real_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
fs::write(&file_path, "hello").unwrap();
|
||||
let result = validate_file_exists(file_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_file_exists_fails_for_missing_file() {
|
||||
let result = validate_file_exists("/nonexistent/file.txt");
|
||||
assert!(matches!(result, Err(ShellError::FileNotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_file_exists_fails_for_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = validate_file_exists(dir.path().to_str().unwrap());
|
||||
assert!(matches!(result, Err(ShellError::FileNotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_exists_succeeds_for_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
fs::write(&file_path, "hello").unwrap();
|
||||
let result = validate_path_exists(file_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_exists_succeeds_for_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = validate_path_exists(dir.path().to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_path_exists_fails_for_nonexistent() {
|
||||
let result = validate_path_exists("/nonexistent/path");
|
||||
assert!(matches!(result, Err(ShellError::FileNotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_directory_exists_succeeds() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = validate_directory_exists(dir.path().to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_directory_exists_fails_for_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
fs::write(&file_path, "hello").unwrap();
|
||||
let result = validate_directory_exists(file_path.to_str().unwrap());
|
||||
assert!(matches!(result, Err(ShellError::DirectoryNotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_directory_exists_fails_for_nonexistent() {
|
||||
let result = validate_directory_exists("/nonexistent/dir");
|
||||
assert!(matches!(result, Err(ShellError::DirectoryNotFound(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_accepts_http() {
|
||||
assert!(validate_url("http://example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_accepts_https() {
|
||||
assert!(validate_url("https://example.com/path?q=1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_accepts_mailto() {
|
||||
assert!(validate_url("mailto:user@example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_file_scheme() {
|
||||
let result = validate_url("file:///etc/passwd");
|
||||
assert!(matches!(result, Err(ShellError::InvalidUrl(msg)) if msg.contains("scheme")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_ftp_scheme() {
|
||||
let result = validate_url("ftp://example.com");
|
||||
assert!(matches!(result, Err(ShellError::InvalidUrl(msg)) if msg.contains("scheme")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_javascript_scheme() {
|
||||
let result = validate_url("javascript:alert(1)");
|
||||
assert!(matches!(result, Err(ShellError::InvalidUrl(msg)) if msg.contains("scheme")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_invalid_url() {
|
||||
let result = validate_url("; rm -rf /");
|
||||
assert!(matches!(result, Err(ShellError::InvalidUrl(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_empty_string() {
|
||||
let result = validate_url("");
|
||||
assert!(matches!(result, Err(ShellError::InvalidUrl(_))));
|
||||
}
|
||||
|
||||
// --- launch target validation (reliable open tool; the `\\` dialog guard) ---
|
||||
|
||||
#[test]
|
||||
fn validate_launch_target_accepts_url_path_and_app() {
|
||||
assert!(validate_launch_target("https://www.baidu.com/s?wd=x").is_ok());
|
||||
assert!(validate_launch_target("C:\\Users\\rika0\\file.txt").is_ok());
|
||||
assert!(validate_launch_target("/usr/bin/firefox").is_ok());
|
||||
assert!(validate_launch_target("msedge").is_ok());
|
||||
assert!(validate_launch_target("notepad.exe").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_launch_target_rejects_empty_and_blank() {
|
||||
assert!(matches!(validate_launch_target(""), Err(ShellError::InvalidTarget(_))));
|
||||
assert!(matches!(validate_launch_target(" "), Err(ShellError::InvalidTarget(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_launch_target_rejects_bare_separators() {
|
||||
// The exact failure mode: a bare UNC root / lone separators that
|
||||
// ShellExecute cannot open (each Rust literal below: "\\\\" == two
|
||||
// backslashes == the `\\` the user saw).
|
||||
for t in ["\\", "\\\\", "/", "//", "\\\\\\\\", " \\\\ ", "\\/"] {
|
||||
assert!(
|
||||
matches!(validate_launch_target(t), Err(ShellError::InvalidTarget(_))),
|
||||
"should reject {t:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_rejects_bare_backslash_before_opening() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
assert!(matches!(
|
||||
svc.launch("\\\\", None).await,
|
||||
Err(ShellError::InvalidTarget(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_accepts_url_with_and_without_app() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
assert!(svc.launch("https://www.baidu.com", None).await.is_ok());
|
||||
assert!(svc.launch("https://www.baidu.com", Some("msedge")).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_tool_terminal_always_true() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
assert!(svc.check_tool_installed(ToolType::Terminal).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn check_tool_explorer_always_true() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
assert!(svc.check_tool_installed(ToolType::Explorer).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_file_fails_for_missing_file() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
let result = svc.open_file("/nonexistent/file.txt").await;
|
||||
assert!(matches!(result, Err(ShellError::FileNotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn show_item_in_folder_fails_for_missing_path() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
let result = svc.show_item_in_folder("/nonexistent/path").await;
|
||||
assert!(matches!(result, Err(ShellError::FileNotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_external_fails_for_invalid_url() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
let result = svc.open_external("; rm -rf /").await;
|
||||
assert!(matches!(result, Err(ShellError::InvalidUrl(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_external_fails_for_file_scheme() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
let result = svc.open_external("file:///etc/passwd").await;
|
||||
assert!(matches!(result, Err(ShellError::InvalidUrl(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_folder_with_fails_for_missing_dir() {
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
let result = svc.open_folder_with("/nonexistent/dir", ToolType::Explorer).await;
|
||||
assert!(matches!(result, Err(ShellError::DirectoryNotFound(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_folder_with_fails_for_file_path() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
std::fs::write(&file_path, "data").unwrap();
|
||||
let svc = ShellService::new(Arc::new(NoopSystemOpener));
|
||||
let result = svc
|
||||
.open_folder_with(file_path.to_str().unwrap(), ToolType::Explorer)
|
||||
.await;
|
||||
assert!(matches!(result, Err(ShellError::DirectoryNotFound(_))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_system::ClientPrefService;
|
||||
|
||||
use crate::shell::ShellService;
|
||||
use crate::stt::SttService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ShellRouterState {
|
||||
pub shell_service: Arc<ShellService>,
|
||||
pub stt_service: Arc<SttService>,
|
||||
pub client_pref_service: ClientPrefService,
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use nomifun_api_types::{SpeechToTextConfig, SpeechToTextProvider, SpeechToTextResult};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::error::SttError;
|
||||
use crate::{stt_deepgram, stt_openai};
|
||||
|
||||
pub struct SttService {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl SttService {
|
||||
pub fn new(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub async fn transcribe(
|
||||
&self,
|
||||
audio_data: Vec<u8>,
|
||||
file_name: &str,
|
||||
mime_type: &str,
|
||||
language_hint: Option<&str>,
|
||||
config: &SpeechToTextConfig,
|
||||
) -> Result<SpeechToTextResult, SttError> {
|
||||
if !config.enabled {
|
||||
return Err(SttError::Disabled);
|
||||
}
|
||||
|
||||
match config.provider {
|
||||
SpeechToTextProvider::Openai => {
|
||||
let openai_config = config.openai.as_ref().ok_or(SttError::OpenaiNotConfigured)?;
|
||||
stt_openai::transcribe(
|
||||
&self.client,
|
||||
openai_config,
|
||||
audio_data,
|
||||
file_name,
|
||||
mime_type,
|
||||
language_hint,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SpeechToTextProvider::Deepgram => {
|
||||
let deepgram_config = config.deepgram.as_ref().ok_or(SttError::DeepgramNotConfigured)?;
|
||||
stt_deepgram::transcribe(&self.client, deepgram_config, audio_data, mime_type, language_hint).await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nomifun_api_types::{DeepgramSpeechToTextConfig, OpenAISpeechToTextConfig};
|
||||
|
||||
fn make_disabled_config() -> SpeechToTextConfig {
|
||||
SpeechToTextConfig {
|
||||
enabled: false,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_openai_config(api_key: &str) -> SpeechToTextConfig {
|
||||
SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: Some(OpenAISpeechToTextConfig {
|
||||
api_key: api_key.to_owned(),
|
||||
base_url: None,
|
||||
model: "whisper-1".into(),
|
||||
language: None,
|
||||
prompt: None,
|
||||
temperature: None,
|
||||
}),
|
||||
deepgram: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_deepgram_config(api_key: &str) -> SpeechToTextConfig {
|
||||
SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: Some(DeepgramSpeechToTextConfig {
|
||||
api_key: api_key.to_owned(),
|
||||
base_url: None,
|
||||
model: "nova-2".into(),
|
||||
language: None,
|
||||
detect_language: None,
|
||||
punctuate: None,
|
||||
smart_format: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_config_returns_disabled_error() {
|
||||
let svc = SttService::new(Client::new());
|
||||
let result = svc
|
||||
.transcribe(vec![0u8; 10], "test.wav", "audio/wav", None, &make_disabled_config())
|
||||
.await;
|
||||
assert!(matches!(result, Err(SttError::Disabled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_provider_missing_config_returns_not_configured() {
|
||||
let svc = SttService::new(Client::new());
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: None,
|
||||
};
|
||||
let result = svc
|
||||
.transcribe(vec![0u8; 10], "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
assert!(matches!(result, Err(SttError::OpenaiNotConfigured)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deepgram_provider_missing_config_returns_not_configured() {
|
||||
let svc = SttService::new(Client::new());
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: None,
|
||||
};
|
||||
let result = svc
|
||||
.transcribe(vec![0u8; 10], "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
assert!(matches!(result, Err(SttError::DeepgramNotConfigured)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_empty_api_key_returns_not_configured() {
|
||||
let svc = SttService::new(Client::new());
|
||||
let config = make_openai_config("");
|
||||
let result = svc
|
||||
.transcribe(vec![0u8; 10], "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
assert!(matches!(result, Err(SttError::OpenaiNotConfigured)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deepgram_empty_api_key_returns_not_configured() {
|
||||
let svc = SttService::new(Client::new());
|
||||
let config = make_deepgram_config("");
|
||||
let result = svc
|
||||
.transcribe(vec![0u8; 10], "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
assert!(matches!(result, Err(SttError::DeepgramNotConfigured)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
use nomifun_api_types::{DeepgramSpeechToTextConfig, SpeechToTextProvider, SpeechToTextResult};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::error::SttError;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.deepgram.com";
|
||||
|
||||
pub async fn transcribe(
|
||||
client: &Client,
|
||||
config: &DeepgramSpeechToTextConfig,
|
||||
audio_data: Vec<u8>,
|
||||
mime_type: &str,
|
||||
language_hint: Option<&str>,
|
||||
) -> Result<SpeechToTextResult, SttError> {
|
||||
if config.api_key.is_empty() {
|
||||
return Err(SttError::DeepgramNotConfigured);
|
||||
}
|
||||
|
||||
let base_url = config
|
||||
.base_url
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_BASE_URL)
|
||||
.trim_end_matches('/');
|
||||
|
||||
let mut query_params = vec![("model", config.model.clone())];
|
||||
|
||||
let language = language_hint.or(config.language.as_deref()).filter(|s| !s.is_empty());
|
||||
if let Some(lang) = language {
|
||||
query_params.push(("language", lang.to_owned()));
|
||||
} else if config.detect_language == Some(true) {
|
||||
query_params.push(("detect_language", "true".to_owned()));
|
||||
}
|
||||
|
||||
if config.punctuate == Some(true) {
|
||||
query_params.push(("punctuate", "true".to_owned()));
|
||||
}
|
||||
|
||||
if config.smart_format == Some(true) {
|
||||
query_params.push(("smart_format", "true".to_owned()));
|
||||
}
|
||||
|
||||
let url = format!("{base_url}/v1/listen");
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Token {}", config.api_key))
|
||||
.header("Content-Type", mime_type)
|
||||
.query(&query_params)
|
||||
.body(audio_data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SttError::RequestFailed(format!("Deepgram request error: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_else(|_| "<unreadable>".to_owned());
|
||||
return Err(SttError::RequestFailed(format!(
|
||||
"Deepgram API returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SttError::RequestFailed(format!("failed to parse Deepgram response: {e}")))?;
|
||||
|
||||
let transcript = body["results"]["channels"]
|
||||
.get(0)
|
||||
.and_then(|ch| ch["alternatives"].get(0))
|
||||
.and_then(|alt| alt["transcript"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
|
||||
let detected_language = body["results"]["channels"]
|
||||
.get(0)
|
||||
.and_then(|ch| ch["detected_language"].as_str())
|
||||
.map(|s| s.to_owned())
|
||||
.or_else(|| language.map(|s| s.to_owned()));
|
||||
|
||||
let model_name = extract_model_name(&body).unwrap_or_else(|| config.model.clone());
|
||||
|
||||
Ok(SpeechToTextResult {
|
||||
text: transcript,
|
||||
model: model_name,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
language: detected_language,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_model_name(body: &serde_json::Value) -> Option<String> {
|
||||
body["metadata"]["model_info"]
|
||||
.as_object()
|
||||
.and_then(|map| map.values().next())
|
||||
.and_then(|info| info["name"].as_str())
|
||||
.map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_base_url_value() {
|
||||
assert_eq!(DEFAULT_BASE_URL, "https://api.deepgram.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_api_key_returns_not_configured() {
|
||||
let config = DeepgramSpeechToTextConfig {
|
||||
api_key: String::new(),
|
||||
base_url: None,
|
||||
model: "nova-2".into(),
|
||||
language: None,
|
||||
detect_language: None,
|
||||
punctuate: None,
|
||||
smart_format: None,
|
||||
};
|
||||
let result = transcribe(&Client::new(), &config, vec![0u8; 10], "audio/wav", None).await;
|
||||
assert!(matches!(result, Err(SttError::DeepgramNotConfigured)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_name_from_response() {
|
||||
let body = serde_json::json!({
|
||||
"metadata": {
|
||||
"model_info": {
|
||||
"some-uuid": {
|
||||
"name": "2-general-nova",
|
||||
"version": "2024-01-18.26916"
|
||||
}
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"channels": [{
|
||||
"alternatives": [{ "transcript": "hello" }]
|
||||
}]
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_model_name(&body), Some("2-general-nova".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_name_missing_metadata() {
|
||||
let body = serde_json::json!({
|
||||
"results": {
|
||||
"channels": [{ "alternatives": [{ "transcript": "hi" }] }]
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_model_name(&body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_model_name_empty_model_info() {
|
||||
let body = serde_json::json!({
|
||||
"metadata": { "model_info": {} },
|
||||
"results": {
|
||||
"channels": [{ "alternatives": [{ "transcript": "hi" }] }]
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_model_name(&body), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use nomifun_api_types::{OpenAISpeechToTextConfig, SpeechToTextProvider, SpeechToTextResult};
|
||||
use reqwest::Client;
|
||||
|
||||
use crate::error::SttError;
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.openai.com";
|
||||
|
||||
pub async fn transcribe(
|
||||
client: &Client,
|
||||
config: &OpenAISpeechToTextConfig,
|
||||
audio_data: Vec<u8>,
|
||||
file_name: &str,
|
||||
mime_type: &str,
|
||||
language_hint: Option<&str>,
|
||||
) -> Result<SpeechToTextResult, SttError> {
|
||||
if config.api_key.is_empty() {
|
||||
return Err(SttError::OpenaiNotConfigured);
|
||||
}
|
||||
|
||||
let base_url = config
|
||||
.base_url
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_BASE_URL)
|
||||
.trim_end_matches('/');
|
||||
let url = format!("{base_url}/v1/audio/transcriptions");
|
||||
|
||||
let file_part = reqwest::multipart::Part::bytes(audio_data)
|
||||
.file_name(file_name.to_owned())
|
||||
.mime_str(mime_type)
|
||||
.map_err(|e| SttError::Unknown(format!("invalid MIME type: {e}")))?;
|
||||
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.part("file", file_part)
|
||||
.text("model", config.model.clone());
|
||||
|
||||
let language = language_hint.or(config.language.as_deref()).filter(|s| !s.is_empty());
|
||||
if let Some(lang) = language {
|
||||
form = form.text("language", lang.to_owned());
|
||||
}
|
||||
|
||||
if let Some(prompt) = config.prompt.as_deref().filter(|s| !s.is_empty()) {
|
||||
form = form.text("prompt", prompt.to_owned());
|
||||
}
|
||||
|
||||
if let Some(temp) = config.temperature {
|
||||
form = form.text("temperature", temp.to_string());
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.api_key))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SttError::RequestFailed(format!("OpenAI request error: {e}")))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_else(|_| "<unreadable>".to_owned());
|
||||
return Err(SttError::RequestFailed(format!("OpenAI API returned {status}: {body}")));
|
||||
}
|
||||
|
||||
let body: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SttError::RequestFailed(format!("failed to parse OpenAI response: {e}")))?;
|
||||
|
||||
let text = body["text"].as_str().unwrap_or("").to_owned();
|
||||
|
||||
Ok(SpeechToTextResult {
|
||||
text,
|
||||
model: config.model.clone(),
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
language: language.map(|s| s.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_base_url_value() {
|
||||
assert_eq!(DEFAULT_BASE_URL, "https://api.openai.com");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_api_key_returns_not_configured() {
|
||||
let config = OpenAISpeechToTextConfig {
|
||||
api_key: String::new(),
|
||||
base_url: None,
|
||||
model: "whisper-1".into(),
|
||||
language: None,
|
||||
prompt: None,
|
||||
temperature: None,
|
||||
};
|
||||
let result = transcribe(&Client::new(), &config, vec![0u8; 10], "test.wav", "audio/wav", None).await;
|
||||
assert!(matches!(result, Err(SttError::OpenaiNotConfigured)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_api_types::ToolType;
|
||||
use nomifun_shell::{NoopSystemOpener, ShellService};
|
||||
|
||||
fn service() -> ShellService {
|
||||
ShellService::new(Arc::new(NoopSystemOpener))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-2: open_file — file does not exist
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh2_open_file_not_found() {
|
||||
let err = service().open_file("/nonexistent/file.txt").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("not found") || msg.contains("does not exist"),
|
||||
"expected 'not found', got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-4: show_item_in_folder — path does not exist
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh4_show_item_in_folder_not_found() {
|
||||
let err = service().show_item_in_folder("/nonexistent/path").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("not found"), "expected 'not found', got: {msg}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-6: open_external — command injection attempt
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh6_open_external_command_injection() {
|
||||
let err = service().open_external("; rm -rf /").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.to_lowercase().contains("invalid") || msg.to_lowercase().contains("url"),
|
||||
"expected 'invalid' or 'URL', got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-7: open_external — disallowed scheme (file://)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh7_open_external_file_scheme() {
|
||||
let err = service().open_external("file:///etc/passwd").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("scheme") || msg.to_lowercase().contains("not allowed"),
|
||||
"expected scheme error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-8: check_tool_installed — terminal always true
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh8_check_tool_terminal_always_true() {
|
||||
assert!(service().check_tool_installed(ToolType::Terminal).await);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-9: check_tool_installed — explorer always true
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh9_check_tool_explorer_always_true() {
|
||||
assert!(service().check_tool_installed(ToolType::Explorer).await);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-10: check_tool_installed — vscode (environment-dependent)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh10_check_tool_vscode_returns_bool() {
|
||||
let _installed = service().check_tool_installed(ToolType::Vscode).await;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-12: open_folder_with — directory does not exist
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh12_open_folder_with_dir_not_found() {
|
||||
let err = service()
|
||||
.open_folder_with("/nonexistent/dir", ToolType::Explorer)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("not found"), "expected 'not found', got: {msg}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-13: open_file — missing filePath (tested via empty string)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh13_open_file_empty_path() {
|
||||
let err = service().open_file("").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("not found"),
|
||||
"expected 'not found' for empty path, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SH-14: open_external — empty string
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn sh14_open_external_empty_url() {
|
||||
let err = service().open_external("").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.to_lowercase().contains("invalid") || msg.to_lowercase().contains("url"),
|
||||
"expected invalid URL error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: open_folder_with — file path instead of directory
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn open_folder_with_file_path_rejected() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
std::fs::write(&file, "data").unwrap();
|
||||
let err = service()
|
||||
.open_folder_with(file.to_str().unwrap(), ToolType::Explorer)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("directory not found"),
|
||||
"expected 'directory not found', got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: open_external — ftp scheme rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn open_external_ftp_scheme_rejected() {
|
||||
let err = service().open_external("ftp://evil.com/file").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("scheme"), "expected scheme error, got: {msg}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: open_external — javascript scheme rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn open_external_javascript_scheme_rejected() {
|
||||
let err = service().open_external("javascript:alert(1)").await.unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(
|
||||
msg.contains("scheme") || msg.to_lowercase().contains("invalid"),
|
||||
"expected scheme or invalid error, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error conversion: ShellError → AppError mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn shell_error_converts_to_app_error() {
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_shell::ShellError;
|
||||
|
||||
let err: AppError = ShellError::FileNotFound("/tmp/x".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
|
||||
let err: AppError = ShellError::DirectoryNotFound("/tmp/y".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
|
||||
let err: AppError = ShellError::InvalidUrl("bad".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
|
||||
let err: AppError = ShellError::ToolNotInstalled("vscode".into()).into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
|
||||
let err: AppError = ShellError::CommandFailed("oops".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
use nomifun_api_types::{
|
||||
DeepgramSpeechToTextConfig, OpenAISpeechToTextConfig, SpeechToTextConfig, SpeechToTextProvider,
|
||||
};
|
||||
use nomifun_shell::{SttError, SttService};
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn dummy_audio() -> Vec<u8> {
|
||||
vec![0u8; 64]
|
||||
}
|
||||
|
||||
fn stt_service() -> SttService {
|
||||
SttService::new(reqwest::Client::new())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-1: OpenAI transcription — success
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st1_openai_transcribe_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.and(header("Authorization", "Bearer sk-test-key"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": "hello world" })))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: Some(OpenAISpeechToTextConfig {
|
||||
api_key: "sk-test-key".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "whisper-1".into(),
|
||||
language: None,
|
||||
prompt: None,
|
||||
temperature: None,
|
||||
}),
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.text, "hello world");
|
||||
assert_eq!(result.model, "whisper-1");
|
||||
assert_eq!(result.provider, SpeechToTextProvider::Openai);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-2: Deepgram transcription — success
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st2_deepgram_transcribe_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/listen"))
|
||||
.and(header("Authorization", "Token dg-test-key"))
|
||||
.and(header("Content-Type", "audio/wav"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"metadata": {
|
||||
"model_info": {
|
||||
"uuid-1": {
|
||||
"name": "2-general-nova",
|
||||
"version": "2024-01"
|
||||
}
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"channels": [{
|
||||
"detected_language": "en",
|
||||
"alternatives": [{
|
||||
"transcript": "hello deepgram"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: Some(DeepgramSpeechToTextConfig {
|
||||
api_key: "dg-test-key".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "nova-2".into(),
|
||||
language: None,
|
||||
detect_language: Some(true),
|
||||
punctuate: Some(true),
|
||||
smart_format: Some(true),
|
||||
}),
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.text, "hello deepgram");
|
||||
assert_eq!(result.model, "2-general-nova");
|
||||
assert_eq!(result.provider, SpeechToTextProvider::Deepgram);
|
||||
assert_eq!(result.language.as_deref(), Some("en"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-3: STT disabled
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st3_stt_disabled() {
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: false,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SttError::Disabled)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-4: STT config missing — treated as disabled at service layer
|
||||
// (the handler reads from ClientPrefService; if key is absent, config
|
||||
// will have enabled=false or we surface STT_DISABLED upstream)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-5: OpenAI missing API key
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st5_openai_empty_api_key() {
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: Some(OpenAISpeechToTextConfig {
|
||||
api_key: String::new(),
|
||||
base_url: None,
|
||||
model: "whisper-1".into(),
|
||||
language: None,
|
||||
prompt: None,
|
||||
temperature: None,
|
||||
}),
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SttError::OpenaiNotConfigured)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn st5b_openai_config_section_missing() {
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SttError::OpenaiNotConfigured)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-6: Deepgram missing API key
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st6_deepgram_empty_api_key() {
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: Some(DeepgramSpeechToTextConfig {
|
||||
api_key: String::new(),
|
||||
base_url: None,
|
||||
model: "nova-2".into(),
|
||||
language: None,
|
||||
detect_language: None,
|
||||
punctuate: None,
|
||||
smart_format: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SttError::DeepgramNotConfigured)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn st6b_deepgram_config_section_missing() {
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SttError::DeepgramNotConfigured)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-7: OpenAI upstream API failure (401)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st7_openai_upstream_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": "Incorrect API key provided",
|
||||
"type": "invalid_request_error"
|
||||
}
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: Some(OpenAISpeechToTextConfig {
|
||||
api_key: "sk-invalid".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "whisper-1".into(),
|
||||
language: None,
|
||||
prompt: None,
|
||||
temperature: None,
|
||||
}),
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(SttError::RequestFailed(msg)) => {
|
||||
assert!(msg.contains("401"), "expected 401 in error: {msg}");
|
||||
}
|
||||
other => panic!("expected RequestFailed, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-7b: Deepgram upstream API failure (403)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st7b_deepgram_upstream_failure() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/listen"))
|
||||
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ "err_msg": "Invalid credentials" })))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: Some(DeepgramSpeechToTextConfig {
|
||||
api_key: "dg-invalid".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "nova-2".into(),
|
||||
language: None,
|
||||
detect_language: None,
|
||||
punctuate: None,
|
||||
smart_format: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", None, &config)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(SttError::RequestFailed(msg)) => {
|
||||
assert!(msg.contains("403"), "expected 403 in error: {msg}");
|
||||
}
|
||||
other => panic!("expected RequestFailed, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-10: languageHint passed to OpenAI
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st10_openai_language_hint_passed() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": "你好世界" })))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: None,
|
||||
openai: Some(OpenAISpeechToTextConfig {
|
||||
api_key: "sk-test".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "whisper-1".into(),
|
||||
language: Some("en".into()),
|
||||
prompt: None,
|
||||
temperature: None,
|
||||
}),
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", Some("zh"), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.text, "你好世界");
|
||||
assert_eq!(result.language.as_deref(), Some("zh"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ST-10b: languageHint passed to Deepgram
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn st10b_deepgram_language_hint_passed() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/listen"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"metadata": { "model_info": {} },
|
||||
"results": {
|
||||
"channels": [{
|
||||
"detected_language": "zh",
|
||||
"alternatives": [{ "transcript": "你好" }]
|
||||
}]
|
||||
}
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: Some(DeepgramSpeechToTextConfig {
|
||||
api_key: "dg-test".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "nova-2".into(),
|
||||
language: None,
|
||||
detect_language: None,
|
||||
punctuate: None,
|
||||
smart_format: None,
|
||||
}),
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.wav", "audio/wav", Some("zh"), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.text, "你好");
|
||||
assert_eq!(result.language.as_deref(), Some("zh"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: OpenAI with all optional params (prompt, temperature)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn openai_with_all_optional_params() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/audio/transcriptions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "text": "technical terms test" })))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Openai,
|
||||
auto_send: Some(true),
|
||||
openai: Some(OpenAISpeechToTextConfig {
|
||||
api_key: "sk-full".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "whisper-1".into(),
|
||||
language: Some("en".into()),
|
||||
prompt: Some("technical terms".into()),
|
||||
temperature: Some(0.2),
|
||||
}),
|
||||
deepgram: None,
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "audio.m4a", "audio/mp4", None, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.text, "technical terms test");
|
||||
assert_eq!(result.model, "whisper-1");
|
||||
assert_eq!(result.provider, SpeechToTextProvider::Openai);
|
||||
assert_eq!(result.language.as_deref(), Some("en"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: Deepgram with all optional flags
|
||||
// ---------------------------------------------------------------------------
|
||||
#[tokio::test]
|
||||
async fn deepgram_with_all_optional_flags() {
|
||||
let mock_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/listen"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"metadata": {
|
||||
"model_info": {
|
||||
"id-1": { "name": "nova-2-general" }
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"channels": [{
|
||||
"detected_language": "fr",
|
||||
"alternatives": [{ "transcript": "bonjour" }]
|
||||
}]
|
||||
}
|
||||
})))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let config = SpeechToTextConfig {
|
||||
enabled: true,
|
||||
provider: SpeechToTextProvider::Deepgram,
|
||||
auto_send: None,
|
||||
openai: None,
|
||||
deepgram: Some(DeepgramSpeechToTextConfig {
|
||||
api_key: "dg-full".into(),
|
||||
base_url: Some(mock_server.uri()),
|
||||
model: "nova-2".into(),
|
||||
language: Some("fr".into()),
|
||||
detect_language: Some(false),
|
||||
punctuate: Some(true),
|
||||
smart_format: Some(true),
|
||||
}),
|
||||
};
|
||||
|
||||
let result = stt_service()
|
||||
.transcribe(dummy_audio(), "test.ogg", "audio/ogg", None, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.text, "bonjour");
|
||||
assert_eq!(result.model, "nova-2-general");
|
||||
assert_eq!(result.language.as_deref(), Some("fr"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SttError → AppError conversion (black-box integration test)
|
||||
// ---------------------------------------------------------------------------
|
||||
#[test]
|
||||
fn stt_error_to_app_error_mapping() {
|
||||
use nomifun_common::AppError;
|
||||
|
||||
let err: AppError = SttError::Disabled.into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
|
||||
let err: AppError = SttError::OpenaiNotConfigured.into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
|
||||
let err: AppError = SttError::DeepgramNotConfigured.into();
|
||||
assert!(matches!(err, AppError::BadRequest(_)));
|
||||
|
||||
let err: AppError = SttError::RequestFailed("upstream".into()).into();
|
||||
assert!(matches!(err, AppError::BadGateway(_)));
|
||||
|
||||
let err: AppError = SttError::Unknown("bug".into()).into();
|
||||
assert!(matches!(err, AppError::Internal(_)));
|
||||
}
|
||||
Reference in New Issue
Block a user