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                    // The shipped default is English; the wizard used to offer
172                    // "ru", so pressing Enter opted the user into the pre-1.0
173                    // language without saying so.
174                    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                _ => {} // Unknown module keys are safely ignored
201            }
202        }
203
204        Ok(config)
205    }
206}
207
208/// Interactive editor for the task discovery ignore list.
209fn 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
261/// Interactive wizard for Jira inbox polling settings.
262fn 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        // Change/gone toasts keep their previous (or default) values; the
345        // wizard stays short and these are tuned via the config file.
346        notify_changes: default.notify_changes,
347        notify_gone: default.notify_gone,
348        custom_fields,
349        sort_by_field,
350    })
351}