nodus 0.6.0

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

#[cfg(not(target_os = "windows"))]
use anyhow::Context;
use anyhow::Result;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallScope {
    Project,
    Global,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallPaths {
    pub scope: InstallScope,
    pub config_root: PathBuf,
    pub runtime_root: PathBuf,
    pub adapter_detection_root: PathBuf,
}

impl InstallPaths {
    pub fn project(root: &Path) -> Self {
        let root = root.to_path_buf();
        Self {
            scope: InstallScope::Project,
            config_root: root.clone(),
            runtime_root: root.clone(),
            adapter_detection_root: root,
        }
    }

    pub fn global(store_root: &Path) -> Result<Self> {
        let home = resolve_home_dir()?;
        Ok(Self::new(
            InstallScope::Global,
            store_root.join("global"),
            home.clone(),
            home,
        ))
    }

    pub fn new(
        scope: InstallScope,
        config_root: PathBuf,
        runtime_root: PathBuf,
        adapter_detection_root: PathBuf,
    ) -> Self {
        Self {
            scope,
            config_root,
            runtime_root,
            adapter_detection_root,
        }
    }

    pub const fn is_global(&self) -> bool {
        matches!(self.scope, InstallScope::Global)
    }
}

fn resolve_home_dir() -> Result<PathBuf> {
    #[cfg(target_os = "windows")]
    {
        if let Some(profile) = env::var_os("USERPROFILE") {
            return Ok(PathBuf::from(profile));
        }
        if let (Some(drive), Some(path)) = (env::var_os("HOMEDRIVE"), env::var_os("HOMEPATH")) {
            return Ok(PathBuf::from(drive).join(path));
        }
        anyhow::bail!("failed to determine the home directory for global installs");
    }

    #[cfg(not(target_os = "windows"))]
    {
        env::var_os("HOME")
            .map(PathBuf::from)
            .context("failed to determine the home directory for global installs")
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn project_scope_reuses_the_same_root_for_all_paths() {
        let root = Path::new("/tmp/project");
        let paths = InstallPaths::project(root);

        assert_eq!(paths.scope, InstallScope::Project);
        assert_eq!(paths.config_root, root);
        assert_eq!(paths.runtime_root, root);
        assert_eq!(paths.adapter_detection_root, root);
    }

    #[test]
    fn global_scope_uses_store_root_for_config_and_home_for_runtime() {
        let store_root = Path::new("/tmp/nodus-store");
        let home = PathBuf::from("/tmp/home");
        let paths = InstallPaths::new(
            InstallScope::Global,
            store_root.join("global"),
            home.clone(),
            home.clone(),
        );

        assert_eq!(paths.scope, InstallScope::Global);
        assert_eq!(paths.config_root, PathBuf::from("/tmp/nodus-store/global"));
        assert_eq!(paths.runtime_root, home);
        assert_eq!(paths.adapter_detection_root, PathBuf::from("/tmp/home"));
    }
}