Skip to main content

dre_core/
plugins.rs

1//! Finding installed plugin packages.
2//!
3//! A project's plugins live in `<project>/dre_deps/plugins`, hard-linked (or copied) from a shared
4//! download cache in `~/.dre/plugins`. `DRE_PLUGINS_DIR` replaces both. Inside a plugins
5//! directory, packages sit side by side by version, `<dir>/<package>/<version>/`, holding the
6//! package's executable and a [`MANIFEST`] naming it and the plugins it provides, as installed by
7//! the plugin manager. Executables placed by hand (development, tests) sit flat in the directory:
8//! `dre-plugin-<package>`, or `dre-<kind>-<name>` for a package of that one plugin, called `<name>`.
9
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use dre_protocol::{PluginId, executable_name, parse_executable_name, parse_package_executable_name};
15use semver::{Version, VersionReq};
16use serde::{Deserialize, Serialize};
17
18/// What an installed version's directory holds besides the executable.
19pub const MANIFEST: &str = "plugin.json";
20
21/// An installed version's [`MANIFEST`].
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Manifest {
24    /// The executable's file name, in the same directory.
25    pub executable: String,
26    pub provides: Vec<PluginId>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct InstalledPackage {
31    pub name: String,
32    /// Known for versioned installs; flat ones report theirs in the handshake.
33    pub version: Option<Version>,
34    pub path: PathBuf,
35    /// Empty when a flat package executable can't be asked (it doesn't start).
36    pub provides: Vec<PluginId>,
37}
38
39impl InstalledPackage {
40    pub fn provides(&self, id: &PluginId) -> bool {
41        self.provides.contains(id)
42    }
43}
44
45/// Everything a project installs: plugins and macro packages.
46pub const DEPS_DIR: &str = "dre_deps";
47
48/// `DRE_PLUGINS_DIR`, when set: plugins live and install there, for every project.
49pub fn override_dir() -> Option<PathBuf> {
50    std::env::var_os("DRE_PLUGINS_DIR")
51        .filter(|p| !p.is_empty())
52        .map(PathBuf::from)
53}
54
55/// The shared download cache, which project installs link from; also where `dre init` installs
56/// before a project exists.
57pub fn cache_dir() -> PathBuf {
58    override_dir().unwrap_or_else(|| crate::dre_home().join("plugins"))
59}
60
61/// Where a project's plugins live (`None`: outside any project, the cache).
62pub fn plugins_dir(project: Option<&Path>) -> PathBuf {
63    match (override_dir(), project) {
64        (Some(d), _) => d,
65        (None, Some(root)) => root.join(DEPS_DIR).join("plugins"),
66        (None, None) => cache_dir(),
67    }
68}
69
70/// Put `src` at `dst` without a second copy on disk where possible.
71pub fn link_or_copy(src: &Path, dst: &Path) -> Result<(), String> {
72    let parent = dst.parent().unwrap();
73    std::fs::create_dir_all(parent).map_err(|e| format!("can't create {}: {e}", parent.display()))?;
74    let _ = std::fs::remove_file(dst);
75    if std::fs::hard_link(src, dst).is_ok() {
76        return Ok(());
77    }
78    std::fs::copy(src, dst).map_err(|e| format!("can't copy {} to {}: {e}", src.display(), dst.display()))?;
79    #[cfg(unix)]
80    {
81        use std::os::unix::fs::PermissionsExt;
82        std::fs::set_permissions(dst, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?;
83    }
84    Ok(())
85}
86
87/// Link (or copy) an installed version's directory, executable and manifest, from one plugins
88/// directory to another.
89pub fn link_version(from: &Path, to: &Path) -> Result<(), String> {
90    let m = read_manifest(from).ok_or_else(|| format!("{} has no {MANIFEST}", from.display()))?;
91    link_or_copy(&from.join(&m.executable), &to.join(&m.executable))?;
92    link_or_copy(&from.join(MANIFEST), &to.join(MANIFEST))
93}
94
95pub fn read_manifest(version_dir: &Path) -> Option<Manifest> {
96    let text = std::fs::read_to_string(version_dir.join(MANIFEST)).ok()?;
97    serde_json::from_str(&text).ok()
98}
99
100pub fn write_manifest(version_dir: &Path, m: &Manifest) -> Result<(), String> {
101    let p = version_dir.join(MANIFEST);
102    std::fs::write(&p, serde_json::to_string_pretty(m).unwrap())
103        .map_err(|e| format!("can't write {}: {e}", p.display()))
104}
105
106fn is_executable(p: &Path) -> bool {
107    let Ok(meta) = std::fs::metadata(p) else {
108        return false;
109    };
110    if !meta.is_file() {
111        return false;
112    }
113    #[cfg(unix)]
114    {
115        use std::os::unix::fs::PermissionsExt;
116        meta.permissions().mode() & 0o111 != 0
117    }
118    #[cfg(not(unix))]
119    true
120}
121
122/// What an executable said it provides, and the file's modification time when asked.
123type Asked = (Option<std::time::SystemTime>, Vec<PluginId>);
124
125/// The plugins an executable serves, from its name when that's `dre-<kind>-<name>`, else from
126/// its handshake. Asked once per file and modification time.
127pub fn probe(path: &Path) -> Result<Vec<PluginId>, String> {
128    static ASKED: Mutex<BTreeMap<PathBuf, Asked>> = Mutex::new(BTreeMap::new());
129    let file = path.file_name().unwrap_or_default().to_string_lossy();
130    if let Some((kind, name)) = parse_executable_name(&file) {
131        return Ok(vec![PluginId::new(kind, name)]);
132    }
133    let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok();
134    if let Some((m, ids)) = ASKED.lock().unwrap().get(path)
135        && *m == modified
136    {
137        return Ok(ids.clone());
138    }
139    let quiet: dre_protocol::host::LogSink = std::sync::Arc::new(|_, _| {});
140    let p = dre_protocol::host::PluginProcess::start(path, quiet)
141        .map_err(|e| format!("can't ask {} what it provides: {e}", path.display()))?;
142    let ids = p.info().provides.clone();
143    let _ = p.close();
144    ASKED
145        .lock()
146        .unwrap()
147        .insert(path.to_path_buf(), (modified, ids.clone()));
148    Ok(ids)
149}
150
151/// Every package under `dir`, flat ones first, then versioned ones in version order.
152pub fn discover(dir: &Path) -> Vec<InstalledPackage> {
153    scan(dir, None)
154}
155
156/// [`discover`], limited to the package `only` when given (so no other executable is started).
157fn scan(dir: &Path, only: Option<&str>) -> Vec<InstalledPackage> {
158    let mut out = Vec::new();
159    let Ok(entries) = std::fs::read_dir(dir) else {
160        return out;
161    };
162    let mut entries: Vec<_> = entries.filter_map(Result::ok).map(|e| e.path()).collect();
163    entries.sort();
164    for p in &entries {
165        let file = p.file_name().unwrap_or_default().to_string_lossy();
166        let name =
167            parse_package_executable_name(&file).or_else(|| parse_executable_name(&file).map(|(_, n)| n));
168        if let Some(name) = name
169            && only.is_none_or(|o| o == name)
170            && is_executable(p)
171        {
172            out.push(InstalledPackage {
173                name,
174                version: None,
175                path: p.clone(),
176                provides: probe(p).unwrap_or_default(),
177            });
178        }
179    }
180    for pdir in entries.iter().filter(|p| p.is_dir()) {
181        let name = pdir.file_name().unwrap_or_default().to_string_lossy().to_string();
182        if !dre_protocol::valid_name(&name) || only.is_some_and(|o| o != name) {
183            continue;
184        }
185        let Ok(versions) = std::fs::read_dir(pdir) else {
186            continue;
187        };
188        let mut found: Vec<(Version, InstalledPackage)> = versions
189            .filter_map(Result::ok)
190            .filter_map(|v| {
191                let ver = Version::parse(&v.file_name().to_string_lossy()).ok()?;
192                let m = read_manifest(&v.path())?;
193                let exe = v.path().join(&m.executable);
194                is_executable(&exe).then(|| {
195                    (
196                        ver.clone(),
197                        InstalledPackage {
198                            name: name.clone(),
199                            version: Some(ver),
200                            path: exe,
201                            provides: m.provides,
202                        },
203                    )
204                })
205            })
206            .collect();
207        found.sort_by(|a, b| crate::manager::version_order(&a.0, &b.0));
208        out.extend(found.into_iter().map(|(_, p)| p));
209    }
210    out
211}
212
213/// The installed copies of package `name` to use: an exact `pin` if given, otherwise the highest
214/// installed version matching `req`; failing both, the flat (hand-placed) executables of that
215/// name. Several only when a package is placed flat as one `dre-<kind>-<name>` per plugin.
216pub fn find(
217    dir: &Path,
218    name: &str,
219    req: Option<&VersionReq>,
220    pin: Option<&Version>,
221) -> Vec<InstalledPackage> {
222    let all = scan(dir, Some(name));
223    let flat = || {
224        all.iter()
225            .filter(|p| p.version.is_none())
226            .cloned()
227            .collect::<Vec<_>>()
228    };
229    if let Some(pin) = pin {
230        if let Some(p) = all.iter().find(|p| p.version.as_ref() == Some(pin)) {
231            return vec![p.clone()];
232        }
233        return flat();
234    }
235    let versioned: Vec<(&InstalledPackage, &Version)> = all
236        .iter()
237        .filter_map(|p| p.version.as_ref().map(|v| (p, v)))
238        .collect();
239    match crate::manager::prefer_stable(versioned, req.unwrap_or(&VersionReq::STAR), |(_, v)| v) {
240        Some((p, _)) => vec![p.clone()],
241        None => flat(),
242    }
243}
244
245/// Where a versioned install of a package lives.
246pub fn version_dir(dir: &Path, package: &str, version: &Version) -> PathBuf {
247    dir.join(package).join(version.to_string())
248}
249
250/// The executable file name a package installs under when its archive holds a single plugin's
251/// `dre-<kind>-<name>` (a plugin built before packages).
252pub fn legacy_executable(provides: &[PluginId]) -> Option<String> {
253    match provides {
254        [one] => Some(executable_name(one.kind, &one.name)),
255        _ => None,
256    }
257}
258
259/// The executable to start for one plugin, and the plugin to ask it for.
260#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct Located {
262    pub path: PathBuf,
263    pub plugin: PluginId,
264    /// The package it comes from.
265    pub package: String,
266}
267
268impl Located {
269    /// Start the plugin (in `cwd`, the project directory) and complete the handshake.
270    pub fn start(
271        &self,
272        log: dre_protocol::host::LogSink,
273        cwd: Option<&Path>,
274    ) -> dre_protocol::host::Result<dre_protocol::host::PluginProcess> {
275        dre_protocol::host::PluginProcess::start_for(&self.path, Some(&self.plugin), log, cwd)
276    }
277}
278
279/// The installed copies of a declared package, honouring its `dre.lock` pin and constraint;
280/// `Err` for a `local:` package that can't be used.
281fn installed(
282    root: &Path,
283    lock: &crate::lock::Lock,
284    req: &crate::project::PluginRequirement,
285) -> Result<Vec<InstalledPackage>, String> {
286    use crate::project::PluginSource;
287    if let PluginSource::Local(p) = &req.source {
288        let path = root.join(p);
289        if !path.is_file() {
290            return Err(format!(
291                "the plugin package `{}` is declared `local: {p}`, but there's no file at {}",
292                req.name,
293                path.display()
294            ));
295        }
296        return Ok(vec![InstalledPackage {
297            name: req.name.clone(),
298            version: None,
299            provides: probe(&path)?,
300            path,
301        }]);
302    }
303    let pin = lock
304        .get(&req.name)
305        .filter(|l| l.from == req.source.lock_key())
306        .map(|l| l.version.clone());
307    let dir = plugins_dir(Some(root));
308    Ok(find(&dir, &req.name, Some(&req.req()), pin.as_ref()))
309}
310
311/// Why [`locate`] found nothing.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub enum LocateError {
314    /// No declared package provides the plugin (an `undeclared-plugin` error elsewhere).
315    NotProvided(String),
316    /// A declared package might, but isn't installed or can't be used.
317    Unavailable(String),
318}
319
320impl std::fmt::Display for LocateError {
321    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322        match self {
323            LocateError::NotProvided(m) | LocateError::Unavailable(m) => f.write_str(m),
324        }
325    }
326}
327
328impl From<LocateError> for String {
329    fn from(e: LocateError) -> String {
330        e.to_string()
331    }
332}
333
334/// Find the plugin `id` among the project's declared packages.
335pub fn locate(project: &crate::project::Project, id: &PluginId) -> Result<Located, LocateError> {
336    locate_in(&project.root, &project.plugins, id)
337}
338
339/// [`locate`], given the project's root and declared packages.
340pub fn locate_in(
341    root: &Path,
342    plugins: &[crate::project::PluginRequirement],
343    id: &PluginId,
344) -> Result<Located, LocateError> {
345    let lock = crate::lock::Lock::load(root).unwrap_or_default();
346    let mut missing = Vec::new();
347    for req in plugins {
348        let found = installed(root, &lock, req).map_err(LocateError::Unavailable)?;
349        if found.is_empty() {
350            missing.push(format!("`{}`", req.name));
351        }
352        if let Some(p) = found.into_iter().find(|p| p.provides(id)) {
353            return Ok(Located {
354                path: p.path,
355                plugin: id.clone(),
356                package: req.name.clone(),
357            });
358        }
359    }
360    let what = format!("the {} plugin `{}`", id.kind, id.name);
361    Err(if missing.is_empty() {
362        LocateError::NotProvided(format!("no plugin package the project declares provides {what}"))
363    } else {
364        LocateError::Unavailable(format!(
365            "{what} isn't installed: the plugin package{} {} {} (looked in {}); run `dre deps` to install the project's plugins",
366            if missing.len() == 1 { "" } else { "s" },
367            missing.join(", "),
368            if missing.len() == 1 {
369                "isn't installed"
370            } else {
371                "aren't installed"
372            },
373            plugins_dir(Some(root)).display()
374        ))
375    })
376}
377
378/// Check that a declared package provides every plugin the project uses: from the installed
379/// packages, else from `dre.lock`. A package that's neither installed nor locked is already an
380/// install error, so plugins it might provide aren't reported again.
381pub fn check_uses(project: &crate::project::Project, diags: &mut crate::Diagnostics) {
382    let lock = crate::lock::Lock::load(&project.root).unwrap_or_default();
383    let mut known: Vec<PluginId> = Vec::new();
384    // A package whose declarations conflict is already an error.
385    let mut unknown = project.plugins_incomplete;
386    for req in &project.plugins {
387        let found = installed(&project.root, &lock, req).unwrap_or_default();
388        let from_lock = lock
389            .get(&req.name)
390            .filter(|l| l.from == req.source.lock_key())
391            .map(|l| l.provides.clone())
392            .unwrap_or_default();
393        if found.is_empty() && from_lock.is_empty() {
394            unknown = true;
395        }
396        known.extend(found.into_iter().flat_map(|p| p.provides));
397        known.extend(from_lock);
398    }
399    for u in &project.plugin_uses {
400        if known.contains(&u.plugin) || unknown {
401            continue;
402        }
403        let PluginId { kind, name } = &u.plugin;
404        diags.push(crate::Diagnostic {
405            severity: crate::Severity::Error,
406            code: "undeclared-plugin",
407            message: format!(
408                "{}, but no plugin package the project declares provides it — add the package with the `{name}` {kind} under `plugins:` in dependencies.yml, then run `dre deps`",
409                u.what
410            ),
411            file: u.file.clone(),
412            line: u.line,
413            plugin: Some((*kind, name.clone())),
414        });
415    }
416}