Skip to main content

crates/aube/src/embedded.rs

use crate::commands;
use crate::commands::install::{
    DepSelection, FrozenMode, FrozenOverride, GlobalVirtualStoreFlags, InstallOptions,
};
use miette::{Context, IntoDiagnostic, miette};
use std::ffi::OsString;
use std::fmt;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Instant;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum FrozenLockfile {
    #[default]
    Auto,
    Frozen,
    No,
    Prefer,
}

impl FrozenLockfile {
    fn override_flag(self) -> Option<FrozenOverride> {
        match self {
            Self::Auto => None,
            Self::Frozen => Some(FrozenOverride::Frozen),
            Self::No => Some(FrozenOverride::No),
            Self::Prefer => Some(FrozenOverride::Prefer),
        }
    }
}

#[derive(Debug, Clone)]
pub struct InstallRequest {
    pub project_dir: PathBuf,
    pub frozen_lockfile: FrozenLockfile,
    pub prod: bool,
    pub dev: bool,
    pub no_optional: bool,
    pub offline: bool,
    pub prefer_offline: bool,
    pub ignore_scripts: bool,
    pub lockfile_only: bool,
    pub force: bool,
    pub node_linker: Option<String>,
    pub registry: Option<String>,
}

impl InstallRequest {
    pub fn new(project_dir: impl Into<PathBuf>) -> Self {
        Self {
            project_dir: project_dir.into(),
            frozen_lockfile: FrozenLockfile::Auto,
            prod: false,
            dev: false,
            no_optional: false,
            offline: false,
            prefer_offline: false,
            ignore_scripts: false,
            lockfile_only: false,
            force: false,
            node_linker: None,
            registry: None,
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallOutcome {
    pub project_dir: PathBuf,
    pub duration_ms: u64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallError {
    pub message: String,
    pub code: Option<String>,
}

impl InstallError {
    fn from_report(report: miette::Report) -> Self {
        let code = report.code().map(|code| code.to_string());
        Self {
            message: report.to_string(),
            code,
        }
    }
}

impl fmt::Display for InstallError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message)
    }
}

impl std::error::Error for InstallError {}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunOutcome {
    pub exit_code: i32,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunError {
    pub message: String,
    pub code: Option<String>,
    pub exit_code: i32,
}

pub fn run(args: Vec<String>) -> Result<RunOutcome, RunError> {
    let handle = std::thread::Builder::new()
        .name("aube-embedded-cli".to_string())
        .stack_size(16 * 1024 * 1024)
        .spawn(move || run_on_cli_thread(args))
        .map_err(|err| RunError {
            message: format!("failed to start embedded aube CLI thread: {err}"),
            code: Some("embedded_thread_start".to_string()),
            exit_code: 1,
        })?;

    match handle.join() {
        Ok(result) => result,
        Err(_) => Err(RunError {
            message: "embedded aube CLI thread panicked".to_string(),
            code: Some("embedded_thread_panic".to_string()),
            exit_code: 1,
        }),
    }
}

fn run_on_cli_thread(args: Vec<String>) -> Result<RunOutcome, RunError> {
    let previous_cwd = std::env::current_dir().ok();
    let argv = std::iter::once(OsString::from("aube"))
        .chain(args.into_iter().map(OsString::from))
        .collect();

    let result = crate::run_cli_embedded(argv);
    crate::flush_cli_diagnostics();
    if let Some(cwd) = previous_cwd {
        let _ = std::env::set_current_dir(cwd);
        crate::dirs::reset_cwd();
    }

    match result {
        Ok(Some(exit_code)) => Ok(RunOutcome { exit_code }),
        Ok(None) => Ok(RunOutcome { exit_code: 0 }),
        Err(report) => Err(RunError {
            message: report.to_string(),
            code: report.code().map(|code| code.to_string()),
            exit_code: crate::report_exit_code(&report),
        }),
    }
}

pub async fn install(request: InstallRequest) -> Result<InstallOutcome, InstallError> {
    let started = Instant::now();
    install_inner(request)
        .await
        .map(|project_dir| InstallOutcome {
            project_dir,
            duration_ms: started.elapsed().as_millis().min(u64::MAX as u128) as u64,
        })
        .map_err(InstallError::from_report)
}

async fn install_inner(request: InstallRequest) -> miette::Result<PathBuf> {
    validate_request(&request)?;
    let project_dir = normalize_project_dir(&request.project_dir)?;
    let _install_guard = embedded_install_lock().lock().await;
    let _state_guard = InvocationStateGuard::new(&request);

    let frozen_override = request.frozen_lockfile.override_flag();
    let env = aube_settings::values::capture_env();
    let cli_flags = cli_flag_bag(&request, frozen_override);
    let files = commands::FileSources::load(&project_dir);
    let raw_workspace = aube_manifest::workspace::load_raw(&project_dir)
        .into_diagnostic()
        .wrap_err("failed to load workspace config")?;
    let ctx = files.ctx(&raw_workspace, &env, &cli_flags);
    let yaml_prefer_frozen = aube_settings::resolved::prefer_frozen_lockfile(&ctx);
    let mode = if request.force && frozen_override.is_none() {
        FrozenMode::No
    } else {
        FrozenMode::from_override(frozen_override, yaml_prefer_frozen)
    };

    let mut opts = InstallOptions::with_mode(mode);
    opts.project_dir = Some(project_dir.clone());
    opts.dep_selection = DepSelection::from_flags(request.prod, request.dev, request.no_optional);
    opts.ignore_scripts = request.ignore_scripts;
    opts.lockfile_only = request.lockfile_only;
    opts.force = request.force;
    opts.network_mode = network_mode(&request);
    opts.strict_no_lockfile = matches!(frozen_override, Some(FrozenOverride::Frozen));
    opts.cli_flags = cli_flags;
    opts.env_snapshot = env;
    opts.skip_root_lifecycle = false;

    commands::install::run(opts).await?;
    Ok(project_dir)
}

fn embedded_install_lock() -> &'static tokio::sync::Mutex<()> {
    static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
    LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
}

struct InvocationStateGuard {
    previous_progress_output: clx::progress::ProgressOutput,
}

impl InvocationStateGuard {
    fn new(request: &InstallRequest) -> Self {
        let previous_progress_output = clx::progress::output();
        commands::reset_invocation_state();
        commands::set_registry_override(request.registry.clone());
        commands::set_fetch_cli_overrides(Vec::new());
        commands::set_global_frozen_override(request.frozen_lockfile.override_flag());
        commands::set_global_virtual_store_flags(GlobalVirtualStoreFlags::default());
        commands::set_global_output_flags(commands::GlobalOutputFlags {
            ndjson: false,
            silent: true,
        });
        clx::progress::set_output(clx::progress::ProgressOutput::Text);
        Self {
            previous_progress_output,
        }
    }
}

impl Drop for InvocationStateGuard {
    fn drop(&mut self) {
        commands::reset_invocation_state();
        clx::progress::set_output(self.previous_progress_output);
    }
}

fn validate_request(request: &InstallRequest) -> miette::Result<()> {
    if request.project_dir.as_os_str().is_empty() {
        return Err(miette!("project_dir is required"));
    }
    if request.prod && request.dev {
        return Err(miette!("prod and dev install modes are mutually exclusive"));
    }
    if request.offline && request.prefer_offline {
        return Err(miette!("offline and prefer_offline are mutually exclusive"));
    }
    Ok(())
}

fn normalize_project_dir(project_dir: &PathBuf) -> miette::Result<PathBuf> {
    let expanded = if project_dir.is_absolute() {
        project_dir.clone()
    } else {
        std::env::current_dir()
            .into_diagnostic()
            .wrap_err("failed to read current directory")?
            .join(project_dir)
    };
    let canonical = std::fs::canonicalize(&expanded)
        .into_diagnostic()
        .wrap_err_with(|| format!("failed to resolve project dir {}", expanded.display()))?;
    if !canonical.is_dir() {
        return Err(miette!(
            "project dir is not a directory: {}",
            canonical.display()
        ));
    }
    Ok(canonical)
}

fn cli_flag_bag(
    request: &InstallRequest,
    frozen_override: Option<FrozenOverride>,
) -> Vec<(String, String)> {
    let mut out = Vec::new();
    if let Some(linker) = request.node_linker.as_deref() {
        out.push(("node-linker".to_string(), linker.to_string()));
    }
    if let Some(override_flag) = frozen_override {
        let (key, value) = override_flag.cli_flag_bag_entry();
        out.push((key.to_string(), value.to_string()));
    }
    out
}

fn network_mode(request: &InstallRequest) -> aube_registry::NetworkMode {
    if request.offline {
        aube_registry::NetworkMode::Offline
    } else if request.prefer_offline {
        aube_registry::NetworkMode::PreferOffline
    } else {
        aube_registry::NetworkMode::Online
    }
}

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

