Skip to main content

mars_schema/
dependency.rs

1//! One `[dependencies]` or `[local-dependencies]` entry, as a package manifest or a consumer
2//! `mars.toml` writes it: its source, selectors and filter, and the rules that hold wherever it is
3//! read. The consumer's configuration, a package's manifest reading and `mars add` all read an
4//! entry through [`Entry::read`], which holds every rule and says, by [`Strictness`], which of them
5//! refuse and which only warn.
6
7use 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/// Where an entry's package comes from: a git repository or a directory, never both.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum Source {
21    Git { url: String },
22    Path { path: PathBuf },
23}
24
25/// One dependency entry. It serializes in the spelling of the file (`url` or `path`, `version`,
26/// `subpath`, `dialect`, `plugin`, the filter keys), so a consumer's `mars.toml` writes it back as
27/// it was authored. `version` is a git source's constraint; a path source has none and ignores it.
28#[derive(Debug, Clone, PartialEq)]
29pub struct Entry {
30    pub source: Source,
31    pub version: Option<String>,
32    pub subpath: Option<SourceSubpath>,
33    /// Inbound lift dialect for the package.
34    pub dialect: Option<Dialect>,
35    /// The plugin to install from the marketplace at this source.
36    pub plugin: Option<String>,
37    pub filter: FilterConfig,
38    /// Keys Mars does not read here, kept so the file round-trips and a caller can warn.
39    pub unknown: BTreeMap<String, toml::Value>,
40}
41
42/// The authored shape of an entry, before the rules that need its name. A consumer's `mars.toml`
43/// holds entries in this shape, so a file with a bad entry still loads and `mars remove` can repair
44/// it; [`Entry::read`] applies the rules where an entry is used.
45#[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
65/// A path is written with `/` on every host.
66mod 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/// An entry the schema refuses, by the rule it breaks. Each message names the entry.
89#[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    /// A filter that contradicts itself, refused under [`Strictness::Consumer`].
98    #[error(transparent)]
99    Filter(#[from] FilterError),
100}
101
102impl EntryError {
103    /// The diagnostic code the manifest reading reports it under.
104    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/// Who an entry is read for, which decides what a rule does when it breaks.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Strictness {
116    /// The project's own `mars.toml`, or an entry about to be written to it: every rule refuses.
117    Consumer,
118    /// A dependency's manifest, read as written: its dependents' syncs must not break on it, so a
119    /// conflicting filter warns (and `FilterConfig::to_mode` picks the mode it always has) and a
120    /// `plugin` entry's `dialect` is dropped with a warning.
121    Package,
122}
123
124/// A rule a [`Strictness::Package`] entry broke and was kept anyway.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct EntryWarning {
127    pub code: &'static str,
128    pub message: String,
129}
130
131impl Entry {
132    /// A git entry with no selector, version or filter.
133    pub fn git(url: impl Into<String>) -> Self {
134        Self::from_source(Source::Git { url: url.into() })
135    }
136
137    /// A path entry with no selector or filter.
138    pub fn at_path(path: impl Into<PathBuf>) -> Self {
139        Self::from_source(Source::Path { path: path.into() })
140    }
141
142    /// An entry for `source` with no selector, version or filter.
143    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    /// The git URL, for a git entry.
156    pub fn url(&self) -> Option<&str> {
157        match &self.source {
158            Source::Git { url } => Some(url),
159            Source::Path { .. } => None,
160        }
161    }
162
163    /// The directory, for a path entry.
164    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    /// Read an authored entry under every rule: exactly one of `url` and `path`, a bare
172    /// `host/path` URL spelled with `https://` (see [`crate::git_url::with_default_scheme`]), a
173    /// `plugin` selector that does not contradict `subpath` or `dialect`, and a consistent filter
174    /// ([`FilterConfig::validate`]). `strictness` decides whether a broken filter or a `plugin`
175    /// with `dialect` refuses or warns; the rest refuse for every reader.
176    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/// A package manifest's two dependency tables, entries by name in declaration order.
258#[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    /// Read both tables of a parsed `mars.toml`, entries in declaration order. An entry that
271    /// breaks a rule is left out and reported at `[<table>.<name>]`. A refusal in `[dependencies]`
272    /// is an error, because a dependent installs from it; `[local-dependencies]` is never
273    /// installed from by a dependent, so its refusals are warnings. A filter conflict, and a
274    /// `plugin` entry's `dialect` (ignored there), are warnings on an entry that is kept. A table
275    /// that is not a table leaves both out with one error.
276    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
309/// Read one table's entries. A refused entry is left out and reported at `refusal` severity.
310fn 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/// A dependency entry that selects a marketplace plugin with a key that contradicts it.
363#[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
381/// `plugin` picks one plugin of the marketplace at the source's root, read as a Claude
382/// marketplace; a `subpath` or `dialect` would name a different root or reading.
383pub 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}