mod display;
mod mcp;
use display::*;
pub(crate) use mcp::handle_mcp_command;
use mcp::*;
use crate::agent::context::ConversationContext;
use crate::tui::state::UiState;
use crate::agent::checkpoint::CheckpointManager;
use crate::config::Config;
use crate::skills::SkillRegistry;
pub enum CommandResult {
Message(String),
Handled,
NotACommand,
Clear,
Compact,
Quit,
Rewind { target_id: Option<u32> },
Search(String),
Popup { title: String, content: String },
TableSelectPopup {
title: String,
items: Vec<(String, String)>,
select_prefix: String,
},
ConfigPopup,
ThemeSelectPopup,
ModelSelectPopup,
Init,
SessionFork,
SessionHive,
SessionFlock,
DebugToggle,
McpPopup,
Optimize,
SkillInvoke {
skill_content: String,
user_args: String,
},
}
pub fn is_non_disruptive_command(input: &str) -> bool {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return false;
}
let cmd = trimmed.split_whitespace().next().unwrap_or("");
match cmd {
"/help" | "/h" | "/?" | "/diff" | "/sdiff" | "/cost" | "/checkpoints" | "/skills"
| "/agents" | "/mcp" | "/hive-status" | "/collab-status" | "/monitor" | "/optimize"
| "/telemetry" | "/plugin" => true,
"/search" | "/find" => true,
"/theme" => !trimmed.contains(' '),
"/models" => !trimmed.contains(' '),
"/config" => true,
"/quit" | "/exit" => true,
_ => false,
}
}
pub fn command_list() -> &'static [(&'static str, &'static str)] {
&[
("/init", "Analyze project and generate AGENTS.md"),
("/config", "Edit configuration settings interactively"),
("/help", "Show help message"),
("/clear", "Clear conversation"),
("/compact", "Force context compaction"),
("/cost", "Show token usage statistics"),
("/diff", "Show uncommitted git changes"),
("/sdiff", "Side-by-side diff view"),
("/undo", "Revert the last git commit"),
("/models", "Open model picker or change model directly"),
("/rewind", "Rewind to last checkpoint"),
("/checkpoints", "List available checkpoints"),
("/search", "BM25 search across codebase"),
("/web", "Fetch URL content"),
("/skills", "List available skills"),
("/agents", "List configured agents"),
("/mcp", "MCP servers (space: toggle, a: add, d: remove)"),
("/theme", "Show or change color theme"),
("/fork", "Toggle fork mode for this session"),
("/hive", "Toggle hive mode for this session"),
("/flock", "Toggle flock mode for this session"),
(
"/collab-status",
"Show collaboration mode status and settings",
),
("/monitor", "Toggle debug monitor in sidebar"),
("/optimize", "Analyze usage and suggest parameter tuning"),
("/telemetry", "Show telemetry status and collected data"),
("/bench", "Open bench dashboard (performance analytics)"),
("/save-plan", "Save architect plan to file"),
("/proceed", "Start code implementation from plan"),
("/cancel-plan", "Discard pending architect plan"),
("/resume", "Resume an incomplete session"),
("/evolve", "Start self-improvement evolution loop"),
(
"/plugin",
"Manage plugins (install, remove, update, marketplace)",
),
("/quit", "Exit collet"),
]
}
pub fn subcommand_hint(cmd: &str) -> Option<&'static str> {
match cmd {
"/compact" => Some("<custom summarization instructions>"),
"/models" => Some("<model_name>"),
"/theme" => Some("<theme_name>"),
"/search" | "/find" => Some("<query>"),
"/web" => Some("<url>"),
"/rewind" => Some("<checkpoint_id>"),
"/mcp" => Some("add <name> <source> [-g] | rm <name> [-g]"),
"/plugin" => Some(
"install <name>@<mkt> | install <owner/repo> | remove <name> | update [name] | marketplace add|list|remove",
),
"/diff" => Some("[--side-by-side]"),
"/resume" => Some("[session_id]"),
_ => None,
}
}
pub fn dispatch(
input: &str,
state: &UiState,
_context: Option<&ConversationContext>,
working_dir: &str,
checkpoint_mgr: &CheckpointManager,
config: &Config,
) -> CommandResult {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return CommandResult::NotACommand;
}
let (cmd, args) = match trimmed.find(' ') {
Some(pos) => (&trimmed[..pos], trimmed[pos + 1..].trim()),
None => (trimmed, ""),
};
crate::telemetry::track("command_used", serde_json::json!({"command": cmd}));
match cmd {
"/help" | "/h" | "/?" => CommandResult::Popup {
title: "Help".to_string(),
content: help_text(),
},
"/clear" => CommandResult::Clear,
"/compact" => CommandResult::Compact,
"/cost" => CommandResult::Popup {
title: "Cost & Usage".to_string(),
content: cost_text(&state.token_stats),
},
"/diff" => {
if args == "--side-by-side" {
CommandResult::Popup {
title: "Side-by-Side Diff".to_string(),
content: side_by_side_diff_text(working_dir),
}
} else {
CommandResult::Popup {
title: "Git Diff".to_string(),
content: diff_text(working_dir),
}
}
}
"/sdiff" => CommandResult::Popup {
title: "Side-by-Side Diff".to_string(),
content: side_by_side_diff_text(working_dir),
},
"/undo" => CommandResult::Message(undo_text(working_dir)),
"/quit" | "/exit" => CommandResult::Quit,
"/model" => {
CommandResult::Message("Did you mean `/models`? `/model` has been removed.".to_string())
}
"/theme" if !args.is_empty() => CommandResult::Message(theme_text(args)),
"/noop" => CommandResult::Handled, "/save-plan" | "/proceed" | "/cancel-plan" => {
CommandResult::NotACommand }
"/resume" => CommandResult::NotACommand, "/evolve" => CommandResult::NotACommand, "/rewind" => {
let target_id = args.parse::<u32>().ok();
CommandResult::Rewind { target_id }
}
"/checkpoints" => CommandResult::Popup {
title: "Checkpoints".to_string(),
content: checkpoints_text(checkpoint_mgr),
},
"/search" | "/find" => {
if args.is_empty() {
CommandResult::Message(
"Usage: `/search <query>` — BM25 relevance search across codebase.".to_string(),
)
} else {
CommandResult::Search(args.to_string())
}
}
"/init" => CommandResult::Init,
"/config" => CommandResult::ConfigPopup,
"/web" => CommandResult::Message(web_text(args)),
"/skills" => {
let registry = SkillRegistry::discover(std::path::Path::new(working_dir));
let items: Vec<(String, String)> = registry
.all()
.iter()
.map(|s| (s.name.clone(), s.description.clone()))
.collect();
if items.is_empty() {
CommandResult::Popup {
title: "Skills".to_string(),
content: "No skills found.\n\nPlace skill files in `.claude/skills/<name>/SKILL.md` (project) \
or `~/.config/collet/skills/<name>/SKILL.md` (user).".to_string(),
}
} else {
CommandResult::TableSelectPopup {
title: "Skills".to_string(),
items,
select_prefix: "/".to_string(),
}
}
}
"/agents" => {
let items: Vec<(String, String)> = config
.agents
.iter()
.map(|a| {
let provider_model = match &a.provider {
Some(p) => format!("{} / {}", p, a.model),
None => a.model.clone(),
};
let desc = match &a.description {
Some(d) => format!("{} [{}]", provider_model, d),
None => provider_model,
};
(a.name.clone(), desc)
})
.collect();
if items.is_empty() {
CommandResult::Popup {
title: "Agents".to_string(),
content:
"No agents configured.\n\nAdd agents to `~/.config/collet/config.toml`:\n\
```toml\n[[agents.list]]\nname = \"code\"\nmodel = \"glm-4.7\"\n```"
.to_string(),
}
} else {
CommandResult::TableSelectPopup {
title: "Agents".to_string(),
items,
select_prefix: "@".to_string(),
}
}
}
"/mcp" => {
let tokens: Vec<String> = args.split_whitespace().map(String::from).collect();
let sub = tokens.first().map(|s| s.as_str()).unwrap_or("");
let rest: &[String] = if tokens.len() > 1 { &tokens[1..] } else { &[] };
match sub {
"add" if !rest.is_empty() => CommandResult::Message(mcp_add(rest, working_dir)),
"remove" | "rm" if !rest.is_empty() => {
CommandResult::Message(mcp_remove(rest, working_dir))
}
_ => CommandResult::McpPopup,
}
}
"/models" if args.is_empty() => CommandResult::ModelSelectPopup,
"/models" => CommandResult::Message(format!("MODEL_CHANGE:{args}")),
"/theme" if args.is_empty() => CommandResult::ThemeSelectPopup,
"/fork" => CommandResult::SessionFork,
"/hive" => CommandResult::SessionHive,
"/flock" => CommandResult::SessionFlock,
"/monitor" => CommandResult::DebugToggle,
"/optimize" => CommandResult::Optimize,
"/telemetry" => CommandResult::Popup {
title: "Telemetry".to_string(),
content: crate::telemetry::status_text(),
},
"/bench" => {
let bench_path = crate::config::bench_log_path();
if !bench_path.exists() {
CommandResult::Message("No bench.jsonl found yet. Run some tasks first.".into())
} else {
let dashboard_root =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("dashboard");
let static_dir = dashboard_root.join("static");
if !dashboard_root.join("package.json").exists() {
return CommandResult::Message(format!(
"Dashboard source not found at `{}`.",
dashboard_root.display()
));
}
if !dashboard_root.join("node_modules").exists() {
match std::process::Command::new("npm")
.args(["install"])
.current_dir(&dashboard_root)
.status()
{
Ok(s) if s.success() => {}
Ok(s) => {
return CommandResult::Message(format!(
"npm install failed (exit {}). Run `npm install` manually in `{}`.",
s.code().unwrap_or(-1),
dashboard_root.display()
));
}
Err(e) => {
return CommandResult::Message(format!(
"Could not run npm: {e}. Ensure npm is installed and in PATH."
));
}
}
}
let link_path = static_dir.join("bench.jsonl");
let _ = std::fs::remove_file(&link_path);
#[cfg(unix)]
let _ = std::os::unix::fs::symlink(&bench_path, &link_path);
#[cfg(not(unix))]
let _ = std::fs::copy(&bench_path, &link_path);
let port = 9877u16;
let _ = std::process::Command::new("npm")
.args([
"run",
"dev",
"--",
"--port",
&port.to_string(),
"--host",
"127.0.0.1",
])
.current_dir(&dashboard_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let url = format!("http://localhost:{port}");
let url_for_open = url.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(800));
#[cfg(target_os = "macos")]
let _ = std::process::Command::new("open")
.arg(&url_for_open)
.spawn();
#[cfg(target_os = "linux")]
let _ = std::process::Command::new("xdg-open")
.arg(&url_for_open)
.spawn();
});
CommandResult::Message(format!(
"Dashboard: **{url}**\n\n\
Streaming from `{}`\n\
Press `● Live` to toggle auto-refresh.",
bench_path.display()
))
}
}
"/hive-status" | "/collab-status" => {
let collab = &config.collaboration;
let info = format!(
"## Collaboration Mode: {}\n\n\
- Max agents: {}\n\
- Strategy: {:?}\n\
- Coordinator: {}\n\
- Worker: {}\n\
- Consensus: {}\n\
- Conflicts: {:?}\n\
- Worktree: {}\n\
- Auto-suggest: {}\n\n\
Set `collaboration.mode` in config.toml or `COLLET_COLLAB_MODE=fork|hive|flock`\n\
Toggle per-session: /fork /hive /flock",
collab.mode,
collab.max_agents,
collab.strategy,
collab.coordinator_model.as_deref().unwrap_or("(default)"),
collab.worker_model.as_deref().unwrap_or("(default)"),
collab.require_consensus,
collab.conflict_resolution,
collab.worktree,
collab.auto_suggest,
);
CommandResult::Popup {
title: "Collaboration Mode".to_string(),
content: info,
}
}
"/plugin" => handle_plugin_command(args, working_dir),
_ => {
let plugin_mgr = crate::plugin::PluginManager::discover();
if let Some((plugin_cmd, _plugin)) = plugin_mgr.find_command(cmd) {
CommandResult::SkillInvoke {
skill_content: plugin_cmd.content.clone(),
user_args: args.to_string(),
}
} else {
let skill_name = &cmd[1..];
let registry = SkillRegistry::discover(std::path::Path::new(working_dir));
if let Ok(invocation) = registry.invoke(skill_name) {
let skill_content = invocation.to_context_string();
CommandResult::SkillInvoke {
skill_content,
user_args: args.to_string(),
}
} else {
CommandResult::Message(format!(
"Unknown command: `{cmd}`\nType `/help` for available commands.",
))
}
}
}
}
}
fn handle_plugin_command(args: &str, _working_dir: &str) -> CommandResult {
let tokens: Vec<&str> = args.split_whitespace().collect();
let sub = tokens.first().copied().unwrap_or("");
match sub {
"" | "list" | "ls" => {
let mgr = crate::plugin::PluginManager::discover();
CommandResult::Popup {
title: "Plugins".to_string(),
content: mgr.list_text(),
}
}
"install" => {
let spec = tokens.get(1).copied().unwrap_or("");
if spec.is_empty() {
return CommandResult::Message(
"Usage:\n\
`/plugin install <name>@<marketplace>` — install from marketplace\n\
`/plugin install <owner/repo>` — install directly from GitHub\n\
`/plugin install <owner/repo> <alias>` — install with custom name\n\n\
Examples:\n\
`/plugin install superpowers@superpowers-marketplace`\n\
`/plugin install epicsagas/epic-harness`"
.to_string(),
);
}
let alias = tokens.get(2).copied().filter(|s| !s.is_empty());
match crate::plugin::PluginManager::install(spec, alias) {
Ok((_, plugin)) => CommandResult::Message(format!(
"✅ Plugin **{}** v{} installed.\n{}",
plugin.name, plugin.version, plugin.description
)),
Err(e) => CommandResult::Message(format!("❌ Install failed: {e}")),
}
}
"remove" | "rm" | "uninstall" => {
let name = tokens.get(1).copied().unwrap_or("");
if name.is_empty() {
return CommandResult::Message("Usage: `/plugin remove <name>`".to_string());
}
match crate::plugin::PluginManager::remove(name) {
Ok(()) => CommandResult::Message(format!("✅ Plugin **{name}** removed.")),
Err(e) => CommandResult::Message(format!("❌ {e}")),
}
}
"update" | "upgrade" => {
let name = tokens.get(1).copied();
match crate::plugin::PluginManager::update(name) {
Ok(results) if results.is_empty() => {
CommandResult::Message("No plugins installed.".to_string())
}
Ok(results) => {
let lines: Vec<String> = results
.iter()
.map(|(n, updated)| {
if *updated {
format!("✅ **{n}** — updated")
} else {
format!(" **{n}** — already up to date")
}
})
.collect();
CommandResult::Message(lines.join("\n"))
}
Err(e) => CommandResult::Message(format!("❌ {e}")),
}
}
"marketplace" | "mkt" => {
let sub2 = tokens.get(1).copied().unwrap_or("");
match sub2 {
"add" | "register" => {
let owner_repo = tokens.get(2).copied().unwrap_or("");
if owner_repo.is_empty() {
return CommandResult::Message(
"Usage: `/plugin marketplace add <owner/repo>`\n\
Example: `/plugin marketplace add obra/superpowers-marketplace`"
.to_string(),
);
}
let short_name = tokens.get(3).copied().unwrap_or_else(|| {
crate::plugin::marketplace::derive_marketplace_name(owner_repo)
});
match crate::plugin::marketplace::MarketplaceRegistry::load().and_then(
|mut reg| {
let is_new = reg.add(short_name, owner_repo)?;
Ok(is_new)
},
) {
Ok(true) => CommandResult::Message(format!(
"✅ Marketplace **{short_name}** registered (`{owner_repo}`).\n\
Install plugins from it:\n\
`/plugin install <plugin-name>@{short_name}`"
)),
Ok(false) => CommandResult::Message(format!(
"✅ Marketplace **{short_name}** updated (`{owner_repo}`)."
)),
Err(e) => CommandResult::Message(format!("❌ {e}")),
}
}
"list" | "ls" | "" => {
match crate::plugin::marketplace::MarketplaceRegistry::load() {
Ok(reg) if reg.is_empty() => CommandResult::Message(
"No marketplaces registered.\n\n\
Add one: `/plugin marketplace add <owner/repo>`\n\
Example: `/plugin marketplace add obra/superpowers-marketplace`"
.to_string(),
),
Ok(reg) => {
let lines: Vec<String> = reg
.list()
.iter()
.map(|(name, repo)| format!("**{name}** — `{repo}`"))
.collect();
CommandResult::Popup {
title: "Registered Marketplaces".to_string(),
content: lines.join("\n"),
}
}
Err(e) => CommandResult::Message(format!("❌ {e}")),
}
}
"remove" | "rm" | "unregister" => {
let name = tokens.get(2).copied().unwrap_or("");
if name.is_empty() {
return CommandResult::Message(
"Usage: `/plugin marketplace remove <name>`".to_string(),
);
}
match crate::plugin::marketplace::MarketplaceRegistry::load()
.and_then(|mut reg| reg.remove(name))
{
Ok(true) => {
CommandResult::Message(format!("✅ Marketplace **{name}** removed."))
}
Ok(false) => CommandResult::Message(format!(
"Marketplace **{name}** was not registered."
)),
Err(e) => CommandResult::Message(format!("❌ {e}")),
}
}
_ => CommandResult::Message(format!(
"Unknown marketplace subcommand: `{sub2}`\n\
Available: `add`, `list`, `remove`"
)),
}
}
_ => CommandResult::Message(format!(
"Unknown plugin subcommand: `{sub}`\n\
Available: `list`, `install`, `remove`, `update`, `marketplace`\n\
Type `/help` for more information."
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_state() -> UiState {
UiState::new()
}
fn make_checkpoint_mgr() -> CheckpointManager {
CheckpointManager::new("/tmp")
}
fn make_config() -> Config {
Config {
api_key: String::new(),
base_url: String::new(),
model: "glm-4.7".to_string(),
max_tokens: 8192,
supports_tools: None,
tool_timeout_secs: 120,
task_timeout_secs: 600,
max_iterations: 25,
max_continuations: 3,
circuit_breaker_threshold: 3,
stream_idle_timeout_secs: 120,
stream_max_retries: 5,
iteration_delay_ms: 50,
temperature: None,
thinking_budget_tokens: None,
reasoning_effort: None,
model_overrides: vec![],
agents: vec![],
context_max_tokens: 200_000,
compaction_threshold: 0.8,
adaptive_compaction: true,
auto_commit: true,
lint_cmd: None,
test_cmd: None,
theme: "default".to_string(),
debug_mode: false,
bench_retain_days: 90,
collaboration: crate::agent::swarm::config::CollaborationConfig::default(),
debug_targets: crate::config::DebugTargets::default(),
collet_home: crate::config::collet_home(None),
auto_optimize: true,
web: crate::config::WebConfig::from_section(
&crate::config::types::WebSection::default(),
),
pii_filter: true,
deny_paths: crate::config::default_deny_paths(),
follow_symlinks: false,
telemetry_enabled: false,
telemetry_error_reporting: false,
telemetry_analytics: false,
rag: None,
soul_enabled: false,
evolution_enabled: false,
evolution_model: None,
evolution_cycles: 1,
auto_route: false,
cli: None,
cli_args: Vec::new(),
cli_yolo_args: Vec::new(),
cli_model_env: None,
cli_skip_model: false,
cli_yolo_env: Vec::new(),
cli_max_turns_flag: None,
providers: Vec::new(),
proxy_headers: std::collections::HashMap::new(),
yolo: false,
iteration_budget: None,
}
}
#[test]
fn test_not_a_command() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
let result = dispatch("hello world", &state, None, "/tmp", &mgr, &cfg);
assert!(matches!(result, CommandResult::NotACommand));
}
#[test]
fn test_help_command() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
let result = dispatch("/help", &state, None, "/tmp", &mgr, &cfg);
match result {
CommandResult::Popup { content, .. } => assert!(content.contains("Available Commands")),
_ => panic!("Expected Popup"),
}
}
#[test]
fn test_help_aliases() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
assert!(matches!(
dispatch("/h", &state, None, "/tmp", &mgr, &cfg),
CommandResult::Popup { .. }
));
assert!(matches!(
dispatch("/?", &state, None, "/tmp", &mgr, &cfg),
CommandResult::Popup { .. }
));
}
#[test]
fn test_clear_command() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
assert!(matches!(
dispatch("/clear", &state, None, "/tmp", &mgr, &cfg),
CommandResult::Clear
));
}
#[test]
fn test_compact_command() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
assert!(matches!(
dispatch("/compact", &state, None, "/tmp", &mgr, &cfg),
CommandResult::Compact
));
}
#[test]
fn test_quit_command() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
assert!(matches!(
dispatch("/quit", &state, None, "/tmp", &mgr, &cfg),
CommandResult::Quit
));
assert!(matches!(
dispatch("/exit", &state, None, "/tmp", &mgr, &cfg),
CommandResult::Quit
));
}
#[test]
fn test_mode_commands() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
let result = dispatch("/ask", &state, None, "/tmp", &mgr, &cfg);
assert!(matches!(
result,
CommandResult::Message(_)
| CommandResult::NotACommand
| CommandResult::SkillInvoke { .. }
));
}
#[test]
fn test_models_no_args() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
assert!(matches!(
dispatch("/models", &state, None, "/tmp", &mgr, &cfg),
CommandResult::ModelSelectPopup
));
}
#[test]
fn test_models_with_args() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
match dispatch("/models glm-5", &state, None, "/tmp", &mgr, &cfg) {
CommandResult::Message(msg) => assert!(msg.contains("MODEL_CHANGE:glm-5")),
_ => panic!("Expected Message"),
}
}
#[test]
fn test_model_legacy_redirects() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
match dispatch("/model", &state, None, "/tmp", &mgr, &cfg) {
CommandResult::Message(msg) => assert!(msg.contains("/models")),
_ => panic!("Expected Message with /models hint"),
}
}
#[test]
fn test_rewind_no_args() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
match dispatch("/rewind", &state, None, "/tmp", &mgr, &cfg) {
CommandResult::Rewind { target_id } => assert!(target_id.is_none()),
_ => panic!("Expected Rewind"),
}
}
#[test]
fn test_rewind_with_id() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
match dispatch("/rewind 5", &state, None, "/tmp", &mgr, &cfg) {
CommandResult::Rewind { target_id } => assert_eq!(target_id, Some(5)),
_ => panic!("Expected Rewind"),
}
}
#[test]
fn test_unknown_command() {
let state = make_state();
let mgr = make_checkpoint_mgr();
let cfg = make_config();
match dispatch("/foobar", &state, None, "/tmp", &mgr, &cfg) {
CommandResult::Message(msg) => assert!(msg.contains("Unknown command")),
_ => panic!("Expected Message"),
}
}
#[test]
fn test_cost_command() {
let mut state = make_state();
state.token_stats.prompt_tokens = 1000;
state.token_stats.completion_tokens = 500;
state.token_stats.api_calls = 3;
let mgr = make_checkpoint_mgr();
let cfg = make_config();
match dispatch("/cost", &state, None, "/tmp", &mgr, &cfg) {
CommandResult::Popup { content, .. } => {
assert!(content.contains("1000"));
assert!(content.contains("500"));
assert!(content.contains("1500"));
assert!(content.contains("3"));
}
_ => panic!("Expected Popup"),
}
}
#[test]
fn test_strip_html_tags() {
assert_eq!(strip_html_tags("<p>hello</p>").trim(), "hello");
assert_eq!(
strip_html_tags("<script>var x=1;</script>text").trim(),
"text"
);
assert_eq!(
strip_html_tags("<b>bold</b> and <i>italic</i>").trim(),
"bold and italic"
);
}
#[test]
fn test_strip_html_style() {
let html = "<style>.cls{color:red}</style><p>content</p>";
assert_eq!(strip_html_tags(html).trim(), "content");
}
#[test]
fn test_web_no_args() {
let result = web_text("");
assert!(result.contains("Usage"));
}
#[test]
fn test_web_invalid_url() {
let result = web_text("not-a-url");
assert!(result.contains("Invalid URL"));
}
}