Skip to main content

forge_server/surface/
extensions.rs

1//! `extensions()`: the Extensions page's read.
2//!
3//! One disk scan per read (registry, marketplace manifests, the plugin
4//! cache) plus the two things the scan cannot carry: the LSP servers'
5//! PATH checks, which are facts about this machine, and the last update
6//! run, which is workspace state so a page attaching mid-run draws it.
7
8use forge_primitives::plugins::{PluginUpdateRun, PluginsInventorySnapshot};
9
10use super::ViewSurface;
11
12/// One declared LSP server and whether its binary resolves on PATH.
13pub struct LspServerCheck {
14    /// The plugin whose manifest declares the server (`name@marketplace`).
15    pub plugin: String,
16    pub server: String,
17    /// The command the manifest declares; the check tests this, not the
18    /// server key.
19    pub command: String,
20    pub on_path: bool,
21}
22
23/// The Extensions page's read.
24pub struct ExtensionsView {
25    /// `None` when no plugins root resolves (`CLAUDE_CONFIG_DIR` or
26    /// `HOME`): an empty scan must not read as a clean install.
27    pub snapshot: Option<PluginsInventorySnapshot>,
28    /// The last update run or check, in flight or finished.
29    pub update_run: Option<PluginUpdateRun>,
30    pub lsp: Vec<LspServerCheck>,
31}
32
33impl ViewSurface {
34    pub async fn extensions(&self) -> ExtensionsView {
35        let snapshot = self.workspace.plugins_inventory().await;
36        let path = std::env::var_os("PATH");
37        let path = path.as_deref().map(|path| path.to_string_lossy().into_owned());
38        let lsp = snapshot
39            .as_ref()
40            .map(|snapshot| lsp_checks(snapshot, path.as_deref()))
41            .unwrap_or_default();
42        ExtensionsView { snapshot, update_run: self.workspace.plugin_update_run(), lsp }
43    }
44}
45
46/// One check per declared LSP server of an INSTALLED plugin, in scan
47/// order. An uninstalled plugin's declared servers are not probed: the
48/// page's LSP pane draws installed rows (the plugin is the install unit),
49/// so a check for a plugin that is not here is weight nothing draws.
50pub(crate) fn lsp_checks(
51    snapshot: &PluginsInventorySnapshot,
52    path_var: Option<&str>,
53) -> Vec<LspServerCheck> {
54    let mut checks = Vec::new();
55    for components in snapshot.components.iter().filter(|entry| entry.installed) {
56        for (server, command) in &components.lsp_servers {
57            let probed = if command.is_empty() { server.as_str() } else { command.as_str() };
58            checks.push(LspServerCheck {
59                plugin: components.plugin.clone(),
60                server: server.clone(),
61                command: command.clone(),
62                on_path: forge_workspace::env::lsp_path::lsp_binary_on_path(probed, path_var),
63            });
64        }
65    }
66    checks
67}
68
69#[cfg(test)]
70mod tests {
71    use forge_primitives::plugins::{PluginComponents, PluginsInventorySnapshot};
72
73    use super::*;
74
75    fn snapshot_with_lsp(servers: &[(&str, &str)]) -> PluginsInventorySnapshot {
76        let mut components = PluginComponents {
77            plugin: "lsp-plugin@probe-market".to_owned(),
78            installed: true,
79            enabled: true,
80            ..PluginComponents::default()
81        };
82        for (server, command) in servers {
83            components.lsp_servers.insert((*server).to_owned(), (*command).to_owned());
84        }
85        PluginsInventorySnapshot {
86            components: vec![components],
87            ..PluginsInventorySnapshot::default()
88        }
89    }
90
91    /// Catches a read that reports a server as available without ever
92    /// asking the machine: the row would draw `on PATH` for everything.
93    #[test]
94    fn the_check_reflects_the_injected_path() {
95        use std::os::unix::fs::PermissionsExt;
96        let dir = tempfile::tempdir().expect("tempdir");
97        let binary = dir.path().join("probe-ls");
98        std::fs::write(&binary, b"#!/bin/sh").expect("write binary");
99        std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).expect("chmod");
100        let path = dir.path().to_string_lossy().into_owned();
101
102        let snapshot = snapshot_with_lsp(&[("probe-server", "probe-ls"), ("absent", "absent-bin")]);
103        let checks = lsp_checks(&snapshot, Some(&path));
104
105        assert_eq!(checks.len(), 2);
106        assert_eq!(checks[0].server, "absent");
107        assert!(!checks[0].on_path, "a binary not on the injected PATH is reported missing");
108        assert_eq!(checks[1].server, "probe-server");
109        assert_eq!(checks[1].command, "probe-ls");
110        assert!(checks[1].on_path, "the declared command is what was probed");
111
112        assert!(
113            lsp_checks(&snapshot, None).iter().all(|check| !check.on_path),
114            "no PATH at all reports every server missing rather than guessing"
115        );
116    }
117
118    /// A server an uninstalled plugin declares is not probed (the page's LSP
119    /// pane draws installed rows, so the check would be weight nothing
120    /// draws), and a manifest that declares no command falls back to the
121    /// server key as the binary to look for.
122    #[test]
123    fn only_installed_plugins_are_probed_and_a_blank_command_falls_back_to_the_key() {
124        use std::os::unix::fs::PermissionsExt;
125        let dir = tempfile::tempdir().expect("tempdir");
126        let binary = dir.path().join("key-named");
127        std::fs::write(&binary, b"#!/bin/sh").expect("write binary");
128        std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).expect("chmod");
129        let path = dir.path().to_string_lossy().into_owned();
130
131        let mut installed = PluginComponents {
132            plugin: "installed@probe-market".to_owned(),
133            installed: true,
134            enabled: true,
135            ..PluginComponents::default()
136        };
137        installed.lsp_servers.insert("key-named".to_owned(), String::new());
138        let mut gone = PluginComponents {
139            plugin: "gone@probe-market".to_owned(),
140            installed: false,
141            enabled: false,
142            ..PluginComponents::default()
143        };
144        gone.lsp_servers.insert("not-probed".to_owned(), "not-probed".to_owned());
145        let snapshot = PluginsInventorySnapshot {
146            components: vec![installed, gone],
147            ..PluginsInventorySnapshot::default()
148        };
149
150        let checks = lsp_checks(&snapshot, Some(&path));
151
152        assert_eq!(checks.len(), 1, "an uninstalled plugin's servers are not probed");
153        assert_eq!(checks[0].plugin, "installed@probe-market");
154        assert!(
155            checks[0].on_path,
156            "a blank declared command falls back to the server key as the binary"
157        );
158    }
159}