Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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(_)));
}