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