rialoman 0.2.0

Rialo native toolchain manager
Documentation
//! Resolve and manage the filesystem layout for Rialo releases and toolchains.
// Copyright (c) Subzero Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

use std::{env, fs, path::PathBuf};

use anyhow::{anyhow, Context, Result};

use crate::Channel;

const RIALO_HOME_ENV: &str = "RIALO_HOME";

#[derive(Debug, Clone)]
pub struct RialoDirs {
    home: PathBuf,
    /// Binary releases organized by channel/version (e.g., releases/stable/0.1.9/)
    releases: PathBuf,
    /// Compiler toolchains (e.g., toolchains/rialo-rust-0.3.0/)
    toolchains: PathBuf,
    bin: PathBuf,
    downloads: PathBuf,
    tmp: PathBuf,
    current_file: PathBuf,
}

impl RialoDirs {
    /// Construct directory layout, honoring explicit overrides and XDG defaults.
    pub fn new(explicit_home: Option<&PathBuf>) -> Result<Self> {
        let home = match explicit_home {
            Some(path) => path.clone(),
            None => resolve_rialo_home_dir()?,
        };

        // Ensure path is absolute for stable symlink targets, but avoid
        // canonicalizing to preserve the user's logical directory structure
        // (e.g. symlinked $HOME).
        let home = if home.is_absolute() {
            home
        } else {
            std::env::current_dir()
                .with_context(|| "failed to get current working directory")?
                .join(home)
        };

        Ok(Self {
            releases: home.join("releases"),
            toolchains: home.join("toolchains"),
            bin: home.join("bin"),
            downloads: home.join("downloads"),
            tmp: home.join("tmp"),
            current_file: home.join("current.json"),
            home,
        })
    }

    pub fn home(&self) -> &PathBuf {
        &self.home
    }

    /// Binary releases organized by channel/version (e.g., releases/stable/0.1.9/)
    pub fn releases(&self) -> &PathBuf {
        &self.releases
    }

    /// Compiler toolchains (e.g., toolchains/rialo-rust-0.3.0/)
    pub fn toolchains(&self) -> &PathBuf {
        &self.toolchains
    }

    pub fn bin(&self) -> &PathBuf {
        &self.bin
    }

    pub fn tmp(&self) -> &PathBuf {
        &self.tmp
    }

    pub fn downloads(&self) -> &PathBuf {
        &self.downloads
    }

    pub fn current_file(&self) -> &PathBuf {
        &self.current_file
    }

    /// Ensure the base layout exists on disk.
    ///
    /// This also handles migration from the old directory structure where
    /// `toolchains/` contained binary releases instead of compiler toolchains.
    pub fn ensure_layout(&self) -> Result<()> {
        // Check for old directory structure and migrate if needed
        self.migrate_from_old_layout()?;

        fs::create_dir_all(&self.releases).with_context(|| {
            format!(
                "failed to create releases dir at {}",
                self.releases.display()
            )
        })?;
        fs::create_dir_all(&self.toolchains).with_context(|| {
            format!(
                "failed to create toolchains dir at {}",
                self.toolchains.display()
            )
        })?;
        fs::create_dir_all(&self.bin)
            .with_context(|| format!("failed to create bin dir at {}", self.bin.display()))?;
        fs::create_dir_all(&self.downloads).with_context(|| {
            format!(
                "failed to create downloads dir at {}",
                self.downloads.display()
            )
        })?;
        self.write_cache_tag()?;
        fs::create_dir_all(&self.tmp)
            .with_context(|| format!("failed to create tmp dir at {}", self.tmp.display()))?;
        Ok(())
    }

    /// Migrate from old directory layout where toolchains/ contained releases.
    ///
    /// Old structure:
    /// ```text
    /// toolchains/
    /// ├── stable/0.1.9/
    /// └── commit/abc123/
    /// ```
    ///
    /// New structure:
    /// ```text
    /// releases/
    /// ├── stable/0.1.9/
    /// └── commit/abc123/
    /// toolchains/
    /// ```
    fn migrate_from_old_layout(&self) -> Result<()> {
        let old_toolchains = self.home.join("toolchains");

        // Skip if no old structure exists (toolchains/{stable,nightly,commit}/)
        let channels = Channel::KNOWN;
        if channels
            .iter()
            .all(|c| !old_toolchains.join(c.as_str()).exists())
        {
            return Ok(());
        }

        // Make sure releases/ exists
        match fs::create_dir_all(&self.releases) {
            Ok(..) => eprintln!("Created {}", self.releases.display()),
            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
            err => err.with_context(|| format!("failed to create {}", self.releases.display()))?,
        }

        // Move {toolchains => releases}/{stable,nightly,commit}/
        eprintln!("Migrating from old directory layout...");

        for channel in Channel::KNOWN {
            let src = old_toolchains.join(channel.as_str());
            let dst = self.releases.join(channel.as_str());

            match fs::rename(&src, &dst) {
                Ok(..) => eprintln!("Renamed {} -> {}", src.display(), dst.display()),
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
                err => err.with_context(|| {
                    format!("failed to rename {} to {}", src.display(), dst.display())
                })?,
            }
        }

        eprintln!("Migration complete!");
        Ok(())
    }

    fn write_cache_tag(&self) -> Result<()> {
        let tag_path = self.downloads.join("CACHEDIR.TAG");
        if tag_path.exists() {
            return Ok(());
        }

        // https://bford.info/cachedir/
        const CONTENT: &str =
            "Signature: 8a477f597d28d172789f06886806bc55\n# rialoman download cache\n";
        fs::write(&tag_path, CONTENT)
            .with_context(|| format!("failed to write {}", tag_path.display()))
    }
}

fn resolve_rialo_home_dir() -> Result<PathBuf> {
    if let Some(env_home) = env::var_os(RIALO_HOME_ENV) {
        return Ok(PathBuf::from(env_home));
    }

    if let Some(xdg_data_home) = env::var_os("XDG_DATA_HOME") {
        let mut buf = PathBuf::from(xdg_data_home);
        buf.push("rialo");
        return Ok(buf);
    }

    let mut buf = dirs::home_dir().ok_or_else(|| anyhow!("failed to determine home directory"))?;
    buf.extend([".local", "share", "rialo"]);

    Ok(buf)
}