shell-cell 1.6.2

Shell-Cell. CLI app to spawn and manage containerized shell environments
pub mod errors;
#[cfg(test)]
mod tests;

use std::{
    collections::HashSet,
    path::{Path, PathBuf},
};

use color_eyre::eyre::{Context, ContextCompat};

use crate::{
    error::{OptionUserError, Report, UserError, WrapUserError},
    scell::{
        Link, SCell, SCellContainer,
        compile::errors::{
            CircularTargets, CopySrcNotFound, DirNotFoundFromStmt, DockerfileNotFound,
            FileLoadFromStmt, MissingEntrypoint, MissingHangStmt, MissingShellStmt, MissingTarget,
            MountHostDirNotFound,
        },
        image::SCellImage,
        link::RootNode,
        service::Service,
        types::{
            SCellFile,
            extra_arguments::SCellExtraArguments,
            name::TargetName,
            target::{
                TargetStmt,
                config::ConfigStmt,
                copy::CopyStmt,
                from::{FromStmt, target_ref::TargetRef},
                hang::HangStmt,
                services::{ServiceName, ServicesStmt},
                shell::ShellStmt,
            },
        },
    },
    scell_home_dir,
};

const SCELL_DEFAULT_ENTRY_POINT: &str = "main";

type CompiledTarget = (
    Vec<Link>,
    Option<ShellStmt>,
    Option<HangStmt>,
    Option<ConfigStmt>,
    Vec<(ServiceName, Service)>,
);

impl SCell {
    /// Process the provided `SCellFile` file recursively, to build a proper chain of
    /// links for the Shell-Cell definition.
    pub fn compile<P: AsRef<Path>>(
        path: P,
        entry: Option<TargetName>,
    ) -> color_eyre::Result<Self> {
        let scell_extra_args = SCellExtraArguments::from_path(&path)?;
        let mut scell_f = SCellFile::from_path(path, &scell_extra_args)?;
        let entry_point_target = entry.map_or_else(
            || {
                SCELL_DEFAULT_ENTRY_POINT.parse().context(format!(
                    "'{SCELL_DEFAULT_ENTRY_POINT}' must be a valid Shell-Cell name"
                ))
            },
            Ok,
        )?;

        let entry_point =
            scell_f
                .targets
                .remove(&entry_point_target)
                .user_err(MissingEntrypoint(
                    scell_f.location.clone(),
                    entry_point_target.clone(),
                ))?;

        let (links, shell, hang, config, services) =
            compile_target(scell_f, entry_point, entry_point_target)?;

        let mut report = Report::new();
        if shell.is_none() {
            report.add_error(UserError::wrap(MissingShellStmt));
        }
        if hang.is_none() {
            report.add_error(UserError::wrap(MissingHangStmt));
        }
        report.check()?;

        color_eyre::eyre::ensure!(
            links.len() >= 2,
            "It must be at least two links in the target chain"
        );

        let image = SCellImage::new(links, hang.context("'hang' cannot be 'None'")?)?;
        let container = SCellContainer::new(config);
        Ok(Self {
            image,
            container,
            shell: shell.context("'shell' cannot be 'None'")?,
            services,
        })
    }
}

