Skip to main content

mars_schema/
selection.rs

1//! Item selection: which Agents, skills and package-level documents of a settled dependency graph
2//! a consumer installs.
3//!
4//! A pure pass over the graph the resolver settled. It seeds items from the consumer's own
5//! requests and from unfiltered manifest edges, then closes over the skills the selected Agents
6//! name. Nothing here reads the filesystem, so the answer does not depend on the order the
7//! resolver met the packages in. Items are identified by package, kind and slug; the names they
8//! install under (renames, collision suffixes) are the caller's.
9//!
10//! The rule, in three steps:
11//!
12//! 1. **Seeds.** Each of the consumer's requests seeds its package, and so does each entry of
13//!    the root package's manifest, as the consumer's own request. The root package itself is
14//!    selected whole. A request that takes everything also opens its package: every unfiltered
15//!    entry of an open package's manifest seeds its target in turn and opens it. A filtered entry
16//!    of a dependency's manifest seeds nothing, and its `exclude` blocks nothing; only the consumer's own `exclude` (or the root's) does.
17//! 2. **Items per request.** `All` takes every item; `Include` takes Agents and skills by kind and
18//!    slug, and the package's bootstrap documents; `Exclude` takes all but the items it names by
19//!    slug or path; `only_skills` and `only_agents` take one kind. Hooks are taken by `All` and
20//!    `Exclude` only.
21//! 3. **Skill refs.** Every selected Agent's `skills` resolve to a package: its own, then its
22//!    dependencies breadth first, then any other package in registry order. The skill is selected
23//!    unless the consumer excluded it, and a package it lands in also selects its bootstrap
24//!    documents. A ref `<skill>__<provider>` that no package ships is skipped when the requester
25//!    declares the provider, and is a [`MissingSkill`] otherwise.
26//!
27//! **Registry order** is computed here, not supplied: a depth-first walk from the root package
28//! (if any), then each request in order, descending each package's manifest entries in
29//! declaration order. [`GraphView::packages`] is a set. A package no request reaches is never
30//! selected from and never supplies a skill.
31
32use std::collections::{HashMap, HashSet, VecDeque};
33
34use indexmap::IndexMap;
35use serde::{Deserialize, Serialize};
36
37use crate::diagnostic::{SchemaDiagnostic, Severity};
38use crate::filter::FilterMode;
39use crate::item::{DefinitionKind, ItemKind};
40use crate::names::ItemName;
41
42/// A dependency entry as selection sees it: the package it names and the items it asks for.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Request {
45    pub name: String,
46    pub filter: FilterMode,
47}
48
49/// An item of a package, found by the caller: what the crate does not prepare (bootstrap
50/// documents, hooks). MCP servers are selected outside this pass, by the caller.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Item {
54    pub kind: ItemKind,
55    pub slug: String,
56    /// Package-relative location, `/`-separated.
57    pub path: String,
58}
59
60/// An Agent or skill of a package, with the skills an Agent names.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct Definition {
63    pub kind: DefinitionKind,
64    pub slug: String,
65    pub output_path: String,
66    /// The skills the Agent names; empty for a skill.
67    pub skills: Vec<String>,
68}
69
70/// What selection reads of a prepared package. `prepare` computes it (`Prepared::selection`), so
71/// a caller on the other side of the JSON boundary passes `prepare`'s result and derives nothing.
72#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct SelectionFacts {
74    pub definitions: Vec<Definition>,
75    /// The package's manifest entries, in declaration order: the `[dependencies]` table, never
76    /// `[local-dependencies]`.
77    pub edges: Vec<Request>,
78    pub uses_providers: Vec<String>,
79}
80
81#[derive(Debug, Clone)]
82pub struct PackageView {
83    pub facts: SelectionFacts,
84    /// Bootstrap documents and hooks. They never change which Agents and skills are selected:
85    /// only Agents and skills are followed, so a caller with none to give passes `[]`.
86    pub extra_items: Vec<Item>,
87}
88
89/// A package as a caller holds it across the JSON boundary: the [`crate::prepare::Prepared`] it
90/// got earlier (`prepare`'s result, as serialized) and the items preparation does not read. Only
91/// `prepared.selection` is read; the rest is ignored.
92#[derive(Debug, Deserialize)]
93#[serde(deny_unknown_fields)]
94pub struct PackageInput {
95    prepared: PreparedInput,
96    #[serde(default, deserialize_with = "extra_items")]
97    extra_items: Option<Vec<Item>>,
98}
99
100/// `extra_items` hold bootstrap documents and hooks; an MCP server in them is the caller's mistake,
101/// since selection never reads one.
102fn extra_items<'de, D: serde::Deserializer<'de>>(
103    deserializer: D,
104) -> Result<Option<Vec<Item>>, D::Error> {
105    let items = Option::<Vec<Item>>::deserialize(deserializer)?;
106    match items
107        .iter()
108        .flatten()
109        .find(|item| item.kind == ItemKind::McpServer)
110    {
111        Some(item) => Err(serde::de::Error::custom(format!(
112            "extra_items `{}`: MCP servers are not selected by selectItems",
113            item.slug
114        ))),
115        None => Ok(items),
116    }
117}
118
119#[derive(Debug, Deserialize)]
120struct PreparedInput {
121    selection: SelectionFacts,
122}
123
124impl From<PackageInput> for PackageView {
125    fn from(input: PackageInput) -> Self {
126        Self {
127            facts: input.prepared.selection,
128            extra_items: input.extra_items.unwrap_or_default(),
129        }
130    }
131}
132
133#[derive(Debug)]
134pub struct GraphView {
135    /// The consumer's own entries.
136    pub requests: Vec<Request>,
137    /// The package being selected for, such as a project's own package: it is selected whole and
138    /// its items are in the result with no package. Its manifest entries are the consumer's own
139    /// requests, so its filters and `exclude` apply as in a project's own `mars.toml`. Its Agents' skill refs resolve like any package's.
140    pub root: Option<PackageView>,
141    /// Every package of the graph, in no particular order: [`select`] orders them (see the module
142    /// documentation).
143    pub packages: HashMap<String, PackageView>,
144}
145
146/// Why an item is selected.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148#[serde(tag = "by", rename_all = "kebab-case")]
149pub enum Requester {
150    /// An entry of the consumer, or of the root package: the root's manifest entries, its items
151    /// and its Agents' skill refs are the consumer's own.
152    Consumer,
153    /// An unfiltered entry of this dependency's manifest, reached through unfiltered entries only.
154    Package { package: String },
155    /// A skill ref of this Agent.
156    Item {
157        package: String,
158        kind: ItemKind,
159        slug: String,
160    },
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164pub struct SelectedItem {
165    /// `None` for the root's own item.
166    pub package: Option<String>,
167    pub kind: ItemKind,
168    pub slug: String,
169    /// Location in the prepared package: an Agent's file, a skill's directory (`.` for a flat
170    /// skill), a document's or hook's directory.
171    pub path: String,
172    pub required_by: Vec<Requester>,
173}
174
175/// A skill ref no package ships.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
177pub struct MissingSkill {
178    pub skill: String,
179    /// The Agent that names it.
180    pub agent: String,
181    /// The package of that Agent; `None` for the root.
182    pub package: Option<String>,
183    /// The packages looked in, sorted; the root is not among them.
184    pub searched: Vec<String>,
185    /// For a name shaped like a provider's, when no package declares that provider.
186    pub hint: Option<String>,
187}
188
189#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
190pub struct SelectionResult {
191    /// In package order of the graph, then the package's own item order.
192    pub items: Vec<SelectedItem>,
193    pub missing_skills: Vec<MissingSkill>,
194    /// `selection.duplicate-slug`: an Agent or skill slug that two packages both install, which
195    /// installs under one name only after a rename or a collision suffix.
196    pub diagnostics: Vec<SchemaDiagnostic>,
197}
198
199/// An item with the definition it came from, when it has one.
200struct Entry<'a> {
201    item: Item,
202    definition: Option<&'a Definition>,
203}
204
205type Key<'a> = (&'a str, ItemKind, &'a str);
206
207/// The root's key among the packages. No dependency has an empty name.
208const ROOT: &str = "";
209
210/// The packages in registry order, the root among them.
211struct Graph<'v> {
212    packages: HashMap<&'v str, &'v PackageView>,
213    order: Vec<&'v str>,
214}
215
216impl<'v> Graph<'v> {
217    fn new(view: &'v GraphView) -> Self {
218        let mut packages: HashMap<&str, &PackageView> = view
219            .packages
220            .iter()
221            .map(|(name, package)| (name.as_str(), package))
222            .collect();
223        if let Some(root) = &view.root {
224            packages.insert(ROOT, root);
225        }
226        // Depth first, a package before its entries; a stack pops the first entry first.
227        let mut order = Vec::new();
228        let mut seen: HashSet<&str> = HashSet::new();
229        let mut stack: Vec<&str> = Vec::new();
230        let starts = view
231            .root
232            .iter()
233            .map(|_| ROOT)
234            .chain(view.requests.iter().map(|request| request.name.as_str()));
235        for start in starts {
236            stack.push(start);
237            while let Some(name) = stack.pop() {
238                let Some((&name, package)) = packages.get_key_value(name) else {
239                    continue;
240                };
241                if seen.insert(name) {
242                    order.push(name);
243                    stack.extend(
244                        package
245                            .facts
246                            .edges
247                            .iter()
248                            .rev()
249                            .map(|edge| edge.name.as_str()),
250                    );
251                }
252            }
253        }
254        Self { packages, order }
255    }
256}
257
258pub fn select(view: &GraphView) -> SelectionResult {
259    let graph = Graph::new(view);
260    let entries: HashMap<&str, Vec<Entry>> = graph
261        .order
262        .iter()
263        .map(|&name| (name, package_entries(graph.packages[name])))
264        .collect();
265
266    let seeds = seeds(view, &graph);
267    let excluded: HashMap<&str, Vec<&FilterMode>> = {
268        let mut excluded: HashMap<&str, Vec<&FilterMode>> = HashMap::new();
269        for seed in &seeds {
270            if matches!(seed.filter, FilterMode::Exclude(_)) {
271                excluded.entry(seed.package).or_default().push(seed.filter);
272            }
273        }
274        excluded
275    };
276
277    let mut selected: IndexMap<Key, Vec<Requester>> = IndexMap::new();
278    let mut queue: VecDeque<Key> = VecDeque::new();
279    for seed in &seeds {
280        for entry in &entries[seed.package] {
281            if !seed_accepts(seed.filter, &entry.item)
282                || is_excluded(&excluded, seed.package, &entry.item)
283            {
284                continue;
285            }
286            let key = (seed.package, entry.item.kind, entry.item.slug.as_str());
287            let requesters = selected.entry(key).or_default();
288            if !requesters.contains(&seed.requester) {
289                requesters.push(seed.requester.clone());
290            }
291            if matches!(entry.item.kind, ItemKind::Agent | ItemKind::Skill) {
292                queue.push_back(key);
293            }
294        }
295    }
296
297    let mut missing_skills = Vec::new();
298    // A package a skill ref lands in installs its bootstrap documents, as any requested part of
299    // a package does.
300    let mut pulled: IndexMap<&str, Requester> = IndexMap::new();
301    let mut processed: HashSet<Key> = HashSet::new();
302    while let Some(key) = queue.pop_front() {
303        if !processed.insert(key) {
304            continue;
305        }
306        let (package, kind, slug) = key;
307        let Some(entry) = entries[package]
308            .iter()
309            .find(|entry| entry.item.kind == kind && entry.item.slug == slug)
310        else {
311            continue;
312        };
313        let requester = if package == ROOT {
314            Requester::Consumer
315        } else {
316            Requester::Item {
317                package: package.to_string(),
318                kind,
319                slug: slug.to_string(),
320            }
321        };
322        for skill in entry
323            .definition
324            .map_or(&[][..], |definition| definition.skills.as_slice())
325        {
326            match resolve_skill(&graph, &entries, package, skill) {
327                Resolved::Deferred => {}
328                Resolved::Missing => {
329                    missing_skills.push(missing_skill(&graph, package, slug, skill))
330                }
331                Resolved::In(target) => {
332                    let target_entry = entries[target]
333                        .iter()
334                        .find(|entry| {
335                            entry.item.kind == ItemKind::Skill && entry.item.slug == *skill
336                        })
337                        .expect("a package that has the skill lists it");
338                    if is_excluded(&excluded, target, &target_entry.item) {
339                        continue;
340                    }
341                    pulled.entry(target).or_insert_with(|| requester.clone());
342                    let key = (target, ItemKind::Skill, target_entry.item.slug.as_str());
343                    let requesters = selected.entry(key).or_default();
344                    if !requesters.contains(&requester) {
345                        requesters.push(requester.clone());
346                    }
347                    queue.push_back(key);
348                }
349            }
350        }
351    }
352    for (package, requester) in &pulled {
353        for entry in &entries[package] {
354            if entry.item.kind == ItemKind::BootstrapDoc
355                && !is_excluded(&excluded, package, &entry.item)
356            {
357                let key = (*package, entry.item.kind, entry.item.slug.as_str());
358                let requesters = selected.entry(key).or_default();
359                if !requesters.contains(requester) {
360                    requesters.push(requester.clone());
361                }
362            }
363        }
364    }
365
366    let mut items = Vec::with_capacity(selected.len());
367    for &package in &graph.order {
368        for entry in &entries[package] {
369            let key = (package, entry.item.kind, entry.item.slug.as_str());
370            if let Some(required_by) = selected.get(&key) {
371                items.push(SelectedItem {
372                    package: (package != ROOT).then(|| package.to_string()),
373                    kind: entry.item.kind,
374                    slug: entry.item.slug.clone(),
375                    path: entry.item.path.clone(),
376                    required_by: required_by.clone(),
377                });
378            }
379        }
380    }
381    let diagnostics = duplicate_slugs(&items);
382    SelectionResult {
383        items,
384        missing_skills,
385        diagnostics,
386    }
387}
388
389fn duplicate_slugs(items: &[SelectedItem]) -> Vec<SchemaDiagnostic> {
390    let mut packages: IndexMap<(ItemKind, &str), Vec<&str>> = IndexMap::new();
391    for item in items
392        .iter()
393        .filter(|item| matches!(item.kind, ItemKind::Agent | ItemKind::Skill))
394    {
395        packages
396            .entry((item.kind, item.slug.as_str()))
397            .or_default()
398            .push(item.package.as_deref().unwrap_or("the root package"));
399    }
400    packages
401        .into_iter()
402        .filter(|(_, packages)| packages.len() > 1)
403        .map(|((kind, slug), packages)| {
404            SchemaDiagnostic::new(
405                "selection.duplicate-slug",
406                Severity::Warning,
407                Vec::new(),
408                format!("{kind} `{slug}` is installed from {}", packages.join(", ")),
409            )
410        })
411        .collect()
412}
413
414struct Seed<'a> {
415    package: &'a str,
416    filter: &'a FilterMode,
417    requester: Requester,
418}
419
420/// The requests that seed items: the root and its manifest entries, the consumer's own, and each
421/// unfiltered manifest entry of a package reached through unfiltered requests only. A filtered
422/// entry of a dependency's manifest seeds nothing, and its `exclude` blocks nothing. The root's
423/// entries are the consumer's own requests, filters and `exclude` included.
424fn seeds<'a>(view: &'a GraphView, graph: &Graph<'a>) -> Vec<Seed<'a>> {
425    static ALL: FilterMode = FilterMode::All;
426    let mut seeds = Vec::new();
427    let mut open: HashSet<&str> = HashSet::new();
428    let mut queue: VecDeque<&str> = VecDeque::new();
429    if view.root.is_some() {
430        seeds.push(Seed {
431            package: ROOT,
432            filter: &ALL,
433            requester: Requester::Consumer,
434        });
435    }
436    let root_edges = view.root.iter().flat_map(|root| &root.facts.edges);
437    for request in root_edges.chain(&view.requests) {
438        let Some((&package, _)) = graph.packages.get_key_value(request.name.as_str()) else {
439            continue;
440        };
441        seeds.push(Seed {
442            package,
443            filter: &request.filter,
444            requester: Requester::Consumer,
445        });
446        if request.filter == FilterMode::All && open.insert(package) {
447            queue.push_back(package);
448        }
449    }
450    while let Some(from) = queue.pop_front() {
451        for edge in &graph.packages[from].facts.edges {
452            let Some((&package, _)) = graph.packages.get_key_value(edge.name.as_str()) else {
453                continue;
454            };
455            if edge.filter != FilterMode::All {
456                continue;
457            }
458            seeds.push(Seed {
459                package,
460                filter: &edge.filter,
461                requester: Requester::Package {
462                    package: from.to_string(),
463                },
464            });
465            if open.insert(package) {
466                queue.push_back(package);
467            }
468        }
469    }
470    seeds
471}
472
473fn package_entries<'a>(package: &'a PackageView) -> Vec<Entry<'a>> {
474    let definitions = package.facts.definitions.iter().map(|definition| {
475        let (kind, path) = match definition.kind {
476            DefinitionKind::Agent => (ItemKind::Agent, definition.output_path.clone()),
477            DefinitionKind::Skill => (
478                ItemKind::Skill,
479                match definition.output_path.rsplit_once('/') {
480                    Some((parent, _)) if !parent.is_empty() => parent.to_string(),
481                    _ => ".".to_string(),
482                },
483            ),
484        };
485        Entry {
486            item: Item {
487                kind,
488                slug: definition.slug.clone(),
489                path,
490            },
491            definition: Some(definition),
492        }
493    });
494    let extras = package.extra_items.iter().map(|item| Entry {
495        item: item.clone(),
496        definition: None,
497    });
498    definitions.chain(extras).collect()
499}
500
501/// Whether a request's filter takes the item. Hooks and MCP servers are not selectable by name or
502/// category, only by taking everything.
503fn seed_accepts(filter: &FilterMode, item: &Item) -> bool {
504    match filter {
505        FilterMode::All => true,
506        FilterMode::Include { agents, skills } => match item.kind {
507            ItemKind::Agent => agents.iter().any(|name| name.as_str() == item.slug),
508            ItemKind::Skill => skills.iter().any(|name| name.as_str() == item.slug),
509            // Package-level documents travel with any requested part of the package.
510            ItemKind::BootstrapDoc => true,
511            ItemKind::Hook | ItemKind::McpServer => false,
512        },
513        FilterMode::Exclude(excluded) => !names_item(excluded, item),
514        FilterMode::OnlySkills => item.kind == ItemKind::Skill,
515        FilterMode::OnlyAgents => item.kind == ItemKind::Agent,
516    }
517}
518
519/// An exclude entry names an item by slug or by path, whatever the item's kind.
520fn names_item(excluded: &[ItemName], item: &Item) -> bool {
521    excluded
522        .iter()
523        .any(|name| name.as_str() == item.slug || name.as_str() == item.path)
524}
525
526fn is_excluded(excluded: &HashMap<&str, Vec<&FilterMode>>, package: &str, item: &Item) -> bool {
527    excluded.get(package).is_some_and(|filters| {
528        filters.iter().any(|filter| match filter {
529            FilterMode::Exclude(names) => names_item(names, item),
530            _ => false,
531        })
532    })
533}
534
535enum Resolved<'a> {
536    In(&'a str),
537    /// A ref `<skill>__<provider>` to a provider the requester declares, which no package ships.
538    Deferred,
539    Missing,
540}
541
542/// The package that supplies a skill ref: the requester's own, then its dependencies breadth
543/// first, then any other package in registry order.
544fn resolve_skill<'a>(
545    graph: &Graph<'a>,
546    entries: &HashMap<&str, Vec<Entry>>,
547    requester: &'a str,
548    skill: &str,
549) -> Resolved<'a> {
550    let has_skill = |package: &str| {
551        entries[package]
552            .iter()
553            .any(|entry| entry.item.kind == ItemKind::Skill && entry.item.slug == skill)
554    };
555    if has_skill(requester) {
556        return Resolved::In(requester);
557    }
558
559    // Breadth first keeps direct dependencies ahead of transitive ones.
560    let mut closure: Vec<&str> = Vec::new();
561    let mut seen: HashSet<&str> = HashSet::from([requester]);
562    let mut queue: VecDeque<&str> = VecDeque::from([requester]);
563    while let Some(package) = queue.pop_front() {
564        if package != requester {
565            closure.push(package);
566        }
567        for edge in &graph.packages[package].facts.edges {
568            if let Some((&name, _)) = graph.packages.get_key_value(edge.name.as_str())
569                && seen.insert(name)
570            {
571                queue.push_back(name);
572            }
573        }
574    }
575    if let Some(package) = closure.into_iter().find(|package| has_skill(package)) {
576        return Resolved::In(package);
577    }
578    for &name in &graph.order {
579        if !seen.contains(name) && has_skill(name) {
580            return Resolved::In(name);
581        }
582    }
583
584    if crate::provider::deferred_provider(
585        skill,
586        Some(&graph.packages[requester].facts.uses_providers),
587    )
588    .is_some()
589    {
590        return Resolved::Deferred;
591    }
592    Resolved::Missing
593}
594
595fn missing_skill(graph: &Graph, package: &str, agent: &str, skill: &str) -> MissingSkill {
596    let mut searched: Vec<String> = graph
597        .order
598        .iter()
599        .filter(|&&name| name != ROOT)
600        .map(|name| name.to_string())
601        .collect();
602    searched.sort();
603    MissingSkill {
604        skill: skill.to_string(),
605        agent: agent.to_string(),
606        package: (package != ROOT).then(|| package.to_string()),
607        searched,
608        hint: crate::provider::provider_of(skill).map(crate::provider::undeclared_provider_hint),
609    }
610}