kasl/libs/config.rs
1//! Configuration management system for the kasl application.
2//!
3//! Provides a comprehensive configuration management system that handles application
4//! settings, external API integrations, and activity monitoring parameters.
5//!
6//! ## Features
7//!
8//! - **Multi-Service Integration**: Manages configurations for Jira, GitLab, and custom APIs
9//! - **Activity Monitoring**: Configures behavior for work time tracking and pause detection
10//! - **Interactive Setup**: Provides guided configuration wizards for all modules
11//! - **Cross-Platform Persistence**: Handles configuration storage across Windows, macOS, and Linux
12//! - **System Integration**: Manages global PATH configuration for CLI availability
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! # fn main() -> anyhow::Result<()> {
18//! use kasl::libs::config::Config;
19//!
20//! let config = Config::read()?;
21//! let updated_config = Config::init()?;
22//! updated_config.save()?;
23//! # Ok(())
24//! # }
25//! ```
26
27use super::data_storage::DataStorage;
28use crate::api::gitlab::GitLabConfig;
29use crate::api::jira::JiraConfig;
30use crate::api::si::SiConfig;
31use crate::libs::messages::Message;
32use crate::libs::task::normalize_task_name;
33use crate::{msg_error, msg_info, msg_print, msg_success};
34use anyhow::Result;
35use dialoguer::{Confirm, Input, MultiSelect, theme::ColorfulTheme};
36use serde::{Deserialize, Serialize};
37use std::env;
38use std::fs;
39use std::path::PathBuf;
40use std::process::Command;
41use std::str;
42
43/// Configuration file name used for storing application settings.
44///
45/// This constant ensures consistency across the application when referencing
46/// the main configuration file. The file is stored in platform-specific
47/// application data directories.
48pub const CONFIG_FILE_NAME: &str = "config.json";
49
50/// Represents a configurable module in the application.
51///
52/// This structure is used during interactive configuration setup to display
53/// available modules and allow users to select which integrations they want
54/// to configure. Each module has a unique key for internal identification
55/// and a human-readable name for display purposes.
56#[derive(Debug, Clone)]
57pub struct ConfigModule {
58 /// Unique identifier for the module used in configuration routing
59 pub key: String,
60 /// Display name shown to users during interactive setup
61 pub name: String,
62}
63
64/// Activity monitor configuration settings.
65///
66/// Controls the behavior of the background activity monitoring system that tracks
67/// user presence, detects work patterns, and manages pause recording.
68#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
69pub struct MonitorConfig {
70 /// Minimum pause duration in minutes to be recorded in the database.
71 ///
72 /// Pauses shorter than this threshold are considered brief interruptions
73 /// (like answering a quick question) rather than actual breaks and are
74 /// not stored as separate pause records. This helps keep pause data
75 /// meaningful and reduces database noise.
76 pub min_pause_duration: u64,
77
78 /// Inactivity threshold in seconds before a pause is detected.
79 ///
80 /// When user input (keyboard, mouse) is not detected for this duration,
81 /// the monitor considers the user to be on a pause. This value should
82 /// be long enough to avoid false positives during normal work (like
83 /// reading or thinking) but short enough to capture actual breaks.
84 pub pause_threshold: u64,
85
86 /// Poll interval in milliseconds for checking activity status.
87 ///
88 /// This determines how frequently the monitor checks whether the user
89 /// has been inactive long enough to trigger pause detection. Lower
90 /// values provide more responsive detection but increase CPU usage.
91 /// Values between 500-1000ms provide good balance of responsiveness
92 /// and performance.
93 pub poll_interval: u64,
94
95 /// Activity duration threshold in seconds for workday start detection.
96 ///
97 /// Continuous activity must exceed this duration before the system
98 /// considers a workday to have truly started. This prevents brief
99 /// interactions (like checking time or messages) from incorrectly
100 /// starting work time tracking, especially during off-hours.
101 pub activity_threshold: u64,
102
103 /// Minimum work interval in minutes for interval merging.
104 ///
105 /// Work intervals shorter than this duration are merged with adjacent
106 /// intervals to create more meaningful work blocks. This reduces
107 /// fragmentation in work time reports caused by very brief pauses
108 /// and helps present cleaner time tracking data.
109 pub min_work_interval: u64,
110
111 /// Maximum gap in seconds between two consecutive pauses to merge them.
112 ///
113 /// When the activity monitor briefly registers a stray input between two
114 /// otherwise continuous inactivity periods, it splits a single break into
115 /// several adjacent pause records. Pauses separated by a gap no longer than
116 /// this value are treated as one continuous pause so that sub-threshold
117 /// segments are not dropped from calculations. The value should stay small
118 /// (a few tens of seconds) so that genuine short work periods between pauses
119 /// are preserved rather than swallowed into the break.
120 #[serde(default = "default_pause_merge_gap")]
121 pub pause_merge_gap: u64,
122}
123
124/// Default gap (in seconds) below which consecutive pauses are merged.
125///
126/// Used both by [`MonitorConfig::default`] and by serde when an existing
127/// configuration file predates the `pause_merge_gap` field.
128fn default_pause_merge_gap() -> u64 {
129 30
130}
131
132/// Productivity management configuration settings.
133///
134/// Controls the behavior of productivity monitoring, break recommendations,
135/// and report validation based on productivity thresholds.
136#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
137pub struct ProductivityConfig {
138 /// Minimum acceptable productivity percentage.
139 ///
140 /// Productivity below this threshold triggers warnings in reports
141 /// and prevents report submission until breaks are added to reach
142 /// the minimum level. Should be set between 70-85% for most users.
143 pub min_productivity_threshold: f64,
144
145 /// Expected workday duration in hours.
146 ///
147 /// Used for calculating break recommendations and determining when
148 /// enough of the workday has passed to suggest productivity improvements.
149 /// Typical values are 7.5-8.5 hours for standard workdays.
150 pub workday_hours: f64,
151
152 /// Fraction of workday that must pass before suggesting breaks.
153 ///
154 /// Productivity warnings and break suggestions are only shown after
155 /// this portion of the expected workday has elapsed. This prevents
156 /// premature suggestions during normal workday startup.
157 /// Typical value: 0.5 (50% of workday)
158 pub min_workday_fraction_before_suggest: f64,
159}
160
161/// Daily report export configuration.
162///
163/// Controls where generated report files are stored by default and how their
164/// file names are constructed when an explicit `--output` path is not provided.
165#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
166pub struct ReportConfig {
167 /// Directory where generated report files are saved by default.
168 ///
169 /// When set, report exports without an explicit `--output` path are written
170 /// into this directory (created automatically if missing). When unset, the
171 /// legacy behavior is used (a timestamped file in the current directory).
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub output_dir: Option<String>,
174
175 /// File name template (without extension) for generated reports.
176 ///
177 /// Supported placeholders:
178 /// - `{date}` — the report date in `YYYY-MM-DD` format
179 /// - `{seq}` — a per-day sequence suffix: empty for the first report of the
180 /// day, then `_2`, `_3`, … for subsequent reports on the same date
181 ///
182 /// The file extension is appended automatically based on the export format.
183 /// Defaults to `daily_report_{date}{seq}` when unset.
184 #[serde(skip_serializing_if = "Option::is_none")]
185 pub filename_template: Option<String>,
186
187 /// Language code for localizing report labels, month and weekday names.
188 ///
189 /// Supported built-in values: `ru` (default) and `en`. Unknown or unset
190 /// values fall back to `ru`, preserving the original report wording.
191 #[serde(skip_serializing_if = "Option::is_none")]
192 pub language: Option<String>,
193
194 /// Name of the design template controlling the report's visual style.
195 ///
196 /// Corresponds to a file `<data>/report_templates/<name>.json`. When unset
197 /// or missing on disk, the built-in `siserver` template is used. The
198 /// `siserver` template reproduces the original SiServer look.
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub template: Option<String>,
201}
202
203/// External server configuration for report submission.
204///
205/// This structure contains the connection parameters for external reporting
206/// systems that can receive work time reports and task summaries. It supports
207/// custom company APIs or third-party time tracking services that accept
208/// HTTP-based report submissions.
209///
210/// ## Security Considerations
211///
212/// - API URLs should use HTTPS in production environments
213/// - Auth tokens are stored in plain text in configuration files
214/// - Consider using environment variables for sensitive tokens in production
215#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
216pub struct ServerConfig {
217 /// Base URL of the external reporting API server.
218 ///
219 /// This should be the root URL of the API endpoint that accepts
220 /// report submissions. The actual report endpoints will be constructed
221 /// by appending specific paths to this base URL.
222 ///
223 /// Example: `https://api.company.com/timetracking`
224 pub api_url: String,
225
226 /// Authentication token for API access.
227 ///
228 /// This token is included in HTTP headers when submitting reports
229 /// to authenticate the client with the external server. The token
230 /// format and authentication scheme depend on the target API's
231 /// requirements (Bearer tokens, API keys, etc.).
232 pub auth_token: String,
233}
234
235/// Main configuration container for the entire application.
236///
237/// This structure serves as the root configuration object that encompasses
238/// all service integrations and system settings. Each field represents an
239/// optional module that can be configured independently, allowing users to
240/// enable only the integrations they need.
241///
242/// ## Optional Configuration Pattern
243///
244/// All service configurations are optional (`Option<T>`), which provides
245/// several benefits:
246/// - Users can configure only the services they use
247/// - Missing configurations don't break the application
248/// - New integrations can be added without breaking existing setups
249/// - Configuration files remain clean and focused
250///
251/// ## Serialization Behavior
252///
253/// The `skip_serializing_if = "Option::is_none"` attribute ensures that
254/// unconfigured services are omitted from the JSON output, keeping
255/// configuration files clean and readable.
256#[derive(Serialize, Deserialize, Clone, Debug)]
257pub struct Config {
258 /// SearchInform internal API configuration.
259 ///
260 /// When configured, enables integration with company-specific APIs
261 /// for advanced reporting and task management features.
262 #[serde(skip_serializing_if = "Option::is_none")]
263 pub si: Option<SiConfig>,
264
265 /// GitLab API integration configuration.
266 ///
267 /// Enables automatic discovery of commits and merge requests
268 /// for task creation and progress tracking.
269 #[serde(skip_serializing_if = "Option::is_none")]
270 pub gitlab: Option<GitLabConfig>,
271
272 /// Jira API integration configuration.
273 ///
274 /// Provides access to issue tracking for automatic task import
275 /// and work item synchronization.
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub jira: Option<JiraConfig>,
278
279 /// Activity monitoring configuration.
280 ///
281 /// Controls the behavior of the background process that tracks
282 /// user activity and manages work time detection.
283 #[serde(skip_serializing_if = "Option::is_none")]
284 pub monitor: Option<MonitorConfig>,
285
286 /// External reporting server configuration.
287 ///
288 /// Enables submission of reports to external time tracking
289 /// or project management systems.
290 #[serde(skip_serializing_if = "Option::is_none")]
291 pub server: Option<ServerConfig>,
292
293 /// Productivity management configuration.
294 ///
295 /// Controls productivity thresholds, break recommendations,
296 /// and report validation based on productivity metrics.
297 #[serde(skip_serializing_if = "Option::is_none")]
298 pub productivity: Option<ProductivityConfig>,
299
300 /// Daily report export configuration.
301 ///
302 /// Controls the default output directory and file name template used when
303 /// exporting reports without an explicit output path.
304 #[serde(skip_serializing_if = "Option::is_none")]
305 pub report: Option<ReportConfig>,
306
307 /// Task discovery settings for `kasl task --find`.
308 ///
309 /// Holds the ignore list used to filter out noisy commits and tasks.
310 /// When absent, built-in defaults are applied at runtime.
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub task_discovery: Option<TaskDiscoveryConfig>,
313
314 /// Background Jira inbox polling and toast notifications.
315 ///
316 /// Requires `jira` to be configured. When absent, inbox polling is disabled.
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub jira_inbox: Option<JiraInboxConfig>,
319}
320
321/// Settings for polling assigned open Jira issues into the local inbox.
322#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
323pub struct JiraInboxConfig {
324 /// Whether the watcher should poll Jira for open assigned issues.
325 #[serde(default = "default_true")]
326 pub enabled: bool,
327
328 /// Seconds between Jira inbox polls (default 300 = 5 minutes).
329 #[serde(default = "default_jira_inbox_poll_interval")]
330 pub poll_interval_secs: u64,
331
332 /// Whether to show a desktop toast when a new issue appears.
333 #[serde(default = "default_true")]
334 pub notify: bool,
335
336 /// Whether to show a toast when an existing issue visibly changes
337 /// (status, priority, score).
338 #[serde(default = "default_true")]
339 pub notify_changes: bool,
340
341 /// Whether to show a toast when an issue leaves the inbox
342 /// (closed or reassigned). Off by default.
343 #[serde(default)]
344 pub notify_gone: bool,
345
346 /// Extra Jira fields to fetch (custom fields such as Scoring).
347 #[serde(default)]
348 pub custom_fields: Vec<JiraCustomField>,
349
350 /// Field id used for ranking (DESC), typically Scoring (`customfield_…`).
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub sort_by_field: Option<String>,
353}
354
355/// A user-configured Jira custom field for inbox sync / display.
356#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
357pub struct JiraCustomField {
358 /// Jira field id, e.g. `customfield_12345`.
359 pub id: String,
360 /// Human-readable label, e.g. `Scoring`.
361 pub label: String,
362}
363
364fn default_true() -> bool {
365 true
366}
367
368fn default_jira_inbox_poll_interval() -> u64 {
369 300
370}
371
372impl Default for JiraInboxConfig {
373 fn default() -> Self {
374 Self {
375 enabled: true,
376 poll_interval_secs: default_jira_inbox_poll_interval(),
377 notify: true,
378 notify_changes: true,
379 notify_gone: false,
380 custom_fields: Vec::new(),
381 sort_by_field: None,
382 }
383 }
384}
385
386impl JiraInboxConfig {
387 /// Field ids to request from Jira search (custom fields only).
388 pub fn extra_field_ids(&self) -> Vec<String> {
389 let mut ids: Vec<String> = self.custom_fields.iter().map(|f| f.id.trim().to_string()).filter(|id| !id.is_empty()).collect();
390 if let Some(sort_id) = &self.sort_by_field {
391 let trimmed = sort_id.trim();
392 if !trimmed.is_empty() && !ids.iter().any(|id| id == trimmed) {
393 ids.push(trimmed.to_string());
394 }
395 }
396 ids
397 }
398}
399
400/// Settings for intelligent task discovery (`kasl task --find`).
401#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
402pub struct TaskDiscoveryConfig {
403 /// Names/prefixes of tasks and commits to exclude from discovery.
404 ///
405 /// Matching is case-insensitive after normalization; a pattern matches
406 /// when the candidate name equals it or starts with it.
407 #[serde(default = "default_ignore_names")]
408 pub ignore_names: Vec<String>,
409}
410
411/// Built-in ignore patterns moved from the former hardcoded noise filter.
412pub fn default_ignore_names() -> Vec<String> {
413 vec![
414 "Merge remote-tracking branch".to_string(),
415 "Merge branch ".to_string(),
416 "update webui".to_string(),
417 ]
418}
419
420impl Default for TaskDiscoveryConfig {
421 fn default() -> Self {
422 Self {
423 ignore_names: default_ignore_names(),
424 }
425 }
426}
427
428impl Default for MonitorConfig {
429 /// Provides sensible defaults for monitor configuration.
430 ///
431 /// These default values are carefully chosen based on typical work patterns
432 /// and provide a good balance between accurate detection and minimal false
433 /// positives. Users can adjust these values through the configuration system
434 /// to match their specific work environment and preferences.
435 ///
436 /// ## Default Values Rationale
437 ///
438 /// - **20 minutes minimum pause**: Captures meaningful breaks while ignoring brief interruptions
439 /// - **60 seconds inactivity threshold**: Allows for reading/thinking time while detecting real pauses
440 /// - **500ms polling interval**: Provides responsive detection with reasonable resource usage
441 /// - **30 seconds activity threshold**: Prevents false workday starts from brief interactions
442 /// - **10 minutes minimum work interval**: Reduces fragmentation in work reports
443 ///
444 /// Default values:
445 /// - 20 minutes minimum pause duration
446 /// - 60 seconds inactivity threshold
447 /// - 500ms polling interval
448 /// - 30 seconds activity threshold
449 /// - 10 minutes minimum work interval
450 fn default() -> Self {
451 MonitorConfig {
452 min_pause_duration: 20,
453 pause_threshold: 60,
454 poll_interval: 500,
455 activity_threshold: 30,
456 min_work_interval: 10,
457 pause_merge_gap: default_pause_merge_gap(),
458 }
459 }
460}
461
462impl Default for ProductivityConfig {
463 /// Provides sensible defaults for productivity configuration.
464 ///
465 /// These defaults are based on common productivity management practices
466 /// and provide a good starting point for most users. Values can be
467 /// adjusted through configuration to match individual work styles.
468 ///
469 /// ## Default Values Rationale
470 ///
471 /// - **75% minimum productivity**: Reasonable threshold allowing for breaks
472 /// - **8 hours workday**: Standard full-time work expectation
473 /// - **50% workday fraction**: Wait until mid-day before warning on productivity
474 fn default() -> Self {
475 ProductivityConfig {
476 min_productivity_threshold: 75.0,
477 workday_hours: 8.0,
478 min_workday_fraction_before_suggest: 0.5,
479 }
480 }
481}
482
483impl Default for Config {
484 /// Creates a default configuration with all modules disabled.
485 ///
486 /// This provides a clean starting point for new installations where
487 /// users can selectively enable and configure only the services they need.
488 /// All optional configurations are set to `None`, requiring explicit
489 /// setup through the interactive configuration system or manual editing.
490 fn default() -> Self {
491 Config {
492 si: None,
493 gitlab: None,
494 jira: None,
495 monitor: None,
496 server: None,
497 productivity: None,
498 report: None,
499 task_discovery: None,
500 jira_inbox: None,
501 }
502 }
503}
504
505impl Config {
506 /// Reads configuration from the filesystem.
507 ///
508 /// This method attempts to load the configuration file from the platform-specific
509 /// application data directory. If no configuration file exists, it returns a
510 /// default configuration with all modules disabled, allowing the application
511 /// to function with minimal setup.
512 ///
513 /// ## File Location
514 ///
515 /// The configuration file location varies by platform:
516 /// - **Windows**: `%LOCALAPPDATA%\lacodda\kasl\config.json`
517 /// - **macOS**: `~/Library/Application Support/lacodda/kasl/config.json`
518 /// - **Linux**: `~/.local/share/lacodda/kasl/config.json`
519 ///
520 /// ## Error Handling
521 ///
522 /// - **Missing file**: Returns default configuration (not an error)
523 /// - **Corrupted file**: Returns parsing error
524 /// - **Permission issues**: Returns filesystem error
525 ///
526 /// # Returns
527 ///
528 /// Returns the loaded configuration or a default configuration if no file exists.
529 ///
530 /// # Errors
531 ///
532 /// Returns an error if the configuration file exists but cannot be read or parsed.
533 ///
534 /// # Examples
535 ///
536 /// ```rust,no_run
537 /// # fn main() -> anyhow::Result<()> {
538 /// use kasl::libs::config::Config;
539 ///
540 /// // Load configuration, falling back to defaults if no file exists
541 /// let config = Config::read()?;
542 ///
543 /// // Check if Jira is configured
544 /// if config.jira.is_some() {
545 /// println!("Jira integration is configured");
546 /// }
547 /// # Ok(())
548 /// # }
549 /// ```
550 pub fn read() -> Result<Config> {
551 // Resolve the configuration file path using the data storage system
552 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
553
554 // If no configuration file exists, return default configuration
555 // This allows the application to run with minimal setup
556 if !config_file_path.exists() {
557 return Ok(Config::default());
558 }
559
560 // Read and parse the configuration file
561 let config_str = fs::read_to_string(config_file_path)?;
562 let config: Config = serde_json::from_str(&config_str)?;
563 Ok(config)
564 }
565
566 /// Saves the current configuration to the filesystem.
567 ///
568 /// This method serializes the configuration to JSON format and writes it
569 /// to the platform-specific application data directory. The JSON is
570 /// formatted with proper indentation for human readability and manual editing.
571 ///
572 /// ## File Operations
573 ///
574 /// - Creates the application data directory if it doesn't exist
575 /// - Overwrites any existing configuration file
576 /// - Uses pretty-printing for readable JSON output
577 /// - Sets appropriate file permissions for user-only access
578 ///
579 /// # Returns
580 ///
581 /// Returns `Ok(())` on successful save, or an error if the file cannot be written.
582 ///
583 /// # Errors
584 ///
585 /// Returns an error if:
586 /// - The application data directory cannot be created
587 /// - The configuration file cannot be written due to permission issues
588 /// - JSON serialization fails (should not happen with valid configurations)
589 ///
590 /// # Examples
591 ///
592 /// ```rust,no_run
593 /// # fn main() -> anyhow::Result<()> {
594 /// use kasl::libs::config::{Config, MonitorConfig};
595 ///
596 /// let mut config = Config::read()?;
597 /// config.monitor = Some(MonitorConfig::default());
598 /// config.save()?;
599 /// # Ok(())
600 /// # }
601 /// ```
602 pub fn save(&self) -> Result<()> {
603 // Resolve the configuration file path and ensure directory exists
604 let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
605 // Serialize to JSON string first
606 let json_content = serde_json::to_string_pretty(&self)?;
607 // Write the JSON string to file and ensure it's flushed to disk
608 fs::write(config_file_path, json_content)?;
609 Ok(())
610 }
611
612 /// Returns the ignore list for task discovery.
613 ///
614 /// When `task_discovery` is not configured, returns the built-in defaults.
615 pub fn effective_ignore_names(&self) -> Vec<String> {
616 self.task_discovery
617 .as_ref()
618 .map(|c| c.ignore_names.clone())
619 .unwrap_or_else(default_ignore_names)
620 }
621
622 /// Appends unique names to the discovery ignore list and saves the config.
623 ///
624 /// Uniqueness is determined by [`normalize_task_name`]. Returns how many
625 /// new entries were added.
626 pub fn add_ignore_names(&mut self, names: &[String]) -> Result<usize> {
627 let mut discovery = self.task_discovery.clone().unwrap_or_default();
628 let mut added = 0;
629
630 for name in names {
631 let trimmed = name.trim();
632 if trimmed.is_empty() {
633 continue;
634 }
635 let key = normalize_task_name(trimmed);
636 let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
637 if !exists {
638 discovery.ignore_names.push(trimmed.to_string());
639 added += 1;
640 }
641 }
642
643 self.task_discovery = Some(discovery);
644 self.save()?;
645 Ok(added)
646 }
647
648 /// Runs an interactive configuration setup wizard.
649 ///
650 /// This method provides a comprehensive guided setup experience that allows
651 /// users to configure multiple application modules through an interactive
652 /// command-line interface. The wizard presents available modules, collects
653 /// configuration parameters, and validates inputs before saving.
654 ///
655 /// ## Setup Process
656 ///
657 /// 1. **Load Current Config**: Starts with existing configuration as defaults
658 /// 2. **Module Selection**: Presents a multi-select list of available integrations
659 /// 3. **Parameter Collection**: For each selected module, prompts for required settings
660 /// 4. **Validation**: Performs basic validation on input parameters
661 /// 5. **Configuration Return**: Returns the updated configuration for saving
662 ///
663 /// ## Available Modules
664 ///
665 /// - **SI (SearchInform)**: Company-specific API integration
666 /// - **GitLab**: Source control integration for commit tracking
667 /// - **Jira**: Issue tracking integration for task management
668 /// - **Monitor**: Activity monitoring and pause detection settings
669 /// - **Server**: External reporting API configuration
670 ///
671 /// ## User Experience
672 ///
673 /// - Uses colored prompts for better visual feedback
674 /// - Pre-fills existing values as defaults to simplify updates
675 /// - Provides helpful descriptions for each configuration parameter
676 /// - Allows partial configuration (users can skip unwanted modules)
677 ///
678 /// # Returns
679 ///
680 /// Returns a fully configured `Config` instance ready for saving.
681 ///
682 /// # Errors
683 ///
684 /// Returns an error if:
685 /// - The existing configuration cannot be loaded
686 /// - User input cannot be collected due to terminal issues
687 /// - A module's configuration setup fails
688 ///
689 /// # Examples
690 ///
691 /// ```rust,no_run
692 /// # fn main() -> anyhow::Result<()> {
693 /// use kasl::libs::config::Config;
694 ///
695 /// // Run interactive setup and save the result
696 /// let config = Config::init()?;
697 /// config.save()?;
698 /// # Ok(())
699 /// # }
700 /// ```
701 pub fn init() -> Result<Self> {
702 // The whole wizard is prompts: every module below asks for values, and
703 // several read secrets. Without a terminal there is nobody to answer.
704 crate::libs::prompt::ensure_interactive("`kasl setup` is an interactive wizard and needs a terminal")?;
705
706 // Load existing configuration to use as defaults for the setup wizard
707 let mut config = Self::read().unwrap_or_default();
708
709 // Define available configuration modules with their metadata
710 let node_descriptions = [
711 SiConfig::module(),
712 GitLabConfig::module(),
713 JiraConfig::module(),
714 ConfigModule {
715 key: "monitor".to_string(),
716 name: "Monitor".to_string(),
717 },
718 ConfigModule {
719 key: "server".to_string(),
720 name: "Server".to_string(),
721 },
722 ConfigModule {
723 key: "productivity".to_string(),
724 name: "Productivity".to_string(),
725 },
726 ConfigModule {
727 key: "report".to_string(),
728 name: "Report".to_string(),
729 },
730 ConfigModule {
731 key: "task_discovery".to_string(),
732 name: "Task discovery".to_string(),
733 },
734 ConfigModule {
735 key: "jira_inbox".to_string(),
736 name: "Jira inbox".to_string(),
737 },
738 ];
739
740 // Present multi-select interface for module selection
741 let selected_nodes = MultiSelect::with_theme(&ColorfulTheme::default())
742 .with_prompt(Message::PromptSelectModules.to_string())
743 .items(node_descriptions.iter().map(|module| &module.name).collect::<Vec<_>>())
744 .interact()?;
745
746 // Configure each selected module through its specific setup process
747 for &selection in &selected_nodes {
748 match node_descriptions[selection].key.as_str() {
749 // External API integrations delegate to their own setup methods
750 "si" => config.si = Some(SiConfig::init(&config.si)?),
751 "gitlab" => config.gitlab = Some(GitLabConfig::init(&config.gitlab)?),
752 "jira" => config.jira = Some(JiraConfig::init(&config.jira)?),
753
754 // Monitor configuration uses inline setup for timing parameters
755 "monitor" => {
756 let default = config.monitor.clone().unwrap_or_default();
757 msg_print!(Message::ConfigModuleMonitor);
758 config.monitor = Some(MonitorConfig {
759 // Minimum duration for recording pauses (reduces noise)
760 min_pause_duration: Input::with_theme(&ColorfulTheme::default())
761 .with_prompt(Message::PromptMinPauseDuration.to_string())
762 .default(default.min_pause_duration)
763 .interact_text()?,
764
765 // Inactivity threshold before pause detection begins
766 pause_threshold: Input::with_theme(&ColorfulTheme::default())
767 .with_prompt(Message::PromptPauseThreshold.to_string())
768 .default(default.pause_threshold)
769 .interact_text()?,
770
771 // Frequency of activity status checks
772 poll_interval: Input::with_theme(&ColorfulTheme::default())
773 .with_prompt(Message::PromptPollInterval.to_string())
774 .default(default.poll_interval)
775 .interact_text()?,
776
777 // Continuous activity required to start workday tracking
778 activity_threshold: Input::with_theme(&ColorfulTheme::default())
779 .with_prompt(Message::PromptActivityThreshold.to_string())
780 .default(default.activity_threshold)
781 .interact_text()?,
782
783 // Minimum interval duration for merging work blocks
784 min_work_interval: Input::with_theme(&ColorfulTheme::default())
785 .with_prompt(Message::PromptMinWorkInterval.to_string())
786 .default(default.min_work_interval)
787 .interact_text()?,
788
789 // Preserve the pause-merge gap (edited manually in config.json)
790 pause_merge_gap: default.pause_merge_gap,
791 });
792 }
793
794 // Server configuration for external report submission
795 "server" => {
796 let default = config.server.clone().unwrap_or(ServerConfig {
797 api_url: "".to_string(),
798 auth_token: "".to_string(),
799 });
800 msg_print!(Message::ConfigModuleServer);
801 config.server = Some(ServerConfig {
802 // Base URL for the external reporting API
803 api_url: Input::with_theme(&ColorfulTheme::default())
804 .with_prompt(Message::PromptServerApiUrl.to_string())
805 .default(default.api_url)
806 .interact_text()?,
807
808 // Authentication token for API access
809 auth_token: Input::with_theme(&ColorfulTheme::default())
810 .with_prompt(Message::PromptServerAuthToken.to_string())
811 .default(default.auth_token)
812 .interact_text()?,
813 });
814 }
815
816 // Productivity configuration for break recommendations and reporting
817 "productivity" => {
818 let default = config.productivity.clone().unwrap_or_default();
819 msg_print!(Message::ConfigModuleProductivity);
820 config.productivity = Some(ProductivityConfig {
821 // Minimum productivity percentage threshold
822 min_productivity_threshold: Input::with_theme(&ColorfulTheme::default())
823 .with_prompt(Message::PromptMinProductivityThreshold.to_string())
824 .default(default.min_productivity_threshold)
825 .interact_text()?,
826
827 // Expected workday duration
828 workday_hours: Input::with_theme(&ColorfulTheme::default())
829 .with_prompt(Message::PromptWorkdayHours.to_string())
830 .default(default.workday_hours)
831 .interact_text()?,
832
833 // Minimum workday fraction before the productivity warning
834 min_workday_fraction_before_suggest: Input::with_theme(&ColorfulTheme::default())
835 .with_prompt(Message::PromptMinWorkdayFraction.to_string())
836 .default(default.min_workday_fraction_before_suggest)
837 .interact_text()?,
838 });
839 }
840
841 // Report export configuration: default output directory and file name template
842 "report" => {
843 let default = config.report.clone().unwrap_or(ReportConfig {
844 output_dir: None,
845 filename_template: None,
846 language: None,
847 template: None,
848 });
849 let output_dir: String = Input::with_theme(&ColorfulTheme::default())
850 .with_prompt("Reports output directory")
851 .default(default.output_dir.unwrap_or_default())
852 .allow_empty(true)
853 .interact_text()?;
854 let filename_template: String = Input::with_theme(&ColorfulTheme::default())
855 .with_prompt("Report file name template (placeholders: {date}, {seq})")
856 .default(default.filename_template.unwrap_or_else(|| "daily_report_{date}{seq}".to_string()))
857 .allow_empty(true)
858 .interact_text()?;
859 let language: String = Input::with_theme(&ColorfulTheme::default())
860 .with_prompt("Report language (ru, en)")
861 .default(default.language.unwrap_or_else(|| "ru".to_string()))
862 .allow_empty(true)
863 .interact_text()?;
864 let template: String = Input::with_theme(&ColorfulTheme::default())
865 .with_prompt("Report design template name")
866 .default(default.template.unwrap_or_else(|| "siserver".to_string()))
867 .allow_empty(true)
868 .interact_text()?;
869 config.report = Some(ReportConfig {
870 output_dir: if output_dir.trim().is_empty() { None } else { Some(output_dir) },
871 filename_template: if filename_template.trim().is_empty() { None } else { Some(filename_template) },
872 language: if language.trim().is_empty() { None } else { Some(language) },
873 template: if template.trim().is_empty() { None } else { Some(template) },
874 });
875 }
876
877 "task_discovery" => {
878 config.task_discovery = Some(configure_task_discovery(config.task_discovery.clone().unwrap_or_default())?);
879 }
880
881 "jira_inbox" => {
882 config.jira_inbox = Some(configure_jira_inbox(config.jira_inbox.clone().unwrap_or_default())?);
883 }
884
885 _ => {} // Unknown module keys are safely ignored
886 }
887 }
888
889 Ok(config)
890 }
891
892 /// Adds the application to the global system PATH.
893 ///
894 /// This method ensures that the kasl executable can be run from any directory
895 /// by adding its location to the system's PATH environment variable. This is
896 /// particularly useful on Windows where applications are not automatically
897 /// available globally after installation.
898 ///
899 /// ## Platform Behavior
900 ///
901 /// - **Windows**: Modifies the system registry to update the global PATH
902 /// - **Unix-like**: Currently not implemented (uses shell integration instead)
903 ///
904 /// ## Windows Implementation Details
905 ///
906 /// The Windows implementation:
907 /// 1. Determines the current executable's directory
908 /// 2. Checks if the directory is already in the PATH
909 /// 3. Updates the registry to add the directory if needed
910 /// 4. Requires administrative privileges for system-wide changes
911 ///
912 /// ## Security Considerations
913 ///
914 /// - Modifying the system PATH requires elevated privileges on Windows
915 /// - Changes affect all users on the system
916 /// - The operation is reversible by manually editing the PATH
917 ///
918 /// # Returns
919 ///
920 /// Returns `Ok(())` if the PATH was successfully updated or was already correct.
921 ///
922 /// # Errors
923 ///
924 /// Returns an error if:
925 /// - The current executable path cannot be determined
926 /// - Registry operations fail due to insufficient privileges
927 /// - System commands fail to execute properly
928 ///
929 /// # Examples
930 ///
931 /// ```rust,no_run
932 /// # fn main() -> anyhow::Result<()> {
933 /// use kasl::libs::config::Config;
934 ///
935 /// // Ensure kasl is available globally
936 /// Config::set_app_global()?;
937 /// # Ok(())
938 /// # }
939 /// ```
940 pub fn set_app_global() -> Result<()> {
941 // Get the directory containing the current executable
942 let current_exe_path = env::current_exe()?;
943 let exe_dir = current_exe_path.parent().unwrap();
944
945 // Parse the current PATH environment variable
946 let mut paths: Vec<PathBuf> = env::split_paths(&env::var_os("PATH").unwrap()).collect();
947 let str_paths: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
948
949 // Check if the executable directory is already in PATH
950 if str_paths.contains(&exe_dir.to_str().unwrap()) {
951 return Ok(()); // Already configured, nothing to do
952 }
953
954 // Double-check using a different method to avoid duplicates
955 if paths.iter().any(|p| p.to_str() == Some(exe_dir.to_str().unwrap())) {
956 return Ok(()); // Already present, avoid duplication
957 }
958
959 // Add the executable directory to the PATH list
960 paths.push(exe_dir.to_path_buf());
961
962 // Reconstruct the PATH environment variable
963 let new_path = env::join_paths(paths).unwrap_or_else(|_| panic!("{}", Message::FailedToJoinPaths.to_string()));
964
965 // Define the Windows registry key for system environment variables
966 let path_key = r"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
967
968 // Query the current PATH value from the Windows registry
969 let reg_query_output = Command::new("reg")
970 .arg("query")
971 .arg(path_key)
972 .arg("/v")
973 .arg("Path")
974 .output()
975 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegQuery.to_string()));
976
977 // Handle registry query failures gracefully
978 if !reg_query_output.status.success() {
979 let status = reg_query_output.status.to_string();
980 msg_error!(Message::PathRegistryQueryError { status: status.clone() });
981 return Err(anyhow::anyhow!("{}", Message::PathRegistryQueryError { status }));
982 }
983
984 // Parse the current PATH value from registry output
985 let current_path = str::from_utf8(®_query_output.stdout)
986 .unwrap_or_else(|_| panic!("{}", Message::FailedToParseRegOutput.to_string()))
987 .split_whitespace()
988 .last()
989 .unwrap_or_else(|| panic!("{}", Message::FailedToGetPathFromReg.to_string()));
990
991 // Update the registry with the new PATH value
992 let reg_set_output = Command::new("reg")
993 .arg("add")
994 .arg(path_key)
995 .arg("/v")
996 .arg("Path")
997 .arg("/t")
998 .arg("REG_EXPAND_SZ") // Expandable string type for environment variables
999 .arg("/d")
1000 .arg(format!("{};{}", current_path, new_path.to_string_lossy()))
1001 .arg("/f") // Force overwrite without confirmation
1002 .output()
1003 .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegSet.to_string()));
1004
1005 // Check if the registry update was successful
1006 if !reg_set_output.status.success() {
1007 let status = reg_set_output.status.to_string();
1008 let stderr = String::from_utf8_lossy(®_set_output.stderr).to_string();
1009 msg_error!(Message::PathRegistryUpdateError {
1010 status: status.clone(),
1011 stderr: stderr.clone()
1012 });
1013 return Err(anyhow::anyhow!("{}", Message::PathRegistryUpdateError { status, stderr }));
1014 }
1015
1016 Ok(())
1017 }
1018}
1019
1020/// Interactive editor for the task discovery ignore list.
1021fn configure_task_discovery(mut discovery: TaskDiscoveryConfig) -> Result<TaskDiscoveryConfig> {
1022 msg_print!(Message::ConfigModuleTaskDiscovery, true);
1023
1024 if discovery.ignore_names.is_empty() {
1025 msg_info!(Message::TaskDiscoveryIgnoreListEmpty);
1026 } else {
1027 msg_print!(Message::TaskDiscoveryIgnoreListHeader, true);
1028 for (idx, name) in discovery.ignore_names.iter().enumerate() {
1029 println!(" {}. {}", idx + 1, name);
1030 }
1031 println!();
1032
1033 let remove = MultiSelect::with_theme(&ColorfulTheme::default())
1034 .with_prompt(Message::PromptSelectIgnoreNamesToRemove.to_string())
1035 .items(&discovery.ignore_names)
1036 .interact()?;
1037
1038 if !remove.is_empty() {
1039 let mut keep = Vec::new();
1040 for (idx, name) in discovery.ignore_names.iter().enumerate() {
1041 if !remove.contains(&idx) {
1042 keep.push(name.clone());
1043 }
1044 }
1045 discovery.ignore_names = keep;
1046 }
1047 }
1048
1049 loop {
1050 let name: String = Input::with_theme(&ColorfulTheme::default())
1051 .with_prompt(Message::PromptAddIgnoreName.to_string())
1052 .allow_empty(true)
1053 .interact_text()?;
1054
1055 let trimmed = name.trim();
1056 if trimmed.is_empty() {
1057 break;
1058 }
1059
1060 let key = normalize_task_name(trimmed);
1061 let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
1062 if exists {
1063 msg_info!(Message::TaskDiscoveryIgnoreNameExists(trimmed.to_string()));
1064 } else {
1065 discovery.ignore_names.push(trimmed.to_string());
1066 msg_success!(Message::TaskDiscoveryIgnoreNameAdded(trimmed.to_string()));
1067 }
1068 }
1069
1070 Ok(discovery)
1071}
1072
1073/// Interactive wizard for Jira inbox polling settings.
1074fn configure_jira_inbox(default: JiraInboxConfig) -> Result<JiraInboxConfig> {
1075 msg_print!(Message::ConfigModuleJiraInbox, true);
1076
1077 let enabled = Confirm::with_theme(&ColorfulTheme::default())
1078 .with_prompt(Message::PromptJiraInboxEnabled.to_string())
1079 .default(default.enabled)
1080 .interact()?;
1081
1082 let poll_interval_secs: u64 = Input::with_theme(&ColorfulTheme::default())
1083 .with_prompt(Message::PromptJiraInboxPollInterval.to_string())
1084 .default(default.poll_interval_secs)
1085 .interact_text()?;
1086
1087 let notify = Confirm::with_theme(&ColorfulTheme::default())
1088 .with_prompt(Message::PromptJiraInboxNotify.to_string())
1089 .default(default.notify)
1090 .interact()?;
1091
1092 let default_sort_id = default.sort_by_field.clone().unwrap_or_default();
1093 let sort_field_id: String = Input::with_theme(&ColorfulTheme::default())
1094 .with_prompt(Message::PromptJiraInboxSortFieldId.to_string())
1095 .with_initial_text(&default_sort_id)
1096 .allow_empty(true)
1097 .interact_text()?;
1098
1099 let mut custom_fields = Vec::new();
1100 let mut sort_by_field = None;
1101
1102 let sort_trimmed = sort_field_id.trim();
1103 if !sort_trimmed.is_empty() {
1104 let default_label = default
1105 .custom_fields
1106 .iter()
1107 .find(|f| f.id == sort_trimmed)
1108 .map(|f| f.label.clone())
1109 .unwrap_or_else(|| "Scoring".to_string());
1110
1111 let sort_label: String = Input::with_theme(&ColorfulTheme::default())
1112 .with_prompt(Message::PromptJiraInboxSortFieldLabel.to_string())
1113 .default(default_label)
1114 .interact_text()?;
1115
1116 let label = {
1117 let t = sort_label.trim();
1118 if t.is_empty() { "Scoring".to_string() } else { t.to_string() }
1119 };
1120 custom_fields.push(JiraCustomField {
1121 id: sort_trimmed.to_string(),
1122 label,
1123 });
1124 sort_by_field = Some(sort_trimmed.to_string());
1125 }
1126
1127 loop {
1128 let extra_id: String = Input::with_theme(&ColorfulTheme::default())
1129 .with_prompt(Message::PromptJiraInboxExtraFieldId.to_string())
1130 .allow_empty(true)
1131 .interact_text()?;
1132 let trimmed = extra_id.trim();
1133 if trimmed.is_empty() {
1134 break;
1135 }
1136 if custom_fields.iter().any(|f| f.id == trimmed) {
1137 continue;
1138 }
1139 let extra_label: String = Input::with_theme(&ColorfulTheme::default())
1140 .with_prompt(Message::PromptJiraInboxExtraFieldLabel.to_string())
1141 .default(trimmed.to_string())
1142 .interact_text()?;
1143 custom_fields.push(JiraCustomField {
1144 id: trimmed.to_string(),
1145 label: {
1146 let t = extra_label.trim();
1147 if t.is_empty() { trimmed.to_string() } else { t.to_string() }
1148 },
1149 });
1150 }
1151
1152 Ok(JiraInboxConfig {
1153 enabled,
1154 poll_interval_secs: poll_interval_secs.max(30),
1155 notify,
1156 // Change/gone toasts keep their previous (or default) values; the
1157 // wizard stays short and these are tuned via the config file.
1158 notify_changes: default.notify_changes,
1159 notify_gone: default.notify_gone,
1160 custom_fields,
1161 sort_by_field,
1162 })
1163}