Skip to main content

mermaid_cli/runtime/
orchestrator.rs

1use anyhow::Result;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use crate::{
6    agents::mark_mcp_init_started,
7    app::{Config, load_config, persist_last_model},
8    cli::{Cli, handle_command},
9    mcp::McpServerManager,
10    models::{ModelConfig, ModelFactory},
11    ollama::ensure_model as ensure_ollama_model,
12    session::{ConversationManager, select_conversation},
13    tui::{App, McpInitResult, run_ui},
14    utils::{check_ollama_available, log_error, log_info, log_progress, log_warn},
15};
16
17/// Main runtime orchestrator
18pub struct Orchestrator {
19    cli: Cli,
20    config: Config,
21}
22
23impl Orchestrator {
24    /// Create a new orchestrator from CLI args
25    pub fn new(cli: Cli) -> Result<Self> {
26        // Load configuration (single file + defaults)
27        let config = match load_config() {
28            Ok(cfg) => cfg,
29            Err(e) => {
30                log_warn(
31                    "CONFIG",
32                    format!("Config load failed: {:#}. Using defaults.", e),
33                );
34                Config::default()
35            },
36        };
37
38        Ok(Self { cli, config })
39    }
40
41    /// Run the orchestrator
42    pub async fn run(self) -> Result<()> {
43        // Progress tracking for startup
44        let total_steps = 6; // Total startup steps
45        let mut current_step = 0;
46
47        // Handle subcommands
48        current_step += 1;
49        log_progress(current_step, total_steps, "Processing commands");
50        if let Some(command) = &self.cli.command
51            && handle_command(command, &self.config).await?
52        {
53            return Ok(()); // Command handled, exit
54        }
55        // Continue to chat for Commands::Chat
56
57        // Determine model to use (CLI arg > last_used > default_model)
58        current_step += 1;
59        log_progress(current_step, total_steps, "Configuring model");
60
61        let cli_model_provided = self.cli.model.is_some();
62        let model_id =
63            crate::app::resolve_model_id(self.cli.model.as_deref(), &self.config).await?;
64
65        log_info(
66            "MERMAID",
67            format!("Starting Mermaid with model: {}", model_id),
68        );
69
70        // For remote providers (anthropic, openai, etc.) we skip the
71        // Ollama health check and the local model-pull step. The provider
72        // adapter will surface API errors at chat time. Bare names default
73        // to Ollama, matching legacy behavior.
74        let model_uses_ollama = is_ollama_model(&model_id);
75
76        // Check Ollama availability (only when the chosen model routes
77        // through Ollama).
78        current_step += 1;
79        log_progress(current_step, total_steps, "Checking Ollama availability");
80        if model_uses_ollama {
81            let ollama_check =
82                check_ollama_available(&self.config.ollama.host, self.config.ollama.port).await;
83
84            if !ollama_check.available {
85                log_error("OLLAMA", &ollama_check.message);
86                anyhow::bail!("{}", ollama_check.message);
87            }
88        }
89
90        // Validate model exists (Ollama-only — remote adapters surface
91        // 404 themselves on first chat call).
92        current_step += 1;
93        log_progress(current_step, total_steps, "Checking model availability");
94        if model_uses_ollama {
95            ensure_ollama_model(&model_id, &self.config).await?;
96        }
97
98        // Create model instance with config for authentication.
99        // (Step 5d: persist moved to AFTER successful create — see below.)
100        current_step += 1;
101        log_progress(current_step, total_steps, "Initializing model");
102        let model = ModelFactory::create(&model_id, Some(&self.config))
103            .await
104            .map_err(|e| {
105                log_error("ERROR", format!("Failed to initialize model: {}", e));
106                anyhow::anyhow!(actionable_init_error(&model_id, e))
107            })?;
108
109        // Persist model AFTER successful creation — never persist a
110        // broken model to last_used_model. (Step 5d fix.)
111        if cli_model_provided && let Err(e) = persist_last_model(&model_id) {
112            log_warn("CONFIG", format!("Failed to persist model choice: {}", e));
113        }
114
115        // Set up project path for session management
116        let project_path = self.cli.path.clone().unwrap_or_else(|| PathBuf::from("."));
117
118        // Start UI - LLM explores codebase via tools, no context injection
119        current_step += 1;
120        log_progress(current_step, total_steps, "Starting UI");
121        let mut base_config = ModelConfig::from_app_config(&self.config, &model_id);
122        // CLI `--reasoning` overrides the config-file default for this
123        // session. Slash command + Alt+T can still change it at runtime.
124        if let Some(level) = self.cli.reasoning {
125            base_config.reasoning = level;
126        }
127        let mut app = App::new(model, model_id.clone(), base_config);
128
129        // Step 5h: discover MERMAID.md for project-level instructions.
130        // Walks UP from cwd to git root or $HOME. Silent if absent —
131        // most projects won't have one and we don't want log noise.
132        {
133            let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
134            if let Some(loaded) = crate::app::instructions::find_mermaid_md(&cwd)
135                .and_then(|p| crate::app::instructions::load_from_path(&p))
136            {
137                log_info(
138                    "INSTRUCTIONS",
139                    format!(
140                        "Loaded MERMAID.md ({} bytes{}) from {}",
141                        loaded.byte_len,
142                        if loaded.truncated { ", truncated" } else { "" },
143                        loaded.path.display()
144                    ),
145                );
146                app.instructions = Some(loaded);
147            }
148        }
149
150        // Start MCP servers in background (non-blocking — TUI renders immediately)
151        if !self.config.mcp_servers.is_empty() {
152            let server_count = self.config.mcp_servers.len();
153            log_info(
154                "MCP",
155                format!("Starting {} MCP server(s) in background...", server_count),
156            );
157            mark_mcp_init_started();
158
159            let mcp_configs = self.config.mcp_servers.clone();
160            app.mcp_init_task = Some(tokio::spawn(async move {
161                let manager = McpServerManager::start(&mcp_configs).await;
162                if manager.has_servers() {
163                    let tools = crate::models::tools::mcp_tools_to_ollama(manager.get_all_tools());
164                    log_info("MCP", format!("{} MCP tool(s) available", tools.len()));
165                    McpInitResult {
166                        tools,
167                        manager: Some(Arc::new(manager)),
168                    }
169                } else {
170                    McpInitResult {
171                        tools: Vec::new(),
172                        manager: None,
173                    }
174                }
175            }));
176        }
177
178        // Handle session loading
179        // Default: start fresh (no history)
180        // --continue: resume last conversation
181        // --sessions: show picker to choose a previous conversation
182        if self.cli.continue_session || self.cli.sessions {
183            let conversation_manager = ConversationManager::new(&project_path)?;
184
185            if self.cli.sessions {
186                // Show selection UI for choosing a conversation
187                let conversations = conversation_manager.list_conversations()?;
188                if !conversations.is_empty() {
189                    if let Some(selected) = select_conversation(conversations)? {
190                        log_info(
191                            "RESUME",
192                            format!("Resuming conversation: {}", selected.title),
193                        );
194                        app.load_conversation(selected);
195                    }
196                } else {
197                    log_info("INFO", "No previous conversations found in this directory");
198                }
199            } else {
200                // --continue: resume last conversation
201                if let Some(last_conv) = conversation_manager.load_last_conversation()? {
202                    log_info("RESUME", format!("Resuming: {}", last_conv.title));
203                    app.load_conversation(last_conv);
204                } else {
205                    log_info("INFO", "No previous conversation to continue");
206                }
207            }
208        }
209
210        // Run the TUI
211        run_ui(app).await
212    }
213}
214
215/// Whether the model_id resolves to the Ollama backend. Bare names
216/// (no `/`) default to Ollama; explicit `ollama/...` is also Ollama;
217/// anything with a non-ollama provider prefix is remote.
218fn is_ollama_model(model_id: &str) -> bool {
219    match model_id.split_once('/') {
220        Some((provider, _)) => provider.eq_ignore_ascii_case("ollama"),
221        None => true,
222    }
223}
224
225/// Build a startup error message that tells the user how to escape.
226/// The wrapped error from `ModelFactory::create` already explains WHAT
227/// failed (auth, 404, network); this wrapper appends WHAT TO DO so a
228/// user with a stale or unauthenticated `last_used_model` doesn't have
229/// to dig through docs to recover. Used by both startup paths
230/// (interactive `Orchestrator::run` + non-interactive
231/// `run_non_interactive` in main.rs).
232pub fn actionable_init_error(model_id: &str, source: impl std::fmt::Display) -> String {
233    format!(
234        "Failed to initialize model '{}': {}\n\n\
235         To recover, try one of:\n  \
236         - List available models: mermaid list\n  \
237         - Switch to a different model: mermaid --model <name>\n  \
238         - Use a local Ollama model (no API key needed): mermaid --model ollama/<name>",
239        model_id, source
240    )
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    /// The user's failing model id must appear in the error so they
248    /// know which one to swap out. Without this, "Failed to initialize
249    /// model" with no name is useless — the user can't tell whether
250    /// they picked the wrong model or hit a global config issue.
251    #[test]
252    fn actionable_init_error_names_failing_model() {
253        let msg = actionable_init_error(
254            "anthropic/claude-opus-4-7",
255            "Authentication error: ANTHROPIC_API_KEY not set",
256        );
257        assert!(
258            msg.contains("anthropic/claude-opus-4-7"),
259            "model id missing from error: {}",
260            msg
261        );
262    }
263
264    /// Recommend `mermaid list` so the user can see what they CAN
265    /// actually use right now.
266    #[test]
267    fn actionable_init_error_recommends_mermaid_list() {
268        let msg = actionable_init_error("foo/bar", "404 not found");
269        assert!(
270            msg.contains("mermaid list"),
271            "expected `mermaid list` recovery hint in: {}",
272            msg
273        );
274    }
275
276    /// Recommend `mermaid --model <name>` so the user has a one-line
277    /// command to escape the trap.
278    #[test]
279    fn actionable_init_error_recommends_explicit_model_flag() {
280        let msg = actionable_init_error("foo/bar", "404 not found");
281        assert!(
282            msg.contains("--model"),
283            "expected `--model` recovery hint in: {}",
284            msg
285        );
286    }
287}