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