Skip to main content

kasl/libs/config/
wizard.rs

1//! The interactive setup wizard behind `kasl setup`.
2//!
3//! Every prompt lives here, away from the data model: the module list,
4//! the per-module parameter prompts, and the two list editors (discovery
5//! ignore names, Jira inbox fields).
6
7use 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    /// Runs the setup wizard: pick modules, prompt each one's settings,
19    /// return the updated config (the caller saves it).
20    ///
21    /// ```rust,no_run
22    /// # fn main() -> anyhow::Result<()> {
23    /// use kasl::libs::config::Config;
24    ///
25    /// let config = Config::init()?;
26    /// config.save()?;
27    /// # Ok(())
28    /// # }
29    /// ```
30    pub fn init() -> Result<Self> {
31        // The whole wizard is prompts: every module below asks for values, and
32        // several read secrets. Without a terminal there is nobody to answer.
33        crate::libs::prompt::ensure_interactive("`kasl setup` is an interactive wizard and needs a terminal")?;
34
35        // Existing values become the wizard's defaults.
36        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                // External API integrations delegate to their own setup methods
76                "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                        // Preserve the pause-merge gap (edited manually in config.json)
110                        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                _ => {} // Unknown module keys are safely ignored
198            }
199        }
200
201        Ok(config)
202    }
203}
204
205/// Interactive editor for the task discovery ignore list.
206fn 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
258/// Interactive wizard for Jira inbox polling settings.
259fn 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        // Change/gone toasts keep their previous (or default) values; the
342        // wizard stays short and these are tuned via the config file.
343        notify_changes: default.notify_changes,
344        notify_gone: default.notify_gone,
345        custom_fields,
346        sort_by_field,
347    })
348}