1mod wizard;
17
18use super::data_storage::DataStorage;
19use crate::api::gitlab::GitLabConfig;
20use crate::api::jira::JiraConfig;
21use crate::api::si::SiConfig;
22use crate::libs::messages::Message;
23use crate::libs::task::normalize_task_name;
24use crate::msg_error;
25use anyhow::Result;
26use serde::{Deserialize, Serialize};
27use std::env;
28use std::fs;
29use std::path::PathBuf;
30use std::process::Command;
31use std::str;
32
33pub const CONFIG_FILE_NAME: &str = "config.json";
35
36#[derive(Debug, Clone)]
38pub struct ConfigModule {
39 pub key: String,
41 pub name: String,
43}
44
45#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
47pub struct MonitorConfig {
48 pub min_pause_duration: u64,
51
52 pub pause_threshold: u64,
54
55 pub poll_interval: u64,
57
58 pub activity_threshold: u64,
61
62 pub min_work_interval: u64,
65
66 #[serde(default = "default_pause_merge_gap")]
76 pub pause_merge_gap: u64,
77}
78
79fn default_pause_merge_gap() -> u64 {
84 30
85}
86
87#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
89pub struct ProductivityConfig {
90 pub min_productivity_threshold: f64,
92
93 pub workday_hours: f64,
95
96 pub min_workday_fraction_before_suggest: f64,
99}
100
101#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
103pub struct ReportConfig {
104 #[serde(skip_serializing_if = "Option::is_none")]
107 pub output_dir: Option<String>,
108
109 #[serde(skip_serializing_if = "Option::is_none")]
119 pub filename_template: Option<String>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
124 pub language: Option<String>,
125
126 #[serde(skip_serializing_if = "Option::is_none")]
129 pub template: Option<String>,
130}
131
132#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
134pub struct ServerConfig {
135 pub api_url: String,
137
138 pub auth_token: String,
140}
141
142#[derive(Serialize, Deserialize, Clone, Debug, Default)]
145pub struct Config {
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub si: Option<SiConfig>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub gitlab: Option<GitLabConfig>,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub jira: Option<JiraConfig>,
157
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub monitor: Option<MonitorConfig>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub server: Option<ServerConfig>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub productivity: Option<ProductivityConfig>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub report: Option<ReportConfig>,
173
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub task_discovery: Option<TaskDiscoveryConfig>,
177
178 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub jira_inbox: Option<JiraInboxConfig>,
181}
182
183#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
185pub struct JiraInboxConfig {
186 #[serde(default = "default_true")]
188 pub enabled: bool,
189
190 #[serde(default = "default_jira_inbox_poll_interval")]
192 pub poll_interval_secs: u64,
193
194 #[serde(default = "default_true")]
196 pub notify: bool,
197
198 #[serde(default = "default_true")]
201 pub notify_changes: bool,
202
203 #[serde(default)]
206 pub notify_gone: bool,
207
208 #[serde(default)]
210 pub custom_fields: Vec<JiraCustomField>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub sort_by_field: Option<String>,
215}
216
217#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
219pub struct JiraCustomField {
220 pub id: String,
222 pub label: String,
224}
225
226fn default_true() -> bool {
227 true
228}
229
230fn default_jira_inbox_poll_interval() -> u64 {
231 300
232}
233
234impl Default for JiraInboxConfig {
235 fn default() -> Self {
236 Self {
237 enabled: true,
238 poll_interval_secs: default_jira_inbox_poll_interval(),
239 notify: true,
240 notify_changes: true,
241 notify_gone: false,
242 custom_fields: Vec::new(),
243 sort_by_field: None,
244 }
245 }
246}
247
248impl JiraInboxConfig {
249 pub fn extra_field_ids(&self) -> Vec<String> {
251 let mut ids: Vec<String> = self.custom_fields.iter().map(|f| f.id.trim().to_string()).filter(|id| !id.is_empty()).collect();
252 if let Some(sort_id) = &self.sort_by_field {
253 let trimmed = sort_id.trim();
254 if !trimmed.is_empty() && !ids.iter().any(|id| id == trimmed) {
255 ids.push(trimmed.to_string());
256 }
257 }
258 ids
259 }
260}
261
262#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
264pub struct TaskDiscoveryConfig {
265 #[serde(default = "default_ignore_names")]
270 pub ignore_names: Vec<String>,
271}
272
273pub fn default_ignore_names() -> Vec<String> {
275 vec![
276 "Merge remote-tracking branch".to_string(),
277 "Merge branch ".to_string(),
278 "update webui".to_string(),
279 ]
280}
281
282impl Default for TaskDiscoveryConfig {
283 fn default() -> Self {
284 Self {
285 ignore_names: default_ignore_names(),
286 }
287 }
288}
289
290impl Default for MonitorConfig {
291 fn default() -> Self {
292 MonitorConfig {
293 min_pause_duration: 20,
294 pause_threshold: 60,
295 poll_interval: 500,
296 activity_threshold: 30,
297 min_work_interval: 10,
298 pause_merge_gap: default_pause_merge_gap(),
299 }
300 }
301}
302
303impl Default for ProductivityConfig {
304 fn default() -> Self {
305 ProductivityConfig {
306 min_productivity_threshold: 75.0,
307 workday_hours: 8.0,
308 min_workday_fraction_before_suggest: 0.5,
309 }
310 }
311}
312
313impl Config {
314 pub fn read() -> Result<Config> {
329 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
330
331 if !config_file_path.exists() {
332 return Ok(Config::default());
333 }
334
335 let config_str = fs::read_to_string(config_file_path)?;
336 let config: Config = serde_json::from_str(&config_str)?;
337 Ok(config)
338 }
339
340 pub fn save(&self) -> Result<()> {
353 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
354 let json_content = serde_json::to_string_pretty(&self)?;
355 fs::write(config_file_path, json_content)?;
356 Ok(())
357 }
358
359 pub fn effective_ignore_names(&self) -> Vec<String> {
363 self.task_discovery
364 .as_ref()
365 .map(|c| c.ignore_names.clone())
366 .unwrap_or_else(default_ignore_names)
367 }
368
369 pub fn add_ignore_names(&mut self, names: &[String]) -> Result<usize> {
374 let mut discovery = self.task_discovery.clone().unwrap_or_default();
375 let mut added = 0;
376
377 for name in names {
378 let trimmed = name.trim();
379 if trimmed.is_empty() {
380 continue;
381 }
382 let key = normalize_task_name(trimmed);
383 let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
384 if !exists {
385 discovery.ignore_names.push(trimmed.to_string());
386 added += 1;
387 }
388 }
389
390 self.task_discovery = Some(discovery);
391 self.save()?;
392 Ok(added)
393 }
394
395 pub fn set_app_global() -> Result<()> {
410 let current_exe_path = env::current_exe()?;
411 let exe_dir = current_exe_path.parent().unwrap();
412
413 let mut paths: Vec<PathBuf> = env::split_paths(&env::var_os("PATH").unwrap()).collect();
414 let str_paths: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
415
416 if str_paths.contains(&exe_dir.to_str().unwrap()) {
417 return Ok(());
418 }
419
420 if paths.iter().any(|p| p.to_str() == Some(exe_dir.to_str().unwrap())) {
421 return Ok(());
422 }
423
424 paths.push(exe_dir.to_path_buf());
425
426 let new_path = env::join_paths(paths).unwrap_or_else(|_| panic!("{}", Message::FailedToJoinPaths.to_string()));
427
428 let path_key = r"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
429
430 let reg_query_output = Command::new("reg")
431 .arg("query")
432 .arg(path_key)
433 .arg("/v")
434 .arg("Path")
435 .output()
436 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegQuery.to_string()));
437
438 if !reg_query_output.status.success() {
439 let status = reg_query_output.status.to_string();
440 msg_error!(Message::PathRegistryQueryError { status: status.clone() });
441 return Err(anyhow::anyhow!("{}", Message::PathRegistryQueryError { status }));
442 }
443
444 let current_path = str::from_utf8(®_query_output.stdout)
445 .unwrap_or_else(|_| panic!("{}", Message::FailedToParseRegOutput.to_string()))
446 .split_whitespace()
447 .last()
448 .unwrap_or_else(|| panic!("{}", Message::FailedToGetPathFromReg.to_string()));
449
450 let reg_set_output = Command::new("reg")
451 .arg("add")
452 .arg(path_key)
453 .arg("/v")
454 .arg("Path")
455 .arg("/t")
456 .arg("REG_EXPAND_SZ") .arg("/d")
458 .arg(format!("{};{}", current_path, new_path.to_string_lossy()))
459 .arg("/f") .output()
461 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegSet.to_string()));
462
463 if !reg_set_output.status.success() {
464 let status = reg_set_output.status.to_string();
465 let stderr = String::from_utf8_lossy(®_set_output.stderr).to_string();
466 msg_error!(Message::PathRegistryUpdateError {
467 status: status.clone(),
468 stderr: stderr.clone()
469 });
470 return Err(anyhow::anyhow!("{}", Message::PathRegistryUpdateError { status, stderr }));
471 }
472
473 Ok(())
474 }
475}