| 1 | //! `/lsp` command — enable/disable LSP integration. |
| 2 | //! |
| 3 | //! Bridges to the host LSP state through the portable project facet |
| 4 | //! (FEAT-021 D3): the handler composes byte-identical output from typed |
| 5 | //! status/set delegates; the TUI adapter owns all host-side LSP behavior. |
| 6 | |
| 7 | use codewhale_command_contract::facets::CommandProjectContext; |
| 8 | use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; |
| 9 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 10 | |
| 11 | use crate::commands::CommandResult; |
| 12 | |
| 13 | pub(in crate::commands) const LSP_INFO: CommandInfo = CommandInfo { |
| 14 | name: "lsp", |
| 15 | aliases: &[], |
| 16 | usage: "/lsp [on|off|status]", |
| 17 | description_key: "cmd_lsp_description", |
| 18 | }; |
| 19 | |
| 20 | pub(in crate::commands) struct LspCmd; |
| 21 | |
| 22 | impl RegisterCommand<CommandResult> for LspCmd { |
| 23 | fn info() -> &'static CommandInfo { |
| 24 | &LSP_INFO |
| 25 | } |
| 26 | |
| 27 | fn handler() -> CommandHandler<CommandResult> { |
| 28 | CommandHandler::Contextual { |
| 29 | capabilities: codewhale_command_contract::handler::CommandCapabilities::PROJECT, |
| 30 | handler: lsp_contextual, |
| 31 | } |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// Contextual `/lsp` dispatch (FEAT-021 Phase 4). |
| 36 | /// |
| 37 | /// Destructures the declared `PROJECT` facet with a safe missing-facet error; |
| 38 | /// the portable [`lsp`] handler never panics on absent capabilities. |
| 39 | fn lsp_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { |
| 40 | let mut parts = contexts.into_parts(); |
| 41 | let Some(project) = parts.project.as_deref_mut() else { |
| 42 | return CommandResult::error("Command capability unavailable: project"); |
| 43 | }; |
| 44 | lsp(project, arg) |
| 45 | } |
| 46 | |
| 47 | /// Portable `/lsp` dispatch (FEAT-021 Phase 4). |
| 48 | /// |
| 49 | /// The handler consumes only the typed project facet; all concrete host LSP |
| 50 | /// behavior lives in the TUI adapter (D3). Messages are byte-identical to the |
| 51 | /// baseline `config::config::lsp_command` output. |
| 52 | fn lsp(project: &mut dyn CommandProjectContext, arg: Option<&str>) -> CommandResult { |
| 53 | let raw = arg.map(str::trim).unwrap_or(""); |
| 54 | |
| 55 | match raw { |
| 56 | "" | "status" => { |
| 57 | let enabled = project.lsp_enabled(); |
| 58 | let status = if enabled { "on" } else { "off" }; |
| 59 | CommandResult::message(format!( |
| 60 | "LSP diagnostics are currently **{status}**.\n\n\ |
| 61 | Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits." |
| 62 | )) |
| 63 | } |
| 64 | "on" | "enable" | "1" | "true" => { |
| 65 | if let Err(error) = project.lsp_set(true) { |
| 66 | return CommandResult::error(format!("Failed to enable LSP diagnostics: {error}")); |
| 67 | } |
| 68 | CommandResult::message( |
| 69 | "LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.", |
| 70 | ) |
| 71 | } |
| 72 | "off" | "disable" | "0" | "false" => { |
| 73 | if let Err(error) = project.lsp_set(false) { |
| 74 | return CommandResult::error(format!("Failed to disable LSP diagnostics: {error}")); |
| 75 | } |
| 76 | CommandResult::message("LSP diagnostics disabled.") |
| 77 | } |
| 78 | other => CommandResult::error(format!( |
| 79 | "Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`." |
| 80 | )), |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | #[cfg(test)] |
| 85 | mod tests { |
| 86 | use super::*; |
| 87 | use codewhale_command_contract::facets::{ |
| 88 | ProjectGoalState, ProjectGoalStatus, ProjectShareProjection, |
| 89 | }; |
| 90 | |
| 91 | /// Deterministic fake project facet over portable values only. |
| 92 | struct FakeProject { |
| 93 | lsp_enabled: bool, |
| 94 | } |
| 95 | |
| 96 | impl CommandProjectContext for FakeProject { |
| 97 | fn lsp_enabled(&self) -> bool { |
| 98 | self.lsp_enabled |
| 99 | } |
| 100 | |
| 101 | fn lsp_set(&mut self, enabled: bool) -> Result<(), String> { |
| 102 | self.lsp_enabled = enabled; |
| 103 | Ok(()) |
| 104 | } |
| 105 | |
| 106 | fn share_projection(&self) -> ProjectShareProjection { |
| 107 | ProjectShareProjection { |
| 108 | history_is_empty: true, |
| 109 | history_len: 0, |
| 110 | model: String::new(), |
| 111 | mode_label: String::new(), |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | fn goal_state(&self) -> ProjectGoalState { |
| 116 | ProjectGoalState { |
| 117 | objective: None, |
| 118 | status: ProjectGoalStatus::Active, |
| 119 | pause_reason: None, |
| 120 | started_at_elapsed_seconds: None, |
| 121 | time_used_seconds: 0, |
| 122 | token_budget: None, |
| 123 | tokens_used: 0, |
| 124 | session_total_tokens: 0, |
| 125 | continuation_count: 0, |
| 126 | pending_controls: false, |
| 127 | last_known_objective: None, |
| 128 | last_known_status: None, |
| 129 | conversation_present: false, |
| 130 | is_loading: false, |
| 131 | goal_continuation_waiting: false, |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | fn run(arg: Option<&str>) -> (CommandResult, bool) { |
| 137 | let mut project = FakeProject { lsp_enabled: false }; |
| 138 | let result = lsp(&mut project, arg); |
| 139 | (result, project.lsp_enabled) |
| 140 | } |
| 141 | |
| 142 | #[test] |
| 143 | fn status_off_matches_baseline() { |
| 144 | let (result, _) = run(Some("status")); |
| 145 | let msg = result.message.expect("status must be a message"); |
| 146 | assert!(msg.contains("currently **off**")); |
| 147 | assert!(msg.contains("Use `/lsp on` to enable or `/lsp off` to disable")); |
| 148 | } |
| 149 | |
| 150 | #[test] |
| 151 | fn bare_status_is_same_as_status() { |
| 152 | let (result, _) = run(None); |
| 153 | let msg = result.message.expect("bare must be a message"); |
| 154 | assert!(msg.contains("currently **off**")); |
| 155 | } |
| 156 | |
| 157 | #[test] |
| 158 | fn enable_synonyms_set_state_and_report() { |
| 159 | for synonym in ["on", "enable", "1", "true"] { |
| 160 | let (result, enabled) = run(Some(synonym)); |
| 161 | assert!(enabled, "{synonym} must enable"); |
| 162 | let msg = result.message.expect("enable must be a message"); |
| 163 | assert!(msg.contains("LSP diagnostics enabled")); |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn disable_synonyms_clear_state_and_report() { |
| 169 | let mut project = FakeProject { lsp_enabled: true }; |
| 170 | for synonym in ["off", "disable", "0", "false"] { |
| 171 | let result = lsp(&mut project, Some(synonym)); |
| 172 | assert!(!project.lsp_enabled, "{synonym} must disable"); |
| 173 | let msg = result.message.expect("disable must be a message"); |
| 174 | assert_eq!(msg, "LSP diagnostics disabled."); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | #[test] |
| 179 | fn status_reflects_current_state() { |
| 180 | let mut project = FakeProject { lsp_enabled: true }; |
| 181 | let result = lsp(&mut project, Some("status")); |
| 182 | assert!(result.message.unwrap().contains("currently **on**")); |
| 183 | } |
| 184 | |
| 185 | #[test] |
| 186 | fn unknown_argument_errors() { |
| 187 | let (result, enabled) = run(Some("bogus")); |
| 188 | assert!(!enabled); |
| 189 | assert!(result.is_error, "unknown must error"); |
| 190 | let err = result.message.expect("unknown must carry a message"); |
| 191 | assert!(err.contains("Unknown /lsp argument `bogus`")); |
| 192 | assert!(err.contains("/lsp on`, `/lsp off`, or `/lsp status`")); |
| 193 | } |
| 194 | |
| 195 | #[test] |
| 196 | fn missing_project_facet_fails_safely() { |
| 197 | // An empty envelope must fail safely — never panic — with the exact |
| 198 | // capability-unavailable error. |
| 199 | let result = lsp_contextual(CommandContexts::empty(), Some("status")); |
| 200 | assert!(result.is_error); |
| 201 | assert!( |
| 202 | result |
| 203 | .message |
| 204 | .unwrap() |
| 205 | .contains("Command capability unavailable: project"), |
| 206 | "missing project facet must fail safely" |
| 207 | ); |
| 208 | } |
| 209 | } |
| 210 |