flyboat 2.0.0

Container environment manager for development
Documentation
use crate::config::{EnvConfig, Paths};
use crate::is_valid_name;
use crate::{Error, Result};
use colored::Colorize;
use indexmap::IndexMap;
use indexmap::map::Entry;
use std::path::{Path, PathBuf};
use std::rc::Rc;

/// Delimiter used to separate namespace segments (e.g., "my_collection/rust")
pub const NAMESPACE_DELIMITER: &str = "/";

/// Discovered environment
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Environment {
    /// Short name (folder name, e.g., "rust")
    pub name: String,
    /// Namespace segments (e.g., ["my_collection"] or ["my_collection", "web"]), empty for root-level envs
    pub namespace: Vec<String>,
    /// Full absolute canonical path to env folder
    pub path: PathBuf,
    pub config: EnvConfig,
}

impl Environment {
    /// Get the full qualified name (e.g., "my_collection/rust")
    /// For root-level envs, returns just the name
    pub fn full_name(&self) -> String {
        if self.namespace.is_empty() {
            self.name.clone()
        } else {
            format!(
                "{}{}{}",
                self.namespace.join(NAMESPACE_DELIMITER),
                NAMESPACE_DELIMITER,
                self.name
            )
        }
    }

    /// Get the relative path of the env (e.g., "my_collection/rust")
    /// For root-level envs, returns just the name
    pub fn env_path(&self) -> String {
        if self.namespace.is_empty() {
            self.name.clone()
        } else {
            format!("{}{}{}", self.namespace.join("/"), "/", self.name)
        }
    }
}

/// Environment discovery and management
pub struct EnvironmentManager {
    paths: Paths,
    environments: Vec<Rc<Environment>>,
    references: IndexMap<String, Vec<Rc<Environment>>>,
}

pub enum EnvironmentSearch {
    NoFound,
    SingleMatch(Rc<Environment>),
    MultiMatch(Vec<Rc<Environment>>),
    FuzzyMatch(Vec<(String, Vec<Rc<Environment>>)>),
}

impl EnvironmentManager {
    /// Create a new environment manager and discover environments
    pub fn new() -> Result<Self> {
        let paths = Paths::new()?;
        Self::with_paths(paths)
    }

    /// Create environment manager with custom paths
    pub fn with_paths(paths: Paths) -> Result<Self> {
        let mut manager = Self {
            paths,
            environments: Vec::new(),
            references: IndexMap::new(),
        };
        manager.discover()?;
        Ok(manager)
    }

    /// Discover all environments in ~/.flyboat/env/ recursively
    fn discover(&mut self) -> Result<()> {
        let env_dir = self.paths.env_dir();

        if !env_dir.exists() {
            return Ok(());
        }

        self.discover_recursive(&env_dir, &env_dir)?;
        Ok(())
    }

    /// Recursively discover environments starting from a directory
    ///
    /// `current_dir` is the actual path within base_dir (may contain symlinks)
    /// `base_dir` is the root env directory for calculating namespaces
    fn discover_recursive(&mut self, current_dir: &Path, base_dir: &Path) -> Result<()> {
        let entries = std::fs::read_dir(current_dir).map_err(|e| Error::ReadFile {
            path: current_dir.to_path_buf(),
            source: e,
        })?;

        for entry in entries {
            let entry = entry?;
            let path = entry.path();
            if !path.is_dir() {
                continue;
            }

            // Get the folder name from the original path (not canonicalized)
            // This preserves symlink names for namespace calculation
            let name = match path.file_name().and_then(|n| n.to_str()) {
                Some(n) => n.to_string(),
                None => continue,
            };

            // Canonicalize for actual file operations (resolves symlinks)
            let canonical_path = match path.canonicalize() {
                Ok(p) if p.is_dir() => p,
                _ => continue,
            };

            // Check for required files using canonical path
            let dockerfile = canonical_path.join("Dockerfile");
            let dev_env_yaml = canonical_path.join("dev_env.yaml");

            if dockerfile.exists() && dev_env_yaml.exists() {
                // Use original path for namespace calculation, canonical_path for storage
                self.register_environment(&path, &canonical_path, base_dir, &name, &dev_env_yaml);
            }

            // Recurse using original path to preserve namespace structure
            self.discover_recursive(&path, base_dir)?;
        }

        Ok(())
    }

