a4-cli 0.12.0

CLI tool for generating TypeScript SDKs from Arete stream specifications
use std::path::{Component, Path, PathBuf};

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

#[derive(Debug, Clone)]
pub struct ProjectPaths {
    root: PathBuf,
    manifest_allows_outside: bool,
    cli_allows_outside: bool,
}

impl ProjectPaths {
    pub fn new(
        root: impl AsRef<Path>,
        manifest_allows_outside: bool,
        cli_allows_outside: bool,
    ) -> Result<Self> {
        let root = std::fs::canonicalize(root.as_ref()).with_context(|| {
            format!("Failed to resolve project root {}", root.as_ref().display())
        })?;
        Ok(Self {
            root,
            manifest_allows_outside,
            cli_allows_outside,
        })
    }

    pub fn input(&self, relative: &str, kind: &str) -> Result<PathBuf> {
        let joined = self.join_manifest_relative(relative, kind)?;
        let canonical = std::fs::canonicalize(&joined)
            .with_context(|| format!("Failed to resolve {kind} {}", joined.display()))?;
        if !canonical.starts_with(&self.root) {
            bail!("{kind} {} escapes the project root", joined.display());
        }
        Ok(canonical)
    }

    pub fn input_directory(&self, relative: &str, kind: &str) -> Result<PathBuf> {
        let canonical = self.input(relative, kind)?;
        if !canonical.is_dir() {
            bail!("{kind} {} is not a directory", canonical.display());
        }
        Ok(canonical)
    }

    pub fn output(&self, configured: &str, description: &str) -> Result<PathBuf> {
        if configured.trim().is_empty() {
            bail!("{description} cannot be empty");
        }
        let supplied = Path::new(configured);
        let absolute = if supplied.is_absolute() {
            normalize_absolute(supplied)?
        } else {
            normalize_absolute(&self.root.join(supplied))?
        };
        if !absolute.starts_with(&self.root)
            && !(self.manifest_allows_outside && self.cli_allows_outside)
        {
            if self.manifest_allows_outside {
                bail!(
                    "{description} {} is outside the project; repeat with --allow-outside-project",
                    absolute.display()
                );
            }
            bail!(
                "{description} {} is outside the project; set install.allow_outside_project = true and repeat with --allow-outside-project",
                absolute.display()
            );
        }
        Ok(absolute)
    }

    fn join_manifest_relative(&self, relative: &str, kind: &str) -> Result<PathBuf> {
        let supplied = Path::new(relative);
        if relative.trim().is_empty() || supplied.is_absolute() {
            bail!("{kind} must be a non-empty manifest-relative path");
        }
        if supplied
            .components()
            .any(|component| matches!(component, Component::ParentDir))
        {
            bail!("{kind} cannot contain parent traversal: {relative}");
        }
        normalize_absolute(&self.root.join(supplied))
    }
}

pub fn normalize_absolute(path: &Path) -> Result<PathBuf> {
    if !path.is_absolute() {
        bail!("path {} is not absolute", path.display());
    }
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
            Component::RootDir => normalized.push(component.as_os_str()),
            Component::CurDir => {}
            Component::Normal(segment) => normalized.push(segment),
            Component::ParentDir => {
                if !normalized.pop() {
                    bail!(
                        "path {} traverses above its filesystem root",
                        path.display()
                    );
                }
            }
        }
    }
    Ok(normalized)
}

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

    #[test]
    fn outside_outputs_require_both_manifest_and_cli_consent() {
        let root = std::env::temp_dir().join(format!("arete-paths-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&root).unwrap();
        let outside = root.parent().unwrap().join("outside-sdk");

        let manifest_only = ProjectPaths::new(&root, true, false).unwrap();
        assert!(manifest_only
            .output(outside.to_str().unwrap(), "SDK output")
            .unwrap_err()
            .to_string()
            .contains("--allow-outside-project"));

        let both = ProjectPaths::new(&root, true, true).unwrap();
        assert_eq!(
            both.output(outside.to_str().unwrap(), "SDK output")
                .unwrap(),
            outside
        );
        std::fs::remove_dir_all(root).unwrap();
    }
}