use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::{fs, process};
use crate::roles::definition::{RoleAssignment, RolesConfig};
use crate::team::Extends;
use crate::telemetry;
use crate::tools::{Os, current_os};
pub const DEFAULT_HOOK_TIMEOUT: u64 = 300;
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum EnvValue {
Complex {
value: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
append: bool,
#[serde(default)]
per_tool: bool,
},
Simple(String),
}
impl EnvValue {
pub fn value(&self) -> &str {
match self {
EnvValue::Complex { value, .. } => value,
EnvValue::Simple(s) => s,
}
}
#[allow(dead_code)] pub fn should_append(&self) -> bool {
match self {
EnvValue::Complex { append, .. } => *append,
EnvValue::Simple(_) => false,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(untagged)]
pub enum SecretValue {
FromFile {
from_file: String,
},
Prompt {
#[serde(default)]
env: Option<String>,
#[serde(default = "default_true")]
required: bool,
#[serde(default)]
description: Option<String>,
},
Simple(String),
}
fn default_true() -> bool {
true
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct EnvSettings {
#[serde(default)]
pub shell: Option<String>,
#[serde(default)]
pub update_rc: bool,
#[serde(default = "default_true")]
pub generate_dotenv: bool,
#[serde(default = "default_dotenv_path")]
pub dotenv_path: PathBuf,
#[serde(default)]
pub add_to_gitignore: bool,
#[serde(default = "default_true")]
pub backup_rc: bool,
}
fn default_dotenv_path() -> PathBuf {
PathBuf::from(".env")
}
impl Default for EnvSettings {
fn default() -> Self {
Self {
shell: None,
update_rc: false,
generate_dotenv: true,
dotenv_path: default_dotenv_path(),
add_to_gitignore: false,
backup_rc: true,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct EnvConfig {
#[serde(default)]
pub vars: HashMap<String, EnvValue>,
#[serde(default)]
pub secrets: HashMap<String, SecretValue>,
#[serde(default)]
pub config: EnvSettings,
#[serde(flatten)]
pub tool_env: HashMap<String, ToolEnvConfig>,
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct ToolEnvConfig {
#[serde(default)]
pub vars: HashMap<String, EnvValue>,
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct ToolHooks {
#[serde(default)]
pub post_install: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct HookSettings {
#[serde(default = "default_shell")]
pub shell: String,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default)]
pub continue_on_error: bool,
}
fn default_shell() -> String {
#[cfg(windows)]
{
"powershell".to_string()
}
#[cfg(not(windows))]
{
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
}
fn default_timeout() -> u64 {
DEFAULT_HOOK_TIMEOUT
}
impl Default for HookSettings {
fn default() -> Self {
Self {
shell: default_shell(),
timeout: DEFAULT_HOOK_TIMEOUT,
continue_on_error: false,
}
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct ServicesConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub auto_start: bool,
#[serde(default)]
pub compose_file: Option<PathBuf>,
#[serde(default)]
pub tilt_file: Option<PathBuf>,
#[serde(default)]
pub start_in_ci: bool,
}
impl ServicesConfig {
pub fn should_auto_start(&self, is_ci: bool) -> bool {
if !self.enabled {
return false;
}
if is_ci && !self.start_in_ci {
return false;
}
self.auto_start
}
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct HooksConfig {
#[serde(default)]
pub pre_setup: Option<String>,
#[serde(default)]
pub post_setup: Option<String>,
#[serde(default)]
pub config: HookSettings,
#[serde(flatten)]
pub tool_hooks: HashMap<String, ToolHooks>,
}
#[derive(Deserialize)]
pub struct Config {
#[serde(default)]
#[allow(dead_code)] pub extends: Option<Extends>,
#[serde(default)]
pub role: Option<RoleAssignment>,
#[serde(rename = "provisioner")]
tools: HashMap<String, ToolConfig>,
#[serde(default)]
privileges: Option<PrivilegeConfig>,
#[serde(default)]
pub hooks: HooksConfig,
#[serde(default)]
pub env: EnvConfig,
#[serde(default)]
pub services: ServicesConfig,
#[serde(default, rename = "roles")]
pub roles_config: RolesConfig,
#[serde(default)]
#[allow(dead_code)] pub network: crate::network::NetworkConfig,
#[serde(default)]
pub npm: Option<crate::packages::NpmConfig>,
#[serde(default)]
pub pip: Option<crate::packages::PipConfig>,
#[serde(default)]
pub cargo: Option<crate::packages::CargoConfig>,
#[serde(default)]
pub git: Option<crate::git::GitConfig>,
#[serde(default)]
pub drift: Option<crate::drift::DriftConfig>,
#[serde(default)]
pub telemetry: Option<crate::telemetry::TelemetryConfig>,
#[serde(default)]
#[allow(dead_code)] pub commands: CommandsConfig,
#[serde(default)]
#[allow(dead_code)] pub workspace: Option<crate::workspace::WorkspaceConfig>,
}
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct CommandsConfig {
#[serde(default)]
pub run: Option<String>,
#[serde(default)]
pub test: Option<String>,
#[serde(default)]
pub setup: Option<String>,
}
#[derive(Deserialize, Debug, Default)]
pub struct PrivilegeConfig {
#[serde(default)]
pub use_sudo: Option<bool>,
#[serde(default)]
pub per_os: HashMap<Os, bool>,
}
impl PrivilegeConfig {
fn default_for(_os: Os) -> Option<bool> {
None
}
pub fn effective_for(&self, os: Os) -> Option<bool> {
if let Some(v) = self.per_os.get(&os) {
Some(*v)
} else if let Some(global) = self.use_sudo {
Some(global)
} else {
Self::default_for(os)
}
}
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(untagged)]
pub enum ToolConfig {
Detailed {
version: String,
version_manager: Option<bool>,
use_sudo: Option<bool>,
},
Simple(String),
}
fn build_tool_entry(name: &str, config: &ToolConfig) -> (String, Tool) {
let name_owned = name.to_string();
let (version, version_manager, use_sudo) = match config {
ToolConfig::Detailed {
version,
version_manager,
use_sudo,
} => (version.clone(), version_manager.unwrap_or(true), *use_sudo),
ToolConfig::Simple(version) => (version.clone(), true, None),
};
let tool = Tool {
name: name_owned.clone(),
version,
version_manager,
use_sudo,
};
(name_owned, tool)
}
impl Config {
pub fn new(config_path: &str) -> Self {
let config_content = match fs::read_to_string(config_path) {
Ok(content) => content,
Err(e) => {
telemetry::config_parse_error(config_path, &e.to_string());
println!("Failed to read config file at: {}", config_path);
process::exit(crate::error_codes::CONFIG_ERROR);
}
};
match toml::from_str::<Config>(&config_content) {
Ok(config) => {
telemetry::config_loaded(
config_path,
config.tools.len(),
config.has_hooks(),
config.has_env(),
config.services.enabled,
);
config
}
Err(e) => {
telemetry::config_parse_error(config_path, &e.to_string());
println!("Failed to parse config file: {}", e);
process::exit(crate::error_codes::CONFIG_ERROR);
}
}
}
pub fn new_with_workspace(config_path: &str) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let Some(ctx) = crate::workspace::find_workspace_root(&cwd) else {
return Self::new(config_path);
};
let abs_config_path =
std::fs::canonicalize(config_path).unwrap_or_else(|_| PathBuf::from(config_path));
let abs_root =
std::fs::canonicalize(&ctx.root_config).unwrap_or_else(|_| ctx.root_config.clone());
if abs_config_path == abs_root {
return Self::new(config_path);
}
let root_text = match fs::read_to_string(&ctx.root_config) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
event = "workspace.root_read_failed",
path = %ctx.root_config.display(),
error = %e,
);
return Self::new(config_path);
}
};
let member_text = match fs::read_to_string(config_path) {
Ok(s) => s,
Err(e) => {
telemetry::config_parse_error(config_path, &e.to_string());
println!("Failed to read config file at: {}", config_path);
process::exit(crate::error_codes::CONFIG_ERROR);
}
};
let root_value = match toml::from_str::<toml::Value>(&root_text) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
event = "workspace.root_parse_failed",
path = %ctx.root_config.display(),
error = %e,
);
return Self::new(config_path);
}
};
let member_value = match toml::from_str::<toml::Value>(&member_text) {
Ok(v) => v,
Err(e) => {
telemetry::config_parse_error(config_path, &e.to_string());
println!("Failed to parse config file: {}", e);
process::exit(crate::error_codes::CONFIG_ERROR);
}
};
let merged =
crate::workspace::merge_configs(&root_value, &member_value, &ctx.workspace.inherit);
match merged.try_into::<Config>() {
Ok(config) => {
tracing::info!(
event = "workspace.config_merged",
member = ctx.current_member.as_deref().unwrap_or(""),
inherit_count = ctx.workspace.inherit.len(),
);
telemetry::config_loaded(
config_path,
config.tools.len(),
config.has_hooks(),
config.has_env(),
config.services.enabled,
);
config
}
Err(e) => {
telemetry::config_parse_error(config_path, &e.to_string());
println!("Failed to parse merged workspace config: {}", e);
process::exit(crate::error_codes::CONFIG_ERROR);
}
}
}
pub fn get_tool_configs(&self) -> HashMap<String, Tool> {
self.tools
.iter()
.map(|(name, config)| build_tool_entry(name, config))
.collect()
}
pub fn use_sudo(&self) -> Option<bool> {
let os = current_os();
self.privileges
.as_ref()
.map(|p| p.effective_for(os))
.unwrap_or_else(|| PrivilegeConfig::default_for(os))
}
pub fn get_hooks(&self) -> &HooksConfig {
&self.hooks
}
pub fn get_tool_hooks(&self, tool_name: &str) -> Option<&ToolHooks> {
self.hooks.tool_hooks.get(tool_name)
}
pub fn has_hooks(&self) -> bool {
self.hooks.pre_setup.is_some()
|| self.hooks.post_setup.is_some()
|| self
.hooks
.tool_hooks
.values()
.any(|h| h.post_install.is_some())
}
pub fn get_env(&self) -> &EnvConfig {
&self.env
}
#[allow(dead_code)] pub fn get_tool_env(&self, tool_name: &str) -> Option<&ToolEnvConfig> {
self.env.tool_env.get(tool_name)
}
pub fn has_env(&self) -> bool {
!self.env.vars.is_empty()
|| !self.env.secrets.is_empty()
|| self.env.tool_env.values().any(|t| !t.vars.is_empty())
}
pub fn get_roles_config(&self) -> &RolesConfig {
&self.roles_config
}
pub fn has_roles(&self) -> bool {
!self.roles_config.roles.is_empty()
}
pub fn get_assigned_roles(&self) -> Option<Vec<&str>> {
self.role.as_ref().map(|r| r.as_vec())
}
#[allow(dead_code)] pub fn has_assigned_role(&self) -> bool {
self.role.as_ref().map(|r| !r.is_empty()).unwrap_or(false)
}
pub fn get_packages_config(&self) -> crate::packages::PackagesConfig {
crate::packages::PackagesConfig {
npm: self.npm.clone(),
pip: self.pip.clone(),
cargo: self.cargo.clone(),
gem: None,
go: None,
}
}
pub fn has_packages(&self) -> bool {
self.npm.is_some() || self.pip.is_some() || self.cargo.is_some()
}
pub fn get_git(&self) -> Option<&crate::git::GitConfig> {
self.git.as_ref()
}
pub fn has_git(&self) -> bool {
self.git.is_some()
}
#[allow(dead_code)] pub fn get_tool_configs_with_roles(&self) -> HashMap<String, Tool> {
use crate::roles::resolver::RoleResolver;
let mut result = HashMap::new();
if let Some(role_assignment) = &self.role {
let role_names = role_assignment.as_vec();
if !role_names.is_empty() && self.has_roles() {
let mut resolver = RoleResolver::new(&self.roles_config);
if let Ok(resolved) = resolver.resolve_multiple(&role_names) {
for (name, tool) in resolved.tools {
result.insert(
name.clone(),
Tool {
name,
version: tool.version,
version_manager: tool.version_manager,
use_sudo: tool.use_sudo,
},
);
}
}
}
}
for (name, config) in &self.tools {
let (key, tool) = build_tool_entry(name, config);
result.insert(key, tool);
}
result
}
pub fn get_tool_configs_with_role_override(
&self,
role_override: Option<&str>,
) -> HashMap<String, Tool> {
use crate::roles::resolver::RoleResolver;
let mut result = HashMap::new();
let role_names: Vec<&str> = match (role_override, &self.role) {
(Some(name), _) => vec![name],
(None, Some(assignment)) => assignment.as_vec(),
(None, None) => vec![],
};
if !role_names.is_empty() && self.has_roles() {
let mut resolver = RoleResolver::new(&self.roles_config);
if let Ok(resolved) = resolver.resolve_multiple(&role_names) {
for (name, tool) in resolved.tools {
result.insert(
name.clone(),
Tool {
name,
version: tool.version,
version_manager: tool.version_manager,
use_sudo: tool.use_sudo,
},
);
}
}
}
for (name, config) in &self.tools {
let (key, tool) = build_tool_entry(name, config);
result.insert(key, tool);
}
result
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Tool {
pub name: String,
pub version: String,
pub version_manager: bool,
pub use_sudo: Option<bool>, }
pub fn create_default_config() {
let default_config = r#"
[privileges]
use_sudo = true
[privileges.per_os]
linux = true
macos = false
windows = false
[provisioner]
git = "latest"
docker = "latest"
# Hook configuration (optional)
# [hooks]
# pre_setup = "echo 'Starting Jarvy setup...'"
# post_setup = "echo 'Setup complete!'"
#
# [hooks.config]
# shell = "zsh" # or "bash", "sh", "powershell"
# timeout = 300 # seconds (default: 5 minutes)
# continue_on_error = false
#
# [hooks.node]
# post_install = "npm install -g yarn"
# Environment variables (optional)
# [env.vars]
# MY_VAR = "simple_value"
# PROJECT_ROOT = "$PWD"
# NODE_PATH = { value = "$HOME/.node/bin", append = true }
#
# [env.secrets]
# API_KEY = { env = "MY_API_KEY", required = true }
# DB_PASSWORD = { from_file = "~/.secrets/db_pass" }
#
# [env.config]
# generate_dotenv = true
# dotenv_path = ".env"
# update_rc = false
# add_to_gitignore = true
"#;
let mut file = match File::create("jarvy.toml") {
Ok(f) => f,
Err(e) => {
eprintln!("Could not create jarvy.toml: {e}");
return;
}
};
if let Err(e) = file.write_all(default_config.as_bytes()) {
eprintln!("Could not write to jarvy.toml: {e}");
return;
}
println!("Created jarvy.toml with default configuration");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hooks_config_parsing() {
let toml_str = r#"
[provisioner]
git = "latest"
[hooks]
pre_setup = "echo 'Starting setup'"
post_setup = "echo 'Done'"
[hooks.config]
shell = "zsh"
timeout = 120
continue_on_error = true
[hooks.node]
post_install = "npm install -g yarn"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert_eq!(
config.hooks.pre_setup,
Some("echo 'Starting setup'".to_string())
);
assert_eq!(config.hooks.post_setup, Some("echo 'Done'".to_string()));
assert_eq!(config.hooks.config.shell, "zsh");
assert_eq!(config.hooks.config.timeout, 120);
assert!(config.hooks.config.continue_on_error);
let node_hooks = config
.get_tool_hooks("node")
.expect("node hooks should exist");
assert_eq!(
node_hooks.post_install,
Some("npm install -g yarn".to_string())
);
}
#[test]
fn test_hooks_config_defaults() {
let toml_str = r#"
[provisioner]
git = "latest"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert!(config.hooks.pre_setup.is_none());
assert!(config.hooks.post_setup.is_none());
assert_eq!(config.hooks.config.timeout, DEFAULT_HOOK_TIMEOUT);
assert!(!config.hooks.config.continue_on_error);
assert!(!config.has_hooks());
}
#[test]
fn test_has_hooks() {
let toml_str = r#"
[provisioner]
git = "latest"
[hooks]
pre_setup = "echo 'hi'"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert!(config.has_hooks());
}
#[test]
fn test_hook_settings_default_shell() {
let settings = HookSettings::default();
#[cfg(not(windows))]
{
assert!(!settings.shell.is_empty());
}
#[cfg(windows)]
{
assert_eq!(settings.shell, "powershell");
}
assert_eq!(settings.timeout, DEFAULT_HOOK_TIMEOUT);
assert!(!settings.continue_on_error);
}
#[test]
fn test_env_config_parsing_simple() {
let toml_str = r#"
[provisioner]
git = "latest"
[env.vars]
MY_VAR = "simple_value"
PROJECT_ROOT = "$PWD"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert_eq!(config.env.vars.len(), 2);
assert!(config.has_env());
let my_var = config.env.vars.get("MY_VAR").expect("MY_VAR should exist");
assert_eq!(my_var.value(), "simple_value");
assert!(!my_var.should_append());
}
#[test]
fn test_env_config_parsing_complex() {
let toml_str = r#"
[provisioner]
git = "latest"
[env.vars]
NODE_PATH = { value = "$HOME/.node/bin", append = true, description = "Node binaries" }
SIMPLE = "just_a_value"
[env.config]
generate_dotenv = true
dotenv_path = ".env.local"
update_rc = true
add_to_gitignore = true
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
let node_path = config
.env
.vars
.get("NODE_PATH")
.expect("NODE_PATH should exist");
assert_eq!(node_path.value(), "$HOME/.node/bin");
assert!(node_path.should_append());
assert!(config.env.config.generate_dotenv);
assert_eq!(config.env.config.dotenv_path, PathBuf::from(".env.local"));
assert!(config.env.config.update_rc);
assert!(config.env.config.add_to_gitignore);
}
#[test]
fn test_env_config_secrets() {
let toml_str = r#"
[provisioner]
git = "latest"
[env.secrets]
API_KEY = { env = "MY_API_KEY", required = true }
DB_PASSWORD = { from_file = "~/.secrets/db_pass" }
OPTIONAL_KEY = { required = false, description = "Optional API key" }
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert_eq!(config.env.secrets.len(), 3);
assert!(config.has_env());
}
#[test]
fn test_env_config_defaults() {
let toml_str = r#"
[provisioner]
git = "latest"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert!(config.env.vars.is_empty());
assert!(config.env.secrets.is_empty());
assert!(config.env.config.generate_dotenv);
assert_eq!(config.env.config.dotenv_path, PathBuf::from(".env"));
assert!(!config.env.config.update_rc);
assert!(!config.has_env());
}
#[test]
fn test_env_settings_default() {
let settings = EnvSettings::default();
assert!(settings.shell.is_none());
assert!(!settings.update_rc);
assert!(settings.generate_dotenv);
assert_eq!(settings.dotenv_path, PathBuf::from(".env"));
assert!(!settings.add_to_gitignore);
assert!(settings.backup_rc);
}
#[test]
fn test_services_config_defaults() {
let toml_str = r#"
[provisioner]
git = "latest"
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert!(!config.services.enabled);
assert!(!config.services.auto_start);
assert!(config.services.compose_file.is_none());
assert!(config.services.tilt_file.is_none());
assert!(!config.services.start_in_ci);
}
#[test]
fn test_services_config_parsing() {
let toml_str = r#"
[provisioner]
git = "latest"
[services]
enabled = true
auto_start = true
compose_file = "docker/docker-compose.yml"
start_in_ci = false
"#;
let config: Config = toml::from_str(toml_str).expect("Failed to parse config");
assert!(config.services.enabled);
assert!(config.services.auto_start);
assert_eq!(
config.services.compose_file,
Some(PathBuf::from("docker/docker-compose.yml"))
);
assert!(!config.services.start_in_ci);
}
#[test]
fn test_services_should_auto_start() {
let disabled = ServicesConfig {
enabled: false,
auto_start: true,
..Default::default()
};
assert!(!disabled.should_auto_start(false));
assert!(!disabled.should_auto_start(true));
let no_auto = ServicesConfig {
enabled: true,
auto_start: false,
..Default::default()
};
assert!(!no_auto.should_auto_start(false));
assert!(!no_auto.should_auto_start(true));
let auto_no_ci = ServicesConfig {
enabled: true,
auto_start: true,
start_in_ci: false,
..Default::default()
};
assert!(auto_no_ci.should_auto_start(false)); assert!(!auto_no_ci.should_auto_start(true));
let auto_with_ci = ServicesConfig {
enabled: true,
auto_start: true,
start_in_ci: true,
..Default::default()
};
assert!(auto_with_ci.should_auto_start(false));
assert!(auto_with_ci.should_auto_start(true));
}
}