nodus 0.17.0

Local-first CLI for managing project-scoped agent packages.
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use super::{
    Adapter, ManagedFile, ManagedPackageIdentities, VirtualPluginSurface, global_nodus_home,
    virtual_plugin_surface,
};
use crate::manifest::LoadedManifest;
use crate::paths::{display_path, strip_path_prefix};
use crate::resolver::{PackageSource, ResolvedPackage};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct VirtualPluginEntry {
    pub package_alias: String,
    pub entry_path: PathBuf,
    pub install_root: PathBuf,
    pub loader_path: PathBuf,
}

pub(crate) trait VirtualPluginBackend: Sync {
    fn adapter(&self) -> Adapter;

    fn entry_paths_from_manifest(&self, manifest: &LoadedManifest) -> Result<Vec<PathBuf>>;

    fn manifest_has_installable_content(&self, _manifest: &LoadedManifest) -> Result<bool> {
        Ok(false)
    }

    fn package_has_installable_content(&self, package: &ResolvedPackage) -> Result<bool> {
        self.manifest_has_installable_content(&package.manifest)
    }

    fn loader_path_for_alias(&self, package_alias: &str, entry_path: &Path) -> PathBuf;

    fn loader_contents(&self, package: &ResolvedPackage, entry: &VirtualPluginEntry) -> Vec<u8>;

    fn entry_paths(&self, package: &ResolvedPackage) -> Result<Vec<PathBuf>> {
        self.entry_paths_from_manifest(&package.manifest)
    }

    fn loader_path(&self, package: &ResolvedPackage, entry_path: &Path) -> PathBuf {
        self.loader_path_for_alias(&package.alias, entry_path)
    }

    fn surface(&self) -> VirtualPluginSurface {
        virtual_plugin_surface(self.adapter())
            .expect("virtual plugin backends require a profile surface")
    }

    fn loader_file_prefix_for_alias(&self, package_alias: &str) -> String {
        format!("{}{}-", self.surface().loader_file_prefix, package_alias)
    }

    fn loader_file_prefix(&self, package: &ResolvedPackage) -> String {
        self.loader_file_prefix_for_alias(&package.alias)
    }
}

pub(crate) fn virtual_plugin_entries_for_manifest(
    backend: &dyn VirtualPluginBackend,
    package_alias: &str,
    manifest: &LoadedManifest,
) -> Result<Vec<VirtualPluginEntry>> {
    let install_root =
        virtual_plugin_install_root_relative_for_alias(backend.adapter(), package_alias);
    let mut entries = backend
        .entry_paths_from_manifest(manifest)?
        .into_iter()
        .map(|entry_path| VirtualPluginEntry {
            package_alias: package_alias.to_string(),
            loader_path: backend.loader_path_for_alias(package_alias, &entry_path),
            install_root: install_root.clone(),
            entry_path,
        })
        .collect::<Vec<_>>();
    sort_entries(&mut entries);
    Ok(entries)
}

pub(crate) fn virtual_plugin_entries_for_package(
    backend: &dyn VirtualPluginBackend,
    project_root: &Path,
    package: &ResolvedPackage,
    package_identities: &ManagedPackageIdentities,
) -> Result<Vec<VirtualPluginEntry>> {
    if !package_selects_virtual_plugins(package) {
        return Ok(Vec::new());
    }

    let install_root =
        virtual_plugin_install_root(backend.adapter(), project_root, package, package_identities);
    let mut entries = backend
        .entry_paths(package)?
        .into_iter()
        .map(|entry_path| VirtualPluginEntry {
            package_alias: package.alias.clone(),
            loader_path: backend.loader_path(package, &entry_path),
            install_root: install_root.clone(),
            entry_path,
        })
        .collect::<Vec<_>>();
    sort_entries(&mut entries);
    Ok(entries)
}

pub(crate) fn virtual_plugin_install_root_for_package(
    backend: &dyn VirtualPluginBackend,
    project_root: &Path,
    package: &ResolvedPackage,
    package_identities: &ManagedPackageIdentities,
) -> Result<Option<PathBuf>> {
    if !package_selects_virtual_plugins(package) {
        return Ok(None);
    }

    let has_entries = !backend.entry_paths(package)?.is_empty();
    if matches!(package.source, PackageSource::Root)
        && !package.emits_runtime_outputs()
        && !has_entries
    {
        return Ok(None);
    }

    if !has_entries && !backend.package_has_installable_content(package)? {
        return Ok(None);
    }

    Ok(Some(virtual_plugin_install_root(
        backend.adapter(),
        project_root,
        package,
        package_identities,
    )))
}