    /// Register an environment with its namespace
    ///
    /// `original_path` - path within base_dir (used for namespace calculation)
    /// `canonical_path` - resolved real path (stored in Environment.path)
    fn register_environment(
        &mut self,
        original_path: &Path,
        canonical_path: &Path,
        base_dir: &Path,
        name: &str,
        dev_env_yaml: &Path,
    ) {
        // Calculate namespace from original path relative to base_dir
        // This preserves symlink names in the namespace
        let relative_path = original_path
            .strip_prefix(base_dir)
            .unwrap_or(original_path);

        // Build namespace as Vec<String> from parent path components
        let namespace: Vec<String> = relative_path
            .parent()
            .map(|p| {
                p.components()
                    .filter_map(|c| c.as_os_str().to_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default();

        // Validate environment name (folder name)
        if !is_valid_name(name) {
            eprintln!(
                "Error: Invalid environment name '{}' in {}: names must contain only alphanumeric characters, '-', or '_'",
                name,
                original_path.display()
            );
            return;
        }

        // Validate namespace parts
        for part in &namespace {
            if !is_valid_name(part) {
                eprintln!(
                    "Error: Invalid namespace '{}' in {}: names must contain only alphanumeric characters, '-', or '_'",
                    part,
                    original_path.display()
                );
                return;
            }
        }

        // Load config
        match EnvConfig::load(dev_env_yaml) {
            Ok(config) => {
                // Skip disabled environments
                if config.disable {
                    return;
                }

                // Validate aliases
                for alias in &config.aliases {
                    if !is_valid_name(alias) {
                        eprintln!(
                            "Error: Invalid alias '{}' in {}: aliases must contain only alphanumeric characters, '-', or '_'",
                            alias,
                            dev_env_yaml.display()
                        );
                        return;
                    }
                }

                let environment = Environment {
                    name: name.to_string(),
                    namespace,
                    path: canonical_path.to_path_buf(),
                    config,
                };

                // Add environment
                let rc_env = Rc::new(environment);
                self.environments.push(rc_env.clone());

                // Register name and full name
                self.add_env_references(rc_env.clone(), rc_env.name.clone());
                self.add_env_references(rc_env.clone(), rc_env.full_name());

                // Register aliases
                for alias in &rc_env.config.aliases {
                    self.add_env_references(rc_env.clone(), alias.clone());
                }
            }
            Err(e) => {
                eprintln!("Warning: Failed to load {}: {}", dev_env_yaml.display(), e);
            }
        }
    }

    fn add_env_references(&mut self, env: Rc<Environment>, reference: String) {
        match self.references.entry(reference) {
            Entry::Occupied(mut entry) => {
                let entry_list = entry.get_mut();
                if entry_list.contains(&env) {
                    // already exists in list, no need to enter again
                    return;
                }
                eprintln!(
                    "{}: Reference Already Exists '{}'",
                    "Note".blue(),
                    entry.key()
                );
                entry.get_mut().push(env);
            }
            Entry::Vacant(vacant) => {
                vacant.insert_entry(vec![env]);
            }
        }
    }

    /// Get environment by name or alias
    ///
    /// Resolution order:
    /// 1. Exact match on full_name (e.g., "my_collection/rust")
    /// 2. Exact match on alias
    /// 3. Unique match on short name (e.g., "rust" if only one exists)
    ///
    /// Returns None if not found. Use `get_or_suggest` for error handling with ambiguity.
    pub fn get(&self, name: &str) -> EnvironmentSearch {
        if let Some(list) = self.references.get(name) {
            match list.len() {
                0 => unreachable!("There is a reference but not environment it references."),
                1 => return EnvironmentSearch::SingleMatch(list.first().unwrap().clone()),
                _ => return EnvironmentSearch::MultiMatch(list.clone()),
            }
        }

        EnvironmentSearch::NoFound
    }

    /// Get environment, with fuzzy suggestion if not found
    ///
    /// Resolution order:
    /// 1. Exact match on full_name
    /// 2. Exact match on alias
    /// 3. Unique match on short name
    /// 4. If multiple short name matches: AmbiguousEnvironment error
    /// 5. If no match: fuzzy suggestion or NotFound error
    pub fn search(&self, name: &str) -> EnvironmentSearch {
        let get_env = self.get(name);
        match get_env {
            EnvironmentSearch::NoFound => {}
            EnvironmentSearch::SingleMatch(_) => return get_env,
            EnvironmentSearch::MultiMatch(_) => return get_env,
            EnvironmentSearch::FuzzyMatch(_) => {
                unreachable!("get() should not return a fuzzy match")
            }
        }

        // No match found - try fuzzy suggestion
        // Include both full_names, short names, and aliases for better suggestions
        let all_names: Vec<&String> = self.references.keys().collect();

        // Do Fuzzy match
        // Create list of all matches that are above 0.6, convert to u16 to sort
        let mut all_matches: Vec<(&String, u16)> = all_names
            .iter()
            .map(|n| (n, strsim::jaro_winkler(name, n)))
            .filter(|(_name, score)| *score > 0.6)
            .map(|(name, score)| (*name, (score.clamp(0.0, 1.0) * 10000.0) as u16))
            .collect();

        all_matches.sort_by_cached_key(|(_name, score)| *score);

        // Take the top 5 can get the env that they match, limit response to 10
        let best_matches: Vec<_> = all_matches
            .into_iter()
            .take(5)
            .map(|(name, _score)| (name, self.get(name)))
            .map(|(name, env_search)| match env_search {
                EnvironmentSearch::NoFound => unreachable!("get() should return something"),
                EnvironmentSearch::SingleMatch(value) => (name.clone(), vec![value]),
                EnvironmentSearch::MultiMatch(list) => (name.clone(), list),
                EnvironmentSearch::FuzzyMatch(_) => {
                    unreachable!("get() should not return a fuzzy match")
                }
            })
            .collect();

        if best_matches.is_empty() {
            EnvironmentSearch::NoFound
        } else {
            EnvironmentSearch::FuzzyMatch(best_matches)
        }
    }

    pub fn search_result(&self, name: &str) -> Result<Rc<Environment>> {
        let name = name.to_owned();
        match self.search(&name) {
            EnvironmentSearch::NoFound => {
                eprintln!("Environment not found: {}", name.red().bold());
                Err(Error::EnvironmentNotFound(name))
            }
            EnvironmentSearch::SingleMatch(value) => Ok(value),
            EnvironmentSearch::MultiMatch(list) => {
                print_multi_match(EnvironmentSearch::MultiMatch(list));
                Err(Error::AmbiguousEnvironment(name))
            }
            EnvironmentSearch::FuzzyMatch(matches) => {
                print_fuzzy_match(EnvironmentSearch::FuzzyMatch(matches));
                Err(Error::EnvironmentNotFound(name))
            }
        }
    }

    /// List all environments
    pub fn list(&self) -> Vec<Rc<Environment>> {
        self.environments.clone()
    }

    /// List all references and environment mapping
    pub fn reference_mapping(&self) -> IndexMap<String, Vec<Rc<Environment>>> {
        self.references.clone()
    }

    /// Check if any environments exist
    pub fn is_empty(&self) -> bool {
        self.environments.is_empty()
    }

    /// Get paths instance
    pub fn paths(&self) -> &Paths {
        &self.paths
    }
}

pub fn print_multi_match(found: EnvironmentSearch) {
    if let EnvironmentSearch::MultiMatch(matches) = found {
        eprintln!("Ambiguous name used, multiple matches where found:");
        for env_item in matches {
            eprintln!("  - {}", env_item.full_name());
        }
        eprintln!("You need to clarify your name with one of the options above.");
    }
}

pub fn print_fuzzy_match(found: EnvironmentSearch) {
    if let EnvironmentSearch::FuzzyMatch(matches) = found {
        eprintln!("Multiple matches where found that closely match your search term:");
        for (name, env_list) in matches {
            if env_list.len() == 1 {
                eprintln!("  - '{}' -> {}", name, env_list[0].full_name());
            }
            if env_list.len() > 1 {
                eprintln!(
                    "  - '{}' -> {} (and {} others)",
                    name,
                    env_list[0].full_name(),
                    env_list.len()
                );
            }
        }
    }
}