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//! # 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    /// Extra Jira fields to fetch (custom fields such as Scoring).
337    #[serde(default)]
338    pub custom_fields: Vec<JiraCustomField>,
339
340    /// Field id used for ranking (DESC), typically Scoring (`customfield_…`).
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub sort_by_field: Option<String>,
343}
344
345/// A user-configured Jira custom field for inbox sync / display.
346#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
347pub struct JiraCustomField {
348    /// Jira field id, e.g. `customfield_12345`.
349    pub id: String,
350    /// Human-readable label, e.g. `Scoring`.
351    pub label: String,
352}
353
354fn default_true() -> bool {
355    true
356}
357
358fn default_jira_inbox_poll_interval() -> u64 {
359    300
360}
361
362impl Default for JiraInboxConfig {
363    fn default() -> Self {
364        Self {
365            enabled: true,
366            poll_interval_secs: default_jira_inbox_poll_interval(),
367            notify: true,
368            custom_fields: Vec::new(),
369            sort_by_field: None,
370        }
371    }
372}
373
374impl JiraInboxConfig {
375    /// Field ids to request from Jira search (custom fields only).
376    pub fn extra_field_ids(&self) -> Vec<String> {
377        let mut ids: Vec<String> = self.custom_fields.iter().map(|f| f.id.trim().to_string()).filter(|id| !id.is_empty()).collect();
378        if let Some(sort_id) = &self.sort_by_field {
379            let trimmed = sort_id.trim();
380            if !trimmed.is_empty() && !ids.iter().any(|id| id == trimmed) {
381                ids.push(trimmed.to_string());
382            }
383        }
384        ids
385    }
386}
387
388/// Settings for intelligent task discovery (`kasl task --find`).
389#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
390pub struct TaskDiscoveryConfig {
391    /// Names/prefixes of tasks and commits to exclude from discovery.
392    ///
393    /// Matching is case-insensitive after normalization; a pattern matches
394    /// when the candidate name equals it or starts with it.
395    #[serde(default = "default_ignore_names")]
396    pub ignore_names: Vec<String>,
397}
398
399/// Built-in ignore patterns moved from the former hardcoded noise filter.
400pub fn default_ignore_names() -> Vec<String> {
401    vec![
402        "Merge remote-tracking branch".to_string(),
403        "Merge branch ".to_string(),
404        "update webui".to_string(),
405    ]
406}
407
408impl Default for TaskDiscoveryConfig {
409    fn default() -> Self {
410        Self {
411            ignore_names: default_ignore_names(),
412        }
413    }
414}
415
416impl Default for MonitorConfig {
417    /// Provides sensible defaults for monitor configuration.
418    ///
419    /// These default values are carefully chosen based on typical work patterns
420    /// and provide a good balance between accurate detection and minimal false
421    /// positives. Users can adjust these values through the configuration system
422    /// to match their specific work environment and preferences.
423    ///
424    /// ## Default Values Rationale
425    ///
426    /// - **20 minutes minimum pause**: Captures meaningful breaks while ignoring brief interruptions
427    /// - **60 seconds inactivity threshold**: Allows for reading/thinking time while detecting real pauses
428    /// - **500ms polling interval**: Provides responsive detection with reasonable resource usage
429    /// - **30 seconds activity threshold**: Prevents false workday starts from brief interactions
430    /// - **10 minutes minimum work interval**: Reduces fragmentation in work reports
431    ///
432    /// Default values:
433    /// - 20 minutes minimum pause duration
434    /// - 60 seconds inactivity threshold
435    /// - 500ms polling interval
436    /// - 30 seconds activity threshold
437    /// - 10 minutes minimum work interval
438    fn default() -> Self {
439        MonitorConfig {
440            min_pause_duration: 20,
441            pause_threshold: 60,
442            poll_interval: 500,
443            activity_threshold: 30,
444            min_work_interval: 10,
445            pause_merge_gap: default_pause_merge_gap(),
446        }
447    }
448}
449
450impl Default for ProductivityConfig {
451    /// Provides sensible defaults for productivity configuration.
452    ///
453    /// These defaults are based on common productivity management practices
454    /// and provide a good starting point for most users. Values can be
455    /// adjusted through configuration to match individual work styles.
456    ///
457    /// ## Default Values Rationale
458    ///
459    /// - **75% minimum productivity**: Reasonable threshold allowing for breaks
460    /// - **8 hours workday**: Standard full-time work expectation
461    /// - **50% workday fraction**: Wait until mid-day before warning on productivity
462    fn default() -> Self {
463        ProductivityConfig {
464            min_productivity_threshold: 75.0,
465            workday_hours: 8.0,
466            min_workday_fraction_before_suggest: 0.5,
467        }
468    }
469}
470
471impl Default for Config {
472    /// Creates a default configuration with all modules disabled.
473    ///
474    /// This provides a clean starting point for new installations where
475    /// users can selectively enable and configure only the services they need.
476    /// All optional configurations are set to `None`, requiring explicit
477    /// setup through the interactive configuration system or manual editing.
478    fn default() -> Self {
479        Config {
480            si: None,
481            gitlab: None,
482            jira: None,
483            monitor: None,
484            server: None,
485            productivity: None,
486            report: None,
487            task_discovery: None,
488            jira_inbox: None,
489        }
490    }
491}
492
493impl Config {
494    /// Reads configuration from the filesystem.
495    ///
496    /// This method attempts to load the configuration file from the platform-specific
497    /// application data directory. If no configuration file exists, it returns a
498    /// default configuration with all modules disabled, allowing the application
499    /// to function with minimal setup.
500    ///
501    /// ## File Location
502    ///
503    /// The configuration file location varies by platform:
504    /// - **Windows**: `%LOCALAPPDATA%\lacodda\kasl\config.json`
505    /// - **macOS**: `~/Library/Application Support/lacodda/kasl/config.json`
506    /// - **Linux**: `~/.local/share/lacodda/kasl/config.json`
507    ///
508    /// ## Error Handling
509    ///
510    /// - **Missing file**: Returns default configuration (not an error)
511    /// - **Corrupted file**: Returns parsing error
512    /// - **Permission issues**: Returns filesystem error
513    ///
514    /// # Returns
515    ///
516    /// Returns the loaded configuration or a default configuration if no file exists.
517    ///
518    /// # Errors
519    ///
520    /// Returns an error if the configuration file exists but cannot be read or parsed.
521    ///
522    /// # Examples
523    ///
524    /// ```rust,no_run
525    /// # fn main() -> anyhow::Result<()> {
526    /// use kasl::libs::config::Config;
527    ///
528    /// // Load configuration, falling back to defaults if no file exists
529    /// let config = Config::read()?;
530    ///
531    /// // Check if Jira is configured
532    /// if config.jira.is_some() {
533    ///     println!("Jira integration is configured");
534    /// }
535    /// # Ok(())
536    /// # }
537    /// ```
538    pub fn read() -> Result<Config> {
539        // Resolve the configuration file path using the data storage system
540        let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
541
542        // If no configuration file exists, return default configuration
543        // This allows the application to run with minimal setup
544        if !config_file_path.exists() {
545            return Ok(Config::default());
546        }
547
548        // Read and parse the configuration file
549        let config_str = fs::read_to_string(config_file_path)?;
550        let config: Config = serde_json::from_str(&config_str)?;
551        Ok(config)
552    }
553
554    /// Saves the current configuration to the filesystem.
555    ///
556    /// This method serializes the configuration to JSON format and writes it
557    /// to the platform-specific application data directory. The JSON is
558    /// formatted with proper indentation for human readability and manual editing.
559    ///
560    /// ## File Operations
561    ///
562    /// - Creates the application data directory if it doesn't exist
563    /// - Overwrites any existing configuration file
564    /// - Uses pretty-printing for readable JSON output
565    /// - Sets appropriate file permissions for user-only access
566    ///
567    /// # Returns
568    ///
569    /// Returns `Ok(())` on successful save, or an error if the file cannot be written.
570    ///
571    /// # Errors
572    ///
573    /// Returns an error if:
574    /// - The application data directory cannot be created
575    /// - The configuration file cannot be written due to permission issues
576    /// - JSON serialization fails (should not happen with valid configurations)
577    ///
578    /// # Examples
579    ///
580    /// ```rust,no_run
581    /// # fn main() -> anyhow::Result<()> {
582    /// use kasl::libs::config::{Config, MonitorConfig};
583    ///
584    /// let mut config = Config::read()?;
585    /// config.monitor = Some(MonitorConfig::default());
586    /// config.save()?;
587    /// # Ok(())
588    /// # }
589    /// ```
590    pub fn save(&self) -> Result<()> {
591        // Resolve the configuration file path and ensure directory exists
592        let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
593        // Serialize to JSON string first
594        let json_content = serde_json::to_string_pretty(&self)?;
595        // Write the JSON string to file and ensure it's flushed to disk
596        fs::write(config_file_path, json_content)?;
597        Ok(())
598    }
599
600    /// Returns the ignore list for task discovery.
601    ///
602    /// When `task_discovery` is not configured, returns the built-in defaults.
603    pub fn effective_ignore_names(&self) -> Vec<String> {
604        self.task_discovery
605            .as_ref()
606            .map(|c| c.ignore_names.clone())
607            .unwrap_or_else(default_ignore_names)
608    }
609
610    /// Appends unique names to the discovery ignore list and saves the config.
611    ///
612    /// Uniqueness is determined by [`normalize_task_name`]. Returns how many
613    /// new entries were added.
614    pub fn add_ignore_names(&mut self, names: &[String]) -> Result<usize> {
615        let mut discovery = self.task_discovery.clone().unwrap_or_default();
616        let mut added = 0;
617
618        for name in names {
619            let trimmed = name.trim();
620            if trimmed.is_empty() {
621                continue;
622            }
623            let key = normalize_task_name(trimmed);
624            let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
625            if !exists {
626                discovery.ignore_names.push(trimmed.to_string());
627                added += 1;
628            }
629        }
630
631        self.task_discovery = Some(discovery);
632        self.save()?;
633        Ok(added)
634    }
635
636    /// Runs an interactive configuration setup wizard.
637    ///
638    /// This method provides a comprehensive guided setup experience that allows
639    /// users to configure multiple application modules through an interactive
640    /// command-line interface. The wizard presents available modules, collects
641    /// configuration parameters, and validates inputs before saving.
642    ///
643    /// ## Setup Process
644    ///
645    /// 1. **Load Current Config**: Starts with existing configuration as defaults
646    /// 2. **Module Selection**: Presents a multi-select list of available integrations
647    /// 3. **Parameter Collection**: For each selected module, prompts for required settings
648    /// 4. **Validation**: Performs basic validation on input parameters
649    /// 5. **Configuration Return**: Returns the updated configuration for saving
650    ///
651    /// ## Available Modules
652    ///
653    /// - **SI (SearchInform)**: Company-specific API integration
654    /// - **GitLab**: Source control integration for commit tracking
655    /// - **Jira**: Issue tracking integration for task management
656    /// - **Monitor**: Activity monitoring and pause detection settings
657    /// - **Server**: External reporting API configuration
658    ///
659    /// ## User Experience
660    ///
661    /// - Uses colored prompts for better visual feedback
662    /// - Pre-fills existing values as defaults to simplify updates
663    /// - Provides helpful descriptions for each configuration parameter
664    /// - Allows partial configuration (users can skip unwanted modules)
665    ///
666    /// # Returns
667    ///
668    /// Returns a fully configured `Config` instance ready for saving.
669    ///
670    /// # Errors
671    ///
672    /// Returns an error if:
673    /// - The existing configuration cannot be loaded
674    /// - User input cannot be collected due to terminal issues
675    /// - A module's configuration setup fails
676    ///
677    /// # Examples
678    ///
679    /// ```rust,no_run
680    /// # fn main() -> anyhow::Result<()> {
681    /// use kasl::libs::config::Config;
682    ///
683    /// // Run interactive setup and save the result
684    /// let config = Config::init()?;
685    /// config.save()?;
686    /// # Ok(())
687    /// # }
688    /// ```
689    pub fn init() -> Result<Self> {
690        // The whole wizard is prompts: every module below asks for values, and
691        // several read secrets. Without a terminal there is nobody to answer.
692        crate::libs::prompt::ensure_interactive("`kasl init` is an interactive wizard and needs a terminal")?;
693
694        // Load existing configuration to use as defaults for the setup wizard
695        let mut config = Self::read().unwrap_or_default();
696
697        // Define available configuration modules with their metadata
698        let node_descriptions = [
699            SiConfig::module(),
700            GitLabConfig::module(),
701            JiraConfig::module(),
702            ConfigModule {
703                key: "monitor".to_string(),
704                name: "Monitor".to_string(),
705            },
706            ConfigModule {
707                key: "server".to_string(),
708                name: "Server".to_string(),
709            },
710            ConfigModule {
711                key: "productivity".to_string(),
712                name: "Productivity".to_string(),
713            },
714            ConfigModule {
715                key: "report".to_string(),
716                name: "Report".to_string(),
717            },
718            ConfigModule {
719                key: "task_discovery".to_string(),
720                name: "Task discovery".to_string(),
721            },
722            ConfigModule {
723                key: "jira_inbox".to_string(),
724                name: "Jira inbox".to_string(),
725            },
726        ];
727
728        // Present multi-select interface for module selection
729        let selected_nodes = MultiSelect::with_theme(&ColorfulTheme::default())
730            .with_prompt(Message::PromptSelectModules.to_string())
731            .items(node_descriptions.iter().map(|module| &module.name).collect::<Vec<_>>())
732            .interact()?;
733
734        // Configure each selected module through its specific setup process
735        for &selection in &selected_nodes {
736            match node_descriptions[selection].key.as_str() {
737                // External API integrations delegate to their own setup methods
738                "si" => config.si = Some(SiConfig::init(&config.si)?),
739                "gitlab" => config.gitlab = Some(GitLabConfig::init(&config.gitlab)?),
740                "jira" => config.jira = Some(JiraConfig::init(&config.jira)?),
741
742                // Monitor configuration uses inline setup for timing parameters
743                "monitor" => {
744                    let default = config.monitor.clone().unwrap_or_default();
745                    msg_print!(Message::ConfigModuleMonitor);
746                    config.monitor = Some(MonitorConfig {
747                        // Minimum duration for recording pauses (reduces noise)
748                        min_pause_duration: Input::with_theme(&ColorfulTheme::default())
749                            .with_prompt(Message::PromptMinPauseDuration.to_string())
750                            .default(default.min_pause_duration)
751                            .interact_text()?,
752
753                        // Inactivity threshold before pause detection begins
754                        pause_threshold: Input::with_theme(&ColorfulTheme::default())
755                            .with_prompt(Message::PromptPauseThreshold.to_string())
756                            .default(default.pause_threshold)
757                            .interact_text()?,
758
759                        // Frequency of activity status checks
760                        poll_interval: Input::with_theme(&ColorfulTheme::default())
761                            .with_prompt(Message::PromptPollInterval.to_string())
762                            .default(default.poll_interval)
763                            .interact_text()?,
764
765                        // Continuous activity required to start workday tracking
766                        activity_threshold: Input::with_theme(&ColorfulTheme::default())
767                            .with_prompt(Message::PromptActivityThreshold.to_string())
768                            .default(default.activity_threshold)
769                            .interact_text()?,
770
771                        // Minimum interval duration for merging work blocks
772                        min_work_interval: Input::with_theme(&ColorfulTheme::default())
773                            .with_prompt(Message::PromptMinWorkInterval.to_string())
774                            .default(default.min_work_interval)
775                            .interact_text()?,
776
777                        // Preserve the pause-merge gap (edited manually in config.json)
778                        pause_merge_gap: default.pause_merge_gap,
779                    });
780                }
781
782                // Server configuration for external report submission
783                "server" => {
784                    let default = config.server.clone().unwrap_or(ServerConfig {
785                        api_url: "".to_string(),
786                        auth_token: "".to_string(),
787                    });
788                    msg_print!(Message::ConfigModuleServer);
789                    config.server = Some(ServerConfig {
790                        // Base URL for the external reporting API
791                        api_url: Input::with_theme(&ColorfulTheme::default())
792                            .with_prompt(Message::PromptServerApiUrl.to_string())
793                            .default(default.api_url)
794                            .interact_text()?,
795
796                        // Authentication token for API access
797                        auth_token: Input::with_theme(&ColorfulTheme::default())
798                            .with_prompt(Message::PromptServerAuthToken.to_string())
799                            .default(default.auth_token)
800                            .interact_text()?,
801                    });
802                }
803
804                // Productivity configuration for break recommendations and reporting
805                "productivity" => {
806                    let default = config.productivity.clone().unwrap_or_default();
807                    msg_print!(Message::ConfigModuleProductivity);
808                    config.productivity = Some(ProductivityConfig {
809                        // Minimum productivity percentage threshold
810                        min_productivity_threshold: Input::with_theme(&ColorfulTheme::default())
811                            .with_prompt(Message::PromptMinProductivityThreshold.to_string())
812                            .default(default.min_productivity_threshold)
813                            .interact_text()?,
814
815                        // Expected workday duration
816                        workday_hours: Input::with_theme(&ColorfulTheme::default())
817                            .with_prompt(Message::PromptWorkdayHours.to_string())
818                            .default(default.workday_hours)
819                            .interact_text()?,
820
821                        // Minimum workday fraction before the productivity warning
822                        min_workday_fraction_before_suggest: Input::with_theme(&ColorfulTheme::default())
823                            .with_prompt(Message::PromptMinWorkdayFraction.to_string())
824                            .default(default.min_workday_fraction_before_suggest)
825                            .interact_text()?,
826                    });
827                }
828
829                // Report export configuration: default output directory and file name template
830                "report" => {
831                    let default = config.report.clone().unwrap_or(ReportConfig {
832                        output_dir: None,
833                        filename_template: None,
834                        language: None,
835                        template: None,
836                    });
837                    let output_dir: String = Input::with_theme(&ColorfulTheme::default())
838                        .with_prompt("Reports output directory")
839                        .default(default.output_dir.unwrap_or_default())
840                        .allow_empty(true)
841                        .interact_text()?;
842                    let filename_template: String = Input::with_theme(&ColorfulTheme::default())
843                        .with_prompt("Report file name template (placeholders: {date}, {seq})")
844                        .default(default.filename_template.unwrap_or_else(|| "daily_report_{date}{seq}".to_string()))
845                        .allow_empty(true)
846                        .interact_text()?;
847                    let language: String = Input::with_theme(&ColorfulTheme::default())
848                        .with_prompt("Report language (ru, en)")
849                        .default(default.language.unwrap_or_else(|| "ru".to_string()))
850                        .allow_empty(true)
851                        .interact_text()?;
852                    let template: String = Input::with_theme(&ColorfulTheme::default())
853                        .with_prompt("Report design template name")
854                        .default(default.template.unwrap_or_else(|| "siserver".to_string()))
855                        .allow_empty(true)
856                        .interact_text()?;
857                    config.report = Some(ReportConfig {
858                        output_dir: if output_dir.trim().is_empty() { None } else { Some(output_dir) },
859                        filename_template: if filename_template.trim().is_empty() { None } else { Some(filename_template) },
860                        language: if language.trim().is_empty() { None } else { Some(language) },
861                        template: if template.trim().is_empty() { None } else { Some(template) },
862                    });
863                }
864
865                "task_discovery" => {
866                    config.task_discovery = Some(configure_task_discovery(config.task_discovery.clone().unwrap_or_default())?);
867                }
868
869                "jira_inbox" => {
870                    config.jira_inbox = Some(configure_jira_inbox(config.jira_inbox.clone().unwrap_or_default())?);
871                }
872
873                _ => {} // Unknown module keys are safely ignored
874            }
875        }
876
877        Ok(config)
878    }
879
880    /// Adds the application to the global system PATH.
881    ///
882    /// This method ensures that the kasl executable can be run from any directory
883    /// by adding its location to the system's PATH environment variable. This is
884    /// particularly useful on Windows where applications are not automatically
885    /// available globally after installation.
886    ///
887    /// ## Platform Behavior
888    ///
889    /// - **Windows**: Modifies the system registry to update the global PATH
890    /// - **Unix-like**: Currently not implemented (uses shell integration instead)
891    ///
892    /// ## Windows Implementation Details
893    ///
894    /// The Windows implementation:
895    /// 1. Determines the current executable's directory
896    /// 2. Checks if the directory is already in the PATH
897    /// 3. Updates the registry to add the directory if needed
898    /// 4. Requires administrative privileges for system-wide changes
899    ///
900    /// ## Security Considerations
901    ///
902    /// - Modifying the system PATH requires elevated privileges on Windows
903    /// - Changes affect all users on the system
904    /// - The operation is reversible by manually editing the PATH
905    ///
906    /// # Returns
907    ///
908    /// Returns `Ok(())` if the PATH was successfully updated or was already correct.
909    ///
910    /// # Errors
911    ///
912    /// Returns an error if:
913    /// - The current executable path cannot be determined
914    /// - Registry operations fail due to insufficient privileges
915    /// - System commands fail to execute properly
916    ///
917    /// # Examples
918    ///
919    /// ```rust,no_run
920    /// # fn main() -> anyhow::Result<()> {
921    /// use kasl::libs::config::Config;
922    ///
923    /// // Ensure kasl is available globally
924    /// Config::set_app_global()?;
925    /// # Ok(())
926    /// # }
927    /// ```
928    pub fn set_app_global() -> Result<()> {
929        // Get the directory containing the current executable
930        let current_exe_path = env::current_exe()?;
931        let exe_dir = current_exe_path.parent().unwrap();
932
933        // Parse the current PATH environment variable
934        let mut paths: Vec<PathBuf> = env::split_paths(&env::var_os("PATH").unwrap()).collect();
935        let str_paths: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
936
937        // Check if the executable directory is already in PATH
938        if str_paths.contains(&exe_dir.to_str().unwrap()) {
939            return Ok(()); // Already configured, nothing to do
940        }
941
942        // Double-check using a different method to avoid duplicates
943        if paths.iter().any(|p| p.to_str() == Some(exe_dir.to_str().unwrap())) {
944            return Ok(()); // Already present, avoid duplication
945        }
946
947        // Add the executable directory to the PATH list
948        paths.push(exe_dir.to_path_buf());
949
950        // Reconstruct the PATH environment variable
951        let new_path = env::join_paths(paths).unwrap_or_else(|_| panic!("{}", Message::FailedToJoinPaths.to_string()));
952
953        // Define the Windows registry key for system environment variables
954        let path_key = r"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
955
956        // Query the current PATH value from the Windows registry
957        let reg_query_output = Command::new("reg")
958            .arg("query")
959            .arg(path_key)
960            .arg("/v")
961            .arg("Path")
962            .output()
963            .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegQuery.to_string()));
964
965        // Handle registry query failures gracefully
966        if !reg_query_output.status.success() {
967            let status = reg_query_output.status.to_string();
968            msg_error!(Message::PathRegistryQueryError { status: status.clone() });
969            return Err(anyhow::anyhow!("{}", Message::PathRegistryQueryError { status }));
970        }
971
972        // Parse the current PATH value from registry output
973        let current_path = str::from_utf8(&reg_query_output.stdout)
974            .unwrap_or_else(|_| panic!("{}", Message::FailedToParseRegOutput.to_string()))
975            .split_whitespace()
976            .last()
977            .unwrap_or_else(|| panic!("{}", Message::FailedToGetPathFromReg.to_string()));
978
979        // Update the registry with the new PATH value
980        let reg_set_output = Command::new("reg")
981            .arg("add")
982            .arg(path_key)
983            .arg("/v")
984            .arg("Path")
985            .arg("/t")
986            .arg("REG_EXPAND_SZ") // Expandable string type for environment variables
987            .arg("/d")
988            .arg(format!("{};{}", current_path, new_path.to_string_lossy()))
989            .arg("/f") // Force overwrite without confirmation
990            .output()
991            .unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegSet.to_string()));
992
993        // Check if the registry update was successful
994        if !reg_set_output.status.success() {
995            let status = reg_set_output.status.to_string();
996            let stderr = String::from_utf8_lossy(&reg_set_output.stderr).to_string();
997            msg_error!(Message::PathRegistryUpdateError {
998                status: status.clone(),
999                stderr: stderr.clone()
1000            });
1001            return Err(anyhow::anyhow!("{}", Message::PathRegistryUpdateError { status, stderr }));
1002        }
1003
1004        Ok(())
1005    }
1006}
1007
1008/// Interactive editor for the task discovery ignore list.
1009fn configure_task_discovery(mut discovery: TaskDiscoveryConfig) -> Result<TaskDiscoveryConfig> {
1010    msg_print!(Message::ConfigModuleTaskDiscovery, true);
1011
1012    if discovery.ignore_names.is_empty() {
1013        msg_info!(Message::TaskDiscoveryIgnoreListEmpty);
1014    } else {
1015        msg_print!(Message::TaskDiscoveryIgnoreListHeader, true);
1016        for (idx, name) in discovery.ignore_names.iter().enumerate() {
1017            println!("  {}. {}", idx + 1, name);
1018        }
1019        println!();
1020
1021        let remove = MultiSelect::with_theme(&ColorfulTheme::default())
1022            .with_prompt(Message::PromptSelectIgnoreNamesToRemove.to_string())
1023            .items(&discovery.ignore_names)
1024            .interact()?;
1025
1026        if !remove.is_empty() {
1027            let mut keep = Vec::new();
1028            for (idx, name) in discovery.ignore_names.iter().enumerate() {
1029                if !remove.contains(&idx) {
1030                    keep.push(name.clone());
1031                }
1032            }
1033            discovery.ignore_names = keep;
1034        }
1035    }
1036
1037    loop {
1038        let name: String = Input::with_theme(&ColorfulTheme::default())
1039            .with_prompt(Message::PromptAddIgnoreName.to_string())
1040            .allow_empty(true)
1041            .interact_text()?;
1042
1043        let trimmed = name.trim();
1044        if trimmed.is_empty() {
1045            break;
1046        }
1047
1048        let key = normalize_task_name(trimmed);
1049        let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
1050        if exists {
1051            msg_info!(Message::TaskDiscoveryIgnoreNameExists(trimmed.to_string()));
1052        } else {
1053            discovery.ignore_names.push(trimmed.to_string());
1054            msg_success!(Message::TaskDiscoveryIgnoreNameAdded(trimmed.to_string()));
1055        }
1056    }
1057
1058    Ok(discovery)
1059}
1060
1061/// Interactive wizard for Jira inbox polling settings.
1062fn configure_jira_inbox(default: JiraInboxConfig) -> Result<JiraInboxConfig> {
1063    msg_print!(Message::ConfigModuleJiraInbox, true);
1064
1065    let enabled = Confirm::with_theme(&ColorfulTheme::default())
1066        .with_prompt(Message::PromptJiraInboxEnabled.to_string())
1067        .default(default.enabled)
1068        .interact()?;
1069
1070    let poll_interval_secs: u64 = Input::with_theme(&ColorfulTheme::default())
1071        .with_prompt(Message::PromptJiraInboxPollInterval.to_string())
1072        .default(default.poll_interval_secs)
1073        .interact_text()?;
1074
1075    let notify = Confirm::with_theme(&ColorfulTheme::default())
1076        .with_prompt(Message::PromptJiraInboxNotify.to_string())
1077        .default(default.notify)
1078        .interact()?;
1079
1080    let default_sort_id = default.sort_by_field.clone().unwrap_or_default();
1081    let sort_field_id: String = Input::with_theme(&ColorfulTheme::default())
1082        .with_prompt(Message::PromptJiraInboxSortFieldId.to_string())
1083        .with_initial_text(&default_sort_id)
1084        .allow_empty(true)
1085        .interact_text()?;
1086
1087    let mut custom_fields = Vec::new();
1088    let mut sort_by_field = None;
1089
1090    let sort_trimmed = sort_field_id.trim();
1091    if !sort_trimmed.is_empty() {
1092        let default_label = default
1093            .custom_fields
1094            .iter()
1095            .find(|f| f.id == sort_trimmed)
1096            .map(|f| f.label.clone())
1097            .unwrap_or_else(|| "Scoring".to_string());
1098
1099        let sort_label: String = Input::with_theme(&ColorfulTheme::default())
1100            .with_prompt(Message::PromptJiraInboxSortFieldLabel.to_string())
1101            .default(default_label)
1102            .interact_text()?;
1103
1104        let label = {
1105            let t = sort_label.trim();
1106            if t.is_empty() { "Scoring".to_string() } else { t.to_string() }
1107        };
1108        custom_fields.push(JiraCustomField {
1109            id: sort_trimmed.to_string(),
1110            label,
1111        });
1112        sort_by_field = Some(sort_trimmed.to_string());
1113    }
1114
1115    loop {
1116        let extra_id: String = Input::with_theme(&ColorfulTheme::default())
1117            .with_prompt(Message::PromptJiraInboxExtraFieldId.to_string())
1118            .allow_empty(true)
1119            .interact_text()?;
1120        let trimmed = extra_id.trim();
1121        if trimmed.is_empty() {
1122            break;
1123        }
1124        if custom_fields.iter().any(|f| f.id == trimmed) {
1125            continue;
1126        }
1127        let extra_label: String = Input::with_theme(&ColorfulTheme::default())
1128            .with_prompt(Message::PromptJiraInboxExtraFieldLabel.to_string())
1129            .default(trimmed.to_string())
1130            .interact_text()?;
1131        custom_fields.push(JiraCustomField {
1132            id: trimmed.to_string(),
1133            label: {
1134                let t = extra_label.trim();
1135                if t.is_empty() { trimmed.to_string() } else { t.to_string() }
1136            },
1137        });
1138    }
1139
1140    Ok(JiraInboxConfig {
1141        enabled,
1142        poll_interval_secs: poll_interval_secs.max(30),
1143        notify,
1144        custom_fields,
1145        sort_by_field,
1146    })
1147}