use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum LlmProvider {
Anthropic,
OpenAi,
#[default]
Unknown,
}
impl std::fmt::Display for LlmProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Anthropic => write!(f, "anthropic"),
Self::OpenAi => write!(f, "openai"),
Self::Unknown => write!(f, "unknown"),
}
}
}
impl From<&str> for LlmProvider {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"anthropic" => Self::Anthropic,
"openai" | "open_ai" => Self::OpenAi,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig {
pub provider: LlmProvider,
pub model: String,
pub api_key: String,
#[serde(default)]
pub base_url: Option<String>,
}
impl LlmConfig {
pub fn validate(&self) -> Result<()> {
if self.api_key.is_empty() {
anyhow::bail!("LLM api_key is required");
}
if self.model.is_empty() {
anyhow::bail!("LLM model is required");
}
if matches!(self.provider, LlmProvider::Unknown) {
anyhow::bail!("LLM provider must be 'anthropic' or 'openai'");
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Paths {
#[serde(default = "default_workspace_path")]
pub workspace: PathBuf,
#[serde(default = "default_wiki_path")]
pub wiki: PathBuf,
#[serde(default = "default_system_md_path")]
pub system_md: PathBuf,
}
fn default_workspace_path() -> PathBuf {
data_dir().join("workspace")
}
fn default_wiki_path() -> PathBuf {
data_dir().join("wiki")
}
fn default_system_md_path() -> PathBuf {
config_dir().join("SYSTEM.md")
}
fn data_dir() -> PathBuf {
ProjectDirs::from("com", "wind-cli", "wind")
.map(|p| p.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("~/.local/share/wind"))
}
fn config_dir() -> PathBuf {
ProjectDirs::from("com", "wind-cli", "wind")
.map(|p| p.config_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("~/.config/wind"))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub llm: Option<LlmConfig>,
#[serde(default)]
pub paths: Paths,
}
impl Default for Config {
fn default() -> Self {
Self {
llm: None,
paths: Paths {
workspace: default_workspace_path(),
wiki: default_wiki_path(),
system_md: default_system_md_path(),
},
}
}
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct RawWikiConfig {
#[serde(default)]
llm: Option<LlmConfig>,
#[serde(default)]
paths: Option<Paths>,
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct RawConfig {
#[serde(default)]
wiki: Option<RawWikiConfig>,
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_file_path();
if !path.exists() {
return Ok(Config::default());
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read config at {}", path.display()))?;
let raw: RawConfig = toml::from_str(&content)
.with_context(|| format!("failed to parse config at {}", path.display()))?;
let wiki_section = raw.wiki.unwrap_or_default();
Ok(Config {
llm: wiki_section.llm,
paths: wiki_section.paths.unwrap_or_default(),
})
}
pub fn resolved_llm(&self) -> Result<LlmConfig> {
if let Some(ref llm) = self.llm {
return llm.validate().map(|_| llm.clone());
}
let provider =
std::env::var("WIND_WIKI_PROVIDER").unwrap_or_else(|_| "anthropic".to_string());
let model = std::env::var("WIND_WIKI_MODEL")
.unwrap_or_else(|_| "claude-haiku-4-20250514".to_string());
let api_key = std::env::var("WIND_WIKI_API_KEY")
.with_context(|| "WIND_WIKI_API_KEY not set and [wiki.llm] not in config")?;
let config = LlmConfig {
provider: LlmProvider::from(provider.as_str()),
model,
api_key,
base_url: std::env::var("WIND_WIKI_BASE_URL").ok(),
};
config.validate()?;
Ok(config)
}
pub fn wiki_dir(&self) -> Result<PathBuf> {
let dir = &self.paths.wiki;
if !dir.exists() {
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create wiki dir: {}", dir.display()))?;
}
Ok(dir.clone())
}
pub fn workspace_dir(&self) -> PathBuf {
self.paths.workspace.clone()
}
pub fn system_md_content(&self) -> Result<String> {
if self.paths.system_md.exists() {
Ok(std::fs::read_to_string(&self.paths.system_md)?)
} else {
Ok(DEFAULT_SYSTEM_MD.to_string())
}
}
pub fn system_md_path(&self) -> PathBuf {
self.paths.system_md.clone()
}
}
fn config_file_path() -> PathBuf {
config_dir().join("config.toml")
}
const DEFAULT_SYSTEM_MD: &str = r#"# WinWork Wiki — System Prompt
你是一个专业的知识库编辑助手。
## 工作原则
1. **准确性**:所有信息必须基于提供的源文档,不得编造。
2. **简洁性**:用简洁的中文描述,避免冗余。
3. **可追溯**:每个知识条目需注明来源文件。
## 格式规范
- 使用 Markdown 格式
- 每个文件对应一个主题
- 文件名使用中文描述主题
- 包含 `<!-- source: filename -->` 元数据注释
"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_provider_from_str() {
assert!(matches!(
LlmProvider::from("anthropic"),
LlmProvider::Anthropic
));
assert!(matches!(
LlmProvider::from("ANTHROPIC"),
LlmProvider::Anthropic
));
assert!(matches!(LlmProvider::from("openai"), LlmProvider::OpenAi));
assert!(matches!(LlmProvider::from("xyz"), LlmProvider::Unknown));
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.llm.is_none());
let _ = config.paths.wiki.exists(); }
}