Skip to main content

agent_skill_installer/
plan.rs

1// SPDX-License-Identifier: LGPL-3.0-or-later
2// Copyright (C) 2026 Fabian Schmieder
3
4//! Where a skill should go on this machine, and why.
5//!
6//! The planner decides nothing a user has to agree to and writes nothing. It
7//! reports which tools are installed, which directories they read, which of
8//! those are one physical place, which copies serve every installed tool best,
9//! and which tool would still see the skill twice.
10
11use std::path::PathBuf;
12
13use crate::catalog::Catalog;
14use crate::env::Environment;
15use crate::resolve;
16
17/// The most physical places the planner compares every combination of. The
18/// built-in catalog has six; a program adding more than this many is told so
19/// rather than left waiting on an exponential search.
20const MAX_PLACES: usize = 16;
21
22/// One directory the skill would be written to.
23#[derive(Debug, Clone, PartialEq, Eq)]
24#[non_exhaustive]
25pub struct Target {
26    /// The physical directory, every symlink resolved. This is where the
27    /// skill is written.
28    pub path: PathBuf,
29    /// The catalog directories that lead here, in catalog order. More than
30    /// one when a symlink joins them.
31    pub dirs: Vec<String>,
32    /// The installed tools that read a skill here, in catalog order.
33    pub tools: Vec<String>,
34    /// Whether the directory has to be created.
35    pub create: bool,
36    /// The root of the git working tree it lies in, if any.
37    pub git_root: Option<PathBuf>,
38}
39
40/// A tool the planner found installed but cannot serve, and why.
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub struct Unserved {
44    /// The tool.
45    pub tool: String,
46    /// Why no directory it reads can be used.
47    pub reason: String,
48}
49
50/// What the planner found and proposes.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52#[non_exhaustive]
53pub struct Plan {
54    /// Installed tools, in catalog order.
55    pub installed: Vec<String>,
56    /// Where the skill would be written.
57    pub targets: Vec<Target>,
58    /// Tools that would see the skill more than once with these targets. Empty
59    /// unless that cannot be avoided.
60    pub duplicated: Vec<String>,
61    /// Installed tools no target reaches.
62    pub unserved: Vec<Unserved>,
63}
64
65/// Why no plan could be made.
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum PlanError {
69    /// More physical places than the planner compares exhaustively.
70    TooManyPlaces(usize),
71}
72
73impl std::fmt::Display for PlanError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::TooManyPlaces(n) => write!(
77                f,
78                "{n} candidate places is more than the {MAX_PLACES} the planner compares"
79            ),
80        }
81    }
82}
83
84impl std::error::Error for PlanError {}
85
86/// A catalog directory as found on this machine.
87struct Found {
88    /// Index of the physical place it leads to, when it can be used.
89    place: Option<usize>,
90    /// Why it cannot be used, when it cannot.
91    refused: Option<String>,
92}
93
94/// A physical place one or more usable directories lead to.
95struct Place {
96    path: PathBuf,
97    dirs: Vec<usize>,
98    preferred: bool,
99    exists: bool,
100}
101
102/// Work out where a skill should go.
103///
104/// # Errors
105/// Returns an error only for a catalog too large to plan exhaustively.
106pub fn plan(catalog: &Catalog, env: &Environment) -> Result<Plan, PlanError> {
107    let installed: Vec<usize> = catalog
108        .tools
109        .iter()
110        .enumerate()
111        .filter(|(_, tool)| tool.home.resolve(env).is_dir())
112        .map(|(index, _)| index)
113        .collect();
114
115    // Only directories an installed tool reads are candidates; a directory
116    // nobody installed reads is not ours to create.
117    let wanted = |dir: usize| {
118        let id = &catalog.dirs[dir].id;
119        installed.iter().any(|&tool| {
120            let tool = &catalog.tools[tool];
121            tool.reads.contains(id) || tool.may_read.contains(id)
122        })
123    };
124
125    let mut places: Vec<Place> = Vec::new();
126    let found: Vec<Found> = (0..catalog.dirs.len())
127        .map(|index| {
128            if !wanted(index) {
129                return Found {
130                    place: None,
131                    refused: Some("no installed tool reads it".to_string()),
132                };
133            }
134            locate(catalog, env, index, &mut places)
135        })
136        .collect();
137
138    if places.len() > MAX_PLACES {
139        return Err(PlanError::TooManyPlaces(places.len()));
140    }
141
142    // A tool is served through the places its reliably read directories lead
143    // to, and sees a copy in every place any directory it reads leads to,
144    // once per directory: a tool does not resolve symlinks, as Gemini CLI's
145    // conflict warning for a symlinked duplicate shows (docs/tools.md).
146    let place_of = |id: &String| catalog.dir_index(id).and_then(|dir| found[dir].place);
147    let serving: Vec<(usize, Vec<usize>, Vec<usize>)> = installed
148        .iter()
149        .map(|&tool| {
150            let tool_entry = &catalog.tools[tool];
151            let reaches = tool_entry.reads.iter().filter_map(place_of).collect();
152            let sees = tool_entry
153                .reads
154                .iter()
155                .chain(&tool_entry.may_read)
156                .filter_map(place_of)
157                .collect();
158            (tool, reaches, sees)
159        })
160        .collect();
161
162    let unserved = unserved(catalog, &found, &serving);
163
164    let servable: Vec<&(usize, Vec<usize>, Vec<usize>)> = serving
165        .iter()
166        .filter(|(_, reaches, _)| !reaches.is_empty())
167        .collect();
168    let chosen = choose(&places, &servable);
169
170    let targets = chosen
171        .iter()
172        .map(|&place| {
173            let entry = &places[place];
174            Target {
175                path: entry.path.clone(),
176                dirs: entry
177                    .dirs
178                    .iter()
179                    .map(|&dir| catalog.dirs[dir].id.clone())
180                    .collect(),
181                tools: servable
182                    .iter()
183                    .filter(|(_, reaches, _)| reaches.contains(&place))
184                    .map(|(tool, _, _)| catalog.tools[*tool].id.clone())
185                    .collect(),
186                create: !entry.exists,
187                git_root: resolve::git_root(&entry.path),
188            }
189        })
190        .collect();
191
192    let duplicated = servable
193        .iter()
194        .filter(|(_, _, sees)| sees.iter().filter(|place| chosen.contains(place)).count() > 1)
195        .map(|(tool, _, _)| catalog.tools[*tool].id.clone())
196        .collect();
197
198    Ok(Plan {
199        installed: installed
200            .iter()
201            .map(|&tool| catalog.tools[tool].id.clone())
202            .collect(),
203        targets,
204        duplicated,
205        unserved,
206    })
207}
208
209/// Installed tools no usable directory reaches, with the reason per directory.
210fn unserved(
211    catalog: &Catalog,
212    found: &[Found],
213    serving: &[(usize, Vec<usize>, Vec<usize>)],
214) -> Vec<Unserved> {
215    serving
216        .iter()
217        .filter(|(_, reaches, _)| reaches.is_empty())
218        .map(|(tool, _, _)| {
219            let tool = &catalog.tools[*tool];
220            let reasons: Vec<String> = tool
221                .reads
222                .iter()
223                .filter_map(|id| {
224                    let dir = catalog.dir_index(id)?;
225                    found[dir]
226                        .refused
227                        .as_ref()
228                        .map(|why| format!("{id}: {why}"))
229                })
230                .collect();
231            Unserved {
232                tool: tool.id.clone(),
233                reason: reasons.join("; "),
234            }
235        })
236        .collect()
237}
238
239/// Resolve one catalog directory and file it under its physical place.
240fn locate(catalog: &Catalog, env: &Environment, index: usize, places: &mut Vec<Place>) -> Found {
241    let dir = &catalog.dirs[index];
242    let path = dir.path.resolve(env);
243    let exists = path.is_dir();
244    if !exists
245        && let Some(requires) = &dir.requires
246        && !requires.resolve(env).is_dir()
247    {
248        // Creating the directory would create a tool's home too, which is
249        // never ours to do (D2).
250        return Found {
251            place: None,
252            refused: Some(format!(
253                "{} does not exist",
254                requires.resolve(env).display()
255            )),
256        };
257    }
258    match resolve::physical(&path) {
259        Ok(physical) => {
260            let place = places.iter().position(|place| place.path == physical);
261            let place = place.unwrap_or_else(|| {
262                places.push(Place {
263                    path: physical,
264                    dirs: Vec::new(),
265                    preferred: false,
266                    exists,
267                });
268                places.len() - 1
269            });
270            places[place].dirs.push(index);
271            places[place].preferred |= dir.preferred;
272            places[place].exists |= exists;
273            Found {
274                place: Some(place),
275                refused: None,
276            }
277        }
278        Err(error) => Found {
279            place: None,
280            refused: Some(format!("{} cannot be resolved: {error}", path.display())),
281        },
282    }
283}
284
285/// The set of places D3 asks for.
286///
287/// Every combination is compared. Among those that reach every servable tool,
288/// the winner leaves the fewest tools seeing the skill twice, then has the
289/// fewest places, then the most preferred ones, then comes first in catalog
290/// order, so the same machine always gets the same plan.
291fn choose(places: &[Place], servable: &[&(usize, Vec<usize>, Vec<usize>)]) -> Vec<usize> {
292    if servable.is_empty() {
293        return Vec::new();
294    }
295    let mut best: Option<((usize, usize, usize), Vec<usize>)> = None;
296    for mask in 1u32..(1u32 << places.len()) {
297        let set: Vec<usize> = (0..places.len()).filter(|i| mask & (1 << i) != 0).collect();
298        if !servable
299            .iter()
300            .all(|(_, reaches, _)| reaches.iter().any(|place| set.contains(place)))
301        {
302            continue;
303        }
304        let duplicates = servable
305            .iter()
306            .filter(|(_, _, sees)| sees.iter().filter(|place| set.contains(place)).count() > 1)
307            .count();
308        let unpreferred = set
309            .iter()
310            .filter(|&&place| !places[place].preferred)
311            .count();
312        let score = (duplicates, set.len(), unpreferred);
313        // Masks count up, so the first set with a given score is the one that
314        // comes first in catalog order; only a strictly better score replaces
315        // it.
316        if best.as_ref().is_none_or(|(known, _)| score < *known) {
317            best = Some((score, set));
318        }
319    }
320    best.map(|(_, set)| set).unwrap_or_default()
321}
322
323/// Every existing physical directory of the catalog, resolved and without
324/// duplicates.
325///
326/// Updates and removals look here rather than only at a plan's targets: a
327/// skill installed for a tool that has since been uninstalled is still the
328/// program's own, and still worth keeping current or taking away.
329#[must_use]
330pub fn existing_places(catalog: &Catalog, env: &Environment) -> Vec<PathBuf> {
331    let mut places: Vec<PathBuf> = Vec::new();
332    for dir in &catalog.dirs {
333        let path = dir.path.resolve(env);
334        if !path.is_dir() {
335            continue;
336        }
337        if let Ok(physical) = resolve::physical(&path)
338            && !places.contains(&physical)
339        {
340            places.push(physical);
341        }
342    }
343    places
344}