kasl/libs/config/
wizard.rs1use super::{Config, ConfigModule, JiraCustomField, JiraInboxConfig, MonitorConfig, ProductivityConfig, ReportConfig, ServerConfig, TaskDiscoveryConfig};
8use crate::api::gitlab::GitLabConfig;
9use crate::api::jira::JiraConfig;
10use crate::api::si::SiConfig;
11use crate::libs::messages::Message;
12use crate::libs::task::normalize_task_name;
13use crate::{msg_info, msg_print, msg_success};
14use anyhow::Result;
15use dialoguer::{Confirm, Input, MultiSelect, theme::ColorfulTheme};
16
17impl Config {
18 pub fn init() -> Result<Self> {
31 crate::libs::prompt::ensure_interactive("`kasl setup` is an interactive wizard and needs a terminal")?;
34
35 let mut config = Self::read().unwrap_or_default();
37
38 let node_descriptions = [
39 SiConfig::module(),
40 GitLabConfig::module(),
41 JiraConfig::module(),
42 ConfigModule {
43 key: "monitor".to_string(),
44 name: "Monitor".to_string(),
45 },
46 ConfigModule {
47 key: "server".to_string(),
48 name: "Server".to_string(),
49 },
50 ConfigModule {
51 key: "productivity".to_string(),
52 name: "Productivity".to_string(),
53 },
54 ConfigModule {
55 key: "report".to_string(),
56 name: "Report".to_string(),
57 },
58 ConfigModule {
59 key: "task_discovery".to_string(),
60 name: "Task discovery".to_string(),
61 },
62 ConfigModule {
63 key: "jira_inbox".to_string(),
64 name: "Jira inbox".to_string(),
65 },
66 ];
67
68 let selected_nodes = MultiSelect::with_theme(&ColorfulTheme::default())
69 .with_prompt(Message::PromptSelectModules.to_string())
70 .items(node_descriptions.iter().map(|module| &module.name).collect::<Vec<_>>())
71 .interact()?;
72
73 for &selection in &selected_nodes {
74 match node_descriptions[selection].key.as_str() {
75 "si" => config.si = Some(SiConfig::init(&config.si)?),
77 "gitlab" => config.gitlab = Some(GitLabConfig::init(&config.gitlab)?),
78 "jira" => config.jira = Some(JiraConfig::init(&config.jira)?),
79
80 "monitor" => {
81 let default = config.monitor.clone().unwrap_or_default();
82 msg_print!(Message::ConfigModuleMonitor);
83 config.monitor = Some(MonitorConfig {
84 min_pause_duration: Input::with_theme(&ColorfulTheme::default())
85 .with_prompt(Message::PromptMinPauseDuration.to_string())
86 .default(default.min_pause_duration)
87 .interact_text()?,
88
89 pause_threshold: Input::with_theme(&ColorfulTheme::default())
90 .with_prompt(Message::PromptPauseThreshold.to_string())
91 .default(default.pause_threshold)
92 .interact_text()?,
93
94 poll_interval: Input::with_theme(&ColorfulTheme::default())
95 .with_prompt(Message::PromptPollInterval.to_string())
96 .default(default.poll_interval)
97 .interact_text()?,
98
99 activity_threshold: Input::with_theme(&ColorfulTheme::default())
100 .with_prompt(Message::PromptActivityThreshold.to_string())
101 .default(default.activity_threshold)
102 .interact_text()?,
103
104 min_work_interval: Input::with_theme(&ColorfulTheme::default())
105 .with_prompt(Message::PromptMinWorkInterval.to_string())
106 .default(default.min_work_interval)
107 .interact_text()?,
108
109 pause_merge_gap: default.pause_merge_gap,
111 });
112 }
113
114 "server" => {
115 let default = config.server.clone().unwrap_or(ServerConfig {
116 api_url: "".to_string(),
117 auth_token: "".to_string(),
118 });
119 msg_print!(Message::ConfigModuleServer);
120 config.server = Some(ServerConfig {
121 api_url: Input::with_theme(&ColorfulTheme::default())
122 .with_prompt(Message::PromptServerApiUrl.to_string())
123 .default(default.api_url)
124 .interact_text()?,
125
126 auth_token: Input::with_theme(&ColorfulTheme::default())
127 .with_prompt(Message::PromptServerAuthToken.to_string())
128 .default(default.auth_token)
129 .interact_text()?,
130 });
131 }
132
133 "productivity" => {
134 let default = config.productivity.clone().unwrap_or_default();
135 msg_print!(Message::ConfigModuleProductivity);
136 config.productivity = Some(ProductivityConfig {
137 min_productivity_threshold: Input::with_theme(&ColorfulTheme::default())
138 .with_prompt(Message::PromptMinProductivityThreshold.to_string())
139 .default(default.min_productivity_threshold)
140 .interact_text()?,
141
142 workday_hours: Input::with_theme(&ColorfulTheme::default())
143 .with_prompt(Message::PromptWorkdayHours.to_string())
144 .default(default.workday_hours)
145 .interact_text()?,
146
147 min_workday_fraction_before_suggest: Input::with_theme(&ColorfulTheme::default())
148 .with_prompt(Message::PromptMinWorkdayFraction.to_string())
149 .default(default.min_workday_fraction_before_suggest)
150 .interact_text()?,
151 });
152 }
153
154 "report" => {
155 let default = config.report.clone().unwrap_or(ReportConfig {
156 output_dir: None,
157 filename_template: None,
158 language: None,
159 template: None,
160 });
161 let output_dir: String = Input::with_theme(&ColorfulTheme::default())
162 .with_prompt("Reports output directory")
163 .default(default.output_dir.unwrap_or_default())
164 .allow_empty(true)
165 .interact_text()?;
166 let filename_template: String = Input::with_theme(&ColorfulTheme::default())
167 .with_prompt("Report file name template (placeholders: {date}, {seq})")
168 .default(default.filename_template.unwrap_or_else(|| "daily_report_{date}{seq}".to_string()))
169 .allow_empty(true)
170 .interact_text()?;
171 let language: String = Input::with_theme(&ColorfulTheme::default())
172 .with_prompt("Report language (ru, en)")
173 .default(default.language.unwrap_or_else(|| "ru".to_string()))
174 .allow_empty(true)
175 .interact_text()?;
176 let template: String = Input::with_theme(&ColorfulTheme::default())
177 .with_prompt("Report design template name")
178 .default(default.template.unwrap_or_else(|| "siserver".to_string()))
179 .allow_empty(true)
180 .interact_text()?;
181 config.report = Some(ReportConfig {
182 output_dir: if output_dir.trim().is_empty() { None } else { Some(output_dir) },
183 filename_template: if filename_template.trim().is_empty() { None } else { Some(filename_template) },
184 language: if language.trim().is_empty() { None } else { Some(language) },
185 template: if template.trim().is_empty() { None } else { Some(template) },
186 });
187 }
188
189 "task_discovery" => {
190 config.task_discovery = Some(configure_task_discovery(config.task_discovery.clone().unwrap_or_default())?);
191 }
192
193 "jira_inbox" => {
194 config.jira_inbox = Some(configure_jira_inbox(config.jira_inbox.clone().unwrap_or_default())?);
195 }
196
197 _ => {} }
199 }
200
201 Ok(config)
202 }
203}
204
205fn configure_task_discovery(mut discovery: TaskDiscoveryConfig) -> Result<TaskDiscoveryConfig> {
207 msg_print!(Message::ConfigModuleTaskDiscovery, true);
208
209 if discovery.ignore_names.is_empty() {
210 msg_info!(Message::TaskDiscoveryIgnoreListEmpty);
211 } else {
212 msg_print!(Message::TaskDiscoveryIgnoreListHeader, true);
213 for (idx, name) in discovery.ignore_names.iter().enumerate() {
214 println!(" {}. {}", idx + 1, name);
215 }
216 println!();
217
218 let remove = MultiSelect::with_theme(&ColorfulTheme::default())
219 .with_prompt(Message::PromptSelectIgnoreNamesToRemove.to_string())
220 .items(&discovery.ignore_names)
221 .interact()?;
222
223 if !remove.is_empty() {
224 let mut keep = Vec::new();
225 for (idx, name) in discovery.ignore_names.iter().enumerate() {
226 if !remove.contains(&idx) {
227 keep.push(name.clone());
228 }
229 }
230 discovery.ignore_names = keep;
231 }
232 }
233
234 loop {
235 let name: String = Input::with_theme(&ColorfulTheme::default())
236 .with_prompt(Message::PromptAddIgnoreName.to_string())
237 .allow_empty(true)
238 .interact_text()?;
239
240 let trimmed = name.trim();
241 if trimmed.is_empty() {
242 break;
243 }
244
245 let key = normalize_task_name(trimmed);
246 let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
247 if exists {
248 msg_info!(Message::TaskDiscoveryIgnoreNameExists(trimmed.to_string()));
249 } else {
250 discovery.ignore_names.push(trimmed.to_string());
251 msg_success!(Message::TaskDiscoveryIgnoreNameAdded(trimmed.to_string()));
252 }
253 }
254
255 Ok(discovery)
256}
257
258fn configure_jira_inbox(default: JiraInboxConfig) -> Result<JiraInboxConfig> {
260 msg_print!(Message::ConfigModuleJiraInbox, true);
261
262 let enabled = Confirm::with_theme(&ColorfulTheme::default())
263 .with_prompt(Message::PromptJiraInboxEnabled.to_string())
264 .default(default.enabled)
265 .interact()?;
266
267 let poll_interval_secs: u64 = Input::with_theme(&ColorfulTheme::default())
268 .with_prompt(Message::PromptJiraInboxPollInterval.to_string())
269 .default(default.poll_interval_secs)
270 .interact_text()?;
271
272 let notify = Confirm::with_theme(&ColorfulTheme::default())
273 .with_prompt(Message::PromptJiraInboxNotify.to_string())
274 .default(default.notify)
275 .interact()?;
276
277 let default_sort_id = default.sort_by_field.clone().unwrap_or_default();
278 let sort_field_id: String = Input::with_theme(&ColorfulTheme::default())
279 .with_prompt(Message::PromptJiraInboxSortFieldId.to_string())
280 .with_initial_text(&default_sort_id)
281 .allow_empty(true)
282 .interact_text()?;
283
284 let mut custom_fields = Vec::new();
285 let mut sort_by_field = None;
286
287 let sort_trimmed = sort_field_id.trim();
288 if !sort_trimmed.is_empty() {
289 let default_label = default
290 .custom_fields
291 .iter()
292 .find(|f| f.id == sort_trimmed)
293 .map(|f| f.label.clone())
294 .unwrap_or_else(|| "Scoring".to_string());
295
296 let sort_label: String = Input::with_theme(&ColorfulTheme::default())
297 .with_prompt(Message::PromptJiraInboxSortFieldLabel.to_string())
298 .default(default_label)
299 .interact_text()?;
300
301 let label = {
302 let t = sort_label.trim();
303 if t.is_empty() { "Scoring".to_string() } else { t.to_string() }
304 };
305 custom_fields.push(JiraCustomField {
306 id: sort_trimmed.to_string(),
307 label,
308 });
309 sort_by_field = Some(sort_trimmed.to_string());
310 }
311
312 loop {
313 let extra_id: String = Input::with_theme(&ColorfulTheme::default())
314 .with_prompt(Message::PromptJiraInboxExtraFieldId.to_string())
315 .allow_empty(true)
316 .interact_text()?;
317 let trimmed = extra_id.trim();
318 if trimmed.is_empty() {
319 break;
320 }
321 if custom_fields.iter().any(|f| f.id == trimmed) {
322 continue;
323 }
324 let extra_label: String = Input::with_theme(&ColorfulTheme::default())
325 .with_prompt(Message::PromptJiraInboxExtraFieldLabel.to_string())
326 .default(trimmed.to_string())
327 .interact_text()?;
328 custom_fields.push(JiraCustomField {
329 id: trimmed.to_string(),
330 label: {
331 let t = extra_label.trim();
332 if t.is_empty() { trimmed.to_string() } else { t.to_string() }
333 },
334 });
335 }
336
337 Ok(JiraInboxConfig {
338 enabled,
339 poll_interval_secs: poll_interval_secs.max(30),
340 notify,
341 notify_changes: default.notify_changes,
344 notify_gone: default.notify_gone,
345 custom_fields,
346 sort_by_field,
347 })
348}