fn package_selects_virtual_plugins(package: &ResolvedPackage) -> bool {
    matches!(package.source, PackageSource::Root)
        || (package.emits_runtime_outputs() && package.selected_components.is_none())
}

fn sort_entries(entries: &mut [VirtualPluginEntry]) {
    entries.sort_by(|left, right| {
        left.loader_path
            .cmp(&right.loader_path)
            .then(left.entry_path.cmp(&right.entry_path))
    });
}

pub(crate) fn virtual_plugin_install_root_relative(
    adapter: Adapter,
    package: &ResolvedPackage,
) -> PathBuf {
    virtual_plugin_install_root_relative_for_alias(adapter, &package.alias)
}

pub(crate) fn virtual_plugin_install_root(
    adapter: Adapter,
    project_root: &Path,
    package: &ResolvedPackage,
    package_identities: &ManagedPackageIdentities,
) -> PathBuf {
    if matches!(package.source, PackageSource::Root) {
        return virtual_plugin_install_root_relative(adapter, package);
    }
    let surface =
        virtual_plugin_surface(adapter).expect("virtual plugin install roots require a surface");
    global_nodus_home(project_root)
        .join("packages")
        .join(package_identities.managed_package_id(package))
        .join(surface.install_root_name)
}

pub(crate) fn virtual_plugin_install_root_relative_for_alias(
    adapter: Adapter,
    package_alias: &str,
) -> PathBuf {
    let surface =
        virtual_plugin_surface(adapter).expect("virtual plugin install roots require a surface");
    PathBuf::from(".nodus")
        .join("packages")
        .join(package_alias)
        .join(surface.install_root_name)
}

pub(crate) fn emit_virtual_plugin_files(
    project_root: &Path,
    backend: &dyn VirtualPluginBackend,
    plugin_packages: &[(&ResolvedPackage, &Path)],
    package_identities: &ManagedPackageIdentities,
) -> Result<Vec<ManagedFile>> {
    let mut files = Vec::new();

    for (package, snapshot_root) in plugin_packages {
        let Some(install_root) = virtual_plugin_install_root_for_package(
            backend,
            project_root,
            package,
            package_identities,
        )?
        else {
            continue;
        };

        files.extend(copy_package_files(
            if install_root.is_absolute() {
                install_root.clone()
            } else {
                project_root.join(&install_root)
            },
            package,
            snapshot_root,
        )?);

        let entries =
            virtual_plugin_entries_for_package(backend, project_root, package, package_identities)?;
        for entry in entries {
            files.push(ManagedFile {
                path: project_root.join(&entry.loader_path),
                contents: backend.loader_contents(package, &entry),
            });
        }
    }

    files.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(files)
}

fn copy_package_files(
    target_root: impl AsRef<Path>,
    package: &ResolvedPackage,
    source_root: impl AsRef<Path>,
) -> Result<Vec<ManagedFile>> {
    let target_root = target_root.as_ref();
    let source_root = source_root.as_ref();
    let mut files = Vec::new();

    for path in package.package_files()? {
        let relative = strip_path_prefix(&path, &package.manifest.root)
            .with_context(|| format!("failed to make {} relative", path.display()))?;
        files.push(copy_file(
            target_root.join(relative),
            source_root.join(relative),
        )?);
    }

    files.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(files)
}

fn copy_file(target_path: impl AsRef<Path>, source_path: impl AsRef<Path>) -> Result<ManagedFile> {
    let target_path = target_path.as_ref();
    let source_path = source_path.as_ref();
    Ok(ManagedFile {
        path: target_path.to_path_buf(),
        contents: fs::read(source_path)
            .with_context(|| format!("failed to read snapshot file {}", source_path.display()))?,
    })
}

pub(crate) fn display_path_js(path: &Path) -> String {
    display_path(path).replace('\\', "/")
}