1use anyhow::{Context, Result, anyhow, bail};
2use std::path::Path;
3use std::sync::Arc;
4
5use crate::{
6 app::{Config, get_config_dir, init_config, load_config_or_warn},
7 domain::{
8 ChatRequest, Cmd, CompactionRecord, CompactionResult, CompactionTrigger, Msg, SlashCmd,
9 State, build_replacement_messages, estimate_context_usage_for_request, prepare_compaction,
10 update,
11 },
12 models::{BackendConfig, ChatMessage, Model, PROVIDER_REGISTRY, lookup_provider},
13 ollama::is_installed as is_ollama_installed,
14 runtime::{NewProviderProbe, RuntimeClient, RuntimeStore, TaskRecord},
15 session::ConversationManager,
16 utils::{resolve_api_key, resolve_api_key_with_fallback},
17};
18
19use super::{Commands, GitHost, OutputFormat, PairCommand, PluginCommand, PrCommand, QaCommand};
20
21pub async fn handle_command(
25 command: &Commands,
26 config: &Config,
27 cwd: &Path,
28 cli_model: Option<&str>,
29) -> Result<bool> {
30 match command {
31 Commands::Init => {
32 println!("Initializing Mermaid configuration...");
33 init_config()?;
34 println!("Configuration initialized successfully!");
35 Ok(true)
36 },
37 Commands::List => {
38 list_models(config).await?;
39 Ok(true)
40 },
41 Commands::Models => {
42 show_models(config).await?;
43 Ok(true)
44 },
45 Commands::ModelInfo { model } => {
46 show_model_info(model, config).await?;
47 Ok(true)
48 },
49 Commands::Version => {
50 show_version();
51 Ok(true)
52 },
53 Commands::Update { check, force } => {
54 run_update(*check, *force).await?;
55 Ok(true)
56 },
57 Commands::Status => {
58 show_status(config).await?;
59 Ok(true)
60 },
61 Commands::Doctor { format } => {
62 show_doctor(config, cwd, cli_model, *format).await?;
63 Ok(true)
64 },
65 Commands::SelfTest {
66 format,
67 keep_workspace,
68 } => {
69 run_self_test(config, *format, *keep_workspace)?;
70 Ok(true)
71 },
72 Commands::Tasks { limit } => {
73 show_tasks(*limit)?;
74 Ok(true)
75 },
76 Commands::Task { id } => {
77 show_task(id)?;
78 Ok(true)
79 },
80 Commands::Processes { limit } => {
81 show_processes(*limit)?;
82 Ok(true)
83 },
84 Commands::Logs { id } => {
85 show_logs(id)?;
86 Ok(true)
87 },
88 Commands::Stop { id } => {
89 stop_process(id)?;
90 Ok(true)
91 },
92 Commands::Restart { id } => {
93 restart_process(id)?;
94 Ok(true)
95 },
96 Commands::Open { target } => {
97 open_target(target)?;
98 Ok(true)
99 },
100 Commands::Ports => {
101 show_ports()?;
102 Ok(true)
103 },
104 Commands::Approvals => {
105 show_approvals()?;
106 Ok(true)
107 },
108 Commands::Approve { id } => {
109 approve(id)?;
110 Ok(true)
111 },
112 Commands::Deny { id } => {
113 deny(id)?;
114 Ok(true)
115 },
116 Commands::ToolRuns { limit } => {
117 show_tool_runs(*limit)?;
118 Ok(true)
119 },
120 Commands::Checkpoints { limit } => {
121 show_checkpoints(*limit)?;
122 Ok(true)
123 },
124 Commands::Restore { id, force } => {
125 restore_checkpoint(id, *force)?;
126 Ok(true)
127 },
128 Commands::Plugin { command } => {
129 handle_plugin(command)?;
130 Ok(true)
131 },
132 Commands::Daemon { command } => {
133 super::daemon::handle_daemon_command(command)?;
134 Ok(true)
135 },
136 Commands::Pair { command } => {
137 handle_pair(command)?;
138 Ok(true)
139 },
140 Commands::Qa { command } => {
141 handle_qa(command, config, cwd)?;
142 Ok(true)
143 },
144 Commands::Add { name, yes } => {
145 crate::mcp::add_server(name, *yes).await?;
146 Ok(true)
147 },
148 Commands::Remove { name } => {
149 crate::mcp::remove_server(name).await?;
150 Ok(true)
151 },
152 Commands::Pr { command } => {
153 handle_pr(command)?;
154 Ok(true)
155 },
156 Commands::Mcp => {
157 show_mcp_servers();
158 Ok(true)
159 },
160 Commands::CloudSetup => {
161 let _ = crate::ollama::setup_cloud_interactive();
165 Ok(true)
166 },
167 Commands::Chat => Ok(false), Commands::Run { .. } => Ok(false), }
170}
171
172fn handle_qa(command: &QaCommand, config: &Config, cwd: &Path) -> Result<()> {
173 match command {
174 QaCommand::CompactSmoke { turns, format } => {
175 let report = match run_qa_compact_smoke(config, cwd, *turns) {
176 Ok(report) => report,
177 Err(err) => QaCompactSmokeReport::failed(cwd, *turns, err.to_string()),
178 };
179 print_qa_compact_report(&report, *format)?;
180 anyhow::ensure!(report.ok, "qa compact smoke failed");
181 Ok(())
182 },
183 }
184}
185
186#[derive(Debug, serde::Serialize)]
187struct DoctorReport {
188 ok: bool,
189 cwd: String,
190 active_model: Option<String>,
191 model_error: Option<String>,
192 model_capabilities: Option<DoctorModelCapabilities>,
193 safety_mode: String,
194 checkpoint_on_mutation: bool,
195 prompt_customized: bool,
196 ollama: DoctorCheck,
197 remote_providers: Vec<String>,
198 project_instructions: DoctorCheck,
199 tools: Vec<String>,
200 runtime: DoctorRuntime,
201 next_steps: Vec<String>,
202}
203
204#[derive(Debug, serde::Serialize)]
205struct DoctorModelCapabilities {
206 provider: String,
207 name: String,
208 supports_tools: bool,
209 supports_vision: bool,
210 reasoning: String,
211 max_context_tokens: Option<usize>,
212}
213
214#[derive(Debug, serde::Serialize)]
215struct DoctorCheck {
216 status: &'static str,
217 message: String,
218}
219
220#[derive(Debug, serde::Serialize)]
221struct DoctorRuntime {
222 daemon: DoctorCheck,
223 local_store: DoctorCheck,
224}
225
226async fn show_doctor(
227 config: &Config,
228 cwd: &Path,
229 cli_model: Option<&str>,
230 format: OutputFormat,
231) -> Result<()> {
232 let active_model_result = crate::app::resolve_model_id(cli_model, config).await;
233 let (active_model, model_error, model_capabilities) = match active_model_result {
234 Ok(model) => {
235 let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(&model);
236 (
237 Some(model),
238 None,
239 Some(DoctorModelCapabilities {
240 provider: snapshot.provider,
241 name: snapshot.model,
242 supports_tools: snapshot.supports_tools,
243 supports_vision: snapshot.supports_vision,
244 reasoning: snapshot.reasoning,
245 max_context_tokens: snapshot.max_context_tokens,
246 }),
247 )
248 },
249 Err(err) => (None, Some(err.to_string()), None),
250 };
251
252 let ollama_models = if is_ollama_installed() {
257 list_ollama_models(config, false).await
258 } else {
259 None
260 };
261 let ollama = if !is_ollama_installed() {
262 DoctorCheck {
263 status: "warning",
264 message: "Ollama is not installed; remote providers can still work if configured."
265 .to_string(),
266 }
267 } else {
268 match &ollama_models {
269 None => DoctorCheck {
270 status: "warning",
271 message: "Ollama is installed but not running; mermaid starts it \
272 automatically when an Ollama model is used."
273 .to_string(),
274 },
275 Some(models) if models.is_empty() => DoctorCheck {
276 status: "warning",
277 message: "Ollama is running but no local/cloud models were listed.".to_string(),
278 },
279 Some(models) => DoctorCheck {
280 status: "ok",
281 message: format!("Ollama reachable with {} models.", models.len()),
282 },
283 }
284 };
285
286 let remote_providers = configured_remote_providers(config);
287 let instruction_paths = crate::app::instructions::find_instruction_files(cwd);
288 let project_instructions = if instruction_paths.is_empty() {
289 DoctorCheck {
290 status: "info",
291 message: "No AGENTS.md or MERMAID.md found.".to_string(),
292 }
293 } else if let Some(loaded) = crate::app::instructions::load_from_paths(&instruction_paths) {
294 DoctorCheck {
295 status: "ok",
296 message: format!(
297 "{} bytes loaded from {} source(s){}.",
298 loaded.byte_len,
299 loaded.sources.len(),
300 if loaded.truncated { " (truncated)" } else { "" }
301 ),
302 }
303 } else {
304 DoctorCheck {
305 status: "warning",
306 message: "Instruction files were found but could not be loaded.".to_string(),
307 }
308 };
309
310 let daemon = match RuntimeClient::daemon().health() {
311 Ok(read) => DoctorCheck {
312 status: "ok",
313 message: format!("daemon attached; database {}", read.value.database),
314 },
315 Err(err) => DoctorCheck {
316 status: "info",
317 message: format!("daemon not attached; CLI will use local runtime store ({err})"),
318 },
319 };
320 let local_store = match RuntimeClient::local().health() {
321 Ok(read) => DoctorCheck {
322 status: "ok",
323 message: format!("local runtime store ready at {}", read.value.database),
324 },
325 Err(err) => DoctorCheck {
326 status: "warning",
327 message: format!("local runtime store unavailable: {err}"),
328 },
329 };
330
331 let has_ollama_key = std::env::var("OLLAMA_API_KEY").is_ok();
332 let mut tools = vec![
333 "read/edit/write files".to_string(),
334 "run shell commands".to_string(),
335 "create checkpoints before risky mutations".to_string(),
336 ];
337 match config.web.fetch_backend {
339 crate::app::FetchBackend::Native => tools.push("web_fetch (native, no key)".to_string()),
340 crate::app::FetchBackend::Ollama if has_ollama_key => {
341 tools.push("web_fetch (Ollama Cloud)".to_string())
342 },
343 crate::app::FetchBackend::Ollama => {},
344 }
345 match config.web.search_backend {
348 crate::app::SearchBackend::Auto if has_ollama_key => {
349 tools.push("web_search (auto: Ollama Cloud)".to_string())
350 },
351 crate::app::SearchBackend::Auto => {
352 tools.push("web_search (auto: managed local SearXNG)".to_string())
353 },
354 crate::app::SearchBackend::Searxng => {
355 tools.push(format!("web_search (SearXNG {})", config.web.searxng_url))
356 },
357 crate::app::SearchBackend::Ollama if has_ollama_key => {
358 tools.push("web_search (Ollama Cloud)".to_string())
359 },
360 crate::app::SearchBackend::Ollama => {},
361 }
362 if !config.mcp_servers.is_empty() {
363 tools.push(format!(
364 "{} configured MCP server(s)",
365 config.mcp_servers.len()
366 ));
367 }
368
369 let mut next_steps = Vec::new();
370 if active_model.is_none() {
371 next_steps.push(
372 "Pick a model with `mermaid --model <provider/model>` or run `mermaid list`."
373 .to_string(),
374 );
375 }
376 if remote_providers.is_empty() && ollama_models.as_deref().unwrap_or_default().is_empty() {
377 next_steps.push(
378 "Install or start Ollama, pull a model, or set a remote provider API key.".to_string(),
379 );
380 }
381 if instruction_paths.is_empty() {
382 next_steps.push("Optional: add MERMAID.md or AGENTS.md with project-specific run commands and conventions.".to_string());
383 }
384 if matches!(config.web.search_backend, crate::app::SearchBackend::Ollama) && !has_ollama_key {
385 next_steps.push(
386 "Web search needs a backend: set OLLAMA_API_KEY (run `mermaid cloud-setup`) or set `[web] search_backend = \"searxng\"` with a running SearXNG.".to_string(),
387 );
388 }
389 if matches!(config.web.search_backend, crate::app::SearchBackend::Auto)
390 && !has_ollama_key
391 && which::which("podman").is_err()
392 && which::which("docker").is_err()
393 {
394 next_steps.push(
395 "Web search (auto) starts a local SearXNG container, but no podman or docker was found. Install one, or set OLLAMA_API_KEY.".to_string(),
396 );
397 }
398 if next_steps.is_empty() {
399 next_steps.push(
400 "Start Mermaid with `mermaid` or run one prompt with `mermaid run \"...\"`."
401 .to_string(),
402 );
403 }
404
405 let ok = active_model.is_some()
406 && local_store.status != "warning"
407 && (ollama.status == "ok" || !remote_providers.is_empty());
408 let report = DoctorReport {
409 ok,
410 cwd: cwd.display().to_string(),
411 active_model,
412 model_error,
413 model_capabilities,
414 safety_mode: safety_mode_name(config.safety.mode).to_string(),
415 checkpoint_on_mutation: config.safety.checkpoint_on_mutation,
416 prompt_customized: config.prompt.is_customized(),
417 ollama,
418 remote_providers,
419 project_instructions,
420 tools,
421 runtime: DoctorRuntime {
422 daemon,
423 local_store,
424 },
425 next_steps,
426 };
427 print_doctor_report(&report, format)
428}
429
430fn print_doctor_report(report: &DoctorReport, format: OutputFormat) -> Result<()> {
431 match format {
432 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
433 OutputFormat::Markdown => {
434 println!("# Mermaid Doctor\n");
435 print_doctor_text(report);
436 },
437 OutputFormat::Text => print_doctor_text(report),
438 }
439 Ok(())
440}
441
442fn print_doctor_text(report: &DoctorReport) {
443 println!(
444 "Mermaid Doctor: {}",
445 if report.ok {
446 "ready"
447 } else {
448 "needs attention"
449 }
450 );
451 println!("Project: {}", report.cwd);
452 match (&report.active_model, &report.model_error) {
453 (Some(model), _) => println!(" [OK] Active model: {model}"),
454 (None, Some(error)) => println!(" [WARNING] Active model: {error}"),
455 _ => println!(" [WARNING] Active model: unresolved"),
456 }
457 if let Some(caps) = &report.model_capabilities {
458 println!(
459 " provider={} tools={} vision={} reasoning={} context={}",
460 caps.provider,
461 caps.supports_tools,
462 caps.supports_vision,
463 caps.reasoning,
464 caps.max_context_tokens
465 .map(|n| n.to_string())
466 .unwrap_or_else(|| "unknown".to_string())
467 );
468 }
469 println!(
470 " [{}] Ollama: {}",
471 label(report.ollama.status),
472 report.ollama.message
473 );
474 println!(
475 " [INFO] Remote providers: {}",
476 if report.remote_providers.is_empty() {
477 "none configured".to_string()
478 } else {
479 report.remote_providers.join(", ")
480 }
481 );
482 println!(
483 " [{}] Project instructions: {}",
484 label(report.project_instructions.status),
485 report.project_instructions.message
486 );
487 println!(
488 " [INFO] Safety: mode={}, checkpoint_on_mutation={}",
489 report.safety_mode, report.checkpoint_on_mutation
490 );
491 println!(
492 " [INFO] Prompt customization: {}",
493 if report.prompt_customized {
494 "active"
495 } else {
496 "default"
497 }
498 );
499 println!(
500 " [{}] Runtime daemon: {}",
501 label(report.runtime.daemon.status),
502 report.runtime.daemon.message
503 );
504 println!(
505 " [{}] Runtime store: {}",
506 label(report.runtime.local_store.status),
507 report.runtime.local_store.message
508 );
509 println!(" [OK] Tool surface:");
510 for tool in &report.tools {
511 println!(" - {tool}");
512 }
513 println!("\nNext steps:");
514 for step in &report.next_steps {
515 println!(" - {step}");
516 }
517}
518
519#[derive(Debug, serde::Serialize)]
520struct SelfTestReport {
521 ok: bool,
522 workspace: String,
523 checks: Vec<String>,
524 compact_smoke: QaCompactSmokeReport,
525 runtime_store: DoctorCheck,
526 kept_workspace: bool,
527}
528
529fn run_self_test(config: &Config, format: OutputFormat, keep_workspace: bool) -> Result<()> {
530 let workspace = std::env::temp_dir().join(format!("mermaid-self-test-{}", fresh_qa_id()));
531 std::fs::create_dir_all(&workspace)
532 .with_context(|| format!("failed to create {}", workspace.display()))?;
533
534 let compact_smoke = match run_qa_compact_smoke(config, &workspace, 6) {
535 Ok(report) => report,
536 Err(err) => QaCompactSmokeReport::failed(&workspace, 6, err.to_string()),
537 };
538 let runtime_store = match RuntimeClient::local().health() {
539 Ok(read) => DoctorCheck {
540 status: "ok",
541 message: format!("local runtime store ready at {}", read.value.database),
542 },
543 Err(err) => DoctorCheck {
544 status: "warning",
545 message: err.to_string(),
546 },
547 };
548
549 let checks = vec![
550 "compact smoke exercises reducer compaction path".to_string(),
551 "compact smoke persists conversation and archive artifacts".to_string(),
552 "local runtime store opens without daemon".to_string(),
553 ];
554 let ok = compact_smoke.ok && runtime_store.status == "ok";
555 let report = SelfTestReport {
556 ok,
557 workspace: workspace.display().to_string(),
558 checks,
559 compact_smoke,
560 runtime_store,
561 kept_workspace: keep_workspace,
562 };
563
564 print_self_test_report(&report, format)?;
565 if !keep_workspace {
566 let _ = std::fs::remove_dir_all(&workspace);
567 }
568 anyhow::ensure!(report.ok, "mermaid self-test failed");
569 Ok(())
570}
571
572fn print_self_test_report(report: &SelfTestReport, format: OutputFormat) -> Result<()> {
573 match format {
574 OutputFormat::Json => println!("{}", serde_json::to_string_pretty(report)?),
575 OutputFormat::Markdown => {
576 println!("# Mermaid Self-Test\n");
577 print_self_test_text(report);
578 },
579 OutputFormat::Text => print_self_test_text(report),
580 }
581 Ok(())
582}
583
584fn print_self_test_text(report: &SelfTestReport) {
585 println!(
586 "Mermaid self-test: {}",
587 if report.ok { "ok" } else { "failed" }
588 );
589 println!("workspace: {}", report.workspace);
590 println!(
591 "compact smoke: {}",
592 if report.compact_smoke.ok {
593 "ok"
594 } else {
595 "failed"
596 }
597 );
598 println!("runtime store: {}", report.runtime_store.message);
599 println!("checks:");
600 for check in &report.checks {
601 println!(" - {check}");
602 }
603 if !report.ok
604 && let Some(failure) = &report.compact_smoke.failure
605 {
606 println!("failure: {failure}");
607 }
608}
609
610fn label(status: &str) -> &'static str {
611 match status {
612 "ok" => "OK",
613 "warning" => "WARNING",
614 "error" => "ERROR",
615 _ => "INFO",
616 }
617}
618
619fn safety_mode_name(mode: crate::runtime::SafetyMode) -> &'static str {
620 mode.as_str()
621}
622
623fn configured_remote_providers(config: &Config) -> Vec<String> {
624 let mut names = Vec::new();
625 for profile in PROVIDER_REGISTRY {
626 let env = config
627 .providers
628 .get(profile.name)
629 .and_then(|c| c.api_key_env.as_deref())
630 .unwrap_or(profile.api_key_env);
631 if resolve_api_key(env, None).is_some() {
632 names.push(profile.name.to_string());
633 }
634 }
635 for (name, provider) in &config.providers {
636 if names.iter().any(|existing| existing == name) {
637 continue;
638 }
639 if let Some(env) = provider.api_key_env.as_deref()
640 && resolve_api_key(env, None).is_some()
641 {
642 names.push(name.clone());
643 }
644 }
645 names.sort();
646 names
647}
648
649#[derive(Debug, serde::Serialize)]
650struct QaCompactSmokeReport {
651 ok: bool,
652 turns: usize,
653 archived_messages: usize,
654 preserved_messages: usize,
655 replacement_messages: usize,
656 conversation_path: Option<String>,
657 archive_path: Option<String>,
658 checks: Vec<String>,
659 failure: Option<String>,
660}
661
662impl QaCompactSmokeReport {
663 fn failed(cwd: &Path, turns: usize, failure: String) -> Self {
664 Self {
665 ok: false,
666 turns,
667 archived_messages: 0,
668 preserved_messages: 0,
669 replacement_messages: 0,
670 conversation_path: Some(
671 cwd.join(".mermaid")
672 .join("conversations")
673 .display()
674 .to_string(),
675 ),
676 archive_path: None,
677 checks: Vec::new(),
678 failure: Some(failure),
679 }
680 }
681}
682
683fn run_qa_compact_smoke(
684 config: &Config,
685 cwd: &Path,
686 requested_turns: usize,
687) -> Result<QaCompactSmokeReport> {
688 let turns = requested_turns.max(3);
689 let mut state = State::new(
690 config.clone(),
691 cwd.to_path_buf(),
692 qa_model_id(config),
693 chrono::Local::now(),
694 );
695 for message in synthetic_compaction_messages(turns) {
696 state.session.append(message, state.now);
697 }
698
699 let (state_after_slash, compact_cmds) = update(
700 state,
701 Msg::Slash(SlashCmd::Compact(Some("qa compact smoke".to_string()))),
702 );
703 let turn = state_after_slash
704 .turn
705 .id()
706 .context("manual compaction did not enter a compaction turn")?;
707 let request = compact_cmds
708 .iter()
709 .find_map(|cmd| match cmd {
710 Cmd::CompactConversation { request, .. } => Some(request.clone()),
711 _ => None,
712 })
713 .context("manual compaction did not emit a CompactConversation command")?;
714
715 let before_snapshot = estimate_context_usage_for_request(&request.chat, Some(100_000));
716 let prepared = prepare_compaction(&request, Some(100_000))
717 .map_err(|reason| anyhow::anyhow!("prepare_compaction skipped: {reason}"))?;
718 anyhow::ensure!(
719 !prepared.archived_messages.is_empty(),
720 "compaction archived no messages"
721 );
722 anyhow::ensure!(
723 !prepared.preserved_messages.is_empty(),
724 "compaction preserved no messages"
725 );
726
727 let summary = deterministic_compaction_summary(&prepared, turns);
728 let mut record = CompactionRecord {
729 id: format!("qa_compact_{}", fresh_qa_id()),
730 trigger: CompactionTrigger::Manual,
731 created_at: chrono::Local::now(),
732 before_tokens: before_snapshot.used_tokens,
733 after_tokens: 0,
734 archived_message_count: prepared.archived_messages.len(),
735 preserved_message_count: prepared.preserved_messages.len(),
736 summary_tokens: summary.len().div_ceil(4),
737 duration_secs: 0.0,
738 verified: true,
739 verification_error: None,
740 focus: Some("qa compact smoke".to_string()),
741 archive_path: None,
742 };
743 let mut replacement = build_replacement_messages(&summary, &prepared, &record);
744 let mut after_chat: ChatRequest = request.chat.clone();
745 after_chat.messages = replacement.clone();
746 let mut after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
747 record.after_tokens = after_snapshot.used_tokens;
748 replacement = build_replacement_messages(&summary, &prepared, &record);
749 after_chat.messages = replacement.clone();
750 after_snapshot = estimate_context_usage_for_request(&after_chat, Some(100_000));
751
752 let result = CompactionResult {
753 record,
754 replacement_messages: replacement,
755 archived_messages: prepared.archived_messages,
756 before_snapshot,
757 after_snapshot,
758 usage: None,
759 };
760 let (final_state, save_cmds) =
761 update(state_after_slash, Msg::CompactionFinished { turn, result });
762
763 let manager = ConversationManager::new(cwd)?;
764 let mut conversation_path = None;
765 let mut archive_path = None;
766 for cmd in save_cmds {
767 match cmd {
768 Cmd::SaveConversation(conversation) => {
769 manager.save_conversation(&conversation)?;
770 conversation_path = Some(
771 manager
772 .conversations_dir()
773 .join(format!("{}.json", conversation.id))
774 .display()
775 .to_string(),
776 );
777 },
778 Cmd::SaveCompactionArchive {
779 archive,
780 conversation,
781 ..
782 } => {
783 archive_path = Some(
787 manager
788 .save_compaction_archive(&archive)?
789 .display()
790 .to_string(),
791 );
792 manager.save_conversation(&conversation)?;
793 conversation_path = Some(
794 manager
795 .conversations_dir()
796 .join(format!("{}.json", conversation.id))
797 .display()
798 .to_string(),
799 );
800 },
801 _ => {},
802 }
803 }
804
805 let conversation_path = conversation_path.context("compaction did not save conversation")?;
806 let archive_path = archive_path.context("compaction did not save archive")?;
807 let messages = final_state.session.messages();
808 let compactions = &final_state.session.conversation.compactions;
809
810 let mut checks = Vec::new();
811 anyhow::ensure!(
812 !compactions.is_empty(),
813 "conversation did not record compaction metadata"
814 );
815 checks.push("conversation records compaction metadata".to_string());
816 anyhow::ensure!(
817 messages
818 .first()
819 .is_some_and(|msg| msg.kind == crate::models::ChatMessageKind::ContextCheckpoint),
820 "replacement does not start with a context checkpoint"
821 );
822 checks.push("replacement starts with context checkpoint".to_string());
823 anyhow::ensure!(
824 std::path::Path::new(&conversation_path).exists(),
825 "conversation file missing after save"
826 );
827 checks.push("conversation file saved".to_string());
828 anyhow::ensure!(
829 std::path::Path::new(&archive_path).exists(),
830 "compaction archive file missing after save"
831 );
832 checks.push("archive file saved".to_string());
833 anyhow::ensure!(
834 compactions[0].archived_message_count > 0 && compactions[0].preserved_message_count > 0,
835 "compaction did not archive and preserve messages"
836 );
837 checks.push("archived and preserved message counts are non-zero".to_string());
838
839 Ok(QaCompactSmokeReport {
840 ok: true,
841 turns,
842 archived_messages: compactions[0].archived_message_count,
843 preserved_messages: compactions[0].preserved_message_count,
844 replacement_messages: messages.len(),
845 conversation_path: Some(conversation_path),
846 archive_path: Some(archive_path),
847 checks,
848 failure: None,
849 })
850}
851
852fn print_qa_compact_report(report: &QaCompactSmokeReport, format: OutputFormat) -> Result<()> {
853 match format {
854 OutputFormat::Json => {
855 println!("{}", serde_json::to_string_pretty(report)?);
856 },
857 OutputFormat::Text => {
858 println!(
859 "qa compact smoke: {}",
860 if report.ok { "ok" } else { "failed" }
861 );
862 println!("turns: {}", report.turns);
863 println!("archived messages: {}", report.archived_messages);
864 println!("preserved messages: {}", report.preserved_messages);
865 println!("replacement messages: {}", report.replacement_messages);
866 if let Some(path) = &report.conversation_path {
867 println!("conversation: {path}");
868 }
869 if let Some(path) = &report.archive_path {
870 println!("archive: {path}");
871 }
872 if let Some(failure) = &report.failure {
873 println!("failure: {failure}");
874 }
875 },
876 OutputFormat::Markdown => {
877 println!(
878 "# QA Compact Smoke\n\n- Status: {}\n- Turns: {}\n- Archived messages: {}\n- Preserved messages: {}\n- Replacement messages: {}",
879 if report.ok { "ok" } else { "failed" },
880 report.turns,
881 report.archived_messages,
882 report.preserved_messages,
883 report.replacement_messages
884 );
885 if let Some(path) = &report.conversation_path {
886 println!("- Conversation: `{path}`");
887 }
888 if let Some(path) = &report.archive_path {
889 println!("- Archive: `{path}`");
890 }
891 if let Some(failure) = &report.failure {
892 println!("\nFailure: `{failure}`");
893 }
894 },
895 }
896 Ok(())
897}
898
899fn qa_model_id(config: &Config) -> String {
900 if let Some(model) = config
901 .last_used_model
902 .as_ref()
903 .filter(|value| !value.is_empty())
904 {
905 return model.clone();
906 }
907 if !config.default_model.name.is_empty() {
908 if config.default_model.provider.is_empty() {
909 return config.default_model.name.clone();
910 }
911 return format!(
912 "{}/{}",
913 config.default_model.provider, config.default_model.name
914 );
915 }
916 "qa/deterministic".to_string()
917}
918
919fn synthetic_compaction_messages(turns: usize) -> Vec<ChatMessage> {
920 let mut messages = Vec::with_capacity(turns.saturating_mul(2));
921 for idx in 1..=turns {
922 messages.push(ChatMessage::user(format!(
923 "User turn {idx}: investigate Mermaid compaction behavior in src/domain/compaction.rs and keep exact file paths in the summary."
924 )));
925 messages.push(ChatMessage::assistant(format!(
926 "Assistant turn {idx}: inspected src/domain/compaction.rs, tests/reducer_flows.rs, and scripts/qa_mermaid.py; noted command `cargo test --all-targets` result placeholder {idx}."
927 )));
928 }
929 messages
930}
931
932fn deterministic_compaction_summary(
933 prepared: &crate::domain::PreparedCompaction,
934 turns: usize,
935) -> String {
936 format!(
937 "## Goal\n- Verify Mermaid can compact a multi-turn conversation through the reducer path.\n\n## User Preferences And Constraints\n- Headless QA must not require a human to open the TUI.\n\n## Project State\n- Synthetic QA conversation seeded with {turns} user/assistant turns.\n\n## Completed Work\n- Prepared compaction archived {} messages and preserved {} messages.\n\n## Current Work\n- Running deterministic compact smoke from the hidden QA command.\n\n## Key Decisions\n- Use deterministic summary text so fast QA does not call a real model.\n\n## Critical Files And Symbols\n- src/domain/compaction.rs: compaction preparation and replacement shape.\n- src/domain/reducer.rs: manual compaction completion handling.\n- scripts/qa_mermaid.py: headless QA harness.\n\n## Commands Tests And Results\n- mermaid qa compact-smoke --format json: running inside this smoke.\n\n## Open Questions Or Risks\n- Full TUI automation remains intentionally deferred.\n\n## Next Steps\n- Keep using the real-model QA tier for end-to-end dogfood checks.",
938 prepared.archived_messages.len(),
939 prepared.preserved_messages.len()
940 )
941}
942
943fn fresh_qa_id() -> u128 {
944 std::time::SystemTime::now()
945 .duration_since(std::time::UNIX_EPOCH)
946 .map(|duration| duration.as_nanos())
947 .unwrap_or_default()
948}
949
950fn show_tasks(limit: usize) -> Result<()> {
951 let read = RuntimeClient::auto().list_tasks(limit)?;
952 let mut tasks = read.value;
953 tasks.truncate(limit);
954 println!("Mermaid runtime tasks");
955 println!("Source: {}", read.source.as_str());
956 println!();
957 if tasks.is_empty() {
958 println!("No tasks recorded yet.");
959 return Ok(());
960 }
961 for task in tasks {
962 println!(
963 "{} [{}] {} {} {}",
964 task.id, task.status, task.priority, task.updated_at, task.title
965 );
966 println!(" project: {}", task.project_path);
967 println!(" model: {}", task.model_id);
968 }
969 Ok(())
970}
971
972fn show_task(id: &str) -> Result<()> {
973 let detail = RuntimeClient::auto().task_detail(id)?.value;
974 print_task_detail(&detail.task);
975 let events = detail.events;
976 if !events.is_empty() {
977 println!();
978 println!("Timeline:");
979 for event in events {
980 println!(" {} {} {}", event.created_at, event.kind, event.message);
981 }
982 }
983 Ok(())
984}
985
986fn print_task_detail(task: &TaskRecord) {
987 println!("Task: {}", task.id);
988 println!("Title: {}", task.title);
989 println!("Status: {}", task.status);
990 println!("Priority: {}", task.priority);
991 println!("Project: {}", task.project_path);
992 println!("Model: {}", task.model_id);
993 if let Some(conversation_id) = &task.conversation_id {
994 println!("Conversation: {}", conversation_id);
995 }
996 println!("Created: {}", task.created_at);
997 println!("Updated: {}", task.updated_at);
998 if let Some(report) = &task.final_report {
999 println!();
1000 println!("Final report:");
1001 println!("{}", sanitize_terminal_text(report));
1002 }
1003}
1004
1005fn show_processes(limit: usize) -> Result<()> {
1006 let read = RuntimeClient::auto().list_processes(limit)?;
1007 let mut processes = read.value;
1008 processes.truncate(limit);
1009 println!("Mermaid runtime processes");
1010 println!("Source: {}", read.source.as_str());
1011 println!();
1012 if processes.is_empty() {
1013 println!("No processes recorded yet.");
1014 return Ok(());
1015 }
1016 for process in processes {
1017 println!(
1018 "{} pid={} status={} {}",
1019 process.id,
1020 process.pid,
1021 process.status.as_str(),
1022 process.command
1023 );
1024 if let Some(task_id) = process.task_id {
1025 println!(" task: {}", task_id);
1026 }
1027 if let Some(cwd) = process.cwd {
1028 println!(" cwd: {}", cwd);
1029 }
1030 if let Some(log_path) = process.log_path {
1031 println!(" log: {}", log_path);
1032 }
1033 if let Some(url) = process.detected_url {
1034 println!(" url: {}", url);
1035 }
1036 }
1037 Ok(())
1038}
1039
1040async fn show_models(config: &Config) -> Result<()> {
1041 list_models(config).await?;
1042 probe_configured_provider_models(config).await?;
1043 let store = RuntimeStore::open_default()?;
1044 let probes = store.provider_probes().list(None, None)?;
1045 if !probes.is_empty() {
1046 println!("\nCached capability probes:");
1047 for probe in probes {
1048 println!(
1049 " - {}/{} {}={} ({})",
1050 probe.provider,
1051 probe.model_id,
1052 probe.capability_key,
1053 probe.capability_value,
1054 probe.confidence
1055 );
1056 }
1057 }
1058 Ok(())
1059}
1060
1061async fn show_model_info(model: &str, config: &Config) -> Result<()> {
1062 let snapshot = crate::domain::ProviderCapabilitySnapshot::from_model_id(model);
1063 let store = RuntimeStore::open_default()?;
1064 let provider = snapshot.provider.clone();
1065
1066 let mut context_tokens = snapshot.max_context_tokens;
1069 let mut context_confidence = "static";
1070 if provider == "ollama" {
1071 let backend = std::sync::Arc::new(crate::providers::factory::ollama_backend_config(config));
1072 if let Ok(adapter) =
1073 crate::models::adapters::ollama::OllamaAdapter::new(&snapshot.model, backend).await
1074 && let Some(info) = adapter.show_model_info().await
1075 && let Some(ctx) = info.context_length
1076 {
1077 context_tokens = Some(ctx);
1078 context_confidence = "probed";
1079 }
1080 }
1081
1082 for (key, value) in [
1083 ("supports_tools", snapshot.supports_tools.to_string()),
1084 ("supports_vision", snapshot.supports_vision.to_string()),
1085 ("reasoning", snapshot.reasoning.clone()),
1086 ] {
1087 let _ = store.provider_probes().upsert(NewProviderProbe {
1088 provider: provider.clone(),
1089 model_id: snapshot.model.clone(),
1090 capability_key: key.to_string(),
1091 capability_value: value,
1092 confidence: "static".to_string(),
1093 error: None,
1094 });
1095 }
1096 let _ = store.provider_probes().upsert(NewProviderProbe {
1098 provider: provider.clone(),
1099 model_id: snapshot.model.clone(),
1100 capability_key: "max_context_tokens".to_string(),
1101 capability_value: context_tokens
1102 .map(|n| n.to_string())
1103 .unwrap_or_else(|| "unknown".to_string()),
1104 confidence: context_confidence.to_string(),
1105 error: None,
1106 });
1107 println!("Model: {}", model);
1108 println!("Provider: {}", snapshot.provider);
1109 println!("Name: {}", snapshot.model);
1110 println!("Supports tools: {}", snapshot.supports_tools);
1111 println!("Supports vision: {}", snapshot.supports_vision);
1112 println!("Reasoning: {}", snapshot.reasoning);
1113 println!(
1114 "Context: {}",
1115 context_tokens
1116 .map(|n| n.to_string())
1117 .unwrap_or_else(|| "unknown".to_string())
1118 );
1119 if let Some(profile) = lookup_provider(&snapshot.provider) {
1120 for (key, value) in [
1121 (
1122 "max_output_tokens_param",
1123 format!("{:?}", profile.max_tokens_param),
1124 ),
1125 (
1126 "parallel_tool_calls",
1127 (!profile
1128 .disable_parallel_tool_calls_for
1129 .iter()
1130 .any(|disabled| *disabled == snapshot.model))
1131 .to_string(),
1132 ),
1133 (
1134 "reasoning_parameter_shape",
1135 format!("{:?}", profile.reasoning_strategy),
1136 ),
1137 (
1138 "streaming_usage_available",
1139 "provider_dependent".to_string(),
1140 ),
1141 ("token_usage_field_shape", "openai_compatible".to_string()),
1142 ] {
1143 let _ = store.provider_probes().upsert(NewProviderProbe {
1144 provider: provider.clone(),
1145 model_id: snapshot.model.clone(),
1146 capability_key: key.to_string(),
1147 capability_value: value,
1148 confidence: "static".to_string(),
1149 error: None,
1150 });
1151 }
1152 println!("Token budget field: {:?}", profile.max_tokens_param);
1153 println!(
1154 "Single-tool-call models: {}",
1155 if profile.disable_parallel_tool_calls_for.is_empty() {
1156 "(none)".to_string()
1157 } else {
1158 profile.disable_parallel_tool_calls_for.join(", ")
1159 }
1160 );
1161 }
1162 Ok(())
1163}
1164
1165async fn probe_configured_provider_models(config: &Config) -> Result<()> {
1166 let client = reqwest::Client::builder()
1167 .timeout(std::time::Duration::from_secs(5))
1168 .build()?;
1169 for profile in PROVIDER_REGISTRY {
1170 let user_cfg = config.providers.get(profile.name);
1171 let env = user_cfg
1172 .and_then(|c| c.api_key_env.as_deref())
1173 .unwrap_or(profile.api_key_env);
1174 let Some(api_key) = resolve_api_key(env, None) else {
1175 continue;
1176 };
1177 let base_url = user_cfg
1178 .and_then(|c| c.base_url.clone())
1179 .unwrap_or_else(|| profile.base_url.to_string());
1180 let url = format!("{}/models", base_url.trim_end_matches('/'));
1181 let mut request = client.get(&url).bearer_auth(api_key);
1182 for (name, value) in profile.extra_headers {
1183 request = request.header(*name, *value);
1184 }
1185 if let Some(user_cfg) = user_cfg {
1186 for (name, value) in &user_cfg.extra_headers {
1187 request = request.header(name, value);
1188 }
1189 }
1190
1191 let result = request.send().await;
1192 match result {
1193 Ok(response) if response.status().is_success() => {
1194 let status = response.status();
1195 let body: serde_json::Value = response.json().await.unwrap_or_default();
1196 let ids = body
1197 .get("data")
1198 .and_then(|v| v.as_array())
1199 .map(|items| {
1200 items
1201 .iter()
1202 .filter_map(|item| item.get("id").and_then(|id| id.as_str()))
1203 .map(str::to_string)
1204 .collect::<Vec<_>>()
1205 })
1206 .unwrap_or_default();
1207 record_provider_probe(
1208 profile.name,
1209 "*",
1210 "models_availability",
1211 &format!("available:{}:{}", status.as_u16(), ids.len()),
1212 "probed",
1213 None,
1214 );
1215 for model_id in ids.into_iter().take(200) {
1216 record_provider_probe(
1217 profile.name,
1218 &model_id,
1219 "model_listed",
1220 "true",
1221 "listed",
1222 None,
1223 );
1224 }
1225 },
1226 Ok(response) => {
1227 record_provider_probe(
1228 profile.name,
1229 "*",
1230 "models_availability",
1231 "failed",
1232 "failed",
1233 Some(format!("HTTP {}", response.status().as_u16())),
1234 );
1235 },
1236 Err(error) => {
1237 record_provider_probe(
1238 profile.name,
1239 "*",
1240 "models_availability",
1241 "failed",
1242 "failed",
1243 Some(error.to_string()),
1244 );
1245 },
1246 }
1247 }
1248 Ok(())
1249}
1250
1251fn record_provider_probe(
1252 provider: &str,
1253 model_id: &str,
1254 key: &str,
1255 value: &str,
1256 confidence: &str,
1257 error: Option<String>,
1258) {
1259 if let Ok(store) = RuntimeStore::open_default() {
1260 let _ = store.provider_probes().upsert(NewProviderProbe {
1261 provider: provider.to_string(),
1262 model_id: model_id.to_string(),
1263 capability_key: key.to_string(),
1264 capability_value: value.to_string(),
1265 confidence: confidence.to_string(),
1266 error,
1267 });
1268 }
1269}
1270
1271fn show_approvals() -> Result<()> {
1272 let approvals = RuntimeClient::auto().list_approvals()?.value;
1273 if approvals.is_empty() {
1274 println!("No pending approvals.");
1275 return Ok(());
1276 }
1277 for approval in approvals {
1278 println!(
1279 "{} [{} -> {}] {}",
1280 approval.id,
1281 approval.risk_classification,
1282 approval.policy_decision,
1283 approval.proposed_action
1284 );
1285 if let Some(args) = approval.args_summary {
1286 println!(" args: {}", args);
1287 }
1288 if let Some(checkpoint_id) = approval.checkpoint_id {
1289 println!(" checkpoint: {}", checkpoint_id);
1290 }
1291 if approval.pending_action_json.is_some() {
1292 println!(" pending action: recorded");
1293 }
1294 }
1295 Ok(())
1296}
1297
1298fn approve(id: &str) -> Result<()> {
1299 let result = RuntimeClient::auto().approve(id)?;
1300 println!("Approved {}", id);
1301 if result.replayed {
1302 println!("{}", result.summary);
1303 }
1304 Ok(())
1305}
1306
1307fn deny(id: &str) -> Result<()> {
1308 let _ = RuntimeClient::auto().deny(id)?;
1309 println!("Denied {}", id);
1310 Ok(())
1311}
1312
1313fn show_tool_runs(limit: usize) -> Result<()> {
1314 let mut runs = RuntimeClient::auto().list_tool_runs(limit)?.value;
1315 runs.truncate(limit);
1316 if runs.is_empty() {
1317 println!("No tool runs recorded yet.");
1318 return Ok(());
1319 }
1320 for run in runs {
1321 println!(
1322 "{} [{}] {} started {}",
1323 run.id, run.status, run.tool_name, run.started_at
1324 );
1325 if let Some(turn_id) = run.turn_id {
1326 println!(" turn: {}", turn_id);
1327 }
1328 if let Some(call_id) = run.call_id {
1329 println!(" call: {}", call_id);
1330 }
1331 if let Some(finished_at) = run.finished_at {
1332 println!(" finished: {}", finished_at);
1333 }
1334 }
1335 Ok(())
1336}
1337
1338fn show_checkpoints(limit: usize) -> Result<()> {
1339 let mut checkpoints = RuntimeClient::auto().list_checkpoints(limit)?.value;
1340 checkpoints.truncate(limit);
1341 if checkpoints.is_empty() {
1342 println!("No checkpoints recorded yet.");
1343 return Ok(());
1344 }
1345 for checkpoint in checkpoints {
1346 println!(
1347 "{} {} {}",
1348 checkpoint.id, checkpoint.created_at, checkpoint.project_path
1349 );
1350 println!(" snapshot: {}", checkpoint.snapshot_path);
1351 println!(" files: {}", checkpoint.changed_files_json);
1352 if let Some(approval_id) = checkpoint.approval_id {
1353 println!(" approval: {}", approval_id);
1354 }
1355 }
1356 Ok(())
1357}
1358
1359fn restore_checkpoint(id: &str, force: bool) -> Result<()> {
1360 if !crate::utils::confirm_or_refuse(
1364 &format!("Restore checkpoint {id}? This overwrites the current working tree."),
1365 force,
1366 )? {
1367 println!("Restore cancelled.");
1368 return Ok(());
1369 }
1370 let manifest = RuntimeClient::auto().restore_checkpoint(id)?.checkpoint;
1371 println!("Restored {} ({} files)", manifest.id, manifest.files.len());
1372 if let Some(repo) = manifest.shadow_git_repo {
1373 println!("Shadow repo: {}", repo);
1374 }
1375 if let Some(commit) = manifest.shadow_git_commit {
1376 println!("Shadow commit: {}", commit);
1377 }
1378 if let Some(action) = manifest.pending_action {
1379 println!("Pending action: {}", serde_json::to_string_pretty(&action)?);
1380 }
1381 Ok(())
1382}
1383
1384fn handle_plugin(command: &PluginCommand) -> Result<()> {
1385 match command {
1386 PluginCommand::Install { path } => {
1387 let preview = crate::runtime::plugin_capability_preview(path)?;
1388 print_plugin_capability_preview(&preview);
1389 let record = crate::runtime::install_plugin_from_path(path)?;
1390 println!(
1391 "Installed plugin {} ({}) — DISABLED.",
1392 record.name, record.id
1393 );
1394 println!(
1395 "Run `mermaid plugin enable {}` to activate it (this runs the plugin's hook code).",
1396 record.id
1397 );
1398 },
1399 PluginCommand::List => {
1400 let plugins = RuntimeClient::auto().list_plugins()?.value;
1401 if plugins.is_empty() {
1402 println!("No plugins installed.");
1403 } else {
1404 for plugin in plugins {
1405 println!(
1406 "{} [{}] {} ({})",
1407 plugin.id,
1408 if plugin.enabled {
1409 "enabled"
1410 } else {
1411 "disabled"
1412 },
1413 plugin.name,
1414 plugin.source
1415 );
1416 }
1417 }
1418 },
1419 PluginCommand::Enable { id } => {
1420 let client = RuntimeClient::auto();
1422 if let Some(plugin) = client
1423 .list_plugins()?
1424 .value
1425 .into_iter()
1426 .find(|p| p.id == *id || p.name == *id)
1427 && let Ok(preview) =
1428 crate::runtime::plugin_capability_preview(Path::new(&plugin.source))
1429 {
1430 print_plugin_capability_preview(&preview);
1431 }
1432 client.set_plugin_enabled(id, true)?;
1433 println!("Enabled plugin {} — its hooks will now run.", id);
1434 },
1435 PluginCommand::Disable { id } => {
1436 RuntimeClient::auto().set_plugin_enabled(id, false)?;
1437 println!("Disabled plugin {}", id);
1438 },
1439 PluginCommand::Audit { path } => {
1440 let manifest_path = if path.is_dir() {
1441 path.join("plugin.toml")
1442 } else {
1443 path.clone()
1444 };
1445 let raw = std::fs::read_to_string(&manifest_path)?;
1446 let manifest: crate::runtime::PluginManifest = toml::from_str(&raw)?;
1447 let root = manifest_path.parent().unwrap_or_else(|| Path::new("."));
1448 crate::runtime::validate_plugin_manifest(&manifest, root)?;
1449 let preview = crate::runtime::plugin_capability_preview(path)?;
1450 println!("Plugin manifest is valid: {}", manifest.name);
1451 print_plugin_capability_preview(&preview);
1452 },
1453 }
1454 Ok(())
1455}
1456
1457fn print_plugin_capability_preview(preview: &crate::runtime::PluginCapabilityPreview) {
1458 println!(
1459 "Capabilities declared by plugin {} (advisory, not sandbox-enforced):",
1460 preview.name
1461 );
1462 if preview.declared_capabilities.is_empty() && preview.capabilities_toml.is_none() {
1463 println!(" capabilities: (none declared)");
1464 } else {
1465 if !preview.declared_capabilities.is_empty() {
1466 println!(" declared: {}", preview.declared_capabilities.join(", "));
1467 }
1468 if let Some(value) = &preview.capabilities_toml {
1469 println!(
1470 " capabilities.toml: {}",
1471 serde_json::to_string(value).unwrap_or_else(|_| "<unprintable>".to_string())
1472 );
1473 }
1474 }
1475 if !preview.hooks.is_empty() {
1476 println!(" hooks: {}", preview.hooks.join(", "));
1477 }
1478 if !preview.mcp.is_empty() {
1479 println!(" mcp: {}", preview.mcp.join(", "));
1480 }
1481 if !preview.bin.is_empty() {
1482 println!(" bin: {}", preview.bin.join(", "));
1483 }
1484}
1485
1486fn handle_pair(command: &PairCommand) -> Result<()> {
1487 let store = RuntimeStore::open_default()?;
1488 match command {
1489 PairCommand::Create { label, ttl_days } => {
1490 let ttl = ttl_days.unwrap_or(crate::runtime::DEFAULT_PAIRING_TTL_DAYS);
1491 let expires_at = crate::runtime::pairing_expiry_from_now(ttl);
1492 let (token, hash) = crate::runtime::generate_pairing_token()?;
1493 let record =
1494 store
1495 .pairing_tokens()
1496 .create(&hash, label.as_deref(), expires_at.as_deref())?;
1497 println!("Pairing token id: {}", record.id);
1498 println!("Pairing token: {}", token);
1499 println!(
1500 "Expires: {}",
1501 record.expires_at.as_deref().unwrap_or("never")
1502 );
1503 println!(
1504 "Use with daemon JSON by setting {}.",
1505 crate::runtime::daemon::DAEMON_TOKEN_ENV
1506 );
1507 println!("Store this now; Mermaid will not print it again.");
1508 },
1509 PairCommand::List => {
1510 let tokens = store.pairing_tokens().list()?;
1511 if tokens.is_empty() {
1512 println!("No pairing tokens.");
1513 } else {
1514 for t in tokens {
1516 println!(
1517 "{} [{}] label={} created={} expires={} last_used={}",
1518 t.id,
1519 if t.enabled { "active" } else { "revoked" },
1520 t.label.as_deref().unwrap_or("-"),
1521 t.created_at,
1522 t.expires_at.as_deref().unwrap_or("never"),
1523 t.last_used_at.as_deref().unwrap_or("never"),
1524 );
1525 }
1526 }
1527 },
1528 PairCommand::Revoke { id } => {
1529 if store.pairing_tokens().revoke(id)? {
1530 println!("Revoked pairing token {id}");
1531 } else {
1532 println!("No active pairing token with id {id}");
1533 }
1534 },
1535 }
1536 Ok(())
1537}
1538
1539fn sanitize_terminal_text(input: &str) -> String {
1548 let mut out = String::with_capacity(input.len());
1549 let mut chars = input.chars();
1550 while let Some(c) = chars.next() {
1551 match c {
1552 '\n' | '\t' => out.push(c),
1553 '\u{1b}' => match chars.next() {
1554 Some('[') => {
1557 for p in chars.by_ref() {
1558 if ('@'..='~').contains(&p) {
1559 break;
1560 }
1561 }
1562 },
1563 Some(']') | Some('P') | Some('^') | Some('_') | Some('X') => {
1566 while let Some(p) = chars.next() {
1567 if p == '\u{07}' {
1568 break;
1569 }
1570 if p == '\u{1b}' {
1571 let mut peek = chars.clone();
1573 if peek.next() == Some('\\') {
1574 chars = peek;
1575 }
1576 break;
1577 }
1578 }
1579 },
1580 Some(mut b) => {
1583 while ('\u{20}'..='\u{2f}').contains(&b) {
1584 match chars.next() {
1585 Some(next) => b = next,
1586 None => break,
1587 }
1588 }
1589 },
1590 None => {},
1591 },
1592 c if (c as u32) < 0x20 || matches!(c as u32, 0x7f..=0x9f) => {},
1594 c => out.push(c),
1595 }
1596 }
1597 out
1598}
1599
1600fn show_logs(id: &str) -> Result<()> {
1601 let content = RuntimeClient::auto().process_log(id, None)?.content;
1602 print!("{}", sanitize_terminal_text(&content));
1603 Ok(())
1604}
1605
1606fn stop_process(id: &str) -> Result<()> {
1607 let process = RuntimeClient::auto().stop_process(id)?.item;
1608 println!("Stopped process {} (pid {})", id, process.pid);
1609 Ok(())
1610}
1611
1612fn restart_process(id: &str) -> Result<()> {
1613 let process = RuntimeClient::auto().restart_process(id)?.item;
1614 println!("Restarted process {} (pid {})", id, process.pid);
1615 Ok(())
1616}
1617
1618fn open_target(target: &str) -> Result<()> {
1619 if RuntimeClient::auto().open_process(target).is_err() {
1620 crate::utils::open_file(target);
1621 }
1622 Ok(())
1623}
1624
1625fn show_ports() -> Result<()> {
1626 let ports = RuntimeClient::auto().ports()?.ports;
1627 print!("{}", sanitize_terminal_text(&ports));
1628 Ok(())
1629}
1630
1631pub async fn list_models(config: &Config) -> Result<()> {
1633 let ollama_models = list_ollama_models(config, true).await.unwrap_or_default();
1636 if ollama_models.is_empty() {
1637 println!("No Ollama models installed locally.");
1638 } else {
1639 println!("Ollama models (local/cloud):");
1640 for name in &ollama_models {
1641 println!(" - ollama/{}", name);
1642 }
1643 }
1644
1645 println!("\nConfigured remote providers:");
1646 let mut any = false;
1647 for profile in PROVIDER_REGISTRY {
1648 let env = config
1649 .providers
1650 .get(profile.name)
1651 .and_then(|c| c.api_key_env.as_deref())
1652 .unwrap_or(profile.api_key_env);
1653 if resolve_api_key(env, None).is_some() {
1654 any = true;
1655 println!(" - {} (via ${})", profile.name, env);
1656 }
1657 }
1658 if !any {
1659 println!(" (none — set a provider API key env var to enable)");
1660 }
1661 println!("\nSwitch models in-session with /model <name>.");
1662 Ok(())
1663}
1664
1665async fn list_ollama_models(config: &Config, autostart: bool) -> Option<Vec<String>> {
1673 use crate::models::adapters::ollama::OllamaAdapter;
1674 let backend = BackendConfig {
1675 ollama_url: format!("{}:{}", config.ollama.host, config.ollama.port),
1676 timeout_secs: 5,
1677 max_idle_per_host: 2,
1678 ollama_autostart: autostart && config.ollama.auto_start,
1679 };
1680 match OllamaAdapter::new("__list__", Arc::new(backend)).await {
1681 Ok(adapter) => adapter.list_models().await.ok(),
1682 Err(_) => None,
1683 }
1684}
1685
1686pub fn show_version() {
1688 println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
1689 println!(" An open-source, model-agnostic AI pair programmer");
1690}
1691
1692const RELEASE_LATEST_API: &str =
1693 "https://api.github.com/repos/noahsabaj/mermaid-cli/releases/latest";
1694const INSTALL_SH_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.sh";
1695const INSTALL_PS1_URL: &str = "https://noahsabaj.github.io/mermaid-cli/install.ps1";
1696
1697async fn run_update(check: bool, force: bool) -> Result<()> {
1703 let current = env!("CARGO_PKG_VERSION");
1704 println!("Installed: v{current}");
1705
1706 let client = reqwest::Client::builder()
1707 .timeout(std::time::Duration::from_secs(15))
1708 .build()?;
1709 let resp = client
1710 .get(RELEASE_LATEST_API)
1711 .header("User-Agent", "mermaid-cli")
1712 .header("Accept", "application/vnd.github+json")
1713 .send()
1714 .await
1715 .map_err(|e| anyhow!("could not reach GitHub Releases: {e}"))?;
1716 if !resp.status().is_success() {
1717 bail!("GitHub Releases API returned HTTP {}", resp.status());
1718 }
1719 let release: serde_json::Value = resp.json().await?;
1720 let tag = release
1721 .get("tag_name")
1722 .and_then(|v| v.as_str())
1723 .ok_or_else(|| anyhow!("release response had no tag_name"))?;
1724 println!("Latest: {tag}");
1725
1726 let up_to_date = version_at_least(current, tag.trim_start_matches('v'));
1727 if check {
1728 if up_to_date {
1729 println!("You're on the latest version.");
1730 } else {
1731 println!("Update available: v{current} -> {tag}. Run `mermaid update` to install it.");
1732 }
1733 return Ok(());
1734 }
1735 if up_to_date && !force {
1736 println!("Already up to date.");
1737 return Ok(());
1738 }
1739
1740 let exe =
1742 std::env::current_exe().map_err(|e| anyhow!("could not locate current executable: {e}"))?;
1743 let install_dir = exe
1744 .parent()
1745 .ok_or_else(|| anyhow!("current executable has no parent directory"))?;
1746
1747 let script_url = if cfg!(target_os = "windows") {
1752 INSTALL_PS1_URL
1753 } else {
1754 INSTALL_SH_URL
1755 };
1756 if !crate::utils::confirm_or_refuse(
1757 &format!(
1758 "About to download and run {script_url} to replace {}.",
1759 install_dir.display()
1760 ),
1761 force,
1762 )? {
1763 println!("Update cancelled.");
1764 return Ok(());
1765 }
1766
1767 println!("Updating {} …", install_dir.display());
1768 run_install_script(&client, install_dir).await?;
1769 println!("Updated. New version takes effect on the next run.");
1770 Ok(())
1771}
1772
1773async fn run_install_script(client: &reqwest::Client, install_dir: &Path) -> Result<()> {
1776 let windows = cfg!(target_os = "windows");
1777 let url = if windows {
1778 INSTALL_PS1_URL
1779 } else {
1780 INSTALL_SH_URL
1781 };
1782 let script = client
1783 .get(url)
1784 .header("User-Agent", "mermaid-cli")
1785 .send()
1786 .await
1787 .map_err(|e| anyhow!("could not fetch install script: {e}"))?
1788 .error_for_status()?
1789 .text()
1790 .await?;
1791
1792 let ext = if windows { "ps1" } else { "sh" };
1793 let dir = crate::utils::private_temp_dir()
1800 .map_err(|e| anyhow!("could not create private temp dir for install script: {e}"))?;
1801 let nanos = std::time::SystemTime::now()
1802 .duration_since(std::time::UNIX_EPOCH)
1803 .map(|d| d.as_nanos())
1804 .unwrap_or_default();
1805 let script_path = dir.join(format!(
1806 "mermaid-update-{}-{nanos}.{ext}",
1807 std::process::id()
1808 ));
1809 stage_install_script(&script_path, script.as_bytes())
1810 .map_err(|e| anyhow!("could not stage install script: {e}"))?;
1811
1812 let mut cmd = if windows {
1813 let mut c = tokio::process::Command::new("powershell");
1814 c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]);
1815 c.arg(&script_path);
1816 c
1817 } else {
1818 let mut c = tokio::process::Command::new("sh");
1819 c.arg(&script_path);
1820 c
1821 };
1822 cmd.env("MERMAID_INSTALL_DIR", install_dir)
1823 .env("MERMAID_NO_MODIFY_PATH", "1");
1824
1825 let status = cmd
1826 .status()
1827 .await
1828 .map_err(|e| anyhow!("could not run install script: {e}"))?;
1829 let _ = std::fs::remove_file(&script_path);
1830 if !status.success() {
1831 bail!("install script exited with {:?}", status.code());
1832 }
1833 Ok(())
1834}
1835
1836fn stage_install_script(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1842 use std::io::Write;
1843 #[cfg(unix)]
1844 let mut file = {
1845 use std::os::unix::fs::OpenOptionsExt;
1846 std::fs::OpenOptions::new()
1847 .write(true)
1848 .create_new(true)
1849 .mode(0o600)
1850 .open(path)?
1851 };
1852 #[cfg(not(unix))]
1853 let mut file = std::fs::OpenOptions::new()
1854 .write(true)
1855 .create_new(true)
1856 .open(path)?;
1857 file.write_all(bytes)
1858}
1859
1860fn parse_semver(s: &str) -> Option<(u64, u64, u64)> {
1862 let core = s.trim().trim_start_matches('v');
1863 let core = core.split(['-', '+']).next().unwrap_or(core);
1864 let mut parts = core.split('.');
1865 let major = parts.next()?.parse().ok()?;
1866 let minor = parts.next().unwrap_or("0").parse().ok()?;
1867 let patch = parts.next().unwrap_or("0").parse().ok()?;
1868 Some((major, minor, patch))
1869}
1870
1871fn version_at_least(current: &str, latest: &str) -> bool {
1875 match (parse_semver(current), parse_semver(latest)) {
1876 (Some(c), Some(l)) => c >= l,
1877 _ => current == latest,
1878 }
1879}
1880
1881fn show_mcp_servers() {
1883 let config = load_config_or_warn();
1884
1885 if config.mcp_servers.is_empty() {
1886 println!("No MCP servers configured.\n");
1887 println!("Add one with: mermaid add <name>");
1888 println!("Examples:");
1889 println!(" mermaid add context7 # Library documentation");
1890 println!(" mermaid add playwright # Browser automation");
1891 println!(" mermaid add memory # Persistent knowledge graph");
1892 return;
1893 }
1894
1895 println!("Configured MCP servers:\n");
1896 for (name, server_cfg) in &config.mcp_servers {
1897 let package = server_cfg
1898 .args
1899 .iter()
1900 .find(|a| !a.starts_with('-'))
1901 .unwrap_or(&server_cfg.command);
1902 let env_keys: Vec<&String> = server_cfg.env.keys().collect();
1903 let env_display = if env_keys.is_empty() {
1904 String::new()
1905 } else {
1906 format!(
1907 " (env: {})",
1908 env_keys
1909 .iter()
1910 .map(|k| k.as_str())
1911 .collect::<Vec<_>>()
1912 .join(", ")
1913 )
1914 };
1915 println!(" {} — {}{}", name, package, env_display);
1916 }
1917 println!("\nManage with: mermaid add <name> / mermaid remove <name>");
1918}
1919
1920async fn show_status(config: &Config) -> Result<()> {
1922 println!("Mermaid Status:");
1923 println!();
1924
1925 let mut available: Vec<&'static str> = Vec::new();
1928 for profile in PROVIDER_REGISTRY {
1929 let env = config
1930 .providers
1931 .get(profile.name)
1932 .and_then(|c| c.api_key_env.as_deref())
1933 .unwrap_or(profile.api_key_env);
1934 if resolve_api_key(env, None).is_some() {
1935 available.push(profile.name);
1936 }
1937 }
1938 if available.is_empty() {
1939 println!(" [WARNING] Remote providers: none (no API keys in env)");
1940 } else {
1941 println!(" [OK] Remote providers: {}", available.join(", "));
1942 }
1943
1944 if is_ollama_installed() {
1949 match list_ollama_models(config, false).await {
1950 None => println!(
1951 " [WARNING] Ollama: Installed but not running (started automatically \
1952 when an Ollama model is used)"
1953 ),
1954 Some(models) if models.is_empty() => {
1955 println!(" [WARNING] Ollama: Running (no models installed)");
1956 },
1957 Some(models) => {
1958 println!(" [OK] Ollama: Running ({} models installed)", models.len());
1959 for model in models.iter().take(3) {
1960 println!(" - {}", model);
1961 }
1962 if models.len() > 3 {
1963 println!(" ... and {} more", models.len() - 3);
1964 }
1965 },
1966 }
1967 } else {
1968 println!(" [ERROR] Ollama: Not installed");
1969 }
1970
1971 if let Ok(config_dir) = get_config_dir() {
1973 let config_path = config_dir.join("config.toml");
1974 if config_path.exists() {
1975 println!(" [OK] Configuration: {}", config_path.display());
1976 } else {
1977 println!(" [WARNING] Configuration: Not found (using defaults)");
1978 }
1979 }
1980
1981 if config.mcp_servers.is_empty() {
1983 println!(" [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
1984 } else {
1985 println!(
1986 " [OK] MCP Servers: {} configured",
1987 config.mcp_servers.len()
1988 );
1989 for (name, server_cfg) in &config.mcp_servers {
1990 println!(
1991 " - {} ({})",
1992 name,
1993 server_cfg.args.get(1).unwrap_or(&server_cfg.command)
1994 );
1995 }
1996 }
1997
1998 {
2001 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2002 let paths = crate::app::instructions::find_instruction_files(&cwd);
2003 if paths.is_empty() {
2004 println!(" [INFO] Project instructions: not found (AGENTS.md, MERMAID.md)");
2005 } else {
2006 match crate::app::instructions::load_from_paths(&paths) {
2007 Some(loaded) => {
2008 let files = loaded
2009 .sources
2010 .iter()
2011 .map(|source| {
2012 source
2013 .path
2014 .file_name()
2015 .and_then(|name| name.to_str())
2016 .unwrap_or("instructions")
2017 })
2018 .collect::<Vec<_>>()
2019 .join(", ");
2020 println!(
2021 " [OK] Project instructions: {} at {} ({} bytes{})",
2022 files,
2023 loaded.path.display(),
2024 loaded.byte_len,
2025 if loaded.truncated { ", truncated" } else { "" }
2026 );
2027 },
2028 None => {
2029 println!(
2030 " [WARNING] Project instructions: found but unreadable ({})",
2031 paths
2032 .iter()
2033 .map(|path| path.display().to_string())
2034 .collect::<Vec<_>>()
2035 .join(", ")
2036 );
2037 },
2038 }
2039 }
2040 }
2041
2042 show_provider_status(config);
2046
2047 println!("\n Environment:");
2049 if std::env::var("OLLAMA_API_KEY").is_ok() {
2050 println!(" - OLLAMA_API_KEY: Set (for Ollama Cloud)");
2051 }
2052
2053 println!();
2054 Ok(())
2055}
2056
2057fn show_provider_status(config: &Config) {
2062 let mut configured: Vec<(String, String)> = Vec::new(); let anth_cfg = config.providers.get("anthropic");
2067 if resolve_api_key(
2068 "ANTHROPIC_API_KEY",
2069 anth_cfg.and_then(|c| c.api_key_env.as_deref()),
2070 )
2071 .is_some()
2072 {
2073 let url = anth_cfg
2074 .and_then(|c| c.base_url.clone())
2075 .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
2076 configured.push(("anthropic".to_string(), url));
2077 }
2078
2079 let gem_cfg = config.providers.get("gemini");
2081 if resolve_api_key_with_fallback(
2082 "GOOGLE_API_KEY",
2083 "GEMINI_API_KEY",
2084 gem_cfg.and_then(|c| c.api_key_env.as_deref()),
2085 )
2086 .is_some()
2087 {
2088 let url = gem_cfg
2089 .and_then(|c| c.base_url.clone())
2090 .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
2091 configured.push(("gemini".to_string(), url));
2092 }
2093
2094 for profile in PROVIDER_REGISTRY {
2095 let user_cfg = config.providers.get(profile.name);
2096 let api_key_present = resolve_api_key(
2097 profile.api_key_env,
2098 user_cfg.and_then(|c| c.api_key_env.as_deref()),
2099 )
2100 .is_some();
2101 if api_key_present {
2102 let url = user_cfg
2103 .and_then(|c| c.base_url.clone())
2104 .unwrap_or_else(|| profile.base_url.to_string());
2105 configured.push((profile.name.to_string(), url));
2106 }
2107 }
2108
2109 for (name, cfg) in &config.providers {
2112 if name == "anthropic" || name == "gemini" || lookup_provider(name).is_some() {
2113 continue;
2114 }
2115 if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
2116 && resolve_api_key(env, None).is_some()
2117 {
2118 configured.push((name.clone(), url.clone()));
2119 }
2120 }
2121
2122 if configured.is_empty() {
2123 println!(
2124 " [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
2125 $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
2126 add [providers.<name>] to config.toml)"
2127 );
2128 } else {
2129 println!(" [OK] Remote providers: {} configured", configured.len());
2130 for (name, url) in configured {
2131 println!(" - {} ({})", name, url);
2132 }
2133 }
2134}
2135
2136fn handle_pr(command: &PrCommand) -> Result<()> {
2138 match command {
2139 PrCommand::Create {
2140 title,
2141 body,
2142 summary,
2143 base,
2144 draft,
2145 web,
2146 provider,
2147 } => create_pr(CreatePrArgs {
2148 title: title.as_deref(),
2149 body: body.as_deref(),
2150 summary: summary.as_deref(),
2151 base: base.as_deref(),
2152 draft: *draft,
2153 web: *web,
2154 provider: *provider,
2155 }),
2156 }
2157}
2158
2159struct CreatePrArgs<'a> {
2160 title: Option<&'a str>,
2161 body: Option<&'a str>,
2162 summary: Option<&'a Path>,
2163 base: Option<&'a str>,
2164 draft: bool,
2165 web: bool,
2166 provider: Option<GitHost>,
2167}
2168
2169fn create_pr(args: CreatePrArgs) -> Result<()> {
2174 let body = match args.summary {
2176 Some(path) => Some(
2177 std::fs::read_to_string(path)
2178 .with_context(|| format!("failed to read summary file {}", path.display()))?,
2179 ),
2180 None => args.body.map(str::to_string),
2181 };
2182
2183 let host = match args.provider {
2184 Some(host) => host,
2185 None => detect_git_host()?,
2186 };
2187
2188 let (cli, install_hint) = match host {
2189 GitHost::Github => (
2190 "gh",
2191 "Install the GitHub CLI (https://cli.github.com) and run `gh auth login`.",
2192 ),
2193 GitHost::Gitlab => (
2194 "glab",
2195 "Install the GitLab CLI (https://gitlab.com/gitlab-org/cli) and run `glab auth login`.",
2196 ),
2197 };
2198 if which::which(cli).is_err() {
2199 anyhow::bail!("`{cli}` was not found on your PATH. {install_hint}");
2200 }
2201
2202 let argv = build_pr_argv(
2203 host,
2204 args.title,
2205 body.as_deref(),
2206 args.base,
2207 args.draft,
2208 args.web,
2209 );
2210
2211 println!("Creating pull/merge request via `{cli}`…");
2212 let status = std::process::Command::new(cli)
2213 .args(&argv)
2214 .status()
2215 .with_context(|| format!("failed to run `{cli}`"))?;
2216 anyhow::ensure!(status.success(), "`{cli}` exited unsuccessfully ({status})");
2217 Ok(())
2218}
2219
2220fn detect_git_host() -> Result<GitHost> {
2223 if let Some(host) = git_origin_host() {
2224 return Ok(host);
2225 }
2226 if which::which("gh").is_ok() {
2227 return Ok(GitHost::Github);
2228 }
2229 if which::which("glab").is_ok() {
2230 return Ok(GitHost::Gitlab);
2231 }
2232 anyhow::bail!(
2233 "could not detect a Git host from the `origin` remote. Pass `--provider github|gitlab` and install the matching CLI (`gh`/`glab`)."
2234 )
2235}
2236
2237fn git_origin_host() -> Option<GitHost> {
2238 let output = std::process::Command::new("git")
2239 .args(["config", "--get", "remote.origin.url"])
2240 .output()
2241 .ok()?;
2242 if !output.status.success() {
2243 return None;
2244 }
2245 host_from_remote_url(String::from_utf8_lossy(&output.stdout).trim())
2246}
2247
2248fn host_from_remote_url(url: &str) -> Option<GitHost> {
2249 let lower = url.to_ascii_lowercase();
2250 if lower.contains("github.com") {
2251 Some(GitHost::Github)
2252 } else if lower.contains("gitlab") {
2253 Some(GitHost::Gitlab)
2254 } else {
2255 None
2256 }
2257}
2258
2259fn build_pr_argv(
2261 host: GitHost,
2262 title: Option<&str>,
2263 body: Option<&str>,
2264 base: Option<&str>,
2265 draft: bool,
2266 web: bool,
2267) -> Vec<String> {
2268 let s = |v: &str| v.to_string();
2269 let has_content = title.is_some() || body.is_some();
2270 let mut argv = Vec::new();
2271 match host {
2272 GitHost::Github => {
2273 argv.push(s("pr"));
2274 argv.push(s("create"));
2275 if web {
2276 argv.push(s("--web"));
2277 }
2278 if draft {
2279 argv.push(s("--draft"));
2280 }
2281 if let Some(title) = title {
2282 argv.push(s("--title"));
2283 argv.push(s(title));
2284 }
2285 if let Some(body) = body {
2286 argv.push(s("--body"));
2287 argv.push(s(body));
2288 }
2289 if !has_content && !web {
2293 argv.push(s("--fill"));
2294 }
2295 if let Some(base) = base {
2296 argv.push(s("--base"));
2297 argv.push(s(base));
2298 }
2299 },
2300 GitHost::Gitlab => {
2301 argv.push(s("mr"));
2302 argv.push(s("create"));
2303 if web {
2304 argv.push(s("--web"));
2305 }
2306 if draft {
2307 argv.push(s("--draft"));
2308 }
2309 if let Some(title) = title {
2310 argv.push(s("--title"));
2311 argv.push(s(title));
2312 }
2313 if let Some(body) = body {
2314 argv.push(s("--description"));
2315 argv.push(s(body));
2316 }
2317 if !has_content && !web {
2318 argv.push(s("--fill"));
2319 }
2320 if let Some(base) = base {
2321 argv.push(s("--target-branch"));
2322 argv.push(s(base));
2323 }
2324 },
2325 }
2326 argv
2327}
2328
2329#[cfg(test)]
2330mod tests {
2331 use super::*;
2332
2333 #[test]
2334 fn sanitize_terminal_text_strips_control_sequences() {
2335 assert_eq!(
2337 sanitize_terminal_text("hello\tworld\nline two"),
2338 "hello\tworld\nline two"
2339 );
2340 assert_eq!(
2342 sanitize_terminal_text("\u{1b}[31mRED\u{1b}[0m text"),
2343 "RED text"
2344 );
2345 assert_eq!(
2347 sanitize_terminal_text("before\u{1b}]52;c;cGF5bG9hZA==\u{07}after"),
2348 "beforeafter"
2349 );
2350 assert_eq!(sanitize_terminal_text("a\u{1b}]0;pwned\u{1b}\\b"), "ab");
2352 assert_eq!(sanitize_terminal_text("x\u{1b}(By"), "xy");
2354 assert_eq!(sanitize_terminal_text("a\rb\u{9b}c\n"), "abc\n");
2356 }
2357
2358 #[test]
2359 fn version_compare_handles_update_logic() {
2360 assert!(version_at_least("0.10.2", "0.10.2"));
2362 assert!(version_at_least("0.11.0", "0.10.2"));
2363 assert!(version_at_least("1.0.0", "0.99.99"));
2364 assert!(!version_at_least("0.10.1", "0.10.2"));
2366 assert!(!version_at_least("0.9.0", "0.10.0"));
2367 assert!(!version_at_least("0.10.2", "0.11.0"));
2368 assert!(version_at_least("0.10.2", "v0.10.2"));
2370 assert_eq!(parse_semver("v0.11.0-rc1+build"), Some((0, 11, 0)));
2371 assert_eq!(parse_semver("0.10"), Some((0, 10, 0)));
2372 assert!(!version_at_least("0.10.2", "not-a-version"));
2374 }
2375
2376 #[test]
2377 fn host_from_remote_url_detects_provider() {
2378 assert_eq!(
2379 host_from_remote_url("https://github.com/foo/bar.git"),
2380 Some(GitHost::Github)
2381 );
2382 assert_eq!(
2383 host_from_remote_url("git@github.com:foo/bar.git"),
2384 Some(GitHost::Github)
2385 );
2386 assert_eq!(
2387 host_from_remote_url("https://gitlab.com/foo/bar.git"),
2388 Some(GitHost::Gitlab)
2389 );
2390 assert_eq!(
2391 host_from_remote_url("git@gitlab.example.com:foo/bar.git"),
2392 Some(GitHost::Gitlab)
2393 );
2394 assert_eq!(host_from_remote_url("https://bitbucket.org/foo/bar"), None);
2395 }
2396
2397 #[test]
2398 fn build_pr_argv_github_with_content() {
2399 let argv = build_pr_argv(
2400 GitHost::Github,
2401 Some("T"),
2402 Some("B"),
2403 Some("main"),
2404 true,
2405 false,
2406 );
2407 assert_eq!(
2408 argv,
2409 vec![
2410 "pr", "create", "--draft", "--title", "T", "--body", "B", "--base", "main"
2411 ]
2412 );
2413 }
2414
2415 #[test]
2416 fn build_pr_argv_github_fills_without_content() {
2417 let argv = build_pr_argv(GitHost::Github, None, None, None, false, false);
2418 assert!(argv.contains(&"--fill".to_string()));
2419 assert!(!argv.contains(&"--title".to_string()));
2420 }
2421
2422 #[test]
2423 fn build_pr_argv_web_skips_fill() {
2424 let argv = build_pr_argv(GitHost::Github, None, None, None, false, true);
2425 assert!(argv.contains(&"--web".to_string()));
2426 assert!(!argv.contains(&"--fill".to_string()));
2427 }
2428
2429 #[test]
2430 fn build_pr_argv_gitlab_uses_mr_and_target_branch() {
2431 let argv = build_pr_argv(GitHost::Gitlab, Some("T"), None, Some("main"), false, false);
2432 assert_eq!(&argv[0..2], &["mr", "create"]);
2433 assert!(argv.windows(2).any(|w| w == ["--target-branch", "main"]));
2434 assert!(argv.contains(&"--title".to_string()));
2435 }
2436
2437 #[test]
2438 fn qa_compact_smoke_persists_conversation_and_archive() {
2439 let dir = unique_temp_dir("mermaid-qa-compact-smoke");
2440 std::fs::create_dir_all(&dir).unwrap();
2441
2442 let report = run_qa_compact_smoke(&Config::default(), &dir, 6).unwrap();
2443
2444 assert!(report.ok);
2445 assert!(report.archived_messages > 0);
2446 assert!(report.preserved_messages > 0);
2447 assert!(report.replacement_messages >= 3);
2448 assert!(
2449 std::path::Path::new(report.conversation_path.as_ref().unwrap()).exists(),
2450 "conversation path should exist"
2451 );
2452 assert!(
2453 std::path::Path::new(report.archive_path.as_ref().unwrap()).exists(),
2454 "archive path should exist"
2455 );
2456
2457 let _ = std::fs::remove_dir_all(dir);
2458 }
2459
2460 #[test]
2461 fn qa_model_id_falls_back_to_deterministic() {
2462 assert_eq!(qa_model_id(&Config::default()), "qa/deterministic");
2463 }
2464
2465 fn unique_temp_dir(name: &str) -> std::path::PathBuf {
2466 let nanos = std::time::SystemTime::now()
2467 .duration_since(std::time::UNIX_EPOCH)
2468 .map(|duration| duration.as_nanos())
2469 .unwrap_or_default();
2470 std::env::temp_dir().join(format!("{name}-{nanos}"))
2471 }
2472}