    fn write_package_json(dir: &std::path::Path, body: &str) {
        std::fs::write(dir.join("package.json"), body).expect("package.json should be written");
    }

    #[tokio::test]
    async fn installs_empty_project() {
        let tmp = tempfile::tempdir().expect("temp dir should be created");
        write_package_json(tmp.path(), r#"{"name":"fixture","version":"1.0.0"}"#);

        let outcome = install(InstallRequest::new(tmp.path()))
            .await
            .expect("empty project should install");

        assert_eq!(outcome.project_dir, tmp.path().canonicalize().unwrap());
        assert!(outcome.duration_ms < u64::MAX);
    }

    #[tokio::test]
    async fn repeated_installs_in_one_process_work() {
        let tmp = tempfile::tempdir().expect("temp dir should be created");
        write_package_json(tmp.path(), r#"{"name":"fixture","version":"1.0.0"}"#);

        install(InstallRequest::new(tmp.path())).await.unwrap();
        install(InstallRequest::new(tmp.path())).await.unwrap();
    }

    #[tokio::test]
    async fn different_project_dirs_work_sequentially() {
        let first = tempfile::tempdir().expect("temp dir should be created");
        let second = tempfile::tempdir().expect("temp dir should be created");
        write_package_json(first.path(), r#"{"name":"first","version":"1.0.0"}"#);
        write_package_json(second.path(), r#"{"name":"second","version":"1.0.0"}"#);

        install(InstallRequest::new(first.path())).await.unwrap();
        install(InstallRequest::new(second.path())).await.unwrap();
    }

    #[tokio::test]
    async fn frozen_lockfile_drift_returns_error() {
        let tmp = tempfile::tempdir().expect("temp dir should be created");
        write_package_json(
            tmp.path(),
            r#"{"name":"fixture","version":"1.0.0","dependencies":{"left-pad":"1.3.0"}}"#,
        );
        std::fs::write(
            tmp.path().join("aube-lock.yaml"),
            r#"
lockfileVersion: '9.0'

settings:
  autoInstallPeers: true
  excludeLinksFromLockfile: false

importers:

  .:
    dependencies:
      left-pad:
        specifier: 1.1.3
        version: 1.1.3

packages:

  left-pad@1.1.3:
    resolution: {integrity: sha512-m3z9QHpSXmd2H8Z5jnSXbGONPty4dFQfH1QpGgivzrEzICgsi50j9S+aGc77EaLoHpbw0BzP5+k1pp2UajTRuw==}
    deprecated: use String.prototype.padStart()

snapshots:

  left-pad@1.1.3: {}
"#,
        )
        .expect("lockfile should be written");
        let mut request = InstallRequest::new(tmp.path());
        request.frozen_lockfile = FrozenLockfile::Frozen;

        let err = install(request).await.expect_err("drift should error");
        assert!(err.message.contains("lockfile is out of date"));
    }
}