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
This commit is contained in:
YoVinchen
2025-11-13 15:15:58 +08:00
committed by GitHub
parent 34f7139fda
commit e4d7999294
6 changed files with 349 additions and 40 deletions

42
src-tauri/Cargo.lock generated
View File

@@ -576,6 +576,7 @@ dependencies = [
"rquickjs", "rquickjs",
"serde", "serde",
"serde_json", "serde_json",
"serial_test",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -585,6 +586,7 @@ dependencies = [
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"toml 0.8.2", "toml 0.8.2",
@@ -3727,6 +3729,15 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scc"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc"
dependencies = [
"sdd",
]
[[package]] [[package]]
name = "schemars" name = "schemars"
version = "0.8.22" version = "0.8.22"
@@ -3790,6 +3801,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sdd"
version = "3.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
[[package]] [[package]]
name = "seahash" name = "seahash"
version = "4.1.0" version = "4.1.0"
@@ -3962,6 +3979,31 @@ dependencies = [
"syn 2.0.106", "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]] [[package]]
name = "serialize-to-javascript" name = "serialize-to-javascript"
version = "0.1.2" version = "0.1.2"

View File

@@ -57,3 +57,7 @@ lto = "thin"
opt-level = "s" opt-level = "s"
panic = "abort" panic = "abort"
strip = "symbols" strip = "symbols"
[dev-dependencies]
serial_test = "3"
tempfile = "3"

View File

@@ -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::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
use crate::error::AppError; use crate::error::AppError;
use crate::prompt_files::prompt_file_path;
use crate::provider::ProviderManager; use crate::provider::ProviderManager;
/// 应用类型 /// 应用类型
@@ -122,8 +123,12 @@ impl MultiAppConfig {
let config_path = get_app_config_path(); let config_path = get_app_config_path();
if !config_path.exists() { if !config_path.exists() {
log::info!("配置文件不存在,创建新的多应用配置"); log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
return Ok(Self::default()); // 使用新的方法,支持自动导入提示词
let config = Self::default_with_auto_import()?;
// 立即保存到磁盘
config.save()?;
return Ok(config);
} }
// 尝试读取文件 // 尝试读取文件
@@ -213,4 +218,253 @@ impl MultiAppConfig {
AppType::Gemini => &mut self.mcp.gemini, AppType::Gemini => &mut self.mcp.gemini,
} }
} }
/// 创建默认配置并自动导入已存在的提示词文件
fn default_with_auto_import() -> Result<Self, AppError> {
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<String>,
original_userprofile: Option<String>,
}
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);
}
} }

View File

@@ -10,6 +10,7 @@ mod gemini_config; // 新增
mod init_status; mod init_status;
mod mcp; mod mcp;
mod prompt; mod prompt;
mod prompt_files;
mod provider; mod provider;
mod services; mod services;
mod settings; mod settings;

View File

@@ -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<PathBuf, AppError> {
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))
}

View File

@@ -1,10 +1,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::config::write_text_file; use crate::config::write_text_file;
use crate::error::AppError; use crate::error::AppError;
use crate::prompt::Prompt; use crate::prompt::Prompt;
use crate::prompt_files::prompt_file_path;
use crate::store::AppState; use crate::store::AppState;
pub struct PromptService; pub struct PromptService;
@@ -44,7 +44,7 @@ impl PromptService {
// 如果是已启用的提示词,同步更新到对应的文件 // 如果是已启用的提示词,同步更新到对应的文件
if is_enabled { 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)?; 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> { 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() { if target_path.exists() {
let mut cfg = state.config.write()?; let mut cfg = state.config.write()?;
let prompts = match app { let prompts = match app {
@@ -99,7 +99,7 @@ impl PromptService {
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap() .unwrap()
.as_secs() as i64; .as_secs() as i64;
let backup_id = format!("backup-{}", timestamp); let backup_id = format!("backup-{timestamp}");
let backup_prompt = Prompt { let backup_prompt = Prompt {
id: backup_id.clone(), id: backup_id.clone(),
name: format!("原始提示词 {}", chrono::Local::now().format("%Y-%m-%d %H:%M")), name: format!("原始提示词 {}", chrono::Local::now().format("%Y-%m-%d %H:%M")),
@@ -133,7 +133,7 @@ impl PromptService {
prompt.enabled = true; prompt.enabled = true;
write_text_file(&target_path, &prompt.content)?; write_text_file(&target_path, &prompt.content)?;
} else { } else {
return Err(AppError::InvalidInput(format!("提示词 {} 不存在", id))); return Err(AppError::InvalidInput(format!("提示词 {id} 不存在")));
} }
drop(cfg); drop(cfg);
@@ -141,38 +141,8 @@ impl PromptService {
Ok(()) Ok(())
} }
fn get_prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
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<String, AppError> { pub fn import_from_file(state: &AppState, app: AppType) -> Result<String, AppError> {
let file_path = Self::get_prompt_file_path(&app)?; let file_path = prompt_file_path(&app)?;
if !file_path.exists() { if !file_path.exists() {
return Err(AppError::Message("提示词文件不存在".to_string())); return Err(AppError::Message("提示词文件不存在".to_string()));
@@ -184,7 +154,7 @@ impl PromptService {
.unwrap() .unwrap()
.as_secs() as i64; .as_secs() as i64;
let id = format!("imported-{}", timestamp); let id = format!("imported-{timestamp}");
let prompt = Prompt { let prompt = Prompt {
id: id.clone(), id: id.clone(),
name: format!( name: format!(
@@ -203,7 +173,7 @@ impl PromptService {
} }
pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> { pub fn get_current_file_content(app: AppType) -> Result<Option<String>, AppError> {
let file_path = Self::get_prompt_file_path(&app)?; let file_path = prompt_file_path(&app)?;
if !file_path.exists() { if !file_path.exists() {
return Ok(None); return Ok(None);
} }