返回 CodeWhale
lsp.rs
根目录 / crates / tui / src / commands / groups / project / lsp.rs
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
210 lines RUST