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