| 1 | //! Codewhale native catalog parser. |
| 2 | //! |
| 3 | //! This is Codewhale's own documented catalog format. It is deliberately |
| 4 | //! the smallest of the supported formats: entries carry a `name` and a |
| 5 | //! `source` that is exactly the install spec `/plugin install` accepts |
| 6 | //! (`github:owner/repo`, `path:dir`, an http(s) tarball URL). |
| 7 | //! |
| 8 | //! ```json |
| 9 | //! { |
| 10 | //! "name": "my-catalog", |
| 11 | //! "description": "Team plugins", |
| 12 | //! "version": "1", |
| 13 | //! "plugins": [ |
| 14 | //! { "name": "formatter", "source": "github:owner/repo", "version": "2.1.0" } |
| 15 | //! ] |
| 16 | //! } |
| 17 | //! ``` |
| 18 | |
| 19 | use serde_json::Value; |
| 20 | |
| 21 | use crate::plugins::install::PluginInstallSource; |
| 22 | |
| 23 | use super::super::types::{ |
| 24 | CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId, |
| 25 | MarketplaceCatalog, MarketplaceDiagnostic, MarketplaceEntryKind, MarketplaceFormat, |
| 26 | MarketplaceInstallPlan, MarketplaceSourceSpec, |
| 27 | }; |
| 28 | use super::{MarketplaceDocument, str_field, unknown_fields_warning}; |
| 29 | |
| 30 | const TOP_LEVEL_FIELDS: &[&str] = &["name", "description", "version", "plugins"]; |
| 31 | const ENTRY_FIELDS: &[&str] = &[ |
| 32 | "name", |
| 33 | "source", |
| 34 | "kind", |
| 35 | "description", |
| 36 | "version", |
| 37 | "homepage", |
| 38 | "display_name", |
| 39 | "author", |
| 40 | "icon", |
| 41 | "platforms", |
| 42 | ]; |
| 43 | |
| 44 | pub fn parse_codewhale_catalog(document: MarketplaceDocument) -> MarketplaceCatalog { |
| 45 | let MarketplaceDocument { |
| 46 | catalog_id, |
| 47 | root, |
| 48 | base, |
| 49 | .. |
| 50 | } = document; |
| 51 | let mut diagnostics = Vec::new(); |
| 52 | |
| 53 | let Some(obj) = root.as_object() else { |
| 54 | return MarketplaceCatalog { |
| 55 | id: catalog_id, |
| 56 | format: MarketplaceFormat::Codewhale, |
| 57 | name: String::new(), |
| 58 | display_name: None, |
| 59 | description: None, |
| 60 | version: None, |
| 61 | base, |
| 62 | provenance: CatalogProvenance::default(), |
| 63 | candidates: Vec::new(), |
| 64 | diagnostics: vec![MarketplaceDiagnostic::error( |
| 65 | "NOT_AN_OBJECT", |
| 66 | "Codewhale catalog must be a JSON object", |
| 67 | None, |
| 68 | None, |
| 69 | )], |
| 70 | }; |
| 71 | }; |
| 72 | |
| 73 | if let Some(diag) = unknown_fields_warning(obj, TOP_LEVEL_FIELDS) { |
| 74 | diagnostics.push(diag); |
| 75 | } |
| 76 | |
| 77 | let (name, bad_name) = str_field(obj, "name"); |
| 78 | if let Some(diag) = bad_name { |
| 79 | diagnostics.push(diag); |
| 80 | } |
| 81 | let name = name |
| 82 | .map(ToString::to_string) |
| 83 | .unwrap_or_else(|| catalog_id.as_str().to_string()); |
| 84 | let (description, bad_desc) = str_field(obj, "description"); |
| 85 | if let Some(diag) = bad_desc { |
| 86 | diagnostics.push(diag); |
| 87 | } |
| 88 | let (version, bad_version) = str_field(obj, "version"); |
| 89 | if let Some(diag) = bad_version { |
| 90 | diagnostics.push(diag); |
| 91 | } |
| 92 | |
| 93 | let Some(entries) = obj.get("plugins").and_then(Value::as_array) else { |
| 94 | diagnostics.push(MarketplaceDiagnostic::error( |
| 95 | "MISSING_PLUGINS", |
| 96 | "Codewhale catalog must contain a `plugins` array", |
| 97 | None, |
| 98 | None, |
| 99 | )); |
| 100 | return MarketplaceCatalog { |
| 101 | id: catalog_id, |
| 102 | format: MarketplaceFormat::Codewhale, |
| 103 | name, |
| 104 | display_name: None, |
| 105 | description: description.map(ToString::to_string), |
| 106 | version: version.map(ToString::to_string), |
| 107 | base, |
| 108 | provenance: CatalogProvenance::default(), |
| 109 | candidates: Vec::new(), |
| 110 | diagnostics, |
| 111 | }; |
| 112 | }; |
| 113 | |
| 114 | let mut candidates = Vec::new(); |
| 115 | for (index, entry) in entries.iter().enumerate() { |
| 116 | if let Some(candidate) = parse_codewhale_entry(&catalog_id, index, entry, &mut diagnostics) |
| 117 | { |
| 118 | candidates.push(candidate); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | MarketplaceCatalog { |
| 123 | id: catalog_id, |
| 124 | format: MarketplaceFormat::Codewhale, |
| 125 | name, |
| 126 | display_name: None, |
| 127 | description: description.map(ToString::to_string), |
| 128 | version: version.map(ToString::to_string), |
| 129 | base, |
| 130 | provenance: CatalogProvenance { |
| 131 | tier: CatalogTier::Community, |
| 132 | publisher: None, |
| 133 | source_url: None, |
| 134 | }, |
| 135 | candidates, |
| 136 | diagnostics, |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | fn parse_codewhale_entry( |
| 141 | catalog_id: &super::super::types::MarketplaceCatalogId, |
| 142 | index: usize, |
| 143 | entry: &Value, |
| 144 | diagnostics: &mut Vec<MarketplaceDiagnostic>, |
| 145 | ) -> Option<MarketplaceCandidate> { |
| 146 | let Some(obj) = entry.as_object() else { |
| 147 | diagnostics.push(MarketplaceDiagnostic::error( |
| 148 | "MALFORMED_ENTRY", |
| 149 | format!("Codewhale plugin at index {index} must be a JSON object"), |
| 150 | None, |
| 151 | Some(index), |
| 152 | )); |
| 153 | return None; |
| 154 | }; |
| 155 | |
| 156 | let mut entry_diags = Vec::new(); |
| 157 | if let Some(diag) = unknown_fields_warning(obj, ENTRY_FIELDS) { |
| 158 | entry_diags.push(diag); |
| 159 | } |
| 160 | |
| 161 | let (name, bad_name) = str_field(obj, "name"); |
| 162 | if let Some(diag) = bad_name { |
| 163 | entry_diags.push(diag); |
| 164 | } |
| 165 | let Some(name) = name else { |
| 166 | diagnostics.push(MarketplaceDiagnostic::error( |
| 167 | "MISSING_NAME", |
| 168 | format!("Codewhale plugin at index {index} is missing required `name`"), |
| 169 | None, |
| 170 | Some(index), |
| 171 | )); |
| 172 | return None; |
| 173 | }; |
| 174 | let name = name.to_string(); |
| 175 | |
| 176 | let (source, bad_source) = str_field(obj, "source"); |
| 177 | if let Some(diag) = bad_source { |
| 178 | entry_diags.push(diag); |
| 179 | } |
| 180 | let Some(source) = source else { |
| 181 | diagnostics.push(MarketplaceDiagnostic::error( |
| 182 | "MISSING_SOURCE", |
| 183 | format!("Codewhale plugin `{name}` is missing required `source`"), |
| 184 | Some(name.clone()), |
| 185 | Some(index), |
| 186 | )); |
| 187 | return None; |
| 188 | }; |
| 189 | |
| 190 | let install_plan = match PluginInstallSource::parse(source) { |
| 191 | Ok(parsed) => { |
| 192 | let source_kind = match &parsed { |
| 193 | PluginInstallSource::Remote(crate::skills::install::InstallSource::GitHubRepo( |
| 194 | _, |
| 195 | )) => "GitHub repository".to_string(), |
| 196 | PluginInstallSource::Remote(crate::skills::install::InstallSource::DirectUrl( |
| 197 | _, |
| 198 | )) => "Tarball URL".to_string(), |
| 199 | PluginInstallSource::LocalPath { .. } => "Local directory".to_string(), |
| 200 | PluginInstallSource::Remote(crate::skills::install::InstallSource::Registry(_)) => { |
| 201 | "Registry".to_string() |
| 202 | } |
| 203 | }; |
| 204 | MarketplaceInstallPlan::Supported { |
| 205 | spec: source.to_string(), |
| 206 | source_kind, |
| 207 | } |
| 208 | } |
| 209 | Err(err) => MarketplaceInstallPlan::Unsupported { |
| 210 | reason: format!("invalid Codewhale install spec: {err}"), |
| 211 | raw: source.to_string(), |
| 212 | }, |
| 213 | }; |
| 214 | let normalized = normalize_native_source(source); |
| 215 | |
| 216 | let (description, bad_desc) = str_field(obj, "description"); |
| 217 | if let Some(diag) = bad_desc { |
| 218 | entry_diags.push(diag); |
| 219 | } |
| 220 | let (version, bad_version) = str_field(obj, "version"); |
| 221 | if let Some(diag) = bad_version { |
| 222 | entry_diags.push(diag); |
| 223 | } |
| 224 | let (homepage, bad_home) = str_field(obj, "homepage"); |
| 225 | if let Some(diag) = bad_home { |
| 226 | entry_diags.push(diag); |
| 227 | } |
| 228 | |
| 229 | let mut labels = Vec::new(); |
| 230 | for field in ["display_name", "author", "icon"] { |
| 231 | let (value, bad) = str_field(obj, field); |
| 232 | if let Some(diag) = bad { |
| 233 | entry_diags.push(diag); |
| 234 | } |
| 235 | let bounded = value.filter(|text| { |
| 236 | field == "icon" || (text.chars().count() <= 128 && !text.chars().any(char::is_control)) |
| 237 | }); |
| 238 | if value.is_some() && bounded.is_none() { |
| 239 | entry_diags.push(MarketplaceDiagnostic::error( |
| 240 | "INVALID_IDENTITY", |
| 241 | format!("{field} must fit on one line within 128 characters"), |
| 242 | Some(name.clone()), |
| 243 | Some(index), |
| 244 | )); |
| 245 | } |
| 246 | labels.push(bounded.map(ToString::to_string)); |
| 247 | } |
| 248 | let mut icon = labels.pop().flatten(); |
| 249 | if let Some(value) = &icon |
| 250 | && let Err(reason) = crate::plugins::manifest::validate_icon(value) |
| 251 | { |
| 252 | entry_diags.push(MarketplaceDiagnostic::error( |
| 253 | "INVALID_ICON", |
| 254 | reason, |
| 255 | Some(name.clone()), |
| 256 | Some(index), |
| 257 | )); |
| 258 | icon = None; |
| 259 | } |
| 260 | let author = labels.pop().flatten(); |
| 261 | let display_name = labels.pop().flatten(); |
| 262 | let platforms = obj |
| 263 | .get("platforms") |
| 264 | .and_then(Value::as_array) |
| 265 | .filter(|values| { |
| 266 | values.len() <= 3 |
| 267 | && values |
| 268 | .iter() |
| 269 | .enumerate() |
| 270 | .all(|(i, value)| !values[..i].contains(value)) |
| 271 | }) |
| 272 | .and_then(|values| { |
| 273 | values |
| 274 | .iter() |
| 275 | .map(|value| { |
| 276 | value |
| 277 | .as_str() |
| 278 | .filter(|os| ["macos", "linux", "windows"].contains(os)) |
| 279 | .map(ToString::to_string) |
| 280 | }) |
| 281 | .collect::<Option<Vec<_>>>() |
| 282 | }); |
| 283 | if obj.contains_key("platforms") && platforms.is_none() { |
| 284 | entry_diags.push(MarketplaceDiagnostic::error( |
| 285 | "INVALID_PLATFORMS", |
| 286 | "platforms must be an array of macos, linux or windows", |
| 287 | Some(name.clone()), |
| 288 | Some(index), |
| 289 | )); |
| 290 | } |
| 291 | // What this entry is. The Codewhale marketplace keeps plugins and |
| 292 | // skills in separate top-level directories, so the source path is the |
| 293 | // signal; a document may also declare `kind` explicitly. A skill entry |
| 294 | // stays installable, but it is not a plugin and is never suggested as |
| 295 | // one (#6290 rework). |
| 296 | let kind = match obj.get("kind").and_then(Value::as_str) { |
| 297 | Some("skill") => MarketplaceEntryKind::Skill, |
| 298 | Some("plugin") | None => { |
| 299 | let path = source |
| 300 | .strip_prefix("path:") |
| 301 | .unwrap_or(source) |
| 302 | .trim_start_matches("./"); |
| 303 | if path == "skills" || path.starts_with("skills/") { |
| 304 | MarketplaceEntryKind::Skill |
| 305 | } else { |
| 306 | MarketplaceEntryKind::Plugin |
| 307 | } |
| 308 | } |
| 309 | Some(other) => { |
| 310 | entry_diags.push(MarketplaceDiagnostic::warning( |
| 311 | "UNKNOWN_ENTRY_KIND", |
| 312 | format!("unknown entry kind `{other}`; treated as a plugin"), |
| 313 | Some(name.clone()), |
| 314 | Some(index), |
| 315 | )); |
| 316 | MarketplaceEntryKind::Plugin |
| 317 | } |
| 318 | }; |
| 319 | Some(MarketplaceCandidate { |
| 320 | id: MarketplaceCandidateId::new(catalog_id, &name), |
| 321 | catalog_id: catalog_id.clone(), |
| 322 | kind, |
| 323 | icon, |
| 324 | name, |
| 325 | display_name, |
| 326 | description: description.map(ToString::to_string), |
| 327 | version: version.map(ToString::to_string), |
| 328 | author, |
| 329 | homepage: homepage.map(ToString::to_string), |
| 330 | repository: None, |
| 331 | license: None, |
| 332 | keywords: Vec::new(), |
| 333 | categories: Vec::new(), |
| 334 | source: normalized, |
| 335 | install_plan, |
| 336 | declared_components: None, |
| 337 | compatibility: None, |
| 338 | provenance: CatalogProvenance { |
| 339 | tier: CatalogTier::Community, |
| 340 | publisher: None, |
| 341 | source_url: None, |
| 342 | }, |
| 343 | when: platforms.map(|os| crate::plugins::manifest::PluginWhen { |
| 344 | os: Some(os), |
| 345 | binaries: None, |
| 346 | }), |
| 347 | diagnostics: entry_diags, |
| 348 | }) |
| 349 | } |
| 350 | |
| 351 | fn normalize_native_source(spec: &str) -> MarketplaceSourceSpec { |
| 352 | match PluginInstallSource::parse(spec) { |
| 353 | Ok(PluginInstallSource::LocalPath(path)) => MarketplaceSourceSpec::LocalPath { path }, |
| 354 | Ok(PluginInstallSource::Remote(crate::skills::install::InstallSource::GitHubRepo( |
| 355 | repo, |
| 356 | ))) => { |
| 357 | let (owner, name) = repo.split_once('/').unwrap_or((&repo, "")); |
| 358 | MarketplaceSourceSpec::GitHub { |
| 359 | owner: owner.to_string(), |
| 360 | repo: name.to_string(), |
| 361 | git_ref: None, |
| 362 | sha: None, |
| 363 | } |
| 364 | } |
| 365 | Ok(PluginInstallSource::Remote(crate::skills::install::InstallSource::DirectUrl(url))) => { |
| 366 | MarketplaceSourceSpec::ArchiveUrl { url, sha256: None } |
| 367 | } |
| 368 | Ok(other) => MarketplaceSourceSpec::Invalid { |
| 369 | reason: format!("registry source {other:?} is not a marketplace install"), |
| 370 | }, |
| 371 | Err(err) => MarketplaceSourceSpec::Invalid { |
| 372 | reason: err.to_string(), |
| 373 | }, |
| 374 | } |
| 375 | } |
| 376 |