1use std::collections::BTreeMap;
8use std::path::PathBuf;
9
10use indexmap::IndexMap;
11use serde::{Deserialize, Serialize};
12
13use crate::diagnostic::{SchemaDiagnostic, Severity, child_path};
14use crate::dialect::Dialect;
15use crate::filter::{FilterConfig, FilterError};
16use crate::names::SourceSubpath;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Source {
21 Git { url: String },
22 Path { path: PathBuf },
23}
24
25#[derive(Debug, Clone, PartialEq)]
29pub struct Entry {
30 pub source: Source,
31 pub version: Option<String>,
32 pub subpath: Option<SourceSubpath>,
33 pub dialect: Option<Dialect>,
35 pub plugin: Option<String>,
37 pub filter: FilterConfig,
38 pub unknown: BTreeMap<String, toml::Value>,
40}
41
42#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
46pub struct RawEntry {
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub url: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none", with = "path_serde")]
50 pub path: Option<PathBuf>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub subpath: Option<SourceSubpath>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub version: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub dialect: Option<Dialect>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub plugin: Option<String>,
59 #[serde(flatten)]
60 pub filter: FilterConfig,
61 #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
62 pub unknown: BTreeMap<String, toml::Value>,
63}
64
65mod path_serde {
67 use std::path::PathBuf;
68
69 use serde::{Deserialize, Deserializer, Serializer};
70
71 pub fn serialize<S: Serializer>(
72 path: &Option<PathBuf>,
73 serializer: S,
74 ) -> Result<S::Ok, S::Error> {
75 match path {
76 Some(path) => serializer.serialize_some(&path.to_string_lossy().replace('\\', "/")),
77 None => serializer.serialize_none(),
78 }
79 }
80
81 pub fn deserialize<'de, D: Deserializer<'de>>(
82 deserializer: D,
83 ) -> Result<Option<PathBuf>, D::Error> {
84 Ok(Option::<String>::deserialize(deserializer)?.map(PathBuf::from))
85 }
86}
87
88#[derive(Debug, thiserror::Error, PartialEq, Eq)]
90pub enum EntryError {
91 #[error("source `{name}` has both `url` and `path` — pick one")]
92 BothSources { name: String },
93 #[error("source `{name}` has neither `url` nor `path` — one is required")]
94 NoSource { name: String },
95 #[error(transparent)]
96 PluginSelector(#[from] PluginSelectorError),
97 #[error(transparent)]
99 Filter(#[from] FilterError),
100}
101
102impl EntryError {
103 pub fn code(&self) -> &'static str {
105 match self {
106 Self::BothSources { .. } | Self::NoSource { .. } => "dependency.source",
107 Self::PluginSelector(_) => "dependency.plugin-selector",
108 Self::Filter(_) => "dependency.filter-conflict",
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Strictness {
116 Consumer,
118 Package,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct EntryWarning {
127 pub code: &'static str,
128 pub message: String,
129}
130
131impl Entry {
132 pub fn git(url: impl Into<String>) -> Self {
134 Self::from_source(Source::Git { url: url.into() })
135 }
136
137 pub fn at_path(path: impl Into<PathBuf>) -> Self {
139 Self::from_source(Source::Path { path: path.into() })
140 }
141
142 pub fn from_source(source: Source) -> Self {
144 Self {
145 source,
146 version: None,
147 subpath: None,
148 dialect: None,
149 plugin: None,
150 filter: FilterConfig::default(),
151 unknown: BTreeMap::new(),
152 }
153 }
154
155 pub fn url(&self) -> Option<&str> {
157 match &self.source {
158 Source::Git { url } => Some(url),
159 Source::Path { .. } => None,
160 }
161 }
162
163 pub fn path(&self) -> Option<&std::path::Path> {
165 match &self.source {
166 Source::Path { path } => Some(path),
167 Source::Git { .. } => None,
168 }
169 }
170
171 pub fn read(
177 name: &str,
178 mut raw: RawEntry,
179 strictness: Strictness,
180 ) -> Result<(Self, Vec<EntryWarning>), EntryError> {
181 let mut warnings = Vec::new();
182 if strictness == Strictness::Package && raw.plugin.is_some() && raw.dialect.take().is_some()
183 {
184 warnings.push(EntryWarning {
185 code: "dependency.dialect-ignored",
186 message: format!(
187 "source `{name}` has both `plugin` and `dialect`; the dialect is ignored"
188 ),
189 });
190 }
191 let source = match (raw.url, raw.path) {
192 (Some(url), None) => Source::Git {
193 url: crate::git_url::with_default_scheme(&url),
194 },
195 (None, Some(path)) => Source::Path { path },
196 (Some(_), Some(_)) => return Err(EntryError::BothSources { name: name.into() }),
197 (None, None) => return Err(EntryError::NoSource { name: name.into() }),
198 };
199 validate_plugin_selector(
200 name,
201 raw.plugin.as_deref(),
202 raw.subpath.as_ref(),
203 raw.dialect,
204 )?;
205 match (raw.filter.validate(name), strictness) {
206 (Err(conflict), Strictness::Consumer) => return Err(conflict.into()),
207 (Err(conflict), Strictness::Package) => warnings.push(EntryWarning {
208 code: "dependency.filter-conflict",
209 message: conflict.to_string(),
210 }),
211 (Ok(()), _) => {}
212 }
213 let entry = Self {
214 source,
215 version: raw.version,
216 subpath: raw.subpath,
217 dialect: raw.dialect,
218 plugin: raw.plugin,
219 filter: raw.filter,
220 unknown: raw.unknown,
221 };
222 Ok((entry, warnings))
223 }
224}
225
226impl From<&Entry> for RawEntry {
227 fn from(entry: &Entry) -> Self {
228 let (url, path) = match &entry.source {
229 Source::Git { url } => (Some(url.clone()), None),
230 Source::Path { path } => (None, Some(path.clone())),
231 };
232 Self {
233 url,
234 path,
235 subpath: entry.subpath.clone(),
236 version: entry.version.clone(),
237 dialect: entry.dialect,
238 plugin: entry.plugin.clone(),
239 filter: entry.filter.clone(),
240 unknown: entry.unknown.clone(),
241 }
242 }
243}
244
245impl From<Entry> for RawEntry {
246 fn from(entry: Entry) -> Self {
247 Self::from(&entry)
248 }
249}
250
251impl Serialize for Entry {
252 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
253 RawEntry::from(self).serialize(serializer)
254 }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Serialize)]
259pub struct Dependencies {
260 #[serde(skip_serializing_if = "IndexMap::is_empty")]
261 pub dependencies: IndexMap<String, Entry>,
262 #[serde(
263 rename = "local-dependencies",
264 skip_serializing_if = "IndexMap::is_empty"
265 )]
266 pub local_dependencies: IndexMap<String, Entry>,
267}
268
269impl Dependencies {
270 pub fn read(
277 document: &toml_edit::DocumentMut,
278 diagnostics: &mut Vec<SchemaDiagnostic>,
279 ) -> Self {
280 let tables = ["dependencies", "local-dependencies"].map(|key| match document.get(key) {
281 None => Ok(None),
282 Some(item) => item.as_table_like().map(Some).ok_or(key),
283 });
284 let [Ok(dependencies), Ok(local_dependencies)] = tables else {
285 let bad = tables.iter().find_map(|table| table.as_ref().err());
286 diagnostics.push(SchemaDiagnostic::new(
287 "dependency.invalid",
288 Severity::Error,
289 Vec::new(),
290 format!(
291 "`dependencies` and `local-dependencies` must be tables: `{}` is not",
292 bad.copied().unwrap_or_default()
293 ),
294 ));
295 return Self::default();
296 };
297 Self {
298 dependencies: read_table("dependencies", dependencies, Severity::Error, diagnostics),
299 local_dependencies: read_table(
300 "local-dependencies",
301 local_dependencies,
302 Severity::Warning,
303 diagnostics,
304 ),
305 }
306 }
307}
308
309fn read_table(
311 table: &str,
312 items: Option<&dyn toml_edit::TableLike>,
313 refusal: Severity,
314 diagnostics: &mut Vec<SchemaDiagnostic>,
315) -> IndexMap<String, Entry> {
316 let mut entries = IndexMap::new();
317 for (name, item) in items.into_iter().flat_map(|items| items.iter()) {
318 let path = child_path(table, name);
319 let key_path = format!("{table}.{name}");
320 let raw = match item.clone().into_value() {
321 Ok(value) => RawEntry::deserialize(
322 serde::de::IntoDeserializer::<toml_edit::de::Error>::into_deserializer(value),
323 )
324 .map_err(|error| format!("{key_path}: {}", error.to_string().trim_end())),
325 Err(_) => Err(format!("{key_path}: must be a table")),
326 };
327 let raw = match raw {
328 Ok(raw) => raw,
329 Err(message) => {
330 diagnostics.push(SchemaDiagnostic::new(
331 "dependency.invalid",
332 refusal,
333 path,
334 message,
335 ));
336 continue;
337 }
338 };
339 match Entry::read(name, raw, Strictness::Package) {
340 Ok((entry, warnings)) => {
341 diagnostics.extend(warnings.into_iter().map(|warning| {
342 SchemaDiagnostic::new(
343 warning.code,
344 Severity::Warning,
345 path.clone(),
346 warning.message,
347 )
348 }));
349 entries.insert(name.to_owned(), entry);
350 }
351 Err(error) => diagnostics.push(SchemaDiagnostic::new(
352 error.code(),
353 refusal,
354 path,
355 error.to_string(),
356 )),
357 }
358 }
359 entries
360}
361
362#[derive(Debug, thiserror::Error, PartialEq, Eq)]
364pub enum PluginSelectorError {
365 #[error("source `{name}` has `plugin = \"{plugin}\"`; a plugin name is {rule}")]
366 BadName {
367 name: String,
368 plugin: String,
369 rule: &'static str,
370 },
371 #[error(
372 "source `{name}` has both `plugin` and `subpath` — a marketplace is read at the root of its source, and the marketplace entry says where the plugin is"
373 )]
374 WithSubpath { name: String },
375 #[error(
376 "source `{name}` has both `plugin` and `dialect` — a marketplace plugin is read as a Claude plugin"
377 )]
378 WithDialect { name: String },
379}
380
381pub fn validate_plugin_selector(
384 name: &str,
385 plugin: Option<&str>,
386 subpath: Option<&SourceSubpath>,
387 dialect: Option<Dialect>,
388) -> Result<(), PluginSelectorError> {
389 let Some(plugin) = plugin else {
390 return Ok(());
391 };
392 let name = name.to_string();
393 if !crate::claude::manifest::is_plugin_name(plugin) {
394 return Err(PluginSelectorError::BadName {
395 name,
396 plugin: plugin.to_string(),
397 rule: crate::claude::manifest::PLUGIN_NAME_RULE,
398 });
399 }
400 if subpath.is_some() {
401 return Err(PluginSelectorError::WithSubpath { name });
402 }
403 if dialect.is_some() {
404 return Err(PluginSelectorError::WithDialect { name });
405 }
406 Ok(())
407}