use std::fmt::Write as _;
use std::path::Path;
use crate::localization::{MessageId, tr};
use crate::plugins::types::{LoadedPlugin, PluginDiagnosticLevel};
use crate::tui::app::App;
pub(super) fn render_bundle_detail(
app: &App,
plugin: &LoadedPlugin,
include_hashes: bool,
) -> String {
let unsupported = plugin.inventory.unsupported_labels();
let unsupported = if unsupported.is_empty() {
"none".to_string()
} else {
unsupported.join(", ")
};
let active_components = if plugin.active() {
let labels = plugin.inventory.supported_labels();
if labels.is_empty() {
"none".to_string()
} else {
labels.join(", ")
}
} else {
"none".to_string()
};
let (content_hash, capability_hash) = if include_hashes {
(
plugin.content_hash.as_str(),
plugin.capability_hash.as_str(),
)
} else {
("hidden", "hidden")
};
let mut output = tr(app.ui_locale, MessageId::CmdPluginBundleDetail)
.replace("{name}", &escape_review_text(plugin.name()))
.replace("{id}", &escape_review_text(plugin.id.as_str()))
.replace(
"{version}",
&escape_review_text(&plugin.manifest.plugin.version),
)
.replace("{origin}", plugin.origin.as_str())
.replace("{scope}", plugin.scope.as_str())
.replace("{state}", plugin.state_label())
.replace("{trust}", plugin.trust_status.as_str())
.replace("{inventory}", &plugin.inventory.summary())
.replace("{permissions}", &render_permissions(plugin))
.replace("{mcp}", &render_mcp_inventory(plugin))
.replace("{unsupported}", &unsupported)
.replace("{content_hash}", content_hash)
.replace("{capability_hash}", capability_hash)
.replace("{path}", &escape_review_path(&plugin.canonical_root));
let skills = plugin
.skill_snapshots
.iter()
.map(|skill| escape_review_text(&format!("{}:{}", plugin.name(), skill.name)))
.collect::<Vec<_>>();
let _ = write!(
output,
"\nCompatibility: {}\nActive components: [{active_components}]\nInactive components: [{unsupported}]\nQualified skills: [{}]\nActivation boundary: trust stages the exact reviewed content but does not activate it; enable rebuilds this workspace's Skills, MCP, Commands, Agents, and Hooks immediately. Every plugin command dispatch, Agent spawn, Hook process start, Skill use, and MCP call rechecks current authority. LSP, native, filesystem-roots, and lifecycle-mutation stay inventoried and inactive.",
plugin.compatibility().as_str(),
if skills.is_empty() {
"none".to_string()
} else {
skills.join(", ")
}
);
append_diagnostics(app, &mut output, &plugin.diagnostics);
output
}
fn render_permissions(plugin: &LoadedPlugin) -> String {
let filesystem = if plugin.inventory.filesystem_roots.is_empty() {
"none".to_string()
} else {
plugin
.inventory
.filesystem_roots
.iter()
.map(|value| escape_review_text(value))
.collect::<Vec<_>>()
.join(", ")
};
let network = if plugin.inventory.network_hosts.is_empty() {
"none".to_string()
} else {
plugin
.inventory
.network_hosts
.iter()
.map(|value| escape_review_text(value))
.collect::<Vec<_>>()
.join(", ")
};
let stdio_authority = if plugin.inventory.stdio_mcp_servers == 0 {
"none".to_string()
} else {
format!(
"{} local child process(es) with host-user filesystem/network authority; MCP tool approvals still apply",
plugin.inventory.stdio_mcp_servers
)
};
format!(
"filesystem_roots=[{filesystem}] network_hosts=[{network}] (exact allowlist for Codewhale-managed remote requests; redirects stay same-origin) lifecycle_mutation={} stdio_runtime=[{stdio_authority}]",
plugin.inventory.lifecycle_mutation
)
}
fn render_mcp_inventory(plugin: &LoadedPlugin) -> String {
let Some(servers) = plugin.manifest.mcp_servers.as_ref() else {
return "none".to_string();
};
let mut servers = servers.iter().collect::<Vec<_>>();
servers.sort_by_key(|(name, _)| *name);
servers
.into_iter()
.map(|(name, server)| {
let enabled = if server.is_enabled() {
"configured-on"
} else {
"configured-off"
};
if let Some(command) = server.command.as_deref() {
let mut env_provenance = server
.env
.iter()
.map(|(destination, source)| {
let source = source
.strip_prefix("${")
.and_then(|source| source.strip_suffix('}'))
.unwrap_or("invalid");
format!(
"{} <- {}",
escape_review_text(destination),
escape_review_text(source)
)
})
.collect::<Vec<_>>();
env_provenance.sort_unstable();
let cwd = server
.cwd
.as_deref()
.map(escape_review_path)
.unwrap_or_else(|| "plugin-root".to_string());
let argv = render_review_argv(plugin, &server.args);
format!(
"{}: transport=stdio command={} argv=[{}] cwd={cwd} env=[{}] timeouts={} required={} enabled_tools=[{}] disabled_tools=[{}] host-user-filesystem/network-authority {enabled}",
escape_review_text(name),
escape_review_text(command),
argv.join(", "),
if env_provenance.is_empty() { "none".to_string() } else { env_provenance.join(", ") },
render_mcp_timeouts(server),
server.required,
render_review_values(&server.enabled_tools),
render_review_values(&server.disabled_tools),
)
} else if let Some(url) = server.url.as_deref() {
let endpoint = reqwest::Url::parse(url)
.ok()
.map(|url| escape_review_text(url.as_str()))
.unwrap_or_else(|| "invalid-url".to_string());
let mut env_headers = server
.env_headers
.iter()
.map(|(header, source)| {
format!(
"{} <- {}",
escape_review_text(header),
escape_review_text(source)
)
})
.collect::<Vec<_>>();
env_headers.sort_unstable();
let bearer = server
.bearer_token_env_var
.as_deref()
.map(escape_review_text)
.unwrap_or_else(|| "none".to_string());
let transport = server.transport.as_deref().unwrap_or(
"streamable-http with same-origin SSE fallback",
);
format!(
"{}: transport={} endpoint={} redirects=same-origin-only env_headers=[{}] bearer_env={} oauth=disabled timeouts={} required={} enabled_tools=[{}] disabled_tools=[{}] {enabled}",
escape_review_text(name),
escape_review_text(transport),
endpoint,
if env_headers.is_empty() { "none".to_string() } else { env_headers.join(", ") },
bearer,
render_mcp_timeouts(server),
server.required,
render_review_values(&server.enabled_tools),
render_review_values(&server.disabled_tools),
)
} else {
format!("{name}: invalid")
}
})
.collect::<Vec<_>>()
.join("; ")
}
fn render_review_argv(plugin: &LoadedPlugin, arguments: &[String]) -> Vec<String> {
arguments
.iter()
.enumerate()
.map(|(index, argument)| {
let position = index + 1;
let candidate = plugin.canonical_root.join(argument);
if candidate.exists()
&& candidate
.canonicalize()
.is_ok_and(|path| path.starts_with(&plugin.canonical_root))
{
return format!(
"#{position} plugin-path={}",
render_review_argv_value(argument)
);
}
format!("#{position} value={}", render_review_argv_value(argument))
})
.collect()
}
fn render_review_argv_value(value: &str) -> String {
serde_json::to_string(value).expect("serializing a Rust string cannot fail")
}
fn render_review_values(values: &[String]) -> String {
if values.is_empty() {
return "none".to_string();
}
values
.iter()
.map(|value| escape_review_text(value))
.collect::<Vec<_>>()
.join(", ")
}
fn render_mcp_timeouts(server: &crate::mcp::McpServerConfig) -> String {
format!(
"connect={}/execute={}/read={}",
server
.connect_timeout
.map_or_else(|| "default".to_string(), |value| format!("{value}s")),
server
.execute_timeout
.map_or_else(|| "default".to_string(), |value| format!("{value}s")),
server
.read_timeout
.map_or_else(|| "default".to_string(), |value| format!("{value}s")),
)
}
pub(super) fn escape_review_path(path: &Path) -> String {
escape_review_text(&path.to_string_lossy())
}
pub(super) fn escape_review_text(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
if ch.is_control()
|| matches!(
ch,
'\u{061c}'
| '\u{200e}'
| '\u{200f}'
| '\u{202a}'..='\u{202e}'
| '\u{2066}'..='\u{2069}'
)
{
let _ = write!(escaped, "\\u{{{:x}}}", ch as u32);
} else if matches!(
ch,
'\\' | '`'
| '*'
| '_'
| '{'
| '}'
| '['
| ']'
| '<'
| '>'
| '('
| ')'
| '#'
| '+'
| '-'
| '.'
| '!'
| '|'
) {
escaped.push('\\');
escaped.push(ch);
} else {
escaped.push(ch);
}
}
escaped
}
pub(super) fn review_token(plugin: &LoadedPlugin) -> String {
format!("{}.{}", plugin.content_hash, plugin.capability_hash)
}
pub(super) fn append_diagnostics(
app: &App,
output: &mut String,
diagnostics: &[crate::plugins::types::PluginDiagnostic],
) {
if diagnostics.is_empty() {
return;
}
if !output.ends_with('\n') {
output.push('\n');
}
output.push_str(
&tr(app.ui_locale, MessageId::CmdPluginBundleDiagnosticsHeader)
.replace("{count}", &diagnostics.len().to_string()),
);
output.push('\n');
for diagnostic in diagnostics {
let level = match diagnostic.level {
PluginDiagnosticLevel::Warning => "warning",
PluginDiagnosticLevel::Error => "error",
};
let path = diagnostic
.path
.as_deref()
.map(|path| format!(" ({})", escape_review_path(path)))
.unwrap_or_default();
let _ = writeln!(
output,
"• {level} [{}]: {}{path}",
diagnostic.code,
escape_review_text(&diagnostic.message)
);
}
}