use std::path::Path;
use clap::Subcommand;
#[derive(Subcommand, Debug, Clone)]
pub enum ChannelsCommand {
List {
#[arg(short, long)]
verbose: bool,
#[arg(long)]
json: bool,
},
}
pub async fn run_channels_command(
cmd: ChannelsCommand,
config_path: Option<&Path>,
) -> anyhow::Result<()> {
let config = crate::config::Config::from_env_with_toml(config_path)
.await
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
match cmd {
ChannelsCommand::List { verbose, json } => cmd_list(&config.channels, verbose, json).await,
}
}
struct ChannelInfo {
name: String,
kind: &'static str,
enabled: bool,
details: Vec<(&'static str, String)>,
}
async fn cmd_list(
config: &crate::config::ChannelsConfig,
verbose: bool,
json: bool,
) -> anyhow::Result<()> {
let mut channels = Vec::new();
channels.push(ChannelInfo {
name: "cli".to_string(),
kind: "built-in",
enabled: config.cli.enabled,
details: vec![],
});
if let Some(ref gw) = config.gateway {
channels.push(ChannelInfo {
name: "gateway".to_string(),
kind: "built-in",
enabled: true,
details: vec![("host", gw.host.clone()), ("port", gw.port.to_string())],
});
} else {
channels.push(ChannelInfo {
name: "gateway".to_string(),
kind: "built-in",
enabled: false,
details: vec![],
});
}
if let Some(ref http) = config.http {
channels.push(ChannelInfo {
name: "http".to_string(),
kind: "built-in",
enabled: true,
details: vec![("host", http.host.clone()), ("port", http.port.to_string())],
});
} else {
channels.push(ChannelInfo {
name: "http".to_string(),
kind: "built-in",
enabled: false,
details: vec![],
});
}
if let Some(ref sig) = config.signal {
channels.push(ChannelInfo {
name: "signal".to_string(),
kind: "built-in",
enabled: true,
details: vec![
("http_url", sig.http_url.clone()),
("account", sig.account.clone()),
("dm_policy", sig.dm_policy.clone()),
("group_policy", sig.group_policy.clone()),
],
});
} else {
channels.push(ChannelInfo {
name: "signal".to_string(),
kind: "built-in",
enabled: false,
details: vec![],
});
}
if config.wasm_channels_enabled {
let wasm_channels = discover_wasm_channels(&config.wasm_channels_dir).await;
for name in wasm_channels {
let owner = config.wasm_channel_owner_ids.get(&name);
let mut details = vec![];
if let Some(id) = owner {
details.push(("owner_id", id.to_string()));
}
channels.push(ChannelInfo {
name,
kind: "wasm",
enabled: true,
details,
});
}
}
if json {
let entries: Vec<serde_json::Value> = channels
.iter()
.map(|ch| {
let mut v = serde_json::json!({
"name": ch.name,
"kind": ch.kind,
"enabled": ch.enabled,
});
if verbose {
let details: serde_json::Map<String, serde_json::Value> = ch
.details
.iter()
.map(|(k, v)| (k.to_string(), serde_json::Value::String(v.clone())))
.collect();
v["details"] = serde_json::Value::Object(details);
}
v
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string())
);
return Ok(());
}
let enabled_count = channels.iter().filter(|c| c.enabled).count();
println!(
"Configured channels ({} enabled, {} total):\n",
enabled_count,
channels.len()
);
for ch in &channels {
let status = if ch.enabled { "enabled" } else { "disabled" };
if verbose {
println!(" {} [{}] ({})", ch.name, status, ch.kind);
for (key, val) in &ch.details {
println!(" {}: {}", key, val);
}
if ch.details.is_empty() && ch.enabled {
println!(" (default config)");
}
println!();
} else {
let detail_str = if ch.enabled && !ch.details.is_empty() {
let parts: Vec<String> =
ch.details.iter().map(|(k, v)| format!("{k}={v}")).collect();
format!(" ({})", parts.join(", "))
} else {
String::new()
};
println!(
" {:<16} {:<10} {:<10}{}",
ch.name, status, ch.kind, detail_str
);
}
}
if !verbose {
println!();
println!("Use --verbose for details.");
println!();
println!("Note: enable/disable not yet available. Channel configuration is");
println!("managed via environment variables. See 'ironclaw onboard --channels-only'.");
}
Ok(())
}
async fn discover_wasm_channels(dir: &Path) -> Vec<String> {
let mut names = Vec::new();
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(_) => return names,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("wasm")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
names.push(stem.to_string());
}
}
names.sort();
names
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn discover_wasm_channels_empty_on_missing_dir() {
let result = discover_wasm_channels(Path::new("/nonexistent/path")).await;
assert!(result.is_empty());
}
#[tokio::test]
async fn discover_wasm_channels_finds_flat_wasm_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::File::create(tmp.path().join("slack.wasm")).unwrap();
std::fs::File::create(tmp.path().join("telegram.wasm")).unwrap();
std::fs::File::create(tmp.path().join("readme.txt")).unwrap();
std::fs::create_dir(tmp.path().join("somedir")).unwrap();
let result = discover_wasm_channels(tmp.path()).await;
assert_eq!(result, vec!["slack", "telegram"]);
}
#[test]
fn channel_info_struct() {
let info = ChannelInfo {
name: "test".to_string(),
kind: "built-in",
enabled: true,
details: vec![("port", "3000".to_string())],
};
assert!(info.enabled);
assert_eq!(info.kind, "built-in");
assert_eq!(info.details.len(), 1);
}
}