1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6#[derive(Debug, Serialize, Deserialize, Clone, Default)]
7pub struct VectorConfig {
8 pub model_url: Option<String>,
9 pub model_path: Option<String>,
10 pub model_sha256: Option<String>,
11}
12
13#[derive(Debug, Serialize, Deserialize, Clone)]
14pub struct Config {
15 pub api_url: String,
16 pub public_key: String,
17 pub timeout_seconds: u64,
18 #[serde(default = "default_risk_guard_level")]
19 pub risk_guard_level: String,
20 #[serde(default)]
21 pub vector: VectorConfig,
22 #[serde(default)]
23 pub output: OutputConfig,
24 #[serde(default)]
25 pub install: InstallConfig,
26}
27
28pub const OFFICIAL_PUBLIC_KEY: [u8; 32] = [
32 97, 228, 162, 92, 153, 11, 201, 252, 71, 48, 104, 125, 199, 128, 20, 60, 250, 189, 150, 94,
33 170, 212, 223, 133, 120, 182, 137, 88, 220, 130, 171, 194,
34];
35
36impl Default for Config {
37 fn default() -> Self {
38 Self {
39 api_url: "https://cdn.cmdhub.org".to_string(),
40 public_key: OFFICIAL_PUBLIC_KEY
41 .iter()
42 .map(|b| format!("{:02x}", b))
43 .collect(),
44 timeout_seconds: 30,
45 risk_guard_level: default_risk_guard_level(),
46 vector: VectorConfig::default(),
47 output: OutputConfig::default(),
48 install: InstallConfig::default(),
49 }
50 }
51}
52
53pub fn get_config_dir() -> PathBuf {
54 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
55 if !xdg.is_empty() {
56 return PathBuf::from(xdg).join("cmdhub");
57 }
58 }
59 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/fuyu".to_string());
60 PathBuf::from(home).join(".config").join("cmdhub")
61}
62
63pub fn get_data_dir() -> PathBuf {
64 if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
65 if !xdg.is_empty() {
66 return PathBuf::from(xdg).join("cmdhub");
67 }
68 }
69 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/fuyu".to_string());
70 PathBuf::from(home)
71 .join(".local")
72 .join("share")
73 .join("cmdhub")
74}
75
76pub fn get_cache_dir() -> PathBuf {
77 if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
78 if !xdg.is_empty() {
79 return PathBuf::from(xdg).join("cmdhub");
80 }
81 }
82 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/fuyu".to_string());
83 PathBuf::from(home).join(".cache").join("cmdhub")
84}
85
86pub fn resolve_config_path(custom_path: Option<PathBuf>) -> PathBuf {
87 if let Some(path) = custom_path {
88 path
89 } else if let Ok(env_path) = std::env::var("CMDH_CONFIG") {
90 if !env_path.is_empty() {
91 PathBuf::from(env_path)
92 } else {
93 get_config_dir().join("config.toml")
94 }
95 } else {
96 get_config_dir().join("config.toml")
97 }
98}
99
100pub fn load_or_create_config(custom_path: Option<PathBuf>) -> Result<Config> {
101 let config_path = resolve_config_path(custom_path);
102 let default_xdg_path = get_config_dir().join("config.toml");
103
104 if !config_path.exists() {
105 if config_path != default_xdg_path {
106 anyhow::bail!(
107 "Custom configuration file does not exist at {:?}",
108 config_path
109 );
110 }
111 if let Some(parent) = config_path.parent() {
112 fs::create_dir_all(parent).context("Failed to create config directory")?;
113 }
114 let default_config = Config::default();
115 let toml_str = toml::to_string_pretty(&default_config)
116 .context("Failed to serialize default config")?;
117 fs::write(&config_path, toml_str).context("Failed to write default config file")?;
118 eprintln!(
119 "[INFO] Created default configuration file at: {}",
120 config_path.display()
121 );
122 Ok(default_config)
123 } else {
124 let toml_str = fs::read_to_string(&config_path).context("Failed to read config file")?;
125 let mut config: Config =
126 toml::from_str(&toml_str).context("Failed to parse config TOML")?;
127
128 if let Ok(api_url) = std::env::var("CMDH_API_URL") {
130 if !api_url.is_empty() {
131 config.api_url = api_url;
132 }
133 }
134 if let Ok(model_url) = std::env::var("CMDH_MODEL_URL") {
135 if !model_url.is_empty() {
136 config.vector.model_url = Some(model_url);
137 }
138 }
139
140 Ok(config)
141 }
142}
143
144#[derive(Debug, Serialize, Deserialize, Clone)]
145pub struct OutputConfig {
146 #[serde(default = "default_output_mode")]
147 pub mode: String, }
149
150impl Default for OutputConfig {
151 fn default() -> Self {
152 Self {
153 mode: default_output_mode(),
154 }
155 }
156}
157
158fn default_output_mode() -> String {
159 "full".to_string()
160}
161
162fn default_risk_guard_level() -> String {
163 "ask".to_string()
164}
165
166#[derive(Debug, Serialize, Deserialize, Clone)]
167pub struct InstallConfig {
168 pub os: Option<String>,
169 #[serde(default = "default_package_managers")]
170 pub package_managers: Vec<String>,
171}
172
173impl Default for InstallConfig {
174 fn default() -> Self {
175 Self {
176 os: None,
177 package_managers: default_package_managers(),
178 }
179 }
180}
181
182fn default_package_managers() -> Vec<String> {
183 vec![
184 "uv".to_string(),
185 "npm".to_string(),
186 "cargo".to_string(),
187 "go".to_string(),
188 ]
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_config_parsing_defaults() {
197 let toml_str = r#"
198 api_url = "https://cdn.cmdhub.org"
199 public_key = "01020304"
200 timeout_seconds = 30
201 "#;
202 let config: Config = toml::from_str(toml_str).unwrap();
203 assert_eq!(config.risk_guard_level, "ask");
204 assert_eq!(config.output.mode, "full");
205 assert_eq!(config.install.os, None);
206 assert_eq!(
207 config.install.package_managers,
208 vec![
209 "uv".to_string(),
210 "npm".to_string(),
211 "cargo".to_string(),
212 "go".to_string()
213 ]
214 );
215 }
216}