Skip to main content

kasl/libs/config/
mod.rs

1//! Application configuration: the data model, disk IO, and PATH setup.
2//! The interactive setup wizard lives in [`wizard`] (reached via
3//! [`Config::init`]).
4//!
5//! ```rust,no_run
6//! # fn main() -> anyhow::Result<()> {
7//! use kasl::libs::config::Config;
8//!
9//! let config = Config::read()?;
10//! let updated_config = Config::init()?;
11//! updated_config.save()?;
12//! # Ok(())
13//! # }
14//! ```
15
16mod 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
33/// Configuration filename inside the app data directory.
34pub const CONFIG_FILE_NAME: &str = "config.json";
35
36/// A configurable module as listed by the setup wizard.
37#[derive(Debug, Clone)]
38pub struct ConfigModule {
39    /// Internal key used for routing.
40    pub key: String,
41    /// Name shown in the wizard.
42    pub name: String,
43}
44
45/// Activity monitor thresholds and intervals.
46#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
47pub struct MonitorConfig {
48    /// Minimum pause length in minutes to keep as a pause record; shorter
49    /// interruptions are noise, not breaks.
50    pub min_pause_duration: u64,
51
52    /// Seconds without input before a pause is detected.
53    pub pause_threshold: u64,
54
55    /// Milliseconds between activity checks.
56    pub poll_interval: u64,
57
58    /// Seconds of sustained activity required to start a workday - keeps
59    /// a stray mouse nudge from opening the day.
60    pub activity_threshold: u64,
61
62    /// Minimum work interval in minutes; shorter ones are filtered from
63    /// report display.
64    pub min_work_interval: u64,
65
66    /// Maximum gap in seconds between two consecutive pauses to merge them.
67    ///
68    /// When the activity monitor briefly registers a stray input between two
69    /// otherwise continuous inactivity periods, it splits a single break into
70    /// several adjacent pause records. Pauses separated by a gap no longer than
71    /// this value are treated as one continuous pause so that sub-threshold
72    /// segments are not dropped from calculations. The value should stay small
73    /// (a few tens of seconds) so that genuine short work periods between pauses
74    /// are preserved rather than swallowed into the break.
75    #[serde(default = "default_pause_merge_gap")]
76    pub pause_merge_gap: u64,
77}
78
79/// Default gap (in seconds) below which consecutive pauses are merged.
80///
81/// Used both by [`MonitorConfig::default`] and by serde when an existing
82/// configuration file predates the `pause_merge_gap` field.
83fn default_pause_merge_gap() -> u64 {
84    30
85}
86
87/// Productivity thresholds for warnings and report validation.
88#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
89pub struct ProductivityConfig {
90    /// Productivity percentage below which reports warn.
91    pub min_productivity_threshold: f64,
92
93    /// Expected workday length in hours.
94    pub workday_hours: f64,
95
96    /// Fraction of the workday that must pass before warnings appear -
97    /// early-day ratios swing too wildly to act on.
98    pub min_workday_fraction_before_suggest: f64,
99}
100
101/// Report export defaults: output directory and file naming.
102#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
103pub struct ReportConfig {
104    /// Default directory for exports without an explicit `--output`
105    /// (created if missing); unset = timestamped file in the current dir.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub output_dir: Option<String>,
108
109    /// File name template (without extension) for generated reports.
110    ///
111    /// Supported placeholders:
112    /// - `{date}` — the report date in `YYYY-MM-DD` format
113    /// - `{seq}`  — a per-day sequence suffix: empty for the first report of the
114    ///   day, then `_2`, `_3`, … for subsequent reports on the same date
115    ///
116    /// The file extension is appended automatically based on the export format.
117    /// Defaults to `daily_report_{date}{seq}` when unset.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub filename_template: Option<String>,
120
121    /// Report label language: `en` (default) or `ru`; unknown values fall
122    /// back to `en`.
123    ///
124    /// Russian was the default before 1.0, when the product spoke Russian; the
125    /// shipped default is English and this opts back in.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub language: Option<String>,
128
129    /// Design template name (`<data>/report_templates/<name>.json`);
130    /// unset or missing falls back to the built-in `siserver` look.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub template: Option<String>,
133}
134
135/// External reporting server connection.
136#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
137pub struct ServerConfig {
138    /// Base URL of the reporting API.
139    pub api_url: String,
140
141    /// Token sent with report submissions.
142    pub auth_token: String,
143}
144
145/// The root configuration. Every module is optional, and unset modules
146/// are omitted from the JSON, so the file only names what is configured.
147#[derive(Serialize, Deserialize, Clone, Debug, Default)]
148pub struct Config {
149    /// SiServer integration.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub si: Option<SiConfig>,
152
153    /// GitLab integration (commit discovery).
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub gitlab: Option<GitLabConfig>,
156
157    /// Jira integration (issue discovery, inbox).
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub jira: Option<JiraConfig>,
160
161    /// Activity monitor settings.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub monitor: Option<MonitorConfig>,
164
165    /// External reporting server.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub server: Option<ServerConfig>,
168
169    /// Productivity thresholds.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub productivity: Option<ProductivityConfig>,
172
173    /// Report export defaults.
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub report: Option<ReportConfig>,
176
177    /// Task discovery ignore list; built-in defaults apply when absent.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub task_discovery: Option<TaskDiscoveryConfig>,
180
181    /// Jira inbox polling; requires `jira`, disabled when absent.
182    #[serde(default, skip_serializing_if = "Option::is_none")]
183    pub jira_inbox: Option<JiraInboxConfig>,
184}
185
186/// Settings for polling assigned open Jira issues into the local inbox.
187#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
188pub struct JiraInboxConfig {
189    /// Whether the watcher should poll Jira for open assigned issues.
190    #[serde(default = "default_true")]
191    pub enabled: bool,
192
193    /// Seconds between Jira inbox polls (default 300 = 5 minutes).
194    #[serde(default = "default_jira_inbox_poll_interval")]
195    pub poll_interval_secs: u64,
196
197    /// Whether to show a desktop toast when a new issue appears.
198    #[serde(default = "default_true")]
199    pub notify: bool,
200
201    /// Whether to show a toast when an existing issue visibly changes
202    /// (status, priority, score).
203    #[serde(default = "default_true")]
204    pub notify_changes: bool,
205
206    /// Whether to show a toast when an issue leaves the inbox
207    /// (closed or reassigned). Off by default.
208    #[serde(default)]
209    pub notify_gone: bool,
210
211    /// Extra Jira fields to fetch (custom fields such as Scoring).
212    #[serde(default)]
213    pub custom_fields: Vec<JiraCustomField>,
214
215    /// Field id used for ranking (DESC), typically Scoring (`customfield_…`).
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub sort_by_field: Option<String>,
218}
219
220/// A user-configured Jira custom field for inbox sync / display.
221#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
222pub struct JiraCustomField {
223    /// Jira field id, e.g. `customfield_12345`.
224    pub id: String,
225    /// Human-readable label, e.g. `Scoring`.
226    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    /// Field ids to request from Jira search (custom fields only).
253    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/// Settings for intelligent task discovery (`kasl task find`).
266#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
267pub struct TaskDiscoveryConfig {
268    /// Names/prefixes of tasks and commits to exclude from discovery.
269    ///
270    /// Matching is case-insensitive after normalization; a pattern matches
271    /// when the candidate name equals it or starts with it.
272    #[serde(default = "default_ignore_names")]
273    pub ignore_names: Vec<String>,
274}
275
276/// Built-in ignore patterns moved from the former hardcoded noise filter.
277pub 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    /// Loads the config file, or defaults when none exists yet.
318    ///
319    /// ```rust,no_run
320    /// # fn main() -> anyhow::Result<()> {
321    /// use kasl::libs::config::Config;
322    ///
323    /// let config = Config::read()?;
324    ///
325    /// if config.jira.is_some() {
326    ///     println!("Jira integration is configured");
327    /// }
328    /// # Ok(())
329    /// # }
330    /// ```
331    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    /// Writes the config as pretty-printed JSON, overwriting the file.
344    ///
345    /// ```rust,no_run
346    /// # fn main() -> anyhow::Result<()> {
347    /// use kasl::libs::config::{Config, MonitorConfig};
348    ///
349    /// let mut config = Config::read()?;
350    /// config.monitor = Some(MonitorConfig::default());
351    /// config.save()?;
352    /// # Ok(())
353    /// # }
354    /// ```
355    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    /// Returns the ignore list for task discovery.
363    ///
364    /// When `task_discovery` is not configured, returns the built-in defaults.
365    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    /// Appends unique names to the discovery ignore list and saves the config.
373    ///
374    /// Uniqueness is determined by [`normalize_task_name`]. Returns how many
375    /// new entries were added.
376    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    /// Adds the executable's directory to the global PATH.
399    ///
400    /// Windows-shaped: checks the process PATH first, then edits the
401    /// machine-level registry value via `reg` (which needs admin rights;
402    /// the setup command downgrades a failure here to a warning).
403    ///
404    /// ```rust,no_run
405    /// # fn main() -> anyhow::Result<()> {
406    /// use kasl::libs::config::Config;
407    ///
408    /// Config::set_app_global()?;
409    /// # Ok(())
410    /// # }
411    /// ```
412    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(&reg_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") // Expandable string type for environment variables
460            .arg("/d")
461            .arg(format!("{};{}", current_path, new_path.to_string_lossy()))
462            .arg("/f") // Force overwrite without confirmation
463            .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(&reg_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}