fn compile_target(
    mut walk_f: SCellFile,
    mut walk_target: TargetStmt,
    mut walk_target_name: TargetName,
) -> color_eyre::Result<CompiledTarget> {
    // Store processed target's name and location, to detect circular target dependencies
    let mut visited_targets = HashSet::new();
    let mut links = Vec::new();
    let mut shell = None;
    let mut hang = None;
    let mut config = None;

    // TODO: avoid recursion
    let services = resolve_services(walk_target.services, &walk_f)?;

    loop {
        // Use only the most recent 'shell` and 'hang' statements from the targets chain.
        if shell.is_none() {
            shell = walk_target.shell;
        }
        if hang.is_none() {
            hang = walk_target.hang;
        }
        if config.is_none() {
            config = resolve_config(&walk_f.location, &walk_target_name, walk_target.config)?;
        }
        let copy = resolve_copy(
            &walk_f.location,
            &walk_target_name,
            walk_target.copy.clone(),
        )?;
        links.push(Link::Node {
            name: walk_target_name.clone(),
            location: walk_f.location.clone(),
            workspace: walk_target.workspace.clone(),
            copy,
            build: walk_target.build.clone(),
            env: walk_target.env.clone(),
        });

        match walk_target.from {
            FromStmt::Image(docker_image_def) => {
                links.push(Link::Root(RootNode::Image(docker_image_def)));
                break;
            },
            FromStmt::Docker(docker_file_path) => {
                let docker_path = resolve_path(&walk_f.location, &docker_file_path).user_err(
                    DockerfileNotFound(
                        docker_file_path,
                        walk_target_name.clone(),
                        walk_f.location.clone(),
                    ),
                )?;
                links.push(Link::Root(RootNode::Dockerfile(docker_path)));
                break;
            },
            FromStmt::Target(TargetRef { location, name }) => {
                if let Some(location) = location {
                    let location = resolve_path(&walk_f.location, &location).user_err(
                        DirNotFoundFromStmt(location, name.clone(), walk_f.location.clone()),
                    )?;
                    walk_f = SCellFile::from_path(&location, &SCellExtraArguments::new_emtpy())
                        .wrap_user_err(FileLoadFromStmt(
                            location.clone(),
                            name.clone(),
                            walk_f.location.clone(),
                        ))?;
                }

                if visited_targets.contains(&(name.clone(), walk_f.location.clone())) {
                    return UserError::bail(CircularTargets(name.clone(), walk_f.location))?;
                }

                walk_target = walk_f
                    .targets
                    .get(&name)
                    .user_err(MissingTarget(name.clone(), walk_f.location.clone()))?
                    .clone();
                walk_target_name = name;

                visited_targets.insert((walk_target_name.clone(), walk_f.location.clone()));
            },
        }
    }

    Ok((links, shell, hang, config, services))
}

fn resolve_config(
    location: &Path,
    target_name: &TargetName,
    config: Option<ConfigStmt>,
) -> color_eyre::Result<Option<ConfigStmt>> {
    config
        .map(|mut c| {
            // resolve mounts
            c.mounts.0 = c
                .mounts
                .0
                .into_iter()
                .map(|mut m| {
                    m.host = resolve_path(location, &m.host).user_err(MountHostDirNotFound(
                        m.host,
                        target_name.clone(),
                        location.to_path_buf(),
                    ))?;
                    color_eyre::eyre::Ok(m)
                })
                .collect::<Result<_, _>>()?;

            color_eyre::eyre::Ok(c)
        })
        .transpose()
}

fn resolve_services(
    services: ServicesStmt,
    f: &SCellFile,
) -> color_eyre::Result<Vec<(ServiceName, Service)>> {
    let mut res = Vec::new();
    for (s_name, s) in services.0 {
        let (links, _shell, hang, config, services) = compile_target(f.clone(), s, s_name.clone())?;
        color_eyre::eyre::ensure!(
            links.len() >= 2,
            "It must be at least two links in the target chain"
        );
        let image = SCellImage::new(links, hang.ok_or(MissingHangStmt).mark_as_user_err()?)?;
        let container = SCellContainer::new(config);
        res.push((s_name, Service { image, container }));
        res.extend(services);
    }
    Ok(res)
}

/// **source paths** and are joined with the target `location` to create absolute or
/// relative paths rooted in the build context.
///
/// It is important for the image tar archive builder process, during which it is assumed
/// that all **source paths** are absolute, exists, and properly resolved related to the
/// target `location`
fn resolve_copy(
    location: &Path,
    target_name: &TargetName,
    mut copy: CopyStmt,
) -> color_eyre::Result<CopyStmt> {
    let mut report = Report::new();
    for e in &mut copy.0 {
        for src_item in &mut e.src {
            if let Ok(new_src) = resolve_path(location, src_item) {
                *src_item = new_src;
            } else {
                report.add_error(UserError::wrap(CopySrcNotFound(
                    src_item.clone(),
                    target_name.clone(),
                    location.to_path_buf(),
                )));
            }
        }
    }
    report.check()?;
    Ok(copy)
}

fn resolve_path(
    ctx: &Path,
    path: &Path,
) -> color_eyre::Result<PathBuf> {
    Ok(std::fs::canonicalize(ctx.join(path))?)
}

#[allow(dead_code)]
fn global() -> color_eyre::Result<Option<SCellFile>> {
    const SCELL_GLOBAL: &str = "global.yml";
    let scell_home = scell_home_dir()?;
    SCellFile::from_path(
        scell_home.join(SCELL_GLOBAL),
        &SCellExtraArguments::new_emtpy(),
    )
    .map(Some)
    .or_else(|e| {
        let io_e = e.downcast::<std::io::Error>()?;
        if io_e.kind() == std::io::ErrorKind::NotFound {
            Ok(None)
        } else {
            Err(io_e.into())
        }
    })
}