From e4d799929415d90851236ea2b225fb803f3632cc Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Thu, 13 Nov 2025 15:15:58 +0800 Subject: [PATCH] refactor(prompt): extract file path logic and implement auto-import on first launch (#214) - Extract prompt file path logic to dedicated prompt_files module - Refactor PromptService to use centralized path resolution - Implement auto-import for existing prompt files on first startup - Add comprehensive unit tests for auto-import functionality --- src-tauri/Cargo.lock | 42 +++++ src-tauri/Cargo.toml | 4 + src-tauri/src/app_config.rs | 258 ++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/prompt_files.rs | 38 +++++ src-tauri/src/services/prompt.rs | 46 +----- 6 files changed, 349 insertions(+), 40 deletions(-) create mode 100644 src-tauri/src/prompt_files.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 24ff346..d966f0a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -576,6 +576,7 @@ dependencies = [ "rquickjs", "serde", "serde_json", + "serial_test", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -585,6 +586,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", + "tempfile", "thiserror 1.0.69", "tokio", "toml 0.8.2", @@ -3727,6 +3729,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3790,6 +3801,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + [[package]] name = "seahash" version = "4.1.0" @@ -3962,6 +3979,31 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serial_test" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fcb38f3..066182b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,3 +57,7 @@ lto = "thin" opt-level = "s" panic = "abort" strip = "symbols" + +[dev-dependencies] +serial_test = "3" +tempfile = "3" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 7e657fa..f023e2d 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -41,6 +41,7 @@ pub struct PromptRoot { use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file}; use crate::error::AppError; +use crate::prompt_files::prompt_file_path; use crate::provider::ProviderManager; /// 应用类型 @@ -122,8 +123,12 @@ impl MultiAppConfig { let config_path = get_app_config_path(); if !config_path.exists() { - log::info!("配置文件不存在,创建新的多应用配置"); - return Ok(Self::default()); + log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词"); + // 使用新的方法,支持自动导入提示词 + let config = Self::default_with_auto_import()?; + // 立即保存到磁盘 + config.save()?; + return Ok(config); } // 尝试读取文件 @@ -213,4 +218,253 @@ impl MultiAppConfig { AppType::Gemini => &mut self.mcp.gemini, } } + + /// 创建默认配置并自动导入已存在的提示词文件 + fn default_with_auto_import() -> Result { + log::info!("首次启动,创建默认配置并检测提示词文件"); + + let mut config = Self::default(); + + // 为每个应用尝试自动导入提示词 + Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; + + Ok(config) + } + + /// 检查并自动导入单个应用的提示词文件 + fn auto_import_prompt_if_exists( + config: &mut Self, + app: AppType, + ) -> Result<(), AppError> { + let file_path = prompt_file_path(&app)?; + + // 检查文件是否存在 + if !file_path.exists() { + log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}"); + return Ok(()); + } + + // 读取文件内容 + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(e) => { + log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}"); + return Ok(()); // 失败时不中断,继续处理其他应用 + } + }; + + // 检查内容是否为空 + if content.trim().is_empty() { + log::debug!("提示词文件内容为空,跳过导入: {file_path:?}"); + return Ok(()); + } + + log::info!("发现提示词文件,自动导入: {file_path:?}"); + + // 创建提示词对象 + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let id = format!("auto-imported-{timestamp}"); + let prompt = crate::prompt::Prompt { + id: id.clone(), + name: format!( + "初始提示词 {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content, + description: Some("首次启动时自动导入".to_string()), + enabled: true, // 自动启用 + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + // 插入到对应的应用配置中 + let prompts = match app { + AppType::Claude => &mut config.prompts.claude.prompts, + AppType::Codex => &mut config.prompts.codex.prompts, + AppType::Gemini => &mut config.prompts.gemini.prompts, + }; + + prompts.insert(id, prompt); + + log::info!("自动导入完成: {}", app.as_str()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::env; + use std::fs; + use tempfile::TempDir; + + struct TempHome { + dir: TempDir, + original_home: Option, + original_userprofile: Option, + } + + impl TempHome { + fn new() -> Self { + let dir = TempDir::new().expect("failed to create temp home"); + let original_home = env::var("HOME").ok(); + let original_userprofile = env::var("USERPROFILE").ok(); + + env::set_var("HOME", dir.path()); + env::set_var("USERPROFILE", dir.path()); + + Self { + dir, + original_home, + original_userprofile, + } + } + } + + impl Drop for TempHome { + fn drop(&mut self) { + match &self.original_home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + + match &self.original_userprofile { + Some(value) => env::set_var("USERPROFILE", value), + None => env::remove_var("USERPROFILE"), + } + } + } + + fn write_prompt_file(app: AppType, content: &str) { + let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path"); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dir"); + } + fs::write(path, content).expect("write prompt"); + } + + #[test] + #[serial] + fn auto_imports_existing_prompt_when_config_missing() { + let _home = TempHome::new(); + write_prompt_file(AppType::Claude, "# hello"); + + let config = MultiAppConfig::load().expect("load config"); + + assert_eq!(config.prompts.claude.prompts.len(), 1); + let prompt = config + .prompts + .claude + .prompts + .values() + .next() + .expect("prompt exists"); + assert!(prompt.enabled); + assert_eq!(prompt.content, "# hello"); + + let config_path = crate::config::get_app_config_path(); + assert!( + config_path.exists(), + "auto import should persist config to disk" + ); + } + + #[test] + #[serial] + fn skips_empty_prompt_files_during_import() { + let _home = TempHome::new(); + write_prompt_file(AppType::Claude, " \n "); + + let config = MultiAppConfig::load().expect("load config"); + assert!( + config.prompts.claude.prompts.is_empty(), + "empty files must be ignored" + ); + } + + #[test] + #[serial] + fn auto_import_happens_only_once() { + let _home = TempHome::new(); + write_prompt_file(AppType::Claude, "first version"); + + let first = MultiAppConfig::load().expect("load config"); + assert_eq!(first.prompts.claude.prompts.len(), 1); + let claude_prompt = first + .prompts + .claude + .prompts + .values() + .next() + .expect("prompt exists") + .content + .clone(); + assert_eq!(claude_prompt, "first version"); + + // 覆盖文件内容,但保留 config.json + write_prompt_file(AppType::Claude, "second version"); + let second = MultiAppConfig::load().expect("load config again"); + + assert_eq!(second.prompts.claude.prompts.len(), 1); + let prompt = second + .prompts + .claude + .prompts + .values() + .next() + .expect("prompt exists"); + assert_eq!( + prompt.content, "first version", + "should not re-import when config already exists" + ); + } + + #[test] + #[serial] + fn auto_imports_gemini_prompt_on_first_launch() { + let _home = TempHome::new(); + write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content"); + + let config = MultiAppConfig::load().expect("load config"); + + assert_eq!(config.prompts.gemini.prompts.len(), 1); + let prompt = config + .prompts + .gemini + .prompts + .values() + .next() + .expect("gemini prompt exists"); + assert!(prompt.enabled, "gemini prompt should be enabled"); + assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content"); + assert_eq!(prompt.description, Some("首次启动时自动导入".to_string())); + } + + #[test] + #[serial] + fn auto_imports_all_three_apps_prompts() { + let _home = TempHome::new(); + write_prompt_file(AppType::Claude, "# Claude prompt"); + write_prompt_file(AppType::Codex, "# Codex prompt"); + write_prompt_file(AppType::Gemini, "# Gemini prompt"); + + let config = MultiAppConfig::load().expect("load config"); + + // 验证所有三个应用的提示词都被导入 + assert_eq!(config.prompts.claude.prompts.len(), 1); + assert_eq!(config.prompts.codex.prompts.len(), 1); + assert_eq!(config.prompts.gemini.prompts.len(), 1); + + // 验证所有提示词都被启用 + assert!(config.prompts.claude.prompts.values().next().unwrap().enabled); + assert!(config.prompts.codex.prompts.values().next().unwrap().enabled); + assert!(config.prompts.gemini.prompts.values().next().unwrap().enabled); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c90dd9f..18ba090 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod gemini_config; // 新增 mod init_status; mod mcp; mod prompt; +mod prompt_files; mod provider; mod services; mod settings; diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs new file mode 100644 index 0000000..395e01a --- /dev/null +++ b/src-tauri/src/prompt_files.rs @@ -0,0 +1,38 @@ +use std::path::PathBuf; + +use crate::app_config::AppType; +use crate::codex_config::get_codex_auth_path; +use crate::config::get_claude_settings_path; +use crate::error::AppError; +use crate::gemini_config::get_gemini_dir; + +/// 返回指定应用所使用的提示词文件路径。 +pub fn prompt_file_path(app: &AppType) -> Result { + let base_dir = match app { + AppType::Claude => get_claude_settings_path() + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| { + dirs::home_dir() + .expect("无法获取用户目录") + .join(".claude") + }), + AppType::Codex => get_codex_auth_path() + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| { + dirs::home_dir() + .expect("无法获取用户目录") + .join(".codex") + }), + AppType::Gemini => get_gemini_dir(), + }; + + let filename = match app { + AppType::Claude => "CLAUDE.md", + AppType::Codex => "AGENTS.md", + AppType::Gemini => "GEMINI.md", + }; + + Ok(base_dir.join(filename)) +} diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 942163d..191b0b4 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; -use std::path::PathBuf; use crate::app_config::AppType; use crate::config::write_text_file; use crate::error::AppError; use crate::prompt::Prompt; +use crate::prompt_files::prompt_file_path; use crate::store::AppState; pub struct PromptService; @@ -44,7 +44,7 @@ impl PromptService { // 如果是已启用的提示词,同步更新到对应的文件 if is_enabled { - let target_path = Self::get_prompt_file_path(&app)?; + let target_path = prompt_file_path(&app)?; write_text_file(&target_path, &prompt.content)?; } @@ -75,7 +75,7 @@ impl PromptService { pub fn enable_prompt(state: &AppState, app: AppType, id: &str) -> Result<(), AppError> { // 先保存当前文件内容(如果存在且没有对应的提示词) - let target_path = Self::get_prompt_file_path(&app)?; + let target_path = prompt_file_path(&app)?; if target_path.exists() { let mut cfg = state.config.write()?; let prompts = match app { @@ -99,7 +99,7 @@ impl PromptService { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs() as i64; - let backup_id = format!("backup-{}", timestamp); + let backup_id = format!("backup-{timestamp}"); let backup_prompt = Prompt { id: backup_id.clone(), name: format!("原始提示词 {}", chrono::Local::now().format("%Y-%m-%d %H:%M")), @@ -133,7 +133,7 @@ impl PromptService { prompt.enabled = true; write_text_file(&target_path, &prompt.content)?; } else { - return Err(AppError::InvalidInput(format!("提示词 {} 不存在", id))); + return Err(AppError::InvalidInput(format!("提示词 {id} 不存在"))); } drop(cfg); @@ -141,38 +141,8 @@ impl PromptService { Ok(()) } - fn get_prompt_file_path(app: &AppType) -> Result { - let base_dir = match app { - AppType::Claude => crate::config::get_claude_settings_path() - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| { - dirs::home_dir() - .expect("无法获取用户目录") - .join(".claude") - }), - AppType::Codex => crate::codex_config::get_codex_auth_path() - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| { - dirs::home_dir() - .expect("无法获取用户目录") - .join(".codex") - }), - AppType::Gemini => crate::gemini_config::get_gemini_dir(), - }; - - let filename = match app { - AppType::Claude => "CLAUDE.md", - AppType::Codex => "AGENTS.md", - AppType::Gemini => "GEMINI.md", - }; - - Ok(base_dir.join(filename)) - } - pub fn import_from_file(state: &AppState, app: AppType) -> Result { - let file_path = Self::get_prompt_file_path(&app)?; + let file_path = prompt_file_path(&app)?; if !file_path.exists() { return Err(AppError::Message("提示词文件不存在".to_string())); @@ -184,7 +154,7 @@ impl PromptService { .unwrap() .as_secs() as i64; - let id = format!("imported-{}", timestamp); + let id = format!("imported-{timestamp}"); let prompt = Prompt { id: id.clone(), name: format!( @@ -203,7 +173,7 @@ impl PromptService { } pub fn get_current_file_content(app: AppType) -> Result, AppError> { - let file_path = Self::get_prompt_file_path(&app)?; + let file_path = prompt_file_path(&app)?; if !file_path.exists() { return Ok(None); }