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(®_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(®_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}