use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, anyhow, bail};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use trusty_mpm::client::{Resolvable, resolve_target};
use trusty_mpm::disk::human_bytes;
#[cfg(test)]
#[path = "session_disk_tests.rs"]
mod session_disk_tests;
pub(crate) async fn session_disk(
client: &reqwest::Client,
url: &str,
id_or_name: Option<String>,
json_out: bool,
budget_seconds: Option<u64>,
) -> anyhow::Result<()> {
let directory = session_directory(client, url).await;
let target = match id_or_name {
Some(ref arg) => Some(resolve_session_id(&directory, arg)?),
None => None,
};
let project = match target {
Some(_) => None,
None => current_project_filter(&std::env::current_dir()?),
};
let survey = fetch_survey(client, url, project.as_deref(), budget_seconds).await?;
let names = session_names(&directory);
let report = match target {
Some(ref id) => session_report(&survey, id, &names)?,
None => sessions_report(&survey, project.clone(), &names),
};
if json_out {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render(&report));
}
if survey.partial {
eprintln!(
"warning: the survey hit its {}s budget — every figure here is a floor, not a total",
budget_seconds.unwrap_or(trusty_mpm::daemon::mcp_disk::MAX_BUDGET_SECONDS)
);
}
Ok(())
}
pub(crate) type SessionNames = HashMap<String, String>;
#[derive(Debug, Clone)]
pub(crate) struct DirectoryRow {
pub id: String,
pub name: String,
}
impl Resolvable for DirectoryRow {
fn id_matches(&self, query: &str) -> bool {
self.id == query
}
fn resolve_name(&self) -> &str {
&self.name
}
}
async fn session_directory(client: &reqwest::Client, url: &str) -> Vec<DirectoryRow> {
let executor = super::managed_route::executor(&trusty_mpm::client::DaemonClient::with_client(
client.clone(),
url,
));
let managed = executor
.client()
.list_managed_sessions()
.await
.unwrap_or_default()
.into_iter()
.map(|s| DirectoryRow {
id: s.id,
name: s.name,
});
let project = executor
.client()
.sessions()
.await
.unwrap_or_default()
.into_iter()
.map(|r| DirectoryRow {
id: r.id.0.to_string(),
name: r.tmux_name,
});
managed.chain(project).collect()
}
fn resolve_session_id(directory: &[DirectoryRow], id_or_name: &str) -> anyhow::Result<String> {
if let Some(row) = resolve_target(directory, id_or_name) {
return Ok(row.id.clone());
}
if uuid::Uuid::parse_str(id_or_name).is_ok() {
return Ok(id_or_name.to_string());
}
bail!(
"no session named `{id_or_name}` — `tm sessions ls` lists the sessions \
the daemon knows, and `tm session disk` with no argument lists the \
ones holding bytes"
)
}
fn session_names(directory: &[DirectoryRow]) -> SessionNames {
directory
.iter()
.filter(|row| !row.name.is_empty())
.map(|row| (row.id.clone(), row.name.clone()))
.collect()
}
fn current_project_filter(cwd: &Path) -> Option<String> {
let root = trusty_mpm::core::trusty_tools_config::workspace_root(
&trusty_mpm::core::trusty_tools_config::TrustyToolsConfig::load(),
);
project_filter_within(cwd, &root)
}
fn project_filter_within(cwd: &Path, root: &Path) -> Option<String> {
let rest = cwd.strip_prefix(root).ok()?;
let mut parts = rest.components();
let owner = parts.next()?.as_os_str().to_string_lossy().into_owned();
let repo = parts.next()?.as_os_str().to_string_lossy().into_owned();
Some(format!("{owner}/{repo}"))
}
async fn fetch_survey(
client: &reqwest::Client,
url: &str,
project: Option<&str>,
budget_seconds: Option<u64>,
) -> anyhow::Result<Survey> {
let mut arguments = json!({
"group_by": "session",
"budget_seconds": budget_seconds
.unwrap_or(trusty_mpm::daemon::mcp_disk::MAX_BUDGET_SECONDS),
});
if let (Some(project), Value::Object(map)) = (project, &mut arguments) {
map.insert("project".to_string(), Value::String(project.to_string()));
}
let body: Value = client
.post(format!("{url}/rpc"))
.timeout(trusty_mpm::client::http_client::DISK_SURVEY_REQUEST_TIMEOUT)
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": { "name": "disk_survey", "arguments": arguments },
}))
.send()
.await
.with_context(|| format!("POST {url}/rpc: the trusty-mpm daemon is not reachable"))?
.error_for_status()?
.json()
.await
.context("the daemon's disk_survey response was not JSON")?;
survey_from_rpc(&body)
}
fn survey_from_rpc(body: &Value) -> anyhow::Result<Survey> {
if let Some(err) = body.get("error") {
let message = err
.get("message")
.and_then(Value::as_str)
.unwrap_or("unknown daemon error");
bail!("disk_survey failed: {message}");
}
let text = body
.pointer("/result/content/0/text")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("the daemon returned no disk_survey result"))?;
if body
.pointer("/result/isError")
.and_then(Value::as_bool)
.unwrap_or(false)
{
bail!("disk_survey failed: {text}");
}
serde_json::from_str(text).context("the daemon's disk_survey payload did not parse")
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct Survey {
pub generated_at: String,
pub partial: bool,
pub by_session: Option<Vec<SurveyGroup>>,
pub root: SurveyRoot,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SurveyRoot {
pub projects: Vec<SurveyProject>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SurveyProject {
pub name: String,
pub worktrees: Vec<SurveyWorktree>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SurveyWorktree {
pub path: PathBuf,
pub tier: String,
pub bytes: Option<u64>,
pub build_dir_bytes: Option<u64>,
pub owning_session: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SurveyGroup {
pub session_id: Option<String>,
pub bytes: u64,
pub build_dir_bytes: u64,
pub worktree_count: usize,
pub tiers: Tiers,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
pub(crate) struct Tiers {
pub stale: usize,
pub review: usize,
pub keep: usize,
pub missing: usize,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "view", rename_all = "snake_case")]
pub(crate) enum DiskReport {
Sessions {
generated_at: String,
partial: bool,
project: Option<String>,
sessions: Vec<SessionRow>,
total: Totals,
},
Session {
generated_at: String,
partial: bool,
session_id: String,
session_name: Option<String>,
classes: Vec<ClassRow>,
worktrees: Vec<WorktreeRow>,
total: Totals,
},
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct SessionRow {
pub session_id: Option<String>,
pub session_name: Option<String>,
pub bytes: u64,
pub build_dir_bytes: u64,
pub source_bytes: u64,
pub worktree_count: usize,
pub tiers: Tiers,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ClassRow {
pub class: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct WorktreeRow {
pub path: PathBuf,
pub project: String,
pub tier: String,
pub bytes: Option<u64>,
pub build_dir_bytes: Option<u64>,
pub source_bytes: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
pub(crate) struct Totals {
pub bytes: u64,
pub build_dir_bytes: u64,
pub source_bytes: u64,
pub worktree_count: usize,
}
pub(crate) fn sessions_report(
survey: &Survey,
project: Option<String>,
names: &SessionNames,
) -> DiskReport {
let groups = survey.by_session.clone().unwrap_or_default();
let sessions: Vec<SessionRow> = groups
.into_iter()
.map(|g| SessionRow {
session_name: g
.session_id
.as_ref()
.and_then(|id| names.get(id.as_str()).cloned()),
session_id: g.session_id,
bytes: g.bytes,
build_dir_bytes: g.build_dir_bytes,
source_bytes: g.bytes.saturating_sub(g.build_dir_bytes),
worktree_count: g.worktree_count,
tiers: g.tiers,
})
.collect();
let total = sessions.iter().fold(Totals::default(), |mut t, r| {
t.bytes = t.bytes.saturating_add(r.bytes);
t.build_dir_bytes = t.build_dir_bytes.saturating_add(r.build_dir_bytes);
t.source_bytes = t.source_bytes.saturating_add(r.source_bytes);
t.worktree_count += r.worktree_count;
t
});
DiskReport::Sessions {
generated_at: survey.generated_at.clone(),
partial: survey.partial,
project,
sessions,
total,
}
}
pub(crate) fn session_report(
survey: &Survey,
target: &str,
names: &SessionNames,
) -> anyhow::Result<DiskReport> {
let mut worktrees: Vec<WorktreeRow> = Vec::new();
for project in &survey.root.projects {
for wt in &project.worktrees {
if wt.owning_session.as_deref() != Some(target) {
continue;
}
worktrees.push(WorktreeRow {
path: wt.path.clone(),
project: project.name.clone(),
tier: wt.tier.clone(),
bytes: wt.bytes,
build_dir_bytes: wt.build_dir_bytes,
source_bytes: match (wt.bytes, wt.build_dir_bytes) {
(Some(b), Some(d)) => Some(b.saturating_sub(d)),
_ => None,
},
});
}
}
if worktrees.is_empty() {
let label = names.get(target).map_or(target, String::as_str);
bail!(
"session `{label}` holds no worktrees in the survey — \
`tm session disk` with no argument lists the sessions that hold bytes"
);
}
worktrees.sort_by(|a, b| {
b.bytes
.unwrap_or(0)
.cmp(&a.bytes.unwrap_or(0))
.then_with(|| a.path.cmp(&b.path))
});
let total = worktrees.iter().fold(Totals::default(), |mut t, r| {
t.bytes = t.bytes.saturating_add(r.bytes.unwrap_or(0));
t.build_dir_bytes = t
.build_dir_bytes
.saturating_add(r.build_dir_bytes.unwrap_or(0));
t.source_bytes = t.source_bytes.saturating_add(r.source_bytes.unwrap_or(0));
t.worktree_count += 1;
t
});
let mut classes = vec![
ClassRow {
class: "build".to_string(),
bytes: total.build_dir_bytes,
},
ClassRow {
class: "source".to_string(),
bytes: total.source_bytes,
},
];
classes.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.class.cmp(&b.class)));
Ok(DiskReport::Session {
generated_at: survey.generated_at.clone(),
partial: survey.partial,
session_id: target.to_string(),
session_name: names.get(target).cloned(),
classes,
worktrees,
total,
})
}
pub(crate) fn render(report: &DiskReport) -> String {
let mut out = String::new();
match report {
DiskReport::Sessions {
project,
sessions,
total,
..
} => {
out.push_str(&match project {
Some(name) => format!("project {name}\n"),
None => "every managed project\n".to_string(),
});
out.push_str(&format!(
"{:<SESSION_COLUMN$} {:>6} {:>11} {:>11} {:>11}\n",
"SESSION", "TREES", "BUILD", "SOURCE", "TOTAL"
));
for row in sessions {
out.push_str(&format!(
"{:<SESSION_COLUMN$} {:>6} {:>11} {:>11} {:>11}\n",
session_label(row),
row.worktree_count,
human_bytes(row.build_dir_bytes),
human_bytes(row.source_bytes),
human_bytes(row.bytes),
));
}
out.push_str(&total_line(total));
}
DiskReport::Session {
session_id,
session_name,
classes,
worktrees,
total,
..
} => {
out.push_str(&match session_name {
Some(name) => format!("session {name} ({session_id})\n"),
None => format!("session {session_id}\n"),
});
for class in classes {
out.push_str(&format!(
" {:<10} {:>11}\n",
class.class,
human_bytes(class.bytes)
));
}
out.push('\n');
out.push_str(&format!(
"{:<9} {:>11} {:>11} {}\n",
"TIER", "BUILD", "TOTAL", "WORKTREE"
));
for row in worktrees {
out.push_str(&format!(
"{:<9} {:>11} {:>11} {}\n",
row.tier,
row.build_dir_bytes.map_or_else(unmeasured, human_bytes),
row.bytes.map_or_else(unmeasured, human_bytes),
row.path.display(),
));
}
out.push_str(&total_line(total));
}
}
out
}
const SESSION_COLUMN: usize = 38;
fn session_label(row: &SessionRow) -> String {
let label = row
.session_name
.clone()
.or_else(|| row.session_id.clone())
.unwrap_or_else(unattributed);
fit(&label, SESSION_COLUMN)
}
fn fit(label: &str, width: usize) -> String {
if label.chars().count() <= width {
return label.to_string();
}
let kept: String = label.chars().take(width.saturating_sub(1)).collect();
format!("{kept}…")
}
fn total_line(total: &Totals) -> String {
format!(
"{:<SESSION_COLUMN$} {:>6} {:>11} {:>11} {:>11}\n",
"TOTAL",
total.worktree_count,
human_bytes(total.build_dir_bytes),
human_bytes(total.source_bytes),
human_bytes(total.bytes),
)
}
fn unattributed() -> String {
"(unattributed)".to_string()
}
fn unmeasured() -> String {
"?".to_string()
}