Skip to main content

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