use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
struct TestEnv {
temp_dir: TempDir,
}
impl TestEnv {
fn new() -> Self {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
Self { temp_dir }
}
fn home(&self) -> PathBuf {
self.temp_dir.path().to_path_buf()
}
fn init_flyboat(&self) -> PathBuf {
let flyboat_dir = self.home().join(".flyboat");
let env_dir = flyboat_dir.join("env");
fs::create_dir_all(&env_dir).expect("Failed to create flyboat dirs");
env_dir
}
fn create_env(&self, name: &str, dev_env_yaml: &str) {
let env_dir = self.init_flyboat();
let env_path = env_dir.join(name);
fs::create_dir_all(&env_path).expect("Failed to create env dir");
let dockerfile = env_path.join("Dockerfile");
fs::write(&dockerfile, "FROM alpine:latest\n").expect("Failed to write Dockerfile");
let dev_env = env_path.join("dev_env.yaml");
fs::write(&dev_env, dev_env_yaml).expect("Failed to write dev_env.yaml");
}
fn create_folder(&self, name: &str) {
let env_dir = self.init_flyboat();
let folder_path = env_dir.join(name);
fs::create_dir_all(&folder_path).expect("Failed to create folder");
}
fn create_config(&self, content: &str) {
let flyboat_dir = self.home().join(".flyboat");
fs::create_dir_all(&flyboat_dir).expect("Failed to create flyboat dir");
let config_path = flyboat_dir.join("config.yaml");
fs::write(&config_path, content).expect("Failed to write config");
}
}
mod config_tests {
use super::*;
use flyboat::config::{EnvConfig, GlobalConfig};
#[test]
fn test_load_dev_env_from_file() {
let env = TestEnv::new();
let yaml = r#"
name: python
description: Python development
aliases:
- py
"#;
env.create_env("python", yaml);
let dev_env_path = env.home().join(".flyboat/env/python/dev_env.yaml");
let config = EnvConfig::load(&dev_env_path).unwrap();
assert_eq!(config.name, "python");
assert_eq!(config.description, "Python development");
assert_eq!(config.aliases, vec!["py"]);
}
#[test]
fn test_load_global_config() {
let env = TestEnv::new();
env.create_config(
r#"
container_engine: podman
arch: arm64
"#,
);
let config_path = env.home().join(".flyboat/config.yaml");
let config = GlobalConfig::load(&config_path).unwrap();
assert_eq!(config.container_engine, "podman");
assert_eq!(config.arch, Some("arm64".to_string()));
}
#[test]
fn test_missing_dev_env_yaml_error() {
let env = TestEnv::new();
let dev_env_path = env.home().join(".flyboat/env/nonexistent/dev_env.yaml");
let result = EnvConfig::load(&dev_env_path);
assert!(result.is_err());
}
}
mod environment_tests {
use super::*;
use flyboat::config::Paths;
use flyboat::environment::{EnvironmentManager, EnvironmentSearch};
fn unwrap_single(search: EnvironmentSearch) -> std::rc::Rc<flyboat::environment::Environment> {
match search {
EnvironmentSearch::SingleMatch(env) => env,
other => panic!(
"Expected SingleMatch, got {:?}",
std::mem::discriminant(&other)
),
}
}
fn is_single_match(search: &EnvironmentSearch) -> bool {
matches!(search, EnvironmentSearch::SingleMatch(_))
}
fn is_multi_match(search: &EnvironmentSearch) -> bool {
matches!(search, EnvironmentSearch::MultiMatch(_))
}
#[test]
fn test_discover_environments() {
let env = TestEnv::new();
env.create_env("python", "name: python\n");
env.create_env("rust", "name: rust\n");
env.create_env("node", "name: node\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let envs = manager.list();
assert_eq!(envs.len(), 3);
let names: Vec<&str> = envs.iter().map(|e| e.name.as_str()).collect();
assert!(names.contains(&"python"));
assert!(names.contains(&"rust"));
assert!(names.contains(&"node"));
for e in envs {
assert!(e.namespace.is_empty());
assert_eq!(e.name, e.full_name());
}
}
#[test]
fn test_get_by_name() {
let env = TestEnv::new();
env.create_env("python", "name: python\ndescription: Python env\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let python = unwrap_single(manager.get("python"));
assert_eq!(python.name, "python");
assert_eq!(python.config.description, "Python env");
}
#[test]
fn test_get_by_alias() {
let env = TestEnv::new();
env.create_env(
"python",
r#"
name: python
aliases:
- py
- python3
"#,
);
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
assert!(is_single_match(&manager.get("py")));
assert!(is_single_match(&manager.get("python3")));
assert!(is_single_match(&manager.get("python")));
}
#[test]
fn test_fuzzy_suggestion() {
let env = TestEnv::new();
env.create_env("python", "name: python\n");
env.create_env("rust", "name: rust\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let result = manager.search("pythn");
assert!(
matches!(result, EnvironmentSearch::FuzzyMatch(_)),
"Expected FuzzyMatch for typo"
);
}
#[test]
fn test_fuzzy_suggestion_no_match() {
let env = TestEnv::new();
env.create_env("python", "name: python\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let result = manager.search("xyz");
assert!(
matches!(result, EnvironmentSearch::NoFound),
"Expected NoFound for completely different name"
);
}
#[test]
fn test_empty_environment() {
let env = TestEnv::new();
env.init_flyboat();
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
assert!(manager.is_empty());
}
#[test]
fn test_skip_incomplete_env() {
let env = TestEnv::new();
let env_dir = env.init_flyboat();
let incomplete = env_dir.join("incomplete");
fs::create_dir_all(&incomplete).unwrap();
fs::write(incomplete.join("dev_env.yaml"), "name: incomplete\n").unwrap();
env.create_env("complete", "name: complete\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let envs = manager.list();
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].name, "complete");
}
#[test]
fn test_nested_environments() {
let env = TestEnv::new();
env.create_env("my_collection/rust", "name: rust\n");
env.create_env("my_collection/python", "name: python\n");
env.create_env("standalone", "name: standalone\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let envs = manager.list();
assert_eq!(envs.len(), 3);
let rust = unwrap_single(manager.get("my_collection/rust"));
assert_eq!(rust.name, "rust");
assert_eq!(rust.namespace, vec!["my_collection"]);
assert_eq!(rust.full_name(), "my_collection/rust");
let standalone = unwrap_single(manager.get("standalone"));
assert_eq!(standalone.name, "standalone");
assert!(standalone.namespace.is_empty());
assert_eq!(standalone.full_name(), "standalone");
}
#[test]
fn test_unique_short_name_resolution() {
let env = TestEnv::new();
env.create_env("my_collection/rust", "name: rust\n");
env.create_env("python", "name: python\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let rust = unwrap_single(manager.get("rust"));
assert_eq!(rust.full_name(), "my_collection/rust");
}
#[test]
fn test_ambiguous_short_name() {
let env = TestEnv::new();
env.create_env("collection_a/rust", "name: rust\n");
env.create_env("collection_b/rust", "name: rust\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
assert!(is_multi_match(&manager.get("rust")));
let result = manager.search_result("rust");
assert!(result.is_err());
assert!(is_single_match(&manager.get("collection_a/rust")));
assert!(is_single_match(&manager.get("collection_b/rust")));
}
#[test]
fn test_deeply_nested_environments() {
let env = TestEnv::new();
env.create_env("a/b/c/rust", "name: rust\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let rust = unwrap_single(manager.get("a/b/c/rust"));
assert_eq!(rust.name, "rust");
assert_eq!(rust.namespace, vec!["a", "b", "c"]);
assert_eq!(rust.full_name(), "a/b/c/rust");
let rust2 = unwrap_single(manager.get("rust"));
assert_eq!(rust2.full_name(), "a/b/c/rust");
}
#[test]
fn test_nested_containers_both_valid() {
let env = TestEnv::new();
env.create_env("parent", "name: parent\n");
env.create_env("parent/child", "name: child\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let envs = manager.list();
assert_eq!(envs.len(), 2);
assert!(is_single_match(&manager.get("parent")));
assert!(is_single_match(&manager.get("parent/child")));
}
#[test]
fn test_empty_namespace_folder_ignored() {
let env = TestEnv::new();
env.create_folder("empty_collection");
env.create_env("empty_collection/rust", "name: rust\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let envs = manager.list();
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].full_name(), "empty_collection/rust");
}
#[test]
fn test_disabled_environment_not_discovered() {
let env = TestEnv::new();
env.create_env(
"disabled",
r#"
name: disabled
disable: true
"#,
);
env.create_env("enabled", "name: enabled\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let envs = manager.list();
assert_eq!(envs.len(), 1);
assert_eq!(envs[0].name, "enabled");
assert!(matches!(
manager.get("disabled"),
EnvironmentSearch::NoFound
));
}
#[test]
fn test_symlinked_environment_preserves_namespace() {
let env = TestEnv::new();
let env_dir = env.init_flyboat();
let actual_rust = env.home().join("actual-rust-env");
fs::create_dir_all(&actual_rust).unwrap();
fs::write(actual_rust.join("Dockerfile"), "FROM alpine:latest\n").unwrap();
fs::write(actual_rust.join("dev_env.yaml"), "name: rust\n").unwrap();
let namespace_dir = env_dir.join("my_collection");
fs::create_dir_all(&namespace_dir).unwrap();
std::os::unix::fs::symlink(&actual_rust, namespace_dir.join("rust")).unwrap();
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let rust = unwrap_single(manager.get("my_collection/rust"));
assert_eq!(rust.name, "rust");
assert_eq!(rust.namespace, vec!["my_collection"]);
assert_eq!(rust.full_name(), "my_collection/rust");
assert_eq!(
rust.path.canonicalize().unwrap(),
actual_rust.canonicalize().unwrap()
);
}
}
mod docker_command_tests {
use flyboat::docker::Engine;
use flyboat::docker::build::BuildCommand;
use flyboat::docker::run::{MountSpec, RunCommand};
#[test]
fn test_full_build_command() {
let cmd = BuildCommand {
engine: Engine::Docker,
image_name: "flyboat-python-arm64".to_string(),
context_path: "/home/user/.flyboat/env/python".to_string(),
dockerfile_path: "/home/user/.flyboat/env/python/Dockerfile".to_string(),
platform: Some("linux/arm64".to_string()),
no_cache: true,
};
let display = format!("{}", cmd);
assert!(display.contains("docker build"));
assert!(display.contains("--tag flyboat-python-arm64"));
assert!(display.contains("--platform linux/arm64"));
assert!(display.contains("--no-cache"));
}
#[test]
fn test_full_run_command() {
let cmd = RunCommand {
engine: Engine::Podman,
image_name: "flyboat-python".to_string(),
container_name: "flyboat-python-0".to_string(),
working_dir: Some("/project".to_string()),
network: "bridge".to_string(),
ports: vec!["8080:80".to_string()],
mounts: vec![MountSpec {
host_path: "/home/user/project".to_string(),
container_path: "/project".to_string(),
readonly: false,
}],
custom_args: vec!["-e".to_string(), "DEBUG=1".to_string()],
entrypoint: Some("python".to_string()),
interactive: true,
remove_on_exit: true,
};
let display = format!("{}", cmd);
assert!(display.contains("podman run"));
assert!(display.contains("-it"));
assert!(display.contains("--rm"));
assert!(display.contains("--userns keep-id:uid=3400,gid=3400"));
assert!(display.contains("127.0.0.1:8080:80"));
assert!(display.contains("-e DEBUG=1"));
assert!(display.contains("--entrypoint python"));
}
}
mod port_validation_tests {
use clap::Parser;
use flyboat::cli::{Cli, Command};
fn parse(args: &[&str]) -> Cli {
let mut full_args = vec!["flyboat"];
full_args.extend_from_slice(args);
Cli::parse_from(full_args)
}
#[test]
fn test_single_port_parsing() {
let cli = parse(&["run", "test", "-p", "8080"]);
match cli.command {
Command::Run(args) => {
assert_eq!(args.port, vec!["8080"]);
}
_ => panic!("Expected Run command"),
}
}
#[test]
fn test_port_mapping_parsing() {
let cli = parse(&["run", "test", "-p", "8080:80"]);
match cli.command {
Command::Run(args) => {
assert_eq!(args.port, vec!["8080:80"]);
}
_ => panic!("Expected Run command"),
}
}
#[test]
fn test_multiple_ports() {
let cli = parse(&["run", "test", "-p", "8080", "-p", "3000", "-p", "5432:5432"]);
match cli.command {
Command::Run(args) => {
assert_eq!(args.port.len(), 3);
}
_ => panic!("Expected Run command"),
}
}
}
mod security_tests {
#[test]
fn test_dangerous_paths_documentation() {
let dangerous_paths = ["/", "/root", "/home"];
for path in dangerous_paths {
assert!(
["/", "/root", "/home"].contains(&path),
"Path {} should be in dangerous list",
path
);
}
}
}
mod template_tests {
use super::*;
use flyboat::config::Paths;
use flyboat::environment::{EnvironmentManager, EnvironmentSearch};
use flyboat::template::{ProcessContext, process_templates};
fn unwrap_single(search: EnvironmentSearch) -> std::rc::Rc<flyboat::environment::Environment> {
match search {
EnvironmentSearch::SingleMatch(env) => env,
other => panic!(
"Expected SingleMatch, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn test_template_processing_from_dev_env_yaml() {
let env = TestEnv::new();
let env_dir = env.init_flyboat();
let env_path = env_dir.join("test");
fs::create_dir_all(&env_path).unwrap();
fs::write(env_path.join("Dockerfile"), "FROM alpine:latest\n").unwrap();
let dev_env_yaml = r#"
name: test
templates:
- source: "config.example.toml"
overwrite: on_build
variables:
db_password:
type: fixed
value: "test_password_123"
api_url:
type: fixed
value: "https://api.test.com"
"#;
fs::write(env_path.join("dev_env.yaml"), dev_env_yaml).unwrap();
let template_content = r#"
[database]
password = "{{db_password}}"
[api]
url = "{{api_url}}"
"#;
fs::write(env_path.join("config.example.toml"), template_content).unwrap();
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let found_env = unwrap_single(manager.get("test"));
process_templates(
&found_env.path,
&found_env.config.templates,
ProcessContext::Build,
false,
)
.unwrap();
let output_path = env_path.join("config.toml");
assert!(output_path.exists(), "Output file should exist");
let output_content = fs::read_to_string(&output_path).unwrap();
assert!(output_content.contains("password = \"test_password_123\""));
assert!(output_content.contains("url = \"https://api.test.com\""));
}
#[test]
fn test_template_random_generation() {
let env = TestEnv::new();
let env_dir = env.init_flyboat();
let env_path = env_dir.join("test-random");
fs::create_dir_all(&env_path).unwrap();
fs::write(env_path.join("Dockerfile"), "FROM alpine:latest\n").unwrap();
let dev_env_yaml = r#"
name: test-random
templates:
- source: "secret.example.txt"
overwrite: on_build
variables:
random_key:
type: random
charset: alphanumeric
length: 32
"#;
fs::write(env_path.join("dev_env.yaml"), dev_env_yaml).unwrap();
fs::write(env_path.join("secret.example.txt"), "KEY={{random_key}}").unwrap();
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let found_env = unwrap_single(manager.get("test-random"));
process_templates(
&found_env.path,
&found_env.config.templates,
ProcessContext::Build,
false,
)
.unwrap();
let output = fs::read_to_string(env_path.join("secret.txt")).unwrap();
assert!(output.starts_with("KEY="));
let key = &output[4..];
assert_eq!(key.len(), 32);
assert!(key.chars().all(|c| c.is_ascii_alphanumeric()));
}
#[test]
fn test_template_if_not_exists_skips() {
let env = TestEnv::new();
let env_dir = env.init_flyboat();
let env_path = env_dir.join("test-skip");
fs::create_dir_all(&env_path).unwrap();
fs::write(env_path.join("Dockerfile"), "FROM alpine:latest\n").unwrap();
let dev_env_yaml = r#"
name: test-skip
templates:
- source: "config.example.toml"
overwrite: if_not_exists
variables:
value:
type: fixed
value: "new_value"
"#;
fs::write(env_path.join("dev_env.yaml"), dev_env_yaml).unwrap();
fs::write(env_path.join("config.example.toml"), "VALUE={{value}}").unwrap();
fs::write(env_path.join("config.toml"), "EXISTING_CONTENT").unwrap();
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let found_env = unwrap_single(manager.get("test-skip"));
process_templates(
&found_env.path,
&found_env.config.templates,
ProcessContext::Build,
false,
)
.unwrap();
let output = fs::read_to_string(env_path.join("config.toml")).unwrap();
assert_eq!(output, "EXISTING_CONTENT");
}
#[test]
fn test_template_on_run_only_processes_in_run_context() {
let env = TestEnv::new();
let env_dir = env.init_flyboat();
let env_path = env_dir.join("test-run");
fs::create_dir_all(&env_path).unwrap();
fs::write(env_path.join("Dockerfile"), "FROM alpine:latest\n").unwrap();
let dev_env_yaml = r#"
name: test-run
templates:
- source: "runtime.example.txt"
overwrite: on_run
variables:
session_id:
type: fixed
value: "session123"
"#;
fs::write(env_path.join("dev_env.yaml"), dev_env_yaml).unwrap();
fs::write(
env_path.join("runtime.example.txt"),
"SESSION={{session_id}}",
)
.unwrap();
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let found_env = unwrap_single(manager.get("test-run"));
process_templates(
&found_env.path,
&found_env.config.templates,
ProcessContext::Build,
false,
)
.unwrap();
assert!(
!env_path.join("runtime.txt").exists(),
"Output should NOT exist after Build context"
);
process_templates(
&found_env.path,
&found_env.config.templates,
ProcessContext::Run,
false,
)
.unwrap();
assert!(
env_path.join("runtime.txt").exists(),
"Output should exist after Run context"
);
let output = fs::read_to_string(env_path.join("runtime.txt")).unwrap();
assert_eq!(output, "SESSION=session123");
}
}
mod alias_tests {
use super::*;
use flyboat::config::Paths;
use flyboat::environment::{EnvironmentManager, EnvironmentSearch};
fn unwrap_single(search: EnvironmentSearch) -> std::rc::Rc<flyboat::environment::Environment> {
match search {
EnvironmentSearch::SingleMatch(env) => env,
other => panic!(
"Expected SingleMatch, got {:?}",
std::mem::discriminant(&other)
),
}
}
#[test]
fn test_multiple_aliases() {
let env = TestEnv::new();
env.create_env(
"python",
r#"
name: python
aliases:
- py
- python3
- pydev
"#,
);
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
for alias in ["py", "python3", "pydev", "python"] {
let found = unwrap_single(manager.get(alias));
assert_eq!(found.name, "python", "Should find env by '{}'", alias);
}
}
#[test]
fn test_alias_priority_over_fuzzy() {
let env = TestEnv::new();
env.create_env("test", "name: test\naliases: [t]\n");
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
let result = unwrap_single(manager.get("t"));
assert_eq!(result.name, "test");
}
#[test]
fn test_alias_with_invalid_chars_rejects_environment() {
let env = TestEnv::new();
env.create_env(
"python",
r#"
name: python
aliases:
- py
- my/alias
"#,
);
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
assert!(
manager.is_empty(),
"Environment with invalid alias should not be registered"
);
assert!(
matches!(manager.get("python"), EnvironmentSearch::NoFound),
"Environment should not be found"
);
}
#[test]
fn test_alias_with_space_rejects_environment() {
let env = TestEnv::new();
env.create_env(
"rust",
r#"
name: rust
aliases:
- rs
- "my alias"
"#,
);
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
assert!(
manager.is_empty(),
"Environment with invalid alias should not be registered"
);
}
#[test]
fn test_valid_aliases_work_normally() {
let env = TestEnv::new();
env.create_env(
"python",
r#"
name: python
aliases:
- py
- python3
- py_dev
- py-dev
- Py123
"#,
);
let paths = Paths::with_home(env.home());
let manager = EnvironmentManager::with_paths(paths).unwrap();
assert!(!manager.is_empty());
for alias in ["python", "py", "python3", "py_dev", "py-dev", "Py123"] {
let result = unwrap_single(manager.get(alias));
assert_eq!(result.name, "python");
}
}
}