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")]
127 pub language: Option<String>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
132 pub template: Option<String>,
133}
134
135#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
137pub struct ServerConfig {
138 pub api_url: String,
140
141 pub auth_token: String,
143}
144
145#[derive(Serialize, Deserialize, Clone, Debug, Default)]
148pub struct Config {
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub si: Option<SiConfig>,
152
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub gitlab: Option<GitLabConfig>,
156
157 #[serde(skip_serializing_if = "Option::is_none")]
159 pub jira: Option<JiraConfig>,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub monitor: Option<MonitorConfig>,
164
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub server: Option<ServerConfig>,
168
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub productivity: Option<ProductivityConfig>,
172
173 #[serde(skip_serializing_if = "Option::is_none")]
175 pub report: Option<ReportConfig>,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub task_discovery: Option<TaskDiscoveryConfig>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub jira_inbox: Option<JiraInboxConfig>,
184}
185
186#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
188pub struct JiraInboxConfig {
189 #[serde(default = "default_true")]
191 pub enabled: bool,
192
193 #[serde(default = "default_jira_inbox_poll_interval")]
195 pub poll_interval_secs: u64,
196
197 #[serde(default = "default_true")]
199 pub notify: bool,
200
201 #[serde(default = "default_true")]
204 pub notify_changes: bool,
205
206 #[serde(default)]
209 pub notify_gone: bool,
210
211 #[serde(default)]
213 pub custom_fields: Vec<JiraCustomField>,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub sort_by_field: Option<String>,
218}
219
220#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
222pub struct JiraCustomField {
223 pub id: String,
225 pub label: String,
227}
228
229fn default_true() -> bool {
230 true
231}
232
233fn default_jira_inbox_poll_interval() -> u64 {
234 300
235}
236
237impl Default for JiraInboxConfig {
238 fn default() -> Self {
239 Self {
240 enabled: true,
241 poll_interval_secs: default_jira_inbox_poll_interval(),
242 notify: true,
243 notify_changes: true,
244 notify_gone: false,
245 custom_fields: Vec::new(),
246 sort_by_field: None,
247 }
248 }
249}
250
251impl JiraInboxConfig {
252 pub fn extra_field_ids(&self) -> Vec<String> {
254 let mut ids: Vec<String> = self.custom_fields.iter().map(|f| f.id.trim().to_string()).filter(|id| !id.is_empty()).collect();
255 if let Some(sort_id) = &self.sort_by_field {
256 let trimmed = sort_id.trim();
257 if !trimmed.is_empty() && !ids.iter().any(|id| id == trimmed) {
258 ids.push(trimmed.to_string());
259 }
260 }
261 ids
262 }
263}
264
265#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
267pub struct TaskDiscoveryConfig {
268 #[serde(default = "default_ignore_names")]
273 pub ignore_names: Vec<String>,
274}
275
276pub fn default_ignore_names() -> Vec<String> {
278 vec![
279 "Merge remote-tracking branch".to_string(),
280 "Merge branch ".to_string(),
281 "update webui".to_string(),
282 ]
283}
284
285impl Default for TaskDiscoveryConfig {
286 fn default() -> Self {
287 Self {
288 ignore_names: default_ignore_names(),
289 }
290 }
291}
292
293impl Default for MonitorConfig {
294 fn default() -> Self {
295 MonitorConfig {
296 min_pause_duration: 20,
297 pause_threshold: 60,
298 poll_interval: 500,
299 activity_threshold: 30,
300 min_work_interval: 10,
301 pause_merge_gap: default_pause_merge_gap(),
302 }
303 }
304}
305
306impl Default for ProductivityConfig {
307 fn default() -> Self {
308 ProductivityConfig {
309 min_productivity_threshold: 75.0,
310 workday_hours: 8.0,
311 min_workday_fraction_before_suggest: 0.5,
312 }
313 }
314}
315
316impl Config {
317 pub fn read() -> Result<Config> {
332 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
333
334 if !config_file_path.exists() {
335 return Ok(Config::default());
336 }
337
338 let config_str = fs::read_to_string(config_file_path)?;
339 let config: Config = serde_json::from_str(&config_str)?;
340 Ok(config)
341 }
342
343 pub fn save(&self) -> Result<()> {
356 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
357 let json_content = serde_json::to_string_pretty(&self)?;
358 fs::write(config_file_path, json_content)?;
359 Ok(())
360 }
361
362 pub fn effective_ignore_names(&self) -> Vec<String> {
366 self.task_discovery
367 .as_ref()
368 .map(|c| c.ignore_names.clone())
369 .unwrap_or_else(default_ignore_names)
370 }
371
372 pub fn add_ignore_names(&mut self, names: &[String]) -> Result<usize> {
377 let mut discovery = self.task_discovery.clone().unwrap_or_default();
378 let mut added = 0;
379
380 for name in names {
381 let trimmed = name.trim();
382 if trimmed.is_empty() {
383 continue;
384 }
385 let key = normalize_task_name(trimmed);
386 let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
387 if !exists {
388 discovery.ignore_names.push(trimmed.to_string());
389 added += 1;
390 }
391 }
392
393 self.task_discovery = Some(discovery);
394 self.save()?;
395 Ok(added)
396 }
397
398 pub fn set_app_global() -> Result<()> {
413 let current_exe_path = env::current_exe()?;
414 let exe_dir = current_exe_path.parent().unwrap();
415
416 let mut paths: Vec<PathBuf> = env::split_paths(&env::var_os("PATH").unwrap()).collect();
417 let str_paths: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
418
419 if str_paths.contains(&exe_dir.to_str().unwrap()) {
420 return Ok(());
421 }
422
423 if paths.iter().any(|p| p.to_str() == Some(exe_dir.to_str().unwrap())) {
424 return Ok(());
425 }
426
427 paths.push(exe_dir.to_path_buf());
428
429 let new_path = env::join_paths(paths).unwrap_or_else(|_| panic!("{}", Message::FailedToJoinPaths.to_string()));
430
431 let path_key = r"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
432
433 let reg_query_output = Command::new("reg")
434 .arg("query")
435 .arg(path_key)
436 .arg("/v")
437 .arg("Path")
438 .output()
439 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegQuery.to_string()));
440
441 if !reg_query_output.status.success() {
442 let status = reg_query_output.status.to_string();
443 msg_error!(Message::PathRegistryQueryError { status: status.clone() });
444 return Err(anyhow::anyhow!("{}", Message::PathRegistryQueryError { status }));
445 }
446
447 let current_path = str::from_utf8(®_query_output.stdout)
448 .unwrap_or_else(|_| panic!("{}", Message::FailedToParseRegOutput.to_string()))
449 .split_whitespace()
450 .last()
451 .unwrap_or_else(|| panic!("{}", Message::FailedToGetPathFromReg.to_string()));
452
453 let reg_set_output = Command::new("reg")
454 .arg("add")
455 .arg(path_key)
456 .arg("/v")
457 .arg("Path")
458 .arg("/t")
459 .arg("REG_EXPAND_SZ") .arg("/d")
461 .arg(format!("{};{}", current_path, new_path.to_string_lossy()))
462 .arg("/f") .output()
464 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegSet.to_string()));
465
466 if !reg_set_output.status.success() {
467 let status = reg_set_output.status.to_string();
468 let stderr = String::from_utf8_lossy(®_set_output.stderr).to_string();
469 msg_error!(Message::PathRegistryUpdateError {
470 status: status.clone(),
471 stderr: stderr.clone()
472 });
473 return Err(anyhow::anyhow!("{}", Message::PathRegistryUpdateError { status, stderr }));
474 }
475
476 Ok(())
477 }
478}