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())
175 .with_prompt("Report language (en, ru)")
176 .default(default.language.unwrap_or_else(|| "en".to_string()))
177 .allow_empty(true)
178 .interact_text()?;
179 let template: String = Input::with_theme(&ColorfulTheme::default())
180 .with_prompt("Report design template name")
181 .default(default.template.unwrap_or_else(|| "siserver".to_string()))
182 .allow_empty(true)
183 .interact_text()?;
184 config.report = Some(ReportConfig {
185 output_dir: if output_dir.trim().is_empty() { None } else { Some(output_dir) },
186 filename_template: if filename_template.trim().is_empty() { None } else { Some(filename_template) },
187 language: if language.trim().is_empty() { None } else { Some(language) },
188 template: if template.trim().is_empty() { None } else { Some(template) },
189 });
190 }
191
192 "task_discovery" => {
193 config.task_discovery = Some(configure_task_discovery(config.task_discovery.clone().unwrap_or_default())?);
194 }
195
196 "jira_inbox" => {
197 config.jira_inbox = Some(configure_jira_inbox(config.jira_inbox.clone().unwrap_or_default())?);
198 }
199
200 _ => {} }
202 }
203
204 Ok(config)
205 }
206}
207
208fn configure_task_discovery(mut discovery: TaskDiscoveryConfig) -> Result<TaskDiscoveryConfig> {
210 msg_print!(Message::ConfigModuleTaskDiscovery, true);
211
212 if discovery.ignore_names.is_empty() {
213 msg_info!(Message::TaskDiscoveryIgnoreListEmpty);
214 } else {
215 msg_print!(Message::TaskDiscoveryIgnoreListHeader, true);
216 for (idx, name) in discovery.ignore_names.iter().enumerate() {
217 println!(" {}. {}", idx + 1, name);
218 }
219 println!();
220
221 let remove = MultiSelect::with_theme(&ColorfulTheme::default())
222 .with_prompt(Message::PromptSelectIgnoreNamesToRemove.to_string())
223 .items(&discovery.ignore_names)
224 .interact()?;
225
226 if !remove.is_empty() {
227 let mut keep = Vec::new();
228 for (idx, name) in discovery.ignore_names.iter().enumerate() {
229 if !remove.contains(&idx) {
230 keep.push(name.clone());
231 }
232 }
233 discovery.ignore_names = keep;
234 }
235 }
236
237 loop {
238 let name: String = Input::with_theme(&ColorfulTheme::default())
239 .with_prompt(Message::PromptAddIgnoreName.to_string())
240 .allow_empty(true)
241 .interact_text()?;
242
243 let trimmed = name.trim();
244 if trimmed.is_empty() {
245 break;
246 }
247
248 let key = normalize_task_name(trimmed);
249 let exists = discovery.ignore_names.iter().any(|existing| normalize_task_name(existing) == key);
250 if exists {
251 msg_info!(Message::TaskDiscoveryIgnoreNameExists(trimmed.to_string()));
252 } else {
253 discovery.ignore_names.push(trimmed.to_string());
254 msg_success!(Message::TaskDiscoveryIgnoreNameAdded(trimmed.to_string()));
255 }
256 }
257
258 Ok(discovery)
259}
260
261fn configure_jira_inbox(default: JiraInboxConfig) -> Result<JiraInboxConfig> {
263 msg_print!(Message::ConfigModuleJiraInbox, true);
264
265 let enabled = Confirm::with_theme(&ColorfulTheme::default())
266 .with_prompt(Message::PromptJiraInboxEnabled.to_string())
267 .default(default.enabled)
268 .interact()?;
269
270 let poll_interval_secs: u64 = Input::with_theme(&ColorfulTheme::default())
271 .with_prompt(Message::PromptJiraInboxPollInterval.to_string())
272 .default(default.poll_interval_secs)
273 .interact_text()?;
274
275 let notify = Confirm::with_theme(&ColorfulTheme::default())
276 .with_prompt(Message::PromptJiraInboxNotify.to_string())
277 .default(default.notify)
278 .interact()?;
279
280 let default_sort_id = default.sort_by_field.clone().unwrap_or_default();
281 let sort_field_id: String = Input::with_theme(&ColorfulTheme::default())
282 .with_prompt(Message::PromptJiraInboxSortFieldId.to_string())
283 .with_initial_text(&default_sort_id)
284 .allow_empty(true)
285 .interact_text()?;
286
287 let mut custom_fields = Vec::new();
288 let mut sort_by_field = None;
289
290 let sort_trimmed = sort_field_id.trim();
291 if !sort_trimmed.is_empty() {
292 let default_label = default
293 .custom_fields
294 .iter()
295 .find(|f| f.id == sort_trimmed)
296 .map(|f| f.label.clone())
297 .unwrap_or_else(|| "Scoring".to_string());
298
299 let sort_label: String = Input::with_theme(&ColorfulTheme::default())
300 .with_prompt(Message::PromptJiraInboxSortFieldLabel.to_string())
301 .default(default_label)
302 .interact_text()?;
303
304 let label = {
305 let t = sort_label.trim();
306 if t.is_empty() { "Scoring".to_string() } else { t.to_string() }
307 };
308 custom_fields.push(JiraCustomField {
309 id: sort_trimmed.to_string(),
310 label,
311 });
312 sort_by_field = Some(sort_trimmed.to_string());
313 }
314
315 loop {
316 let extra_id: String = Input::with_theme(&ColorfulTheme::default())
317 .with_prompt(Message::PromptJiraInboxExtraFieldId.to_string())
318 .allow_empty(true)
319 .interact_text()?;
320 let trimmed = extra_id.trim();
321 if trimmed.is_empty() {
322 break;
323 }
324 if custom_fields.iter().any(|f| f.id == trimmed) {
325 continue;
326 }
327 let extra_label: String = Input::with_theme(&ColorfulTheme::default())
328 .with_prompt(Message::PromptJiraInboxExtraFieldLabel.to_string())
329 .default(trimmed.to_string())
330 .interact_text()?;
331 custom_fields.push(JiraCustomField {
332 id: trimmed.to_string(),
333 label: {
334 let t = extra_label.trim();
335 if t.is_empty() { trimmed.to_string() } else { t.to_string() }
336 },
337 });
338 }
339
340 Ok(JiraInboxConfig {
341 enabled,
342 poll_interval_secs: poll_interval_secs.max(30),
343 notify,
344 notify_changes: default.notify_changes,
347 notify_gone: default.notify_gone,
348 custom_fields,
349 sort_by_field,
350 })
351}