1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RegisteredCapability {
22 pub id: CapabilityId,
24 pub title: String,
26 pub provider: ProviderId,
28 pub verbs: Vec<CapabilityVerb>,
30 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#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct CapabilityRegistry {
57 capabilities: Vec<RegisteredCapability>,
58}
59
60impl CapabilityRegistry {
61 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 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 #[must_use]
126 pub fn capabilities(&self) -> &[RegisteredCapability] {
127 &self.capabilities
128 }
129
130 #[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#[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
210pub 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(®istry), 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}