1use anyhow::{Context, Result};
7use directories::ProjectDirs;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "lowercase")]
14pub enum LlmProvider {
15 Anthropic,
16 OpenAi,
17 #[default]
18 Unknown,
19}
20
21impl std::fmt::Display for LlmProvider {
22 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23 match self {
24 Self::Anthropic => write!(f, "anthropic"),
25 Self::OpenAi => write!(f, "openai"),
26 Self::Unknown => write!(f, "unknown"),
27 }
28 }
29}
30
31impl From<&str> for LlmProvider {
32 fn from(s: &str) -> Self {
33 match s.to_lowercase().as_str() {
34 "anthropic" => Self::Anthropic,
35 "openai" | "open_ai" => Self::OpenAi,
36 _ => Self::Unknown,
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct LlmConfig {
44 pub provider: LlmProvider,
46 pub model: String,
48 pub api_key: String,
50 #[serde(default)]
52 pub base_url: Option<String>,
53}
54
55impl LlmConfig {
56 pub fn validate(&self) -> Result<()> {
58 if self.api_key.is_empty() {
59 anyhow::bail!("LLM api_key is required");
60 }
61 if self.model.is_empty() {
62 anyhow::bail!("LLM model is required");
63 }
64 if matches!(self.provider, LlmProvider::Unknown) {
65 anyhow::bail!("LLM provider must be 'anthropic' or 'openai'");
66 }
67 Ok(())
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct Paths {
74 #[serde(default = "default_workspace_path")]
77 pub workspace: PathBuf,
78
79 #[serde(default = "default_wiki_path")]
82 pub wiki: PathBuf,
83
84 #[serde(default = "default_system_md_path")]
87 pub system_md: PathBuf,
88}
89
90fn default_workspace_path() -> PathBuf {
91 data_dir().join("workspace")
92}
93
94fn default_wiki_path() -> PathBuf {
95 data_dir().join("wiki")
96}
97
98fn default_system_md_path() -> PathBuf {
99 config_dir().join("SYSTEM.md")
100}
101
102fn data_dir() -> PathBuf {
103 ProjectDirs::from("com", "wind-cli", "wind")
104 .map(|p| p.data_dir().to_path_buf())
105 .unwrap_or_else(|| PathBuf::from("~/.local/share/wind"))
106}
107
108fn config_dir() -> PathBuf {
109 ProjectDirs::from("com", "wind-cli", "wind")
110 .map(|p| p.config_dir().to_path_buf())
111 .unwrap_or_else(|| PathBuf::from("~/.config/wind"))
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Config {
117 #[serde(default)]
119 pub llm: Option<LlmConfig>,
120
121 #[serde(default)]
123 pub paths: Paths,
124}
125
126impl Default for Config {
127 fn default() -> Self {
128 Self {
129 llm: None,
130 paths: Paths {
131 workspace: default_workspace_path(),
132 wiki: default_wiki_path(),
133 system_md: default_system_md_path(),
134 },
135 }
136 }
137}
138
139#[derive(Debug, Clone, Default, serde::Deserialize)]
141struct RawWikiConfig {
142 #[serde(default)]
143 llm: Option<LlmConfig>,
144 #[serde(default)]
145 paths: Option<Paths>,
146}
147
148#[derive(Debug, Clone, Default, serde::Deserialize)]
150struct RawConfig {
151 #[serde(default)]
152 wiki: Option<RawWikiConfig>,
153}
154
155impl Config {
156 pub fn load() -> Result<Self> {
158 let path = config_file_path();
159
160 if !path.exists() {
161 return Ok(Config::default());
162 }
163
164 let content = std::fs::read_to_string(&path)
165 .with_context(|| format!("failed to read config at {}", path.display()))?;
166
167 let raw: RawConfig = toml::from_str(&content)
168 .with_context(|| format!("failed to parse config at {}", path.display()))?;
169
170 let wiki_section = raw.wiki.unwrap_or_default();
171 Ok(Config {
172 llm: wiki_section.llm,
173 paths: wiki_section.paths.unwrap_or_default(),
174 })
175 }
176
177 pub fn resolved_llm(&self) -> Result<LlmConfig> {
179 if let Some(ref llm) = self.llm {
180 return llm.validate().map(|_| llm.clone());
181 }
182
183 let provider =
185 std::env::var("WIND_WIKI_PROVIDER").unwrap_or_else(|_| "anthropic".to_string());
186 let model = std::env::var("WIND_WIKI_MODEL")
187 .unwrap_or_else(|_| "claude-haiku-4-20250514".to_string());
188 let api_key = std::env::var("WIND_WIKI_API_KEY")
189 .with_context(|| "WIND_WIKI_API_KEY not set and [wiki.llm] not in config")?;
190
191 let config = LlmConfig {
192 provider: LlmProvider::from(provider.as_str()),
193 model,
194 api_key,
195 base_url: std::env::var("WIND_WIKI_BASE_URL").ok(),
196 };
197
198 config.validate()?;
199 Ok(config)
200 }
201
202 pub fn wiki_dir(&self) -> Result<PathBuf> {
204 let dir = &self.paths.wiki;
205 if !dir.exists() {
206 std::fs::create_dir_all(dir)
207 .with_context(|| format!("failed to create wiki dir: {}", dir.display()))?;
208 }
209 Ok(dir.clone())
210 }
211
212 pub fn workspace_dir(&self) -> PathBuf {
214 self.paths.workspace.clone()
215 }
216
217 pub fn system_md_content(&self) -> Result<String> {
219 if self.paths.system_md.exists() {
220 Ok(std::fs::read_to_string(&self.paths.system_md)?)
221 } else {
222 Ok(DEFAULT_SYSTEM_MD.to_string())
223 }
224 }
225
226 pub fn system_md_path(&self) -> PathBuf {
228 self.paths.system_md.clone()
229 }
230}
231
232fn config_file_path() -> PathBuf {
233 config_dir().join("config.toml")
234}
235
236const DEFAULT_SYSTEM_MD: &str = r#"# WinWork Wiki — System Prompt
238
239你是一个专业的知识库编辑助手。
240
241## 工作原则
2421. **准确性**:所有信息必须基于提供的源文档,不得编造。
2432. **简洁性**:用简洁的中文描述,避免冗余。
2443. **可追溯**:每个知识条目需注明来源文件。
245
246## 格式规范
247- 使用 Markdown 格式
248- 每个文件对应一个主题
249- 文件名使用中文描述主题
250- 包含 `<!-- source: filename -->` 元数据注释
251"#;
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_provider_from_str() {
259 assert!(matches!(
260 LlmProvider::from("anthropic"),
261 LlmProvider::Anthropic
262 ));
263 assert!(matches!(
264 LlmProvider::from("ANTHROPIC"),
265 LlmProvider::Anthropic
266 ));
267 assert!(matches!(LlmProvider::from("openai"), LlmProvider::OpenAi));
268 assert!(matches!(LlmProvider::from("xyz"), LlmProvider::Unknown));
269 }
270
271 #[test]
272 fn test_default_config() {
273 let config = Config::default();
274 assert!(config.llm.is_none());
275 let _ = config.paths.wiki.exists(); }
277}