use crate::embeddings::{
DEFAULT_REQUIRED_DIMENSION, EmbeddingConfig, ProviderConfig, infer_embedding_dimension,
};
use crate::tui::detection::{
DetectedProvider, ProviderKind, check_health, detect_providers, dimension_explanation,
};
use crate::tui::health::{HealthCheckResult, HealthChecker};
use crate::tui::host_detection::{
ExtendedHostKind, HostDetection, detect_extended_hosts, generate_extended_snippet,
write_extended_host_config,
};
use crate::tui::indexer::{
DataSetupOption, DataSetupState, DataSetupSubStep, ImportMode, IndexProgress, import_lancedb,
start_indexing,
};
use anyhow::{Result, anyhow};
use crossterm::ExecutableCommand;
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::prelude::*;
use std::io::{Stdout, stdout};
use std::path::PathBuf;
use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Debug, Clone, Default)]
pub struct WizardConfig {
pub config_path: Option<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WizardStep {
Welcome,
EmbedderSetup,
MemexSettings,
HostSelection,
SnippetPreview,
HealthCheck,
DataSetup,
Summary,
}
impl WizardStep {
pub fn title(&self) -> &'static str {
match self {
WizardStep::Welcome => "Welcome",
WizardStep::EmbedderSetup => "Embedder Setup",
WizardStep::MemexSettings => "Database Setup",
WizardStep::HostSelection => "Host Selection",
WizardStep::SnippetPreview => "Config Preview",
WizardStep::HealthCheck => "Health Check",
WizardStep::DataSetup => "Data Setup",
WizardStep::Summary => "Summary & Write",
}
}
pub fn next(&self) -> Option<WizardStep> {
match self {
WizardStep::Welcome => Some(WizardStep::EmbedderSetup),
WizardStep::EmbedderSetup => Some(WizardStep::MemexSettings),
WizardStep::MemexSettings => Some(WizardStep::HostSelection),
WizardStep::HostSelection => Some(WizardStep::SnippetPreview),
WizardStep::SnippetPreview => Some(WizardStep::HealthCheck),
WizardStep::HealthCheck => Some(WizardStep::DataSetup),
WizardStep::DataSetup => Some(WizardStep::Summary),
WizardStep::Summary => None,
}
}
pub fn prev(&self) -> Option<WizardStep> {
match self {
WizardStep::Welcome => None,
WizardStep::EmbedderSetup => Some(WizardStep::Welcome),
WizardStep::MemexSettings => Some(WizardStep::EmbedderSetup),
WizardStep::HostSelection => Some(WizardStep::MemexSettings),
WizardStep::SnippetPreview => Some(WizardStep::HostSelection),
WizardStep::HealthCheck => Some(WizardStep::SnippetPreview),
WizardStep::DataSetup => Some(WizardStep::HealthCheck),
WizardStep::Summary => Some(WizardStep::DataSetup),
}
}
pub fn step_number(&self) -> usize {
match self {
WizardStep::Welcome => 1,
WizardStep::EmbedderSetup => 2,
WizardStep::MemexSettings => 3,
WizardStep::HostSelection => 4,
WizardStep::SnippetPreview => 5,
WizardStep::HealthCheck => 6,
WizardStep::DataSetup => 7,
WizardStep::Summary => 8,
}
}
pub fn total_steps() -> usize {
8
}
}
#[derive(Debug, Clone)]
pub struct EmbedderState {
pub detected_providers: Vec<DetectedProvider>,
pub detecting: bool,
pub selected_provider: Option<DetectedProvider>,
pub manual_url: String,
pub manual_model: String,
pub dimension: usize,
pub use_manual: bool,
}
impl Default for EmbedderState {
fn default() -> Self {
Self {
detected_providers: Vec::new(),
detecting: false,
selected_provider: None,
manual_url: "http://localhost:11434".to_string(),
manual_model: String::new(),
dimension: DEFAULT_REQUIRED_DIMENSION,
use_manual: false,
}
}
}
impl EmbedderState {
pub fn selected_model(&self) -> Option<String> {
if self.use_manual {
let model = self.manual_model.trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
} else if let Some(ref detected) = self.selected_provider {
detected
.model()
.map(str::trim)
.filter(|m| !m.is_empty())
.map(ToOwned::to_owned)
} else {
None
}
}
pub fn dimension_hint(&self) -> &'static str {
dimension_explanation(self.dimension)
}
pub fn build_embedding_config(&self) -> EmbeddingConfig {
let provider = if self.use_manual {
ProviderConfig {
name: "manual".to_string(),
base_url: self.manual_url.clone(),
model: self.manual_model.clone(),
priority: 1,
..Default::default()
}
} else if let Some(ref detected) = self.selected_provider {
ProviderConfig {
name: match detected.kind {
ProviderKind::Ollama => "ollama-local".to_string(),
ProviderKind::Mlx => "mlx-local".to_string(),
ProviderKind::OpenAICompat => "openai-compat".to_string(),
ProviderKind::Manual => "manual".to_string(),
},
base_url: detected.base_url.clone(),
model: detected.model().unwrap_or("unknown").to_string(),
priority: 1,
..Default::default()
}
} else {
ProviderConfig {
name: "ollama-local".to_string(),
base_url: "http://localhost:11434".to_string(),
model: self.selected_model().unwrap_or_default(),
priority: 1,
..Default::default()
}
};
EmbeddingConfig {
required_dimension: self.dimension,
providers: vec![provider],
..Default::default()
}
}
}
fn get_hostname() -> String {
if let Some(name) = std::process::Command::new("hostname")
.arg("-s") .output()
.ok()
.filter(|o| o.status.success())
{
let hostname = String::from_utf8_lossy(&name.stdout).trim().to_string();
if !hostname.is_empty() {
return hostname;
}
}
std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "local".to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DbPathMode {
Shared,
PerHost,
}
#[derive(Debug, Clone)]
pub struct MemexCfg {
pub db_path: String,
pub cache_mb: usize,
pub log_level: String,
pub max_request_bytes: usize,
pub hostname: String,
pub db_path_mode: DbPathMode,
pub http_port: Option<u16>,
}
impl Default for MemexCfg {
fn default() -> Self {
let hostname = get_hostname();
Self {
db_path: "~/.ai-memories/lancedb".to_string(),
cache_mb: 4096,
log_level: "info".to_string(),
max_request_bytes: 10 * 1024 * 1024, hostname,
db_path_mode: DbPathMode::Shared,
http_port: None,
}
}
}
impl MemexCfg {
pub fn resolved_db_path(&self) -> String {
match self.db_path_mode {
DbPathMode::Shared => self.db_path.clone(),
DbPathMode::PerHost => format!("{}.{}", self.db_path, self.hostname),
}
}
}
pub struct App {
pub step: WizardStep,
pub memex_cfg: MemexCfg,
pub embedder_state: EmbedderState,
pub embedding_config: EmbeddingConfig,
pub hosts: Vec<(ExtendedHostKind, HostDetection)>,
pub selected_hosts: Vec<usize>,
pub dry_run: bool,
pub messages: Vec<String>,
pub focus: usize,
pub binary_path: String,
pub health_status: Option<String>,
pub should_quit: bool,
pub input_mode: bool,
pub input_buffer: String,
pub editing_field: Option<usize>,
pub health_result: Option<HealthCheckResult>,
pub health_running: bool,
pub data_setup: DataSetupState,
pub index_progress_rx: Option<mpsc::Receiver<IndexProgress>>,
pub config_written: bool,
}
impl App {
pub fn new(config: WizardConfig) -> Self {
let hosts = detect_extended_hosts();
let binary_path = which_rmcp_memex().unwrap_or_else(|| "rmcp-memex".to_string());
let embedder_state = EmbedderState::default();
let embedding_config = embedder_state.build_embedding_config();
Self {
step: WizardStep::Welcome,
memex_cfg: MemexCfg::default(),
embedder_state,
embedding_config,
hosts,
selected_hosts: Vec::new(),
dry_run: config.dry_run,
messages: Vec::new(),
focus: 0,
binary_path,
health_status: None,
should_quit: false,
input_mode: false,
input_buffer: String::new(),
editing_field: None,
health_result: None,
health_running: false,
data_setup: DataSetupState::new(),
index_progress_rx: None,
config_written: false,
}
}
pub fn next_step(&mut self) {
if let Some(next) = self.step.next() {
if self.step == WizardStep::EmbedderSetup {
self.embedding_config = self.embedder_state.build_embedding_config();
}
self.step = next;
self.focus = 0;
self.input_mode = false;
self.editing_field = None;
if self.step == WizardStep::EmbedderSetup
&& self.embedder_state.detected_providers.is_empty()
{
self.embedder_state.detecting = true;
}
if self.step == WizardStep::HealthCheck && !self.health_running {
self.run_health_check();
self.trigger_health_check();
}
}
}
pub fn prev_step(&mut self) {
if let Some(prev) = self.step.prev() {
self.step = prev;
self.focus = 0;
}
}
pub fn toggle_host(&mut self, idx: usize) {
if self.selected_hosts.contains(&idx) {
self.selected_hosts.retain(|&i| i != idx);
} else {
self.selected_hosts.push(idx);
}
}
pub fn get_selected_hosts(&self) -> Vec<&(ExtendedHostKind, HostDetection)> {
self.selected_hosts
.iter()
.filter_map(|&i| self.hosts.get(i))
.collect()
}
pub fn generate_snippets(&self) -> Vec<(ExtendedHostKind, String)> {
let effective_path = self.memex_cfg.resolved_db_path();
self.get_selected_hosts()
.iter()
.map(|(kind, _detection)| {
let mut snippet =
generate_extended_snippet(*kind, &self.binary_path, &effective_path);
if let Some(port) = self.memex_cfg.http_port {
snippet = snippet.replace(
"\"serve\"",
&format!("\"serve\", \"--http-port\", \"{}\"", port),
);
}
(*kind, snippet)
})
.collect()
}
pub fn run_health_check(&mut self) {
self.health_status = Some("Checking...".to_string());
match std::process::Command::new(&self.binary_path)
.arg("--version")
.output()
{
Ok(output) => {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout);
self.health_status = Some(format!("[OK] Binary OK: {}", version.trim()));
} else {
self.health_status = Some("[ERR] Binary found but failed to run".to_string());
}
}
Err(e) => {
self.health_status = Some(format!("[ERR] Binary not found: {}", e));
}
}
self.messages.push(format!(
"[INFO] Host: {} (path mode: {:?})",
self.memex_cfg.hostname, self.memex_cfg.db_path_mode
));
let effective_path = self.memex_cfg.resolved_db_path();
let expanded_path = shellexpand::tilde(&effective_path).to_string();
let db_path = PathBuf::from(&expanded_path);
if db_path.exists() {
self.messages
.push(format!("[OK] DB path exists: {}", expanded_path));
} else {
self.messages
.push(format!("[-] DB path will be created: {}", expanded_path));
}
if let Some(port) = self.memex_cfg.http_port {
self.messages
.push(format!("[INFO] HTTP/SSE server will run on port {}", port));
}
}
pub fn write_configs(&mut self) -> Result<()> {
let effective_path = self.memex_cfg.resolved_db_path();
if self.dry_run {
self.messages.push("DRY RUN: No files written".to_string());
self.messages.push(format!(
"Host: {} | Path mode: {:?}",
self.memex_cfg.hostname, self.memex_cfg.db_path_mode
));
for &idx in &self.selected_hosts.clone() {
if let Some((kind, detection)) = self.hosts.get(idx) {
let snippet =
generate_extended_snippet(*kind, &self.binary_path, &effective_path);
self.messages.push(format!(
"Would write to {} ({}):\n{}",
kind.label(),
detection.path.display(),
snippet
));
}
}
return Ok(());
}
let mut success_count = 0;
let mut error_count = 0;
for &idx in &self.selected_hosts.clone() {
if let Some((kind, _detection)) = self.hosts.get(idx) {
match write_extended_host_config(*kind, &self.binary_path, &effective_path) {
Ok(result) => {
success_count += 1;
if let Some(backup) = result.backup_path {
self.messages.push(format!(
"[OK] {} backup: {}",
result.host_name,
backup.display()
));
}
if result.created {
self.messages.push(format!(
"[OK] {} created: {}",
result.host_name,
result.config_path.display()
));
} else {
self.messages.push(format!(
"[OK] {} updated: {}",
result.host_name,
result.config_path.display()
));
}
}
Err(e) => {
error_count += 1;
self.messages
.push(format!("[ERR] {} failed: {}", kind.label(), e));
}
}
}
}
if success_count > 0 {
self.messages.push(format!(
"\nConfiguration complete! {} host(s) configured.",
success_count
));
}
if error_count > 0 {
self.messages.push(format!(
"Warning: {} host(s) failed to configure.",
error_count
));
}
Ok(())
}
fn settings_field_count(&self) -> usize {
6 }
pub fn get_field_value(&self, field: usize) -> String {
match field {
0 => self.memex_cfg.db_path.clone(),
1 => match self.memex_cfg.db_path_mode {
DbPathMode::Shared => "shared".to_string(),
DbPathMode::PerHost => format!("per-host ({})", self.memex_cfg.hostname),
},
2 => match self.memex_cfg.http_port {
Some(port) => port.to_string(),
None => "disabled".to_string(),
},
3 => self.memex_cfg.cache_mb.to_string(),
4 => self.memex_cfg.log_level.clone(),
5 => self.memex_cfg.max_request_bytes.to_string(),
_ => String::new(),
}
}
pub fn set_field_value(&mut self, field: usize, value: String) {
match field {
0 => self.memex_cfg.db_path = value,
1 => {
self.memex_cfg.db_path_mode = match self.memex_cfg.db_path_mode {
DbPathMode::Shared => DbPathMode::PerHost,
DbPathMode::PerHost => DbPathMode::Shared,
};
}
2 => {
if value.to_lowercase() == "disabled" || value.is_empty() {
self.memex_cfg.http_port = None;
} else if let Ok(port) = value.parse() {
self.memex_cfg.http_port = Some(port);
}
}
3 => {
if let Ok(v) = value.parse() {
self.memex_cfg.cache_mb = v;
}
}
4 => self.memex_cfg.log_level = value,
5 => {
if let Ok(v) = value.parse() {
self.memex_cfg.max_request_bytes = v;
}
}
_ => {}
}
}
pub fn handle_key(&mut self, key: KeyCode) {
if self.input_mode || self.data_setup.input_mode {
self.handle_input_key(key);
return;
}
match key {
KeyCode::Char('q') => self.should_quit = true,
KeyCode::Esc => {
if self.step != WizardStep::Welcome {
self.prev_step();
} else {
self.should_quit = true;
}
}
KeyCode::Enter | KeyCode::Tab => self.handle_enter(),
KeyCode::Right | KeyCode::Char('n') => self.handle_next(),
KeyCode::Left | KeyCode::Char('p') => self.prev_step(),
KeyCode::Up | KeyCode::Char('k') => self.handle_up(),
KeyCode::Down | KeyCode::Char('j') => self.handle_down(),
KeyCode::Char(' ') => self.handle_space(),
KeyCode::Char('r') => {
if self.step == WizardStep::HealthCheck && !self.health_running {
self.trigger_health_check();
}
}
_ => {}
}
}
fn handle_input_key(&mut self, key: KeyCode) {
if self.data_setup.input_mode {
match key {
KeyCode::Enter => {
match self.data_setup.sub_step {
DataSetupSubStep::EnterPath => {
self.data_setup.confirm_path();
}
DataSetupSubStep::EnterNamespace => {
self.data_setup.confirm_namespace();
if self.data_setup.is_indexing() {
self.start_indexing_task();
}
}
_ => {}
}
}
KeyCode::Esc => {
self.data_setup.input_mode = false;
self.data_setup.input_buffer.clear();
self.data_setup.sub_step = DataSetupSubStep::SelectOption;
}
KeyCode::Backspace => {
self.data_setup.input_buffer.pop();
}
KeyCode::Char(c) => {
self.data_setup.input_buffer.push(c);
}
_ => {}
}
return;
}
if self.input_mode {
match key {
KeyCode::Enter => {
if let Some(field) = self.editing_field {
if self.step == WizardStep::EmbedderSetup && self.embedder_state.use_manual
{
match field {
0 => self.embedder_state.manual_url = self.input_buffer.clone(),
1 => {
self.embedder_state.manual_model = self.input_buffer.clone();
if let Some(dim) =
infer_embedding_dimension(&self.embedder_state.manual_model)
{
self.embedder_state.dimension = dim;
}
}
2 => {
if let Ok(dim) = self.input_buffer.parse() {
self.embedder_state.dimension = dim;
}
}
_ => {}
}
} else {
self.set_field_value(field, self.input_buffer.clone());
}
}
self.input_mode = false;
self.editing_field = None;
self.input_buffer.clear();
}
KeyCode::Esc => {
if self.step == WizardStep::EmbedderSetup && self.embedder_state.use_manual {
self.embedder_state.use_manual = false;
self.focus = 0;
}
self.input_mode = false;
self.editing_field = None;
self.input_buffer.clear();
}
KeyCode::Backspace => {
self.input_buffer.pop();
}
KeyCode::Char(c) => {
self.input_buffer.push(c);
}
_ => {}
}
}
}
fn handle_enter(&mut self) {
match self.step {
WizardStep::EmbedderSetup => {
self.handle_embedder_setup_enter();
}
WizardStep::MemexSettings => {
self.input_mode = true;
self.editing_field = Some(self.focus);
self.input_buffer = self.get_field_value(self.focus);
}
WizardStep::HostSelection => {
if self.focus < self.hosts.len() {
self.toggle_host(self.focus);
}
}
WizardStep::HealthCheck => {
if !self.health_running {
self.trigger_health_check();
}
}
WizardStep::DataSetup => {
self.handle_data_setup_enter();
}
WizardStep::Summary => {
if !self.config_written
&& let Err(e) = self.write_memex_config()
{
self.messages.push(format!("[ERR] {}", e));
}
if let Err(e) = self.write_configs() {
self.messages.push(format!("[ERR] {}", e));
}
}
_ => {}
}
}
fn handle_embedder_setup_enter(&mut self) {
if self.embedder_state.use_manual {
self.input_mode = true;
self.editing_field = Some(self.focus);
self.input_buffer = match self.focus {
0 => self.embedder_state.manual_url.clone(),
1 => self.embedder_state.manual_model.clone(),
2 => self.embedder_state.dimension.to_string(),
_ => String::new(),
};
} else if self.focus < self.embedder_state.detected_providers.len() {
let provider = self.embedder_state.detected_providers[self.focus].clone();
self.embedder_state.dimension = provider
.inferred_dimension()
.unwrap_or(self.embedder_state.dimension);
self.embedder_state.selected_provider = Some(provider);
} else {
self.embedder_state.use_manual = true;
self.focus = 0;
}
}
fn handle_data_setup_enter(&mut self) {
match self.data_setup.sub_step {
DataSetupSubStep::SelectOption => {
self.data_setup.select_focused();
}
DataSetupSubStep::SelectImportMode => {
let modes = ImportMode::all();
if let Some(mode) = modes.get(self.data_setup.focus).cloned() {
self.data_setup.select_import_mode(mode);
if self.data_setup.is_done()
&& self.data_setup.option == DataSetupOption::ImportLanceDB
{
self.perform_import();
}
}
}
_ => {}
}
}
fn handle_next(&mut self) {
if self.step == WizardStep::DataSetup {
if self.data_setup.is_done() || self.data_setup.option == DataSetupOption::Skip {
self.next_step();
}
} else if self.step == WizardStep::HealthCheck {
self.next_step();
} else {
self.next_step();
}
}
fn handle_up(&mut self) {
if self.focus > 0 {
self.focus -= 1;
}
if self.step == WizardStep::DataSetup {
self.data_setup.focus = self.focus;
}
}
fn handle_down(&mut self) {
let max = self.get_max_focus();
if self.focus < max {
self.focus += 1;
}
if self.step == WizardStep::DataSetup {
self.data_setup.focus = self.focus;
}
}
fn handle_space(&mut self) {
if self.step == WizardStep::HostSelection && self.focus < self.hosts.len() {
self.toggle_host(self.focus);
}
}
fn get_max_focus(&self) -> usize {
match self.step {
WizardStep::EmbedderSetup => {
if self.embedder_state.use_manual {
2 } else {
self.embedder_state.detected_providers.len()
}
}
WizardStep::MemexSettings => self.settings_field_count().saturating_sub(1),
WizardStep::HostSelection => self.hosts.len().saturating_sub(1),
WizardStep::DataSetup => match self.data_setup.sub_step {
DataSetupSubStep::SelectOption => DataSetupOption::all().len().saturating_sub(1),
DataSetupSubStep::SelectImportMode => ImportMode::all().len().saturating_sub(1),
_ => 0,
},
_ => 0,
}
}
pub fn trigger_health_check(&mut self) {
self.health_running = true;
self.health_status = Some("Running health checks...".to_string());
self.messages.clear();
if let Ok(output) = std::process::Command::new(&self.binary_path)
.arg("--version")
.output()
&& output.status.success()
{
let version = String::from_utf8_lossy(&output.stdout);
self.health_status = Some(format!("Binary: {} - Running checks...", version.trim()));
}
}
pub async fn run_async_health_check(&mut self) {
if let Some(ref provider) = self.embedder_state.selected_provider {
let url = format!("{}/v1/models", provider.base_url);
if check_health(&url).await {
self.messages
.push(format!("[OK] Provider {} is reachable", provider.base_url));
} else {
self.messages.push(format!(
"[WARN] Provider {} may be offline",
provider.base_url
));
}
}
let checker = HealthChecker::new();
let result = checker
.run_all(&self.embedding_config, &self.memex_cfg.db_path)
.await;
self.health_result = Some(result.clone());
self.health_running = false;
if result.all_passed() {
self.health_status = Some("All health checks passed!".to_string());
} else if result.any_failed() {
self.health_status =
Some("Some health checks failed. Review details below.".to_string());
} else {
self.health_status = Some("Health checks complete.".to_string());
}
}
fn start_indexing_task(&mut self) {
if let Some(ref source_path) = self.data_setup.source_path
&& let Some(ref namespace) = self.data_setup.namespace
{
let path = PathBuf::from(shellexpand::tilde(source_path).to_string());
let rx = start_indexing(
path,
namespace.clone(),
self.embedding_config.clone(),
self.memex_cfg.db_path.clone(),
);
self.index_progress_rx = Some(rx);
}
}
fn perform_import(&mut self) {
if let Some(ref source_path) = self.data_setup.source_path {
let source = PathBuf::from(shellexpand::tilde(source_path).to_string());
let target = PathBuf::from(shellexpand::tilde(&self.memex_cfg.db_path).to_string());
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
let mode = self.data_setup.import_mode.clone();
let result = tokio::task::block_in_place(|| {
handle.block_on(import_lancedb(&source, &target, mode))
});
match result {
Ok(msg) => {
self.messages.push(format!("[OK] {}", msg));
}
Err(e) => {
self.messages.push(format!("[ERR] Import failed: {}", e));
}
}
} else {
self.messages
.push("[INFO] Import will use config path directly".to_string());
}
}
}
pub fn poll_index_progress(&mut self) {
if let Some(ref mut rx) = self.index_progress_rx {
while let Ok(progress) = rx.try_recv() {
self.data_setup.progress = Some(progress.clone());
if progress.complete {
if let Some(ref error) = progress.error {
self.messages
.push(format!("[ERR] Indexing failed: {}", error));
} else {
self.messages.push(format!(
"[OK] Indexed {} files ({} skipped)",
progress.processed - progress.skipped,
progress.skipped
));
}
self.data_setup.sub_step = DataSetupSubStep::Complete;
self.index_progress_rx = None;
break;
}
}
}
}
pub async fn run_provider_detection(&mut self) {
if self.embedder_state.detecting {
self.embedder_state.detected_providers = detect_providers().await;
self.embedder_state.detecting = false;
if let Some(provider) = self
.embedder_state
.detected_providers
.iter()
.find(|p| p.is_usable())
{
self.embedder_state.selected_provider = Some(provider.clone());
self.embedder_state.dimension = provider
.inferred_dimension()
.unwrap_or(self.embedder_state.dimension);
}
}
}
pub fn generate_config_toml(&self) -> String {
const MODEL_PLACEHOLDER: &str = "<set-your-embedding-model>";
let mut toml = String::new();
toml.push_str("# rmcp-memex configuration\n");
toml.push_str(&format!(
"# Generated by wizard on host: {}\n",
self.memex_cfg.hostname
));
toml.push_str(&format!(
"# Path mode: {:?}\n\n",
self.memex_cfg.db_path_mode
));
toml.push_str("# Database configuration\n");
toml.push_str(&format!(
"db_path = \"{}\"\n",
self.memex_cfg.resolved_db_path()
));
toml.push_str(&format!("cache_mb = {}\n", self.memex_cfg.cache_mb));
toml.push_str(&format!("log_level = \"{}\"\n", self.memex_cfg.log_level));
toml.push_str(&format!(
"max_request_bytes = {}\n",
self.memex_cfg.max_request_bytes
));
if let Some(port) = self.memex_cfg.http_port {
toml.push_str("\n# HTTP/SSE server for multi-agent access\n");
toml.push_str(&format!("http_port = {}\n", port));
}
toml.push('\n');
toml.push_str("# Embedding provider configuration\n");
toml.push_str("[embeddings]\n");
toml.push_str(&format!(
"required_dimension = {}\n\n",
self.embedder_state.dimension
));
toml.push_str("[[embeddings.providers]]\n");
if self.embedder_state.use_manual {
toml.push_str("name = \"manual\"\n");
toml.push_str(&format!(
"base_url = \"{}\"\n",
self.embedder_state.manual_url
));
toml.push_str(&format!(
"model = \"{}\"\n",
self.embedder_state
.selected_model()
.unwrap_or_else(|| MODEL_PLACEHOLDER.to_string())
));
} else if let Some(ref provider) = self.embedder_state.selected_provider {
let name = match provider.kind {
ProviderKind::Ollama => "ollama-local",
ProviderKind::Mlx => "mlx-local",
ProviderKind::OpenAICompat => "openai-compat",
ProviderKind::Manual => "manual",
};
toml.push_str(&format!("name = \"{}\"\n", name));
toml.push_str(&format!("base_url = \"{}\"\n", provider.base_url));
toml.push_str(&format!(
"model = \"{}\"\n",
provider.model().unwrap_or(MODEL_PLACEHOLDER)
));
} else {
toml.push_str("name = \"ollama-local\"\n");
toml.push_str("base_url = \"http://localhost:11434\"\n");
toml.push_str(&format!("model = \"{}\"\n", MODEL_PLACEHOLDER));
}
toml.push_str("priority = 1\n");
toml.push_str("endpoint = \"/v1/embeddings\"\n");
toml
}
pub fn write_memex_config(&mut self) -> Result<()> {
if self.embedder_state.selected_model().is_none() {
return Err(anyhow!(
"No embedding model selected. Pick a detected provider or enter a manual model before writing config."
));
}
if self.dry_run {
self.messages
.push("DRY RUN: Config would be written to:".to_string());
self.messages
.push(" ~/.rmcp-servers/rmcp-memex/config.toml".to_string());
self.messages.push(String::new());
self.messages.push("Generated config:".to_string());
self.messages.push("---".to_string());
for line in self.generate_config_toml().lines() {
self.messages.push(format!(" {}", line));
}
self.messages.push("---".to_string());
self.config_written = true;
return Ok(());
}
let config_dir = shellexpand::tilde("~/.rmcp-servers/rmcp-memex").to_string();
let config_path = format!("{}/config.toml", config_dir);
std::fs::create_dir_all(&config_dir)?;
let config_file = PathBuf::from(&config_path);
if config_file.exists() {
let backup_path = format!("{}.bak.{}", config_path, timestamp());
std::fs::copy(&config_file, &backup_path)?;
self.messages
.push(format!("[OK] Backup created: {}", backup_path));
}
let toml_content = self.generate_config_toml();
std::fs::write(&config_path, &toml_content)?;
self.messages
.push(format!("[OK] Config written: {}", config_path));
let db_path = shellexpand::tilde(&self.memex_cfg.db_path).to_string();
if let Some(parent) = PathBuf::from(&db_path).parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
self.messages
.push(format!("[OK] Created directory: {}", parent.display()));
}
self.config_written = true;
self.messages.push(String::new());
self.messages.push("Configuration complete!".to_string());
self.messages
.push("Run 'rmcp-memex serve' to start the server.".to_string());
Ok(())
}
}
fn timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{}", secs)
}
fn which_rmcp_memex() -> Option<String> {
["rmcp-memex", "rmcp_memex"].into_iter().find_map(|binary| {
std::process::Command::new("which")
.arg(binary)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
})
}
type Tui = Terminal<CrosstermBackend<Stdout>>;
fn init_terminal() -> Result<Tui> {
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn restore_terminal() -> Result<()> {
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
pub fn run_wizard(config: WizardConfig) -> Result<()> {
let mut terminal = init_terminal()?;
let mut app = App::new(config);
let result = run_app(&mut terminal, &mut app);
restore_terminal()?;
result
}
fn run_app(terminal: &mut Tui, app: &mut App) -> Result<()> {
use crate::tui::ui::render;
let rt = match tokio::runtime::Handle::try_current() {
Ok(handle) => handle,
Err(_) => {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
Box::leak(Box::new(rt)).handle().clone()
}
};
loop {
terminal.draw(|f| render(f, app))?;
app.poll_index_progress();
if app.embedder_state.detecting {
let rt_clone = rt.clone();
tokio::task::block_in_place(|| {
rt_clone.block_on(async {
app.run_provider_detection().await;
});
});
}
if app.health_running && app.health_result.is_none() {
let rt_clone = rt.clone();
tokio::task::block_in_place(|| {
rt_clone.block_on(async {
app.run_async_health_check().await;
});
});
}
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
app.handle_key(key.code);
}
if app.should_quit {
break;
}
}
Ok(())
}