1use crate::error::MpsError;
7use crate::meta::MetaConfig;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12pub use crate::meta::NotifyConfig;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ChatConfig {
18 #[serde(default)]
20 pub url: Option<String>,
21 #[serde(default = "default_chat_model")]
22 pub model: String,
23 #[serde(default = "default_context_days")]
24 pub context_days: u64,
25 #[serde(default = "default_true")]
26 pub stream: bool,
27 #[serde(default)]
29 pub api_key: String,
30 #[serde(default)]
32 pub sessions_dir: Option<String>,
33 #[serde(default = "default_connect_timeout_secs")]
35 pub connect_timeout_secs: u64,
36}
37
38impl Default for ChatConfig {
39 fn default() -> Self {
40 Self {
41 url: None,
42 model: default_chat_model(),
43 context_days: default_context_days(),
44 stream: true,
45 api_key: String::new(),
46 sessions_dir: None,
47 connect_timeout_secs: default_connect_timeout_secs(),
48 }
49 }
50}
51
52fn default_serve_port() -> u16 {
53 3000
54}
55fn default_serve_host() -> String {
56 "127.0.0.1".into()
57}
58fn default_git_remote() -> String {
59 "origin".into()
60}
61fn default_git_branch() -> String {
62 "master".into()
63}
64fn default_command() -> String {
65 "open".into()
66}
67fn default_chat_model() -> String {
68 "llama3.2".into()
69}
70fn default_context_days() -> u64 {
71 7
72}
73fn default_connect_timeout_secs() -> u64 {
74 10
75}
76fn default_true() -> bool {
77 true
78}
79fn default_type_aliases() -> HashMap<String, String> {
80 HashMap::new()
81}
82fn default_command_aliases() -> HashMap<String, String> {
83 HashMap::new()
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ServeConfig {
89 #[serde(default = "default_serve_port")]
90 pub port: u16,
91 #[serde(default = "default_serve_host")]
92 pub host: String,
93 #[serde(default)]
95 pub token: String,
96}
97
98impl Default for ServeConfig {
99 fn default() -> Self {
100 Self {
101 port: default_serve_port(),
102 host: default_serve_host(),
103 token: String::new(),
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Config {
112 pub mps_dir: PathBuf,
113 pub storage_dir: PathBuf,
114 pub log_file: PathBuf,
115 #[serde(default = "default_git_remote")]
116 pub git_remote: String,
117 #[serde(default = "default_git_branch")]
118 pub git_branch: String,
119 #[serde(default = "default_command")]
121 pub default_command: String,
122 #[serde(default = "default_type_aliases", alias = "aliases")]
125 pub type_aliases: HashMap<String, String>,
126 #[serde(default = "default_command_aliases")]
128 pub command_aliases: HashMap<String, String>,
129 #[serde(default)]
131 pub custom_tags: Vec<String>,
132 #[serde(default)]
134 pub notify: NotifyConfig,
135 #[serde(default)]
137 pub serve: ServeConfig,
138 #[serde(default)]
140 pub chat: ChatConfig,
141}
142
143impl Config {
144 pub fn default_config() -> Result<Self, MpsError> {
146 let home = dirs::home_dir()
147 .ok_or_else(|| MpsError::ConfigInvalid("cannot determine home directory".into()))?;
148 let mps_dir = home.join(".mps");
149 Ok(Config {
150 storage_dir: mps_dir.join("mps"),
151 log_file: mps_dir.join("mps.log"),
152 mps_dir,
153 git_remote: "origin".into(),
154 git_branch: "master".into(),
155 default_command: "open".into(),
156 type_aliases: HashMap::new(),
157 command_aliases: HashMap::new(),
158 custom_tags: Vec::new(),
159 notify: NotifyConfig::default(),
160 serve: ServeConfig::default(),
161 chat: ChatConfig::default(),
162 })
163 }
164
165 pub fn merge_meta(&mut self, meta: &MetaConfig) {
173 for (k, v) in &meta.type_aliases {
174 self.type_aliases
175 .entry(k.clone())
176 .or_insert_with(|| v.clone());
177 }
178 for (k, v) in &meta.command_aliases {
179 self.command_aliases
180 .entry(k.clone())
181 .or_insert_with(|| v.clone());
182 }
183 if let Some(ref dc) = meta.default_command {
184 self.default_command = dc.clone();
185 }
186 for t in &meta.custom_tags {
187 if !self.custom_tags.contains(t) {
188 self.custom_tags.push(t.clone());
189 }
190 }
191 let def = NotifyConfig::default();
195 let n = &meta.notify;
196 if !n.enabled {
197 self.notify.enabled = false;
198 }
199 if !n.notify_open_tasks {
200 self.notify.notify_open_tasks = false;
201 }
202 if n.task_notify_at.is_some() {
203 self.notify.task_notify_at = n.task_notify_at.clone();
204 }
205 if !n.open_task_tags.is_empty() {
206 self.notify.open_task_tags = n.open_task_tags.clone();
207 }
208 if n.window_minutes != def.window_minutes {
209 self.notify.window_minutes = n.window_minutes;
210 }
211 if n.task_cooldown_minutes != def.task_cooldown_minutes {
212 self.notify.task_cooldown_minutes = n.task_cooldown_minutes;
213 }
214 if n.overdue_days != def.overdue_days {
215 self.notify.overdue_days = n.overdue_days;
216 }
217 let c = &meta.chat;
219 if let Some(ref url) = c.url {
220 self.chat.url = Some(url.clone());
221 }
222 if let Some(ref model) = c.model {
223 self.chat.model = model.clone();
224 }
225 if let Some(days) = c.context_days {
226 self.chat.context_days = days;
227 }
228 if let Some(stream) = c.stream {
229 self.chat.stream = stream;
230 }
231 if let Some(secs) = c.connect_timeout_secs {
232 self.chat.connect_timeout_secs = secs;
233 }
234 }
235
236 pub fn load(path: &Path) -> Result<Self, MpsError> {
239 if !path.exists() {
240 return Err(MpsError::ConfigNotFound(path.to_path_buf()));
241 }
242 let content = std::fs::read_to_string(path)?;
243
244 let normalised = content
246 .lines()
247 .map(|line| {
248 if let Some(rest) = line.strip_prefix(':') {
249 rest.to_string()
250 } else {
251 line.to_string()
252 }
253 })
254 .collect::<Vec<_>>()
255 .join("\n");
256
257 let cfg: Config = serde_yaml::from_str(&normalised)
258 .map_err(|e| MpsError::ConfigInvalid(e.to_string()))?;
259 Ok(cfg)
260 }
261
262 pub fn init(path: &Path) -> Result<(), MpsError> {
264 if path.exists() {
265 return Ok(());
266 }
267 let cfg = Self::default_config()?;
268 let yaml = serde_yaml::to_string(&cfg)?;
269 std::fs::write(path, yaml)?;
270 Ok(())
271 }
272
273 pub fn save(&self, path: &Path) -> Result<(), MpsError> {
275 let yaml = serde_yaml::to_string(self)?;
276 let tmp = path.with_extension(format!("yaml.tmp.{}", std::process::id()));
277 std::fs::write(&tmp, &yaml)?;
278 std::fs::rename(&tmp, path)?;
279 Ok(())
280 }
281
282 pub fn ensure_dirs(&self) -> Result<(), MpsError> {
284 std::fs::create_dir_all(&self.mps_dir)?;
285 std::fs::create_dir_all(&self.storage_dir)?;
286 if !self.log_file.exists() {
287 std::fs::write(&self.log_file, "")?;
288 }
289 Ok(())
290 }
291}
292
293pub fn default_config_path() -> PathBuf {
295 std::env::var("MPS_CONFIG")
296 .map(PathBuf::from)
297 .unwrap_or_else(|_| {
298 dirs::home_dir()
299 .unwrap_or_else(|| PathBuf::from("."))
300 .join(".mps_config.yaml")
301 })
302}