Skip to main content

ready_set/
capabilities.rs

1//! Core capability registry and readiness matrix renderers.
2
3use std::collections::BTreeMap;
4use std::fmt::Write as _;
5use std::path::Path;
6
7use ready_set_sdk::config::{Config, load_config};
8use ready_set_sdk::describe::Platform;
9use ready_set_sdk::manifest::Manifest;
10use ready_set_sdk::{
11    CapabilityDescriptor, CapabilityId, CapabilityRelevance, CapabilityReport, CapabilityState,
12    CapabilityVerb, ProviderId, Result,
13};
14
15use crate::cache::PluginCache;
16use crate::discovery::list_all;
17use crate::metadata::resolve_metadata;
18
19/// One effective capability row after applying project configuration.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RegisteredCapability {
22    /// Stable capability id.
23    pub id: CapabilityId,
24    /// Human label for matrix and help output.
25    pub title: String,
26    /// Effective provider id.
27    pub provider: ProviderId,
28    /// Supported lifecycle verbs.
29    pub verbs: Vec<CapabilityVerb>,
30    /// Effective product relevance.
31    pub relevance: CapabilityRelevance,
32}
33
34impl RegisteredCapability {
35    fn from_descriptor(descriptor: CapabilityDescriptor, config: Option<&Config>) -> Self {
36        let capability_config = config.and_then(|cfg| cfg.capabilities.get(descriptor.id.as_str()));
37        let relevance = capability_config
38            .and_then(|cfg| cfg.relevance)
39            .unwrap_or(descriptor.default_relevance);
40        let provider = capability_config
41            .and_then(|cfg| cfg.provider.clone())
42            .unwrap_or(descriptor.provider);
43
44        Self {
45            id: descriptor.id,
46            title: descriptor.title,
47            provider,
48            verbs: descriptor.verbs,
49            relevance,
50        }
51    }
52}
53
54/// Sorted collection of registered capabilities.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct CapabilityRegistry {
57    capabilities: Vec<RegisteredCapability>,
58}
59
60impl CapabilityRegistry {
61    /// Build a registry from optional config and already-resolved plugin
62    /// manifests.
63    ///
64    /// Plugin capabilities with duplicate ids keep the first discovered
65    /// descriptor unless `.ready-set.toml` selects a later provider.
66    pub fn from_parts(
67        config: Option<&Config>,
68        plugin_manifests: impl IntoIterator<Item = Manifest>,
69    ) -> Self {
70        let mut descriptors: BTreeMap<String, CapabilityDescriptor> = BTreeMap::new();
71
72        for manifest in plugin_manifests {
73            for descriptor in manifest.capabilities {
74                let id = descriptor.id.as_str().to_owned();
75                match descriptors.entry(id) {
76                    std::collections::btree_map::Entry::Vacant(entry) => {
77                        entry.insert(descriptor);
78                    },
79                    std::collections::btree_map::Entry::Occupied(mut entry) => {
80                        let selected = provider_override(config, entry.key())
81                            .is_some_and(|provider| provider == &descriptor.provider);
82                        if selected {
83                            entry.insert(descriptor);
84                        }
85                    },
86                }
87            }
88        }
89
90        let capabilities = descriptors
91            .into_values()
92            .map(|descriptor| RegisteredCapability::from_descriptor(descriptor, config))
93            .collect();
94
95        Self { capabilities }
96    }
97
98    /// Discover project config and installed plugins, then build a registry.
99    ///
100    /// # Errors
101    ///
102    /// Returns config loading errors from `.ready-set.toml` parsing or I/O.
103    pub fn discover(cwd: &Path) -> Result<Self> {
104        let config = load_config(cwd)?;
105        let mut cache = PluginCache::default_path()
106            .as_deref()
107            .map_or_else(PluginCache::default, PluginCache::load);
108        let current_platform = Platform::current();
109        let mut manifests = Vec::new();
110
111        for entry in list_all() {
112            let Some(manifest) = resolve_metadata(&entry, &mut cache) else {
113                continue;
114            };
115            if current_platform.is_some_and(|platform| !manifest.platforms.contains(&platform)) {
116                continue;
117            }
118            manifests.push(manifest);
119        }
120
121        Ok(Self::from_parts(config.as_ref(), manifests))
122    }
123
124    /// Borrow the sorted registered capabilities.
125    #[must_use]
126    pub fn capabilities(&self) -> &[RegisteredCapability] {
127        &self.capabilities
128    }
129
130    /// Render unevaluated placeholder reports for every registered capability.
131    #[must_use]
132    pub fn reports_unevaluated(&self) -> Vec<CapabilityReport> {
133        self.capabilities
134            .iter()
135            .map(|capability| {
136                let (state, summary) = match capability.relevance {
137                    CapabilityRelevance::NotNeeded => {
138                        (CapabilityState::NotNeeded, "capability marked not needed")
139                    },
140                    CapabilityRelevance::Required | CapabilityRelevance::Optional => {
141                        (CapabilityState::Blocked, "readiness not evaluated yet")
142                    },
143                };
144                CapabilityReport {
145                    id: capability.id.clone(),
146                    title: capability.title.clone(),
147                    provider: capability.provider.clone(),
148                    state,
149                    relevance: capability.relevance,
150                    summary: summary.into(),
151                    next_action: None,
152                }
153            })
154            .collect()
155    }
156}
157
158/// Render capability reports as a human-readable readiness matrix.
159#[must_use]
160pub fn render_human_matrix(reports: &[CapabilityReport]) -> String {
161    let capability_width = reports
162        .iter()
163        .map(|report| report.id.as_str().len())
164        .max()
165        .unwrap_or(0)
166        .max("capability".len());
167    let state_width = reports
168        .iter()
169        .map(|report| state_label(report.state).len())
170        .max()
171        .unwrap_or(0)
172        .max("state".len());
173    let action_width = reports
174        .iter()
175        .map(|report| {
176            report
177                .next_action
178                .as_ref()
179                .map_or(0, |action| action.command.len())
180        })
181        .max()
182        .unwrap_or(0)
183        .max("next action".len());
184
185    let mut out = String::new();
186    writeln!(
187        &mut out,
188        "{:<capability_width$}  {:<state_width$}  {:<action_width$}  summary",
189        "capability", "state", "next action"
190    )
191    .expect("writing to a string cannot fail");
192    for report in reports {
193        let next_action = report
194            .next_action
195            .as_ref()
196            .map_or("", |action| action.command.as_str());
197        writeln!(
198            &mut out,
199            "{:<capability_width$}  {:<state_width$}  {:<action_width$}  {}",
200            report.id.as_str(),
201            state_label(report.state),
202            next_action,
203            report.summary
204        )
205        .expect("writing to a string cannot fail");
206    }
207    out
208}
209
210/// Render capability reports as JSON using the SDK report shape.
211///
212/// # Errors
213///
214/// Returns a JSON serialization error if a report cannot be serialized.
215pub fn render_json_matrix(reports: &[CapabilityReport]) -> Result<String> {
216    serde_json::to_string(reports).map_err(Into::into)
217}
218
219fn provider_override<'a>(config: Option<&'a Config>, id: &str) -> Option<&'a ProviderId> {
220    config?
221        .capabilities
222        .get(id)
223        .and_then(|capability| capability.provider.as_ref())
224}
225
226const fn state_label(state: CapabilityState) -> &'static str {
227    match state {
228        CapabilityState::Ready => "ready",
229        CapabilityState::Missing => "missing",
230        CapabilityState::Incomplete => "incomplete",
231        CapabilityState::Blocked => "blocked",
232        CapabilityState::Stale => "stale",
233        CapabilityState::Optional => "optional",
234        CapabilityState::NotNeeded => "not-needed",
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use std::collections::BTreeMap;
241    use std::path::PathBuf;
242
243    use ready_set_sdk::config::{CapabilityConfig, ProjectMeta};
244    use ready_set_sdk::describe::{Platform, Stability};
245
246    use super::*;
247
248    fn config_with(
249        overrides: impl IntoIterator<
250            Item = (
251                &'static str,
252                Option<CapabilityRelevance>,
253                Option<&'static str>,
254            ),
255        >,
256    ) -> Config {
257        let capabilities = overrides
258            .into_iter()
259            .map(|(id, relevance, provider)| {
260                (
261                    id.to_string(),
262                    CapabilityConfig {
263                        relevance,
264                        provider: provider.map(ProviderId::from),
265                        unknown_keys: Vec::new(),
266                    },
267                )
268            })
269            .collect();
270
271        Config {
272            path: PathBuf::from(".ready-set.toml"),
273            ready_set: ProjectMeta {
274                schema_version: 2,
275                profile: "rust-workspace".into(),
276            },
277            capabilities,
278            plugins: BTreeMap::new(),
279            unknown_keys: Vec::new(),
280        }
281    }
282
283    fn plugin_manifest(capabilities: Vec<CapabilityDescriptor>) -> Manifest {
284        Manifest {
285            description: "Plugin".into(),
286            version: "0.1.0".parse().unwrap(),
287            stability: Stability::Stable,
288            min_dispatcher_version: "0.1.0".parse().unwrap(),
289            platforms: vec![Platform::Linux, Platform::Macos, Platform::Windows],
290            requires_cargo_workspace: false,
291            capabilities,
292        }
293    }
294
295    fn plugin_descriptor(id: &str, title: &str, provider: &str) -> CapabilityDescriptor {
296        plugin_descriptor_with_relevance(id, title, provider, CapabilityRelevance::Required)
297    }
298
299    fn plugin_descriptor_with_relevance(
300        id: &str,
301        title: &str,
302        provider: &str,
303        default_relevance: CapabilityRelevance,
304    ) -> CapabilityDescriptor {
305        CapabilityDescriptor {
306            id: id.into(),
307            title: title.into(),
308            provider: provider.into(),
309            verbs: vec![CapabilityVerb::Ready, CapabilityVerb::Set],
310            default_relevance,
311        }
312    }
313
314    fn ids(registry: &CapabilityRegistry) -> Vec<&str> {
315        registry
316            .capabilities()
317            .iter()
318            .map(|capability| capability.id.as_str())
319            .collect()
320    }
321
322    #[test]
323    fn empty_registry_contains_no_core_capabilities() {
324        let registry = CapabilityRegistry::from_parts(None, Vec::<Manifest>::new());
325
326        assert!(registry.capabilities().is_empty());
327    }
328
329    #[test]
330    fn config_only_unknown_capability_ids_are_not_registered() {
331        let config = config_with([(
332            "unknown",
333            Some(CapabilityRelevance::Required),
334            Some("missing-provider"),
335        )]);
336        let registry = CapabilityRegistry::from_parts(Some(&config), Vec::<Manifest>::new());
337
338        assert!(registry.capabilities().is_empty());
339    }
340
341    #[test]
342    fn registry_output_is_sorted_by_capability_id() {
343        let manifest = plugin_manifest(vec![
344            plugin_descriptor("zzz", "Zzz", "plugin"),
345            plugin_descriptor("aaa", "Aaa", "plugin"),
346        ]);
347        let registry = CapabilityRegistry::from_parts(None, [manifest]);
348
349        assert_eq!(ids(&registry), vec!["aaa", "zzz"]);
350    }
351
352    #[test]
353    fn config_relevance_override_changes_effective_relevance() {
354        let config = config_with([("linting", Some(CapabilityRelevance::Optional), None)]);
355        let manifest = plugin_manifest(vec![plugin_descriptor("linting", "Linting", "rust")]);
356        let registry = CapabilityRegistry::from_parts(Some(&config), [manifest]);
357        let linting = registry
358            .capabilities()
359            .iter()
360            .find(|capability| capability.id.as_str() == "linting")
361            .unwrap();
362
363        assert_eq!(linting.relevance, CapabilityRelevance::Optional);
364    }
365
366    #[test]
367    fn config_provider_override_changes_effective_provider() {
368        let config = config_with([("formatting", None, Some("external-formatting"))]);
369        let manifest = plugin_manifest(vec![plugin_descriptor("formatting", "Formatting", "rust")]);
370        let registry = CapabilityRegistry::from_parts(Some(&config), [manifest]);
371        let formatting = registry
372            .capabilities()
373            .iter()
374            .find(|capability| capability.id.as_str() == "formatting")
375            .unwrap();
376
377        assert_eq!(formatting.provider.as_str(), "external-formatting");
378    }
379
380    #[test]
381    fn unique_plugin_capability_descriptors_are_preserved() {
382        let manifest = plugin_manifest(vec![plugin_descriptor_with_relevance(
383            "security",
384            "Security",
385            "scan",
386            CapabilityRelevance::Optional,
387        )]);
388        let registry = CapabilityRegistry::from_parts(None, [manifest]);
389        let security = registry
390            .capabilities()
391            .iter()
392            .find(|capability| capability.id.as_str() == "security")
393            .unwrap();
394
395        assert_eq!(security.title, "Security");
396        assert_eq!(security.provider.as_str(), "scan");
397        assert_eq!(security.relevance, CapabilityRelevance::Optional);
398    }
399
400    #[test]
401    fn duplicate_plugin_capability_keeps_first_without_provider_override() {
402        let first = plugin_manifest(vec![plugin_descriptor("linting", "First linting", "first")]);
403        let second = plugin_manifest(vec![plugin_descriptor(
404            "linting",
405            "Second linting",
406            "second",
407        )]);
408        let registry = CapabilityRegistry::from_parts(None, [first, second]);
409        let linting = registry
410            .capabilities()
411            .iter()
412            .find(|capability| capability.id.as_str() == "linting")
413            .unwrap();
414
415        assert_eq!(linting.title, "First linting");
416        assert_eq!(linting.provider.as_str(), "first");
417    }
418
419    #[test]
420    fn duplicate_plugin_capability_is_used_when_provider_override_selects_it() {
421        let config = config_with([("linting", None, Some("second"))]);
422        let first = plugin_manifest(vec![plugin_descriptor("linting", "First linting", "first")]);
423        let second = plugin_manifest(vec![plugin_descriptor(
424            "linting",
425            "Second linting",
426            "second",
427        )]);
428        let registry = CapabilityRegistry::from_parts(Some(&config), [first, second]);
429        let linting = registry
430            .capabilities()
431            .iter()
432            .find(|capability| capability.id.as_str() == "linting")
433            .unwrap();
434
435        assert_eq!(linting.title, "Second linting");
436        assert_eq!(linting.provider.as_str(), "second");
437    }
438
439    #[test]
440    fn json_matrix_round_trips_as_capability_reports() {
441        let manifest = plugin_manifest(vec![plugin_descriptor("linting", "Linting", "rust")]);
442        let reports = CapabilityRegistry::from_parts(None, [manifest]).reports_unevaluated();
443        let json = render_json_matrix(&reports).unwrap();
444        let round_tripped: Vec<CapabilityReport> = serde_json::from_str(&json).unwrap();
445
446        assert_eq!(round_tripped, reports);
447    }
448
449    #[test]
450    fn human_matrix_contains_expected_columns() {
451        let manifest = plugin_manifest(vec![plugin_descriptor("linting", "Linting", "rust")]);
452        let reports = CapabilityRegistry::from_parts(None, [manifest]).reports_unevaluated();
453        let human = render_human_matrix(&reports);
454
455        assert!(human.contains("capability"));
456        assert!(human.contains("state"));
457        assert!(human.contains("next action"));
458        assert!(human.contains("summary"));
459    }
460
461    #[test]
462    fn not_needed_relevance_produces_not_needed_placeholder_report() {
463        let config = config_with([("workspace", Some(CapabilityRelevance::NotNeeded), None)]);
464        let manifest = plugin_manifest(vec![plugin_descriptor("workspace", "Workspace", "rust")]);
465        let reports =
466            CapabilityRegistry::from_parts(Some(&config), [manifest]).reports_unevaluated();
467        let workspace = reports
468            .iter()
469            .find(|report| report.id.as_str() == "workspace")
470            .unwrap();
471
472        assert_eq!(workspace.state, CapabilityState::NotNeeded);
473        assert_eq!(workspace.summary, "capability marked not needed");
474        assert!(workspace.next_action.is_none());
475    }
476}