mod render;
mod steps;
#[cfg(test)]
mod tests;
pub use render::render_onboarding;
use steps::build_steps;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StepKind {
ThemeSelect,
Radio,
Checkbox,
TextInput { masked: bool },
Summary,
}
#[derive(Debug, Clone)]
pub struct StepOption {
pub label: String,
pub hint: Option<String>,
pub tag: Option<&'static str>,
}
impl StepOption {
pub(super) fn new(label: &str) -> Self {
Self {
label: label.to_string(),
hint: None,
tag: None,
}
}
pub(super) fn with_hint(mut self, hint: &str) -> Self {
self.hint = Some(hint.to_string());
self
}
pub(super) const fn with_tag(mut self, tag: &'static str) -> Self {
self.tag = Some(tag);
self
}
}
#[derive(Debug, Clone)]
pub struct OnboardingStep {
pub id: &'static str,
pub title: &'static str,
pub description: &'static str,
pub kind: StepKind,
pub options: Vec<StepOption>,
pub selected: Vec<usize>,
pub text_value: String,
}
#[derive(Debug, Clone)]
pub struct OnboardingWizard {
pub steps: Vec<OnboardingStep>,
pub current_step: usize,
pub cursor: usize,
pub completed: bool,
pub result_summary: Option<String>,
pub project_type: Option<String>,
pub active_steps: Vec<usize>,
pub provider_substep: usize,
pub text_cursor: usize,
pub validation_message: Option<String>,
}
impl OnboardingWizard {
pub fn new() -> Self {
let steps = build_steps();
let active_steps: Vec<usize> = (0..steps.len()).collect();
Self {
steps,
current_step: 0,
cursor: 0,
completed: false,
result_summary: None,
project_type: None,
active_steps,
provider_substep: 0,
text_cursor: 0,
validation_message: None,
}
}
pub fn resume(last_completed_step: usize) -> Self {
let mut wiz = Self::new();
let start = last_completed_step.saturating_add(1);
wiz.current_step = if start < wiz.steps.len() { start } else { 0 };
wiz
}
pub const fn total_visible_steps(&self) -> usize {
self.active_steps.len()
}
pub fn visible_position(&self) -> usize {
self.active_steps
.iter()
.position(|&i| i == self.current_step)
.map_or(1, |p| p + 1)
}
pub fn current(&self) -> Option<&OnboardingStep> {
self.steps.get(self.current_step)
}
pub fn current_mut(&mut self) -> Option<&mut OnboardingStep> {
self.steps.get_mut(self.current_step)
}
pub const fn move_cursor_up(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn move_cursor_down(&mut self) {
if let Some(step) = self.current()
&& self.cursor + 1 < step.options.len()
{
self.cursor += 1;
}
}
pub fn toggle_selection(&mut self) {
let cursor = self.cursor;
if let Some(step) = self.current_mut() {
match step.kind {
StepKind::Radio | StepKind::ThemeSelect => {
step.selected = vec![cursor];
}
StepKind::Checkbox => {
if let Some(pos) = step.selected.iter().position(|&i| i == cursor) {
step.selected.remove(pos);
} else {
step.selected.push(cursor);
}
}
StepKind::TextInput { .. } => {
step.selected = vec![cursor];
}
StepKind::Summary => {}
}
}
}
pub fn select_all(&mut self) {
if let Some(step) = self.current_mut()
&& step.kind == StepKind::Checkbox
{
step.selected = (0..step.options.len()).collect();
}
}
pub fn select_minimum(&mut self) {
if let Some(step) = self.current_mut()
&& step.kind == StepKind::Checkbox
{
step.selected = vec![0];
}
}
pub fn next_step(&mut self) -> bool {
let current_pos = self
.active_steps
.iter()
.position(|&i| i == self.current_step);
if let Some(pos) = current_pos {
if pos + 1 < self.active_steps.len() {
self.current_step = self.active_steps[pos + 1];
self.cursor = 0;
self.provider_substep = 0;
self.text_cursor = 0;
self.validation_message = None;
false
} else {
self.completed = true;
self.build_summary();
true
}
} else {
false
}
}
pub fn prev_step(&mut self) {
let current_pos = self
.active_steps
.iter()
.position(|&i| i == self.current_step);
if let Some(pos) = current_pos
&& pos > 0
{
self.current_step = self.active_steps[pos - 1];
self.cursor = 0;
}
}
pub fn insert_char(&mut self, c: char) {
if let Some(step) = self.steps.get_mut(self.current_step)
&& self.text_cursor <= step.text_value.len()
{
step.text_value.insert(self.text_cursor, c);
self.text_cursor += c.len_utf8();
}
}
pub fn delete_char_before(&mut self) {
if self.text_cursor == 0 {
return;
}
if let Some(step) = self.steps.get_mut(self.current_step)
&& self.text_cursor <= step.text_value.len()
{
let prev = step.text_value[..self.text_cursor]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
step.text_value.remove(prev);
self.text_cursor = prev;
}
}
pub fn step_text_value(&self, id: &str) -> String {
self.steps
.iter()
.find(|s| s.id == id)
.map(|s| s.text_value.clone())
.unwrap_or_default()
}
pub fn recalculate_active_steps(&mut self) {
let pt = self.project_type.as_deref().unwrap_or("existing");
self.active_steps = self
.steps
.iter()
.enumerate()
.filter(|(_, step)| {
match step.id {
"workspace_trust" => pt != "demo",
_ => true,
}
})
.map(|(i, _)| i)
.collect();
}
pub fn answers(&self) -> Vec<(&'static str, Vec<String>)> {
self.steps
.iter()
.map(|step| {
let values: Vec<String> = if matches!(step.kind, StepKind::TextInput { .. }) {
let mut vals: Vec<String> = step
.selected
.iter()
.filter_map(|&i| step.options.get(i).map(|o| o.label.clone()))
.collect();
if !step.text_value.is_empty() {
vals.push("[key set]".to_string());
}
vals
} else {
step.selected
.iter()
.filter_map(|&i| step.options.get(i).map(|o| o.label.clone()))
.collect()
};
(step.id, values)
})
.collect()
}
pub fn selected_config_value(&self, step_id: &str) -> String {
let step = match self.steps.iter().find(|s| s.id == step_id) {
Some(s) => s,
None => return String::new(),
};
let idx = step.selected.first().copied().unwrap_or(0);
match step_id {
"welcome_theme" => {
let names = [
"dark",
"light",
"solarized-dark",
"solarized-light",
"dracula",
"nord",
"monokai",
"gruvbox",
];
names.get(idx).unwrap_or(&"dark").to_string()
}
"project_type" => match idx {
0 => "existing",
1 => "demo",
_ => "existing",
}
.to_string(),
"workspace_trust" => match idx {
0 => "yes",
1 => "no",
_ => "yes",
}
.to_string(),
"role" => match idx {
0 => "deployer",
1 => "provider",
2 => "both",
3 => "auto",
_ => "deployer",
}
.to_string(),
"industry" => match idx {
0 => "general",
1 => "hr",
2 => "finance",
3 => "healthcare",
4 => "education",
5 => "legal",
6 => "security",
7 => "marketing",
8 => "customer-service",
9 => "auto",
_ => "general",
}
.to_string(),
"ai_provider" => match idx {
0 => "openrouter".to_string(),
1 => "anthropic".to_string(),
2 => "openai".to_string(),
3 => "guard_api".to_string(),
4 => "offline".to_string(),
_ => "offline".to_string(),
},
_ => String::new(),
}
}
fn build_summary(&mut self) {
let answers = self.answers();
let mut parts = Vec::new();
for (id, values) in &answers {
if !values.is_empty() {
parts.push(format!("{id}: {}", values.join(", ")));
}
}
self.result_summary = Some(parts.join(" | "));
}
}