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