Skip to main content

llm_wiki_lib/
config.rs

1//! Configuration for llm-wiki-lib.
2//!
3//! Loads from `~/.config/wind/config.toml` with sensible defaults.
4//! Supports both Anthropic and OpenAI providers.
5
6use anyhow::{Context, Result};
7use directories::ProjectDirs;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11/// LLM provider type.
12#[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/// LLM configuration for the wiki engine.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct LlmConfig {
44    /// Provider name: "anthropic" or "openai"
45    pub provider: LlmProvider,
46    /// Model name, e.g. "claude-haiku-4-20250514" or "gpt-4o-mini"
47    pub model: String,
48    /// API key (sk-ant-... or sk-...)
49    pub api_key: String,
50    /// Optional base URL for compatible APIs (e.g. OpenRouter)
51    #[serde(default)]
52    pub base_url: Option<String>,
53}
54
55impl LlmConfig {
56    /// Validate that the config has required fields.
57    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/// Paths used by llm-wiki-lib.
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct Paths {
74    /// Directory containing source files (user workspace).
75    /// Defaults to `~/.local/share/wind/workspace/`
76    #[serde(default = "default_workspace_path")]
77    pub workspace: PathBuf,
78
79    /// Directory where wiki Markdown files are stored.
80    /// Defaults to `~/.local/share/wind/wiki/`
81    #[serde(default = "default_wiki_path")]
82    pub wiki: PathBuf,
83
84    /// SYSTEM.md — AI behavior rules.
85    /// Defaults to `~/.local/share/wind/SYSTEM.md`
86    #[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/// Top-level config file (`[wiki]` section).
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Config {
117    /// LLM settings for the wiki engine.
118    #[serde(default)]
119    pub llm: Option<LlmConfig>,
120
121    /// Directory paths.
122    #[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/// Raw config.toml structure for loading the [wiki] section.
140#[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/// Full config.toml parsed directly.
149#[derive(Debug, Clone, Default, serde::Deserialize)]
150struct RawConfig {
151    #[serde(default)]
152    wiki: Option<RawWikiConfig>,
153}
154
155impl Config {
156    /// Load config from `~/.config/wind/config.toml`, or return defaults.
157    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    /// Resolve the effective LLM config, using env vars as fallback.
178    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        // Try environment variables
184        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    /// Get the wiki directory, creating it if needed.
203    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    /// Get the workspace directory.
213    pub fn workspace_dir(&self) -> PathBuf {
214        self.paths.workspace.clone()
215    }
216
217    /// Get the SYSTEM.md content, or a sensible default.
218    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    /// Get the SYSTEM.md path.
227    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
236/// Default SYSTEM.md content when none is provided.
237const 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(); // may or may not exist
276    }
277}