use super::data_storage::DataStorage;
use crate::api::gitlab::GitLabConfig;
use crate::api::jira::JiraConfig;
use crate::api::si::SiConfig;
use crate::libs::messages::Message;
use crate::libs::task::normalize_task_name;
use crate::{msg_error, msg_info, msg_print, msg_success};
use anyhow::Result;
use dialoguer::{Confirm, Input, MultiSelect, theme::ColorfulTheme};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::str;
pub const CONFIG_FILE_NAME: &str = "config.json";
#[derive(Debug, Clone)]
pub struct ConfigModule {
pub key: String,
pub name: String,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct MonitorConfig {
pub min_pause_duration: u64,
pub pause_threshold: u64,
pub poll_interval: u64,
pub activity_threshold: u64,
pub min_work_interval: u64,
#[serde(default = "default_pause_merge_gap")]
pub pause_merge_gap: u64,
}
fn default_pause_merge_gap() -> u64 {
30
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct ProductivityConfig {
pub min_productivity_threshold: f64,
pub workday_hours: f64,
pub min_workday_fraction_before_suggest: f64,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)]
pub struct ReportConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub output_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename_template: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct ServerConfig {
pub api_url: String,
pub auth_token: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Config {
#[serde(skip_serializing_if = "Option::is_none")]
pub si: Option<SiConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gitlab: Option<GitLabConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jira: Option<JiraConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub monitor: Option<MonitorConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server: Option<ServerConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub productivity: Option<ProductivityConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub report: Option<ReportConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_discovery: Option<TaskDiscoveryConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jira_inbox: Option<JiraInboxConfig>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct JiraInboxConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_jira_inbox_poll_interval")]
pub poll_interval_secs: u64,
#[serde(default = "default_true")]
pub notify: bool,
#[serde(default)]
pub custom_fields: Vec<JiraCustomField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort_by_field: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct JiraCustomField {
pub id: String,
pub label: String,
}
fn default_true() -> bool {
true
}
fn default_jira_inbox_poll_interval() -> u64 {
300
}
impl Default for JiraInboxConfig {
fn default() -> Self {
Self {
enabled: true,
poll_interval_secs: default_jira_inbox_poll_interval(),
notify: true,
custom_fields: Vec::new(),
sort_by_field: None,
}
}
}
impl JiraInboxConfig {
pub fn extra_field_ids(&self) -> Vec<String> {
let mut ids: Vec<String> = self.custom_fields.iter().map(|f| f.id.trim().to_string()).filter(|id| !id.is_empty()).collect();
if let Some(sort_id) = &self.sort_by_field {
let trimmed = sort_id.trim();
if !trimmed.is_empty() && !ids.iter().any(|id| id == trimmed) {
ids.push(trimmed.to_string());
}
}
ids
}
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct TaskDiscoveryConfig {
#[serde(default = "default_ignore_names")]
pub ignore_names: Vec<String>,
}
pub fn default_ignore_names() -> Vec<String> {
vec![
"Merge remote-tracking branch".to_string(),
"Merge branch ".to_string(),
"update webui".to_string(),
]
}
impl Default for TaskDiscoveryConfig {
fn default() -> Self {
Self {
ignore_names: default_ignore_names(),
}
}
}
impl Default for MonitorConfig {
fn default() -> Self {
MonitorConfig {
min_pause_duration: 20,
pause_threshold: 60,
poll_interval: 500,
activity_threshold: 30,
min_work_interval: 10,
pause_merge_gap: default_pause_merge_gap(),
}
}
}
impl Default for ProductivityConfig {
fn default() -> Self {
ProductivityConfig {
min_productivity_threshold: 75.0,
workday_hours: 8.0,
min_workday_fraction_before_suggest: 0.5,
}
}
}
impl Default for Config {
fn default() -> Self {
Config {
si: None,
gitlab: None,
jira: None,
monitor: None,
server: None,
productivity: None,
report: None,
task_discovery: None,
jira_inbox: None,
}
}
}
impl Config {
pub fn read() -> Result<Config> {
let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
if !config_file_path.exists() {
return Ok(Config::default());
}
let config_str = fs::read_to_string(config_file_path)?;
let config: Config = serde_json::from_str(&config_str)?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
let config_file_path = DataStorage::new().get_path(CONFIG_FILE_NAME)?;
let json_content = serde_json::to_string_pretty(&self)?;
fs::write(config_file_path, json_content)?;
Ok(())
}
pub fn effective_ignore_names(&self) -> Vec<String> {
self.task_discovery
.as_ref()
.map(|c| c.ignore_names.clone())
.unwrap_or_else(default_ignore_names)
}
pub fn add_ignore_names(&mut self, names: &[String]) -> Result<usize> {
let mut discovery = self.task_discovery.clone().unwrap_or_default();
let mut added = 0;
for name in names {
let trimmed = name.trim();
if trimmed.is_empty() {
continue;
}
let key = normalize_task_name(trimmed);
let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
if !exists {
discovery.ignore_names.push(trimmed.to_string());
added += 1;
}
}
self.task_discovery = Some(discovery);
self.save()?;
Ok(added)
}
pub fn init() -> Result<Self> {
crate::libs::prompt::ensure_interactive("`kasl init` is an interactive wizard and needs a terminal")?;
let mut config = Self::read().unwrap_or_default();
let node_descriptions = [
SiConfig::module(),
GitLabConfig::module(),
JiraConfig::module(),
ConfigModule {
key: "monitor".to_string(),
name: "Monitor".to_string(),
},
ConfigModule {
key: "server".to_string(),
name: "Server".to_string(),
},
ConfigModule {
key: "productivity".to_string(),
name: "Productivity".to_string(),
},
ConfigModule {
key: "report".to_string(),
name: "Report".to_string(),
},
ConfigModule {
key: "task_discovery".to_string(),
name: "Task discovery".to_string(),
},
ConfigModule {
key: "jira_inbox".to_string(),
name: "Jira inbox".to_string(),
},
];
let selected_nodes = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptSelectModules.to_string())
.items(node_descriptions.iter().map(|module| &module.name).collect::<Vec<_>>())
.interact()?;
for &selection in &selected_nodes {
match node_descriptions[selection].key.as_str() {
"si" => config.si = Some(SiConfig::init(&config.si)?),
"gitlab" => config.gitlab = Some(GitLabConfig::init(&config.gitlab)?),
"jira" => config.jira = Some(JiraConfig::init(&config.jira)?),
"monitor" => {
let default = config.monitor.clone().unwrap_or_default();
msg_print!(Message::ConfigModuleMonitor);
config.monitor = Some(MonitorConfig {
min_pause_duration: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptMinPauseDuration.to_string())
.default(default.min_pause_duration)
.interact_text()?,
pause_threshold: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptPauseThreshold.to_string())
.default(default.pause_threshold)
.interact_text()?,
poll_interval: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptPollInterval.to_string())
.default(default.poll_interval)
.interact_text()?,
activity_threshold: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptActivityThreshold.to_string())
.default(default.activity_threshold)
.interact_text()?,
min_work_interval: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptMinWorkInterval.to_string())
.default(default.min_work_interval)
.interact_text()?,
pause_merge_gap: default.pause_merge_gap,
});
}
"server" => {
let default = config.server.clone().unwrap_or(ServerConfig {
api_url: "".to_string(),
auth_token: "".to_string(),
});
msg_print!(Message::ConfigModuleServer);
config.server = Some(ServerConfig {
api_url: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptServerApiUrl.to_string())
.default(default.api_url)
.interact_text()?,
auth_token: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptServerAuthToken.to_string())
.default(default.auth_token)
.interact_text()?,
});
}
"productivity" => {
let default = config.productivity.clone().unwrap_or_default();
msg_print!(Message::ConfigModuleProductivity);
config.productivity = Some(ProductivityConfig {
min_productivity_threshold: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptMinProductivityThreshold.to_string())
.default(default.min_productivity_threshold)
.interact_text()?,
workday_hours: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptWorkdayHours.to_string())
.default(default.workday_hours)
.interact_text()?,
min_workday_fraction_before_suggest: Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptMinWorkdayFraction.to_string())
.default(default.min_workday_fraction_before_suggest)
.interact_text()?,
});
}
"report" => {
let default = config.report.clone().unwrap_or(ReportConfig {
output_dir: None,
filename_template: None,
language: None,
template: None,
});
let output_dir: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Reports output directory")
.default(default.output_dir.unwrap_or_default())
.allow_empty(true)
.interact_text()?;
let filename_template: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Report file name template (placeholders: {date}, {seq})")
.default(default.filename_template.unwrap_or_else(|| "daily_report_{date}{seq}".to_string()))
.allow_empty(true)
.interact_text()?;
let language: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Report language (ru, en)")
.default(default.language.unwrap_or_else(|| "ru".to_string()))
.allow_empty(true)
.interact_text()?;
let template: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt("Report design template name")
.default(default.template.unwrap_or_else(|| "siserver".to_string()))
.allow_empty(true)
.interact_text()?;
config.report = Some(ReportConfig {
output_dir: if output_dir.trim().is_empty() { None } else { Some(output_dir) },
filename_template: if filename_template.trim().is_empty() { None } else { Some(filename_template) },
language: if language.trim().is_empty() { None } else { Some(language) },
template: if template.trim().is_empty() { None } else { Some(template) },
});
}
"task_discovery" => {
config.task_discovery = Some(configure_task_discovery(config.task_discovery.clone().unwrap_or_default())?);
}
"jira_inbox" => {
config.jira_inbox = Some(configure_jira_inbox(config.jira_inbox.clone().unwrap_or_default())?);
}
_ => {} }
}
Ok(config)
}
pub fn set_app_global() -> Result<()> {
let current_exe_path = env::current_exe()?;
let exe_dir = current_exe_path.parent().unwrap();
let mut paths: Vec<PathBuf> = env::split_paths(&env::var_os("PATH").unwrap()).collect();
let str_paths: Vec<&str> = paths.iter().filter_map(|p| p.to_str()).collect();
if str_paths.contains(&exe_dir.to_str().unwrap()) {
return Ok(()); }
if paths.iter().any(|p| p.to_str() == Some(exe_dir.to_str().unwrap())) {
return Ok(()); }
paths.push(exe_dir.to_path_buf());
let new_path = env::join_paths(paths).unwrap_or_else(|_| panic!("{}", Message::FailedToJoinPaths.to_string()));
let path_key = r"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
let reg_query_output = Command::new("reg")
.arg("query")
.arg(path_key)
.arg("/v")
.arg("Path")
.output()
.unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegQuery.to_string()));
if !reg_query_output.status.success() {
let status = reg_query_output.status.to_string();
msg_error!(Message::PathRegistryQueryError { status: status.clone() });
return Err(anyhow::anyhow!("{}", Message::PathRegistryQueryError { status }));
}
let current_path = str::from_utf8(®_query_output.stdout)
.unwrap_or_else(|_| panic!("{}", Message::FailedToParseRegOutput.to_string()))
.split_whitespace()
.last()
.unwrap_or_else(|| panic!("{}", Message::FailedToGetPathFromReg.to_string()));
let reg_set_output = Command::new("reg")
.arg("add")
.arg(path_key)
.arg("/v")
.arg("Path")
.arg("/t")
.arg("REG_EXPAND_SZ") .arg("/d")
.arg(format!("{};{}", current_path, new_path.to_string_lossy()))
.arg("/f") .output()
.unwrap_or_else(|_| panic!("{}", Message::FailedToExecuteRegSet.to_string()));
if !reg_set_output.status.success() {
let status = reg_set_output.status.to_string();
let stderr = String::from_utf8_lossy(®_set_output.stderr).to_string();
msg_error!(Message::PathRegistryUpdateError {
status: status.clone(),
stderr: stderr.clone()
});
return Err(anyhow::anyhow!("{}", Message::PathRegistryUpdateError { status, stderr }));
}
Ok(())
}
}
fn configure_task_discovery(mut discovery: TaskDiscoveryConfig) -> Result<TaskDiscoveryConfig> {
msg_print!(Message::ConfigModuleTaskDiscovery, true);
if discovery.ignore_names.is_empty() {
msg_info!(Message::TaskDiscoveryIgnoreListEmpty);
} else {
msg_print!(Message::TaskDiscoveryIgnoreListHeader, true);
for (idx, name) in discovery.ignore_names.iter().enumerate() {
println!(" {}. {}", idx + 1, name);
}
println!();
let remove = MultiSelect::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptSelectIgnoreNamesToRemove.to_string())
.items(&discovery.ignore_names)
.interact()?;
if !remove.is_empty() {
let mut keep = Vec::new();
for (idx, name) in discovery.ignore_names.iter().enumerate() {
if !remove.contains(&idx) {
keep.push(name.clone());
}
}
discovery.ignore_names = keep;
}
}
loop {
let name: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptAddIgnoreName.to_string())
.allow_empty(true)
.interact_text()?;
let trimmed = name.trim();
if trimmed.is_empty() {
break;
}
let key = normalize_task_name(trimmed);
let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
if exists {
msg_info!(Message::TaskDiscoveryIgnoreNameExists(trimmed.to_string()));
} else {
discovery.ignore_names.push(trimmed.to_string());
msg_success!(Message::TaskDiscoveryIgnoreNameAdded(trimmed.to_string()));
}
}
Ok(discovery)
}
fn configure_jira_inbox(default: JiraInboxConfig) -> Result<JiraInboxConfig> {
msg_print!(Message::ConfigModuleJiraInbox, true);
let enabled = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraInboxEnabled.to_string())
.default(default.enabled)
.interact()?;
let poll_interval_secs: u64 = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraInboxPollInterval.to_string())
.default(default.poll_interval_secs)
.interact_text()?;
let notify = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraInboxNotify.to_string())
.default(default.notify)
.interact()?;
let default_sort_id = default.sort_by_field.clone().unwrap_or_default();
let sort_field_id: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraInboxSortFieldId.to_string())
.with_initial_text(&default_sort_id)
.allow_empty(true)
.interact_text()?;
let mut custom_fields = Vec::new();
let mut sort_by_field = None;
let sort_trimmed = sort_field_id.trim();
if !sort_trimmed.is_empty() {
let default_label = default
.custom_fields
.iter()
.find(|f| f.id == sort_trimmed)
.map(|f| f.label.clone())
.unwrap_or_else(|| "Scoring".to_string());
let sort_label: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraInboxSortFieldLabel.to_string())
.default(default_label)
.interact_text()?;
let label = {
let t = sort_label.trim();
if t.is_empty() { "Scoring".to_string() } else { t.to_string() }
};
custom_fields.push(JiraCustomField {
id: sort_trimmed.to_string(),
label,
});
sort_by_field = Some(sort_trimmed.to_string());
}
loop {
let extra_id: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraInboxExtraFieldId.to_string())
.allow_empty(true)
.interact_text()?;
let trimmed = extra_id.trim();
if trimmed.is_empty() {
break;
}
if custom_fields.iter().any(|f| f.id == trimmed) {
continue;
}
let extra_label: String = Input::with_theme(&ColorfulTheme::default())
.with_prompt(Message::PromptJiraInboxExtraFieldLabel.to_string())
.default(trimmed.to_string())
.interact_text()?;
custom_fields.push(JiraCustomField {
id: trimmed.to_string(),
label: {
let t = extra_label.trim();
if t.is_empty() { trimmed.to_string() } else { t.to_string() }
},
});
}
Ok(JiraInboxConfig {
enabled,
poll_interval_secs: poll_interval_secs.max(30),
notify,
custom_fields,
sort_by_field,
})
}