//! `projects` chat command — list all registered gateway projects. //! //! Reads the live `gateway_projects_store` and returns one row per project: //! name, URL, ssh_port (if set), host_path (if set), and an `[adopted]` or //! `[built-in]` marker. Output is alphabetised by project name (BTreeMap //! order) and the active project is marked with a leading `*`. //! No liveness checks are performed — this is a pure enumeration. use crate::chat::transport::matrix::bot::context::BotContext; /// Run the `projects` command and return a formatted Markdown string. /// /// Returns a short message when not in gateway mode or when no projects are /// registered. The active project is prefixed with `*`; all others with /// a space. Rows are alphabetised because the underlying store is a /// [`BTreeMap`](std::collections::BTreeMap). pub async fn run_projects_list(ctx: &BotContext) -> String { let store = match ctx.gateway_projects_store.as_ref() { Some(s) => s, None => return "Not running in gateway mode.".to_string(), }; let active = match ctx.gateway_active_project.as_ref() { Some(ap) => ap.read().await.clone(), None => String::new(), }; let projects = store.read().await; if projects.is_empty() { return "No projects registered.".to_string(); } let mut lines = Vec::with_capacity(projects.len() + 1); lines.push(format!("Projects ({} registered):", projects.len())); for (name, entry) in projects.iter() { let marker = if *name == active { "*" } else { " " }; let mut parts = vec![name.clone()]; if let Some(ref url) = entry.url { parts.push(url.clone()); } if let Some(port) = entry.ssh_port { parts.push(format!("ssh:{port}")); } if let Some(ref path) = entry.host_path { parts.push(path.clone()); } let adopted = if entry.host_path.is_some() { "[adopted]" } else { "[built-in]" }; parts.push(adopted.to_string()); lines.push(format!("{marker} {}", parts.join(" "))); } lines.join("\n") } // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::service::gateway::config::ProjectEntry; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use std::sync::atomic::AtomicI64; use tokio::sync::Mutex as TokioMutex; use tokio::sync::RwLock; async fn run_with_projects(projects: BTreeMap, active: &str) -> String { let services = crate::services::Services::new_test( std::path::PathBuf::from("/tmp"), "bot".to_string(), ); let store = Arc::new(RwLock::new(projects)); let active_project = Arc::new(RwLock::new(active.to_string())); let ctx = BotContext { services, matrix_user_id: "@bot:example.com".parse().unwrap(), target_room_ids: vec![], allowed_users: vec![], history: Arc::new(TokioMutex::new(HashMap::new())), history_size: 20, bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())), htop_sessions: Arc::new(TokioMutex::new(HashMap::new())), transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new( "test-phone".to_string(), "test-token".to_string(), "pipeline_notification".to_string(), )), timer_store: Arc::new(crate::service::timer::TimerStore::load( std::path::PathBuf::from("/tmp/timers-projects.json"), )), gateway_active_project: Some(active_project), gateway_projects_store: Some(store), gateway_channels_store: None, handled_incoming_event_ids: Arc::new(TokioMutex::new( crate::chat::transport::matrix::bot::context::SeenEventIds::new( crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP, ), )), gateway_port: None, last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())), model: None, }; run_projects_list(&ctx).await } #[tokio::test] async fn four_projects_all_names_appear_exactly_once() { let mut projects = BTreeMap::new(); projects.insert("alpha".into(), ProjectEntry::with_url("http://sled-a:3001")); projects.insert("beta".into(), ProjectEntry::with_url("http://sled-b:3002")); projects.insert( "gamma".into(), ProjectEntry { url: Some("http://sled-c:3003".into()), auth_token: None, ssh_port: Some(2203), host_path: Some("/home/user/workspace".into()), expected_node_id: None, }, ); projects.insert( "delta".into(), ProjectEntry { url: None, auth_token: Some("tok".into()), ssh_port: None, host_path: None, expected_node_id: None, }, ); let response = run_with_projects(projects, "alpha").await; for name in &["alpha", "beta", "gamma", "delta"] { let count = response.matches(name).count(); assert_eq!( count, 1, "project '{name}' should appear exactly once, response:\n{response}" ); } } #[tokio::test] async fn active_project_marked_with_star() { let mut projects = BTreeMap::new(); projects.insert("proj-a".into(), ProjectEntry::with_url("http://a:3001")); projects.insert("proj-b".into(), ProjectEntry::with_url("http://b:3002")); let response = run_with_projects(projects, "proj-b").await; let active_line = response.lines().find(|l| l.contains("proj-b")).unwrap(); assert!( active_line.starts_with('*'), "active project line should start with '*': {active_line}" ); let inactive_line = response.lines().find(|l| l.contains("proj-a")).unwrap(); assert!( !inactive_line.starts_with('*'), "inactive project line should not start with '*': {inactive_line}" ); } #[tokio::test] async fn adopted_project_shows_marker() { let mut projects = BTreeMap::new(); projects.insert( "adopted".into(), ProjectEntry { url: Some("http://a:3001".into()), auth_token: None, ssh_port: None, host_path: Some("/home/user/adopted".into()), expected_node_id: None, }, ); projects.insert("builtin".into(), ProjectEntry::with_url("http://b:3002")); let response = run_with_projects(projects, "adopted").await; assert!( response.contains("[adopted]"), "adopted project should be marked: {response}" ); assert!( response.contains("[built-in]"), "built-in project should be marked: {response}" ); } #[tokio::test] async fn non_gateway_returns_not_in_gateway_mode() { let services = crate::services::Services::new_test( std::path::PathBuf::from("/tmp"), "bot".to_string(), ); let ctx = BotContext { services, matrix_user_id: "@bot:example.com".parse().unwrap(), target_room_ids: vec![], allowed_users: vec![], history: Arc::new(TokioMutex::new(HashMap::new())), history_size: 20, bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())), htop_sessions: Arc::new(TokioMutex::new(HashMap::new())), transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new( "test-phone".to_string(), "test-token".to_string(), "pipeline_notification".to_string(), )), timer_store: Arc::new(crate::service::timer::TimerStore::load( std::path::PathBuf::from("/tmp/timers-projects2.json"), )), gateway_active_project: None, gateway_projects_store: None, gateway_channels_store: None, handled_incoming_event_ids: Arc::new(TokioMutex::new( crate::chat::transport::matrix::bot::context::SeenEventIds::new( crate::chat::transport::matrix::bot::context::SEEN_EVENT_IDS_CAP, ), )), gateway_port: None, last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())), model: None, }; let response = run_projects_list(&ctx).await; assert!( response.contains("Not running in gateway mode"), "should indicate not in gateway mode: {response}" ); } #[test] fn projects_command_registered_in_commands() { let cmds = crate::chat::commands::commands(); assert!( cmds.iter().any(|c| c.name == "projects"), "projects must be registered in commands()" ); } }