use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{Context, Result};
use clap::Subcommand;
use crate::mcp::BasemindServer;
use crate::mcp::params::*;
use crate::path::{RelPath, normalize_query_path};
use crate::store_gc::{self, CacheComponent};
use super::render::{Emit, emit, render_human, render_json};
use super::run_tool;
#[derive(Subcommand, Debug)]
pub enum AdminCmd {
Status,
Repo,
Rescan {
#[arg(value_name = "PATH")]
paths: Vec<String>,
#[arg(long)]
full: bool,
},
CacheStats,
Gc,
CacheClear {
#[arg(long, default_value = "git-cache")]
component: String,
#[arg(long)]
confirm: bool,
},
Telemetry {
#[arg(long)]
window: Option<String>,
#[arg(long)]
tool: Option<String>,
},
Compress {
#[arg(long)]
path: Option<String>,
#[arg(long)]
text: Option<String>,
#[arg(long)]
level: Option<String>,
#[arg(long)]
target_tokens: Option<u32>,
#[arg(long)]
no_preserve_code: bool,
},
Delta {
#[arg(long, value_name = "FILE")]
old: PathBuf,
#[arg(long, value_name = "FILE")]
new: Option<PathBuf>,
},
Checkpoint {
#[arg(long)]
text: Option<String>,
},
Waste {
#[arg(long, value_name = "FILE")]
log: Option<PathBuf>,
},
}
fn read_stdin() -> Result<String> {
let mut raw = Vec::new();
std::io::stdin().read_to_end(&mut raw).context("read stdin")?;
Ok(String::from_utf8_lossy(&raw).into_owned())
}
fn read_file_or_stdin(path: Option<&Path>) -> Result<String> {
match path {
Some(path) => {
let raw = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
Ok(String::from_utf8_lossy(&raw).into_owned())
}
None => read_stdin(),
}
}
fn resolve_path(server: &BasemindServer, path: &str) -> RelPath {
match normalize_query_path(path, &server.state.shared.root) {
Some(rel) => RelPath::from(rel),
None => RelPath::from(path),
}
}
pub async fn run(server: &BasemindServer, cmd: AdminCmd, opts: &Emit, out: &mut impl Write) -> Result<()> {
let p = match cmd {
AdminCmd::Status => AdminParams::new(AdminMode::Status),
AdminCmd::Repo => AdminParams::new(AdminMode::Repo),
AdminCmd::Rescan { paths, full } => AdminParams {
paths: (!paths.is_empty()).then_some(paths),
full: full.then_some(true),
..AdminParams::new(AdminMode::Rescan)
},
AdminCmd::CacheStats => AdminParams::new(AdminMode::CacheStats),
AdminCmd::Gc => AdminParams::new(AdminMode::Gc),
AdminCmd::CacheClear { component, confirm } => AdminParams {
component: Some(component),
confirm: confirm.then_some(true),
..AdminParams::new(AdminMode::CacheClear)
},
AdminCmd::Telemetry { window, tool } => AdminParams {
window,
tool,
..AdminParams::new(AdminMode::Telemetry)
},
AdminCmd::Compress {
path,
text,
level,
target_tokens,
no_preserve_code,
} => {
let resolved = path.as_deref().map(|p| resolve_path(server, p));
let text = match (&resolved, text) {
(Some(_), text) => text,
(None, Some(text)) => Some(text),
(None, None) => Some(read_stdin()?),
};
AdminParams {
path: resolved,
text,
level,
target_tokens,
preserve_code: no_preserve_code.then_some(false),
..AdminParams::new(AdminMode::Compress)
}
}
AdminCmd::Delta { old, new } => AdminParams {
old: Some(read_file_or_stdin(Some(&old))?),
new: Some(read_file_or_stdin(new.as_deref())?),
..AdminParams::new(AdminMode::Delta)
},
AdminCmd::Checkpoint { text } => AdminParams {
text: Some(match text {
Some(text) => text,
None => read_stdin()?,
}),
..AdminParams::new(AdminMode::Checkpoint)
},
AdminCmd::Waste { log } => AdminParams {
log: Some(read_file_or_stdin(log.as_deref())?),
..AdminParams::new(AdminMode::Waste)
},
};
let key = p.mode.telemetry_key();
let r = run_tool(key, server.admin_cli(p).await)?;
emit(key, &r, opts, out)
}
#[derive(Subcommand, Debug)]
pub enum CacheCmd {
Gc,
Stats,
Clear {
#[arg(long, default_value = "git-cache")]
component: String,
},
}
pub async fn run_telemetry(
server: &BasemindServer,
window: Option<String>,
tool: Option<String>,
opts: &Emit,
out: &mut impl Write,
) -> Result<()> {
run(server, AdminCmd::Telemetry { window, tool }, opts, out).await
}
pub fn run_cache(root: &Path, cmd: CacheCmd, json: bool, out: &mut impl Write) -> Result<()> {
let basemind_dir = crate::store::workspace_cache_dir(root);
match cmd {
CacheCmd::Gc => {
let report = store_gc::run_gc(&basemind_dir).context("run blob GC")?;
let value = serde_json::to_value(&report).context("serialize GC report")?;
if json {
render_json(&value, out)
} else {
render_human("admin:gc", &value, out)
}
}
CacheCmd::Stats => {
let stats = store_gc::cache_stats(&basemind_dir).context("collect cache stats")?;
let value = serde_json::to_value(&stats).context("serialize cache stats")?;
if json {
render_json(&value, out)
} else {
render_human("admin:cache_stats", &value, out)
}
}
CacheCmd::Clear { component } => {
let value = if let Some(name) = component.strip_prefix("views:") {
store_gc::clear_single_view(&basemind_dir, name)
.with_context(|| format!("clear single view {name}"))?;
serde_json::json!({ "component": format!("views:{name}"), "cleared": true })
} else {
let comp = CacheComponent::from_str(&component).map_err(|e| anyhow::anyhow!(e))?;
store_gc::clear_component(&basemind_dir, comp)
.with_context(|| format!("clear cache component {component}"))?;
serde_json::json!({ "component": comp.as_str(), "cleared": true })
};
if json {
render_json(&value, out)
} else {
render_human("admin:cache_clear", &value, out)
}
}
}
}
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser, Subcommand as _};
use super::AdminCmd;
#[derive(Parser)]
struct Harness {
#[command(subcommand)]
cmd: AdminCmd,
}
#[test]
fn should_expose_one_subcommand_per_advertised_admin_mode() {
let command = AdminCmd::augment_subcommands(Harness::command());
let names: Vec<String> = command.get_subcommands().map(|s| s.get_name().to_string()).collect();
for mode in crate::mcp::mode::AdminMode::ALL_MODES {
let expected = mode.replace('_', "-");
assert!(
names.contains(&expected),
"`admin` mode `{mode}` has no `basemind admin {expected}` subcommand; got {names:?}"
);
}
}
}