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/// SiServer connection used by `report --send`.
136///
137/// Named `server` for historical reasons - this is the third-party corporate
138/// reporting API, not kasl-server. The team server lives in
139/// [`KaslServerConfig`] under its own key so the two channels can be
140/// configured independently, and eventually used side by side.
141#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
142pub struct ServerConfig {
143 /// Base URL of the reporting API.
144 pub api_url: String,
145
146 /// Token sent with report submissions.
147 pub auth_token: String,
148}
149
150/// Connection to a kasl-server instance: where to reach it, and how to trust
151/// it.
152///
153/// The agent token is deliberately absent - it lives in the OS keyring, like
154/// every other kasl credential, so a readable config file never carries one.
155#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
156pub struct KaslServerConfig {
157 /// Base URL of the server, e.g. `https://kasl.example.com`. Stored
158 /// without a trailing slash so endpoint paths can be appended as-is.
159 pub url: String,
160
161 /// PEM file holding the certificate authority that signed the server's
162 /// certificate.
163 ///
164 /// Self-hosted deployments are routinely behind a company CA or a
165 /// self-signed certificate, which the system trust store does not know.
166 /// Naming the certificate here keeps verification on: the alternative -
167 /// switching it off - would also accept anyone else's certificate.
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub ca_certificate: Option<String>,
170
171 /// Whether the watcher tells this server, once a minute, if you are
172 /// working, on a break, or not in a day.
173 ///
174 /// Off until the employee turns it on with `kasl server pulse enable`:
175 /// connecting agrees to send finished days, and a live signal of whether
176 /// someone is at their keyboard is a different thing to agree to. It
177 /// lives here, beside the address, so the consent belongs to this
178 /// connection - disconnecting forgets it, and connecting to another
179 /// server does not inherit it.
180 #[serde(default)]
181 pub pulse: bool,
182}
183
184/// The root configuration. Every module is optional, and unset modules
185/// are omitted from the JSON, so the file only names what is configured.
186#[derive(Serialize, Deserialize, Clone, Debug, Default)]
187pub struct Config {
188 /// SiServer integration.
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub si: Option<SiConfig>,
191
192 /// GitLab integration (commit discovery).
193 #[serde(skip_serializing_if = "Option::is_none")]
194 pub gitlab: Option<GitLabConfig>,
195
196 /// Jira integration (issue discovery, inbox).
197 #[serde(skip_serializing_if = "Option::is_none")]
198 pub jira: Option<JiraConfig>,
199
200 /// Activity monitor settings.
201 #[serde(skip_serializing_if = "Option::is_none")]
202 pub monitor: Option<MonitorConfig>,
203
204 /// SiServer, the third-party corporate reporting API.
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub server: Option<ServerConfig>,
207
208 /// kasl-server: the team server this agent reports to.
209 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub kasl_server: Option<KaslServerConfig>,
211
212 /// Productivity thresholds.
213 #[serde(skip_serializing_if = "Option::is_none")]
214 pub productivity: Option<ProductivityConfig>,
215
216 /// Report export defaults.
217 #[serde(skip_serializing_if = "Option::is_none")]
218 pub report: Option<ReportConfig>,
219
220 /// Task discovery ignore list; built-in defaults apply when absent.
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub task_discovery: Option<TaskDiscoveryConfig>,
223
224 /// Jira inbox polling; requires `jira`, disabled when absent.
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub jira_inbox: Option<JiraInboxConfig>,
227}
228
229/// Settings for polling assigned open Jira issues into the local inbox.
230#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
231pub struct JiraInboxConfig {
232 /// Whether the watcher should poll Jira for open assigned issues.
233 #[serde(default = "default_true")]
234 pub enabled: bool,
235
236 /// Seconds between Jira inbox polls (default 300 = 5 minutes).
237 #[serde(default = "default_jira_inbox_poll_interval")]
238 pub poll_interval_secs: u64,
239
240 /// Whether to show a desktop toast when a new issue appears.
241 #[serde(default = "default_true")]
242 pub notify: bool,
243
244 /// Whether to show a toast when an existing issue changes status or
245 /// priority. Score changes and returns only set the badge.
246 #[serde(default = "default_true")]
247 pub notify_changes: bool,
248
249 /// Whether to show a toast when an issue leaves the inbox
250 /// (closed or reassigned). Off by default.
251 #[serde(default)]
252 pub notify_gone: bool,
253
254 /// Extra Jira fields to fetch (custom fields such as Scoring).
255 #[serde(default)]
256 pub custom_fields: Vec<JiraCustomField>,
257
258 /// Field id used for ranking (DESC), typically Scoring (`customfield_…`).
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub sort_by_field: Option<String>,
261
262 /// How long a toast's Snooze button sleeps: 3d, 12h, 2w.
263 ///
264 /// Shares its spelling with `inbox triage --snooze-for`, because a button
265 /// and the picker putting an issue to sleep for different lengths would be
266 /// two behaviours wearing one word.
267 #[serde(default = "default_toast_snooze_for")]
268 pub toast_snooze_for: String,
269
270 /// Whether taking an issue also starts it in Jira.
271 ///
272 /// Moves the issue into the in-progress category of its workflow on every
273 /// take - from the command line, from triage and from a toast button. Off
274 /// by default: it writes to a tracker the whole team reads, and in some
275 /// teams "In Progress" means more than "on my list".
276 #[serde(default)]
277 pub start_on_take: bool,
278
279 /// What the inbox does by itself: when an issue matches a rule's `when`,
280 /// its `then` pins it, toasts it as urgent, dismisses it, or tags the task
281 /// taken from it. See [`crate::libs::inbox_rules`].
282 ///
283 /// Kept as written and read one rule at a time: a rule that does not
284 /// parse is skipped and named, rather than making the whole file
285 /// unreadable. The watcher reads this file for activity tracking too, and
286 /// a typo in an inbox rule must not stop the workday being recorded.
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub rules: Vec<serde_json::Value>,
289}
290
291/// A user-configured Jira custom field for inbox sync / display.
292#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
293pub struct JiraCustomField {
294 /// Jira field id, e.g. `customfield_12345`.
295 pub id: String,
296 /// Human-readable label, e.g. `Scoring`.
297 pub label: String,
298}
299
300fn default_true() -> bool {
301 true
302}
303
304fn default_jira_inbox_poll_interval() -> u64 {
305 300
306}
307
308fn default_toast_snooze_for() -> String {
309 "1d".to_string()
310}
311
312impl Default for JiraInboxConfig {
313 fn default() -> Self {
314 Self {
315 enabled: true,
316 poll_interval_secs: default_jira_inbox_poll_interval(),
317 notify: true,
318 notify_changes: true,
319 notify_gone: false,
320 custom_fields: Vec::new(),
321 sort_by_field: None,
322 toast_snooze_for: default_toast_snooze_for(),
323 start_on_take: false,
324 rules: Vec::new(),
325 }
326 }
327}
328
329impl JiraInboxConfig {
330 /// Field ids to request from Jira search (custom fields only).
331 pub fn extra_field_ids(&self) -> Vec<String> {
332 let mut ids: Vec<String> = self.custom_fields.iter().map(|f| f.id.trim().to_string()).filter(|id| !id.is_empty()).collect();
333 if let Some(sort_id) = &self.sort_by_field {
334 let trimmed = sort_id.trim();
335 if !trimmed.is_empty() && !ids.iter().any(|id| id == trimmed) {
336 ids.push(trimmed.to_string());
337 }
338 }
339 ids
340 }
341}
342
343/// Settings for intelligent task discovery (`kasl task find`).
344#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
345pub struct TaskDiscoveryConfig {
346 /// Names/prefixes of tasks and commits to exclude from discovery.
347 ///
348 /// Matching is case-insensitive after normalization; a pattern matches
349 /// when the candidate name equals it or starts with it.
350 #[serde(default = "default_ignore_names")]
351 pub ignore_names: Vec<String>,
352}
353
354/// Built-in ignore patterns moved from the former hardcoded noise filter.
355pub fn default_ignore_names() -> Vec<String> {
356 vec![
357 "Merge remote-tracking branch".to_string(),
358 "Merge branch ".to_string(),
359 "update webui".to_string(),
360 ]
361}
362
363impl Default for TaskDiscoveryConfig {
364 fn default() -> Self {
365 Self {
366 ignore_names: default_ignore_names(),
367 }
368 }
369}
370
371impl Default for MonitorConfig {
372 fn default() -> Self {
373 MonitorConfig {
374 min_pause_duration: 20,
375 pause_threshold: 60,
376 poll_interval: 500,
377 activity_threshold: 30,
378 min_work_interval: 10,
379 pause_merge_gap: default_pause_merge_gap(),
380 }
381 }
382}
383
384impl Default for ProductivityConfig {
385 fn default() -> Self {
386 ProductivityConfig {
387 min_productivity_threshold: 75.0,
388 workday_hours: 8.0,
389 min_workday_fraction_before_suggest: 0.5,
390 }
391 }
392}
393
394impl Config {
395 /// Loads the config file, or defaults when none exists yet.
396 ///
397 /// ```rust,no_run
398 /// # fn main() -> anyhow::Result<()> {
399 /// use kasl::libs::config::Config;
400 ///
401 /// let config = Config::read()?;
402 ///
403 /// if config.jira.is_some() {
404 /// println!("Jira integration is configured");
405 /// }
406 /// # Ok(())
407 /// # }
408 /// ```
409 pub fn read() -> Result<Config> {
410 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
411
412 if !config_file_path.exists() {
413 return Ok(Config::default());
414 }
415
416 let config_str = fs::read_to_string(config_file_path)?;
417 let config: Config = serde_json::from_str(&config_str)?;
418 Ok(config)
419 }
420
421 /// Writes the config as pretty-printed JSON, overwriting the file.
422 ///
423 /// ```rust,no_run
424 /// # fn main() -> anyhow::Result<()> {
425 /// use kasl::libs::config::{Config, MonitorConfig};
426 ///
427 /// let mut config = Config::read()?;
428 /// config.monitor = Some(MonitorConfig::default());
429 /// config.save()?;
430 /// # Ok(())
431 /// # }
432 /// ```
433 pub fn save(&self) -> Result<()> {
434 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
435 let json_content = serde_json::to_string_pretty(&self)?;
436 fs::write(config_file_path, json_content)?;
437 Ok(())
438 }
439
440 /// Returns the ignore list for task discovery.
441 ///
442 /// When `task_discovery` is not configured, returns the built-in defaults.
443 pub fn effective_ignore_names(&self) -> Vec<String> {
444 self.task_discovery
445 .as_ref()
446 .map(|c| c.ignore_names.clone())
447 .unwrap_or_else(default_ignore_names)
448 }
449
450 /// Appends unique names to the discovery ignore list and saves the config.
451 ///
452 /// Uniqueness is determined by [`normalize_task_name`]. Returns how many
453 /// new entries were added.
454 pub fn add_ignore_names(&mut self, names: &[String]) -> Result<usize> {
455 let mut discovery = self.task_discovery.clone().unwrap_or_default();
456 let mut added = 0;
457
458 for name in names {
459 let trimmed = name.trim();
460 if trimmed.is_empty() {
461 continue;
462 }
463 let key = normalize_task_name(trimmed);
464 let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
465 if !exists {
466 discovery.ignore_names.push(trimmed.to_string());
467 added += 1;
468 }
469 }
470
471 self.task_discovery = Some(discovery);
472 self.save()?;
473 Ok(added)
474 }
475
476 /// Adds the executable's directory to the global PATH.
477 ///
478 /// Windows-shaped: checks the process PATH first, then edits the
479 /// machine-level registry value via `reg` (which needs admin rights;
480 /// the setup command downgrades a failure here to a warning).
481 ///
482 /// ```rust,no_run
483 /// # fn main() -> anyhow::Result<()> {
484 /// use kasl::libs::config::Config;
485 ///
486 /// Config::set_app_global()?;
487 /// # Ok(())
488 /// # }
489 /// ```
490 pub fn set_app_global() -> Result<()> {
491 let current_exe_path = env::current_exe()?;
492 let exe_dir = current_exe_path.parent().unwrap();
493
494 let mut paths: Vec<PathBuf> = env::split_paths(&env::var_os("PATH").unwrap()).collect();
495 let str_paths: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
496
497 if str_paths.contains(&exe_dir.to_str().unwrap()) {
498 return Ok(());
499 }
500
501 if paths.iter().any(|p| p.to_str() == Some(exe_dir.to_str().unwrap())) {
502 return Ok(());
503 }
504
505 paths.push(exe_dir.to_path_buf());
506
507 let new_path = env::join_paths(paths).unwrap_or_else(|_| panic!("{}", Message::FailedToJoinPaths.to_string()));
508
509 let path_key = r"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
510
511 let reg_query_output = Command::new("reg")
512 .arg("query")
513 .arg(path_key)
514 .arg("/v")
515 .arg("Path")
516 .output()
517 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegQuery.to_string()));
518
519 if !reg_query_output.status.success() {
520 let status = reg_query_output.status.to_string();
521 msg_error!(Message::PathRegistryQueryError { status: status.clone() });
522 return Err(anyhow::anyhow!("{}", Message::PathRegistryQueryError { status }));
523 }
524
525 let current_path = str::from_utf8(®_query_output.stdout)
526 .unwrap_or_else(|_| panic!("{}", Message::FailedToParseRegOutput.to_string()))
527 .split_whitespace()
528 .last()
529 .unwrap_or_else(|| panic!("{}", Message::FailedToGetPathFromReg.to_string()));
530
531 let reg_set_output = Command::new("reg")
532 .arg("add")
533 .arg(path_key)
534 .arg("/v")
535 .arg("Path")
536 .arg("/t")
537 .arg("REG_EXPAND_SZ") // Expandable string type for environment variables
538 .arg("/d")
539 .arg(format!("{};{}", current_path, new_path.to_string_lossy()))
540 .arg("/f") // Force overwrite without confirmation
541 .output()
542 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegSet.to_string()));
543
544 if !reg_set_output.status.success() {
545 let status = reg_set_output.status.to_string();
546 let stderr = String::from_utf8_lossy(®_set_output.stderr).to_string();
547 msg_error!(Message::PathRegistryUpdateError {
548 status: status.clone(),
549 stderr: stderr.clone()
550 });
551 return Err(anyhow::anyhow!("{}", Message::PathRegistryUpdateError { status, stderr }));
552 }
553
554 Ok(())
555 }
556}