use trusty_mpm::client::DaemonClient;
use trusty_mpm::client::http_client::manager::{PortfolioStatusWire, RouteTaskOutcome};
use crate::cli::ManagerAction;
fn daemon(client: &reqwest::Client, url: &str) -> DaemonClient {
DaemonClient::with_client(client.clone(), url.to_string())
}
pub(crate) async fn manager(
client: &reqwest::Client,
url: &str,
action: ManagerAction,
) -> anyhow::Result<()> {
match action {
ManagerAction::Status { json } => status(client, url, json).await,
ManagerAction::Digest { scope, json } => digest(client, url, &scope, json).await,
ManagerAction::Chat {
message,
conversation,
json,
} => chat(client, url, message, conversation, json).await,
ManagerAction::Route { text, json } => route(client, url, text, json).await,
}
}
pub(crate) async fn status(client: &reqwest::Client, url: &str, json: bool) -> anyhow::Result<()> {
let status = daemon(client, url).manager_status().await?;
if json {
let t = &status.totals;
let out = serde_json::json!({
"project_count": status.project_count,
"totals": {
"sessions": {
"provisioning": t.sessions.provisioning,
"active": t.sessions.active,
"stopped": t.sessions.stopped,
"errored": t.sessions.errored,
"decommissioned": t.sessions.decommissioned,
"total": t.sessions.total,
},
"deliverables": {
"proposed": t.deliverables.proposed,
"in_progress": t.deliverables.in_progress,
"blocked": t.deliverables.blocked,
"complete": t.deliverables.complete,
"delivered": t.deliverables.delivered,
"shipped": t.deliverables.shipped,
"total": t.deliverables.total,
},
"milestones": {
"proposed": t.milestones.proposed,
"in_progress": t.milestones.in_progress,
"complete": t.milestones.complete,
"shipped": t.milestones.shipped,
"total": t.milestones.total,
"dangling_deliverable_refs": t.milestones.dangling_deliverable_refs,
},
"last_activity_at": t.last_activity_at,
},
"projects": status.projects.iter().map(|p| serde_json::json!({
"project_name": p.project_name,
"repo_url": p.repo_url,
"sessions": {
"provisioning": p.sessions.provisioning,
"active": p.sessions.active,
"stopped": p.sessions.stopped,
"errored": p.sessions.errored,
"decommissioned": p.sessions.decommissioned,
"total": p.sessions.total,
},
"last_activity_at": p.last_activity_at,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
println!("{}", render_status_headline(&status));
if status.projects.is_empty() {
println!(" (no projects registered)");
return Ok(());
}
for line in render_project_rows(&status) {
println!("{line}");
}
Ok(())
}
pub(crate) async fn digest(
client: &reqwest::Client,
url: &str,
scope: &str,
json: bool,
) -> anyhow::Result<()> {
let outcome = daemon(client, url).manager_digest(scope).await?;
let Some(outcome) = outcome else {
anyhow::bail!(
"this daemon does not support `manager digest` yet — upgrade the daemon \
(`tm restart` after `cargo install trusty-mpm --locked`) and try again"
);
};
if json {
println!("{}", serde_json::to_string_pretty(&outcome.raw)?);
return Ok(());
}
if outcome.narrative.is_empty() {
println!("(empty digest — the daemon returned no narrative text)");
return Ok(());
}
if outcome.fallback {
println!("[deterministic fallback — no inference provider configured]");
}
println!("{}", outcome.narrative);
Ok(())
}
pub(crate) async fn chat(
client: &reqwest::Client,
url: &str,
message: Option<String>,
conversation: Option<String>,
json: bool,
) -> anyhow::Result<()> {
let message = match message {
Some(m) => m,
None => read_message_from_reader(&mut std::io::stdin())?,
};
if message.trim().is_empty() {
anyhow::bail!("tm manager chat: message must not be empty");
}
let conversation_key = conversation.unwrap_or_else(default_conversation_key);
let outcome = daemon(client, url)
.manager_chat(&conversation_key, &message)
.await?;
let Some(outcome) = outcome else {
anyhow::bail!(
"this daemon does not support `manager chat` yet — upgrade the daemon \
(`tm restart` after `cargo install trusty-mpm --locked`) and try again"
);
};
if json {
println!("{}", serde_json::to_string_pretty(&outcome.raw)?);
return Ok(());
}
if outcome.reply.is_empty() {
println!("(empty reply — the daemon returned no reply text)");
return Ok(());
}
println!("{}", outcome.reply);
Ok(())
}
pub(crate) async fn route(
client: &reqwest::Client,
url: &str,
text: String,
json: bool,
) -> anyhow::Result<()> {
if text.trim().is_empty() {
anyhow::bail!("tm manager route: text must not be empty");
}
let outcome = daemon(client, url).manager_route_task(&text).await?;
let Some(outcome) = outcome else {
anyhow::bail!(
"this daemon does not support `manager route` yet — upgrade the daemon \
(`tm restart` after `cargo install trusty-mpm --locked`) and try again"
);
};
if json {
println!("{}", serde_json::to_string_pretty(&outcome.raw)?);
return Ok(());
}
println!("{}", render_route(&outcome));
Ok(())
}
pub(crate) fn render_route(outcome: &RouteTaskOutcome) -> String {
match &outcome.project {
Some(project) => format!(
"route: {project} (confidence {:.2}, via {})\n {}",
outcome.confidence, outcome.resolved_by, outcome.rationale,
),
None => format!("route: no match\n {}", outcome.rationale),
}
}
fn read_message_from_reader(reader: &mut impl std::io::Read) -> anyhow::Result<String> {
let mut buf = String::new();
reader
.read_to_string(&mut buf)
.map_err(|e| anyhow::anyhow!("failed to read message from stdin: {e}"))?;
Ok(buf.trim().to_string())
}
pub(crate) fn default_conversation_key() -> String {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "local".to_string());
format!("cli:{user}")
}
pub(crate) fn render_status_headline(status: &PortfolioStatusWire) -> String {
let t = &status.totals;
let activity = t
.last_activity_at
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| "never".to_string());
format!(
"portfolio: {} project{} — sessions {} total ({} active, {} provisioning, {} stopped, \
{} errored, {} decommissioned) — deliverables {} total ({} in_progress, {} blocked, \
{} complete) — milestones {} total — last activity: {activity}",
status.project_count,
if status.project_count == 1 { "" } else { "s" },
t.sessions.total,
t.sessions.active,
t.sessions.provisioning,
t.sessions.stopped,
t.sessions.errored,
t.sessions.decommissioned,
t.deliverables.total,
t.deliverables.in_progress,
t.deliverables.blocked,
t.deliverables.complete,
t.milestones.total,
)
}
pub(crate) fn render_project_rows(status: &PortfolioStatusWire) -> Vec<String> {
status
.projects
.iter()
.map(|p| {
let activity = p
.last_activity_at
.map(|d| d.to_rfc3339())
.unwrap_or_else(|| "never".to_string());
format!(
" {:<20} sessions: {} total ({} active, {} provisioning, {} errored) last activity: {activity}",
p.project_name, p.sessions.total, p.sessions.active, p.sessions.provisioning, p.sessions.errored,
)
})
.collect()
}
#[cfg(test)]
mod tests {
use chrono::{DateTime, Utc};
use trusty_mpm::client::http_client::manager::{
DeliverableStatusCountsWire, MilestoneStatusCountsWire, PortfolioTotalsWire,
};
use trusty_mpm::client::http_client::projects::{ProjectStatusWire, SessionStateCountsWire};
use super::*;
fn ts() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2026-07-13T12:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
fn project(name: &str, active: usize) -> ProjectStatusWire {
ProjectStatusWire {
project_name: name.to_string(),
repo_url: format!("https://github.com/acme/{name}"),
sessions: SessionStateCountsWire {
provisioning: 1,
active,
stopped: 0,
errored: 0,
decommissioned: 0,
total: active + 1,
},
last_activity_at: Some(ts()),
config: Default::default(),
}
}
fn status_with(projects: Vec<ProjectStatusWire>) -> PortfolioStatusWire {
let sessions_total: usize = projects.iter().map(|p| p.sessions.total).sum();
let active_total: usize = projects.iter().map(|p| p.sessions.active).sum();
PortfolioStatusWire {
project_count: projects.len(),
totals: PortfolioTotalsWire {
sessions: SessionStateCountsWire {
provisioning: projects.len(),
active: active_total,
stopped: 0,
errored: 0,
decommissioned: 0,
total: sessions_total,
},
deliverables: DeliverableStatusCountsWire {
total: 2,
in_progress: 1,
complete: 1,
..Default::default()
},
milestones: MilestoneStatusCountsWire {
total: 1,
complete: 1,
..Default::default()
},
last_activity_at: Some(ts()),
},
projects,
}
}
#[test]
fn render_status_headline_sums_totals() {
let status = status_with(vec![project("alpha", 2), project("beta", 1)]);
let line = render_status_headline(&status);
assert!(line.contains("2 projects"));
assert!(line.contains("5 total"), "{line}"); assert!(line.contains("3 active"), "{line}");
assert!(line.contains("2 total (1 in_progress"), "{line}");
assert!(line.contains("2026-07-13"));
}
#[test]
fn render_status_headline_empty_portfolio() {
let status = status_with(vec![]);
let line = render_status_headline(&status);
assert!(line.contains("0 projects"));
assert!(line.contains("0 total"));
}
#[test]
fn render_status_headline_singular_project_noun() {
let status = status_with(vec![project("alpha", 1)]);
let line = render_status_headline(&status);
assert!(line.contains("1 project "), "{line}");
assert!(!line.contains("1 projects"));
}
#[test]
fn render_project_rows_one_row_per_project() {
let status = status_with(vec![project("alpha", 2), project("beta", 0)]);
let rows = render_project_rows(&status);
assert_eq!(rows.len(), 2);
assert!(rows[0].contains("alpha"));
assert!(rows[0].contains("3 total"));
assert!(rows[0].contains("2 active"));
assert!(rows[1].contains("beta"));
assert!(rows[1].contains("0 active"));
}
#[test]
fn default_conversation_key_is_stable_and_prefixed() {
let a = default_conversation_key();
let b = default_conversation_key();
assert_eq!(a, b);
assert!(a.starts_with("cli:"), "{a}");
}
#[test]
fn read_message_from_reader_reads_full_input() {
let mut reader = std::io::Cursor::new(b"what needs my attention?".to_vec());
let msg = read_message_from_reader(&mut reader).unwrap();
assert_eq!(msg, "what needs my attention?");
}
#[test]
fn read_message_from_reader_trims_whitespace() {
let mut reader = std::io::Cursor::new(b" hello there \n".to_vec());
let msg = read_message_from_reader(&mut reader).unwrap();
assert_eq!(msg, "hello there");
}
#[test]
fn render_route_resolved() {
let outcome = RouteTaskOutcome {
project: Some("alpha".to_string()),
confidence: 0.95,
rationale: "resolved to 'alpha' by exact name match".to_string(),
resolved_by: "resolver".to_string(),
raw: serde_json::json!({}),
};
let line = render_route(&outcome);
assert!(line.contains("alpha"), "{line}");
assert!(line.contains("0.95"), "{line}");
assert!(line.contains("resolver"), "{line}");
assert!(line.contains("exact name match"), "{line}");
}
#[test]
fn render_route_no_match() {
let outcome = RouteTaskOutcome {
project: None,
confidence: 0.0,
rationale: "no registered project matched the task".to_string(),
resolved_by: "no_match".to_string(),
raw: serde_json::json!({}),
};
let line = render_route(&outcome);
assert!(line.contains("no match"), "{line}");
assert!(line.contains("no registered project"), "{line}");
}
}