pub mod schema;
use crate::util::fs::{find_config_file, resolve_paths};
use crate::{NylError, Result};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn default_components_search_paths() -> Vec<PathBuf> {
vec![PathBuf::from("components")]
}
fn default_helm_chart_search_paths() -> Vec<PathBuf> {
vec![PathBuf::from(".")]
}
fn default_aliases() -> BTreeMap<String, String> {
BTreeMap::new()
}
fn default_profile_values() -> BTreeMap<String, BTreeMap<String, serde_json::Value>> {
BTreeMap::new()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum StripEmptyMetadataLabelsMode {
#[default]
Always,
Never,
Argocd,
}
impl StripEmptyMetadataLabelsMode {
pub fn should_strip(self, is_argocd: bool) -> bool {
match self {
Self::Always => true,
Self::Never => false,
Self::Argocd => is_argocd,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectSettings {
pub components_search_paths: Vec<PathBuf>,
pub helm_chart_search_paths: Vec<PathBuf>,
pub aliases: BTreeMap<String, String>,
pub strip_empty_metadata_labels: StripEmptyMetadataLabelsMode,
}
impl Default for ProjectSettings {
fn default() -> Self {
Self {
components_search_paths: default_components_search_paths(),
helm_chart_search_paths: default_helm_chart_search_paths(),
aliases: default_aliases(),
strip_empty_metadata_labels: StripEmptyMetadataLabelsMode::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ProfileSettings {
pub values: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
}
impl Default for ProfileSettings {
fn default() -> Self {
Self {
values: default_profile_values(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectFile {
pub project: ProjectSettings,
pub profile: ProfileSettings,
}
#[derive(Debug, Clone)]
pub struct ProjectConfig {
pub file: Option<PathBuf>,
pub config: ProjectFile,
}
impl ProjectConfig {
pub const FILENAMES: &'static [&'static str] = &["nyl.toml"];
pub fn find(cwd: Option<&Path>) -> Result<Option<PathBuf>> {
find_config_file(Self::FILENAMES, cwd, false)
}
pub fn load(file: Option<PathBuf>) -> Result<Self> {
Self::load_from_dir(file, None)
}
pub fn load_from_dir(file: Option<PathBuf>, dir: Option<&Path>) -> Result<Self> {
let file = match file {
Some(f) => Some(f),
None => Self::find(dir)?,
};
if let Some(ref path) = file {
Self::load_from_file(path)
} else {
Ok(Self {
file: None,
config: ProjectFile::default(),
})
}
}
pub fn load_with_warning(file: Option<PathBuf>) -> Result<Self> {
Self::load_with_warning_from_dir(file, None)
}
pub fn load_with_warning_from_dir(file: Option<PathBuf>, dir: Option<&Path>) -> Result<Self> {
let file = match file {
Some(f) => Some(f),
None => Self::find(dir)?,
};
if let Some(ref path) = file {
Self::load_from_file(path)
} else {
tracing::warn!("No project configuration file found");
tracing::info!("Using default settings. Initialize with 'nyl new project' to create one.");
Ok(Self {
file: None,
config: ProjectFile::default(),
})
}
}
fn load_from_file(path: &Path) -> Result<Self> {
if !path.exists() {
return Err(NylError::Config(format!(
"Configuration file does not exist: {}",
path.display()
)));
}
if path.file_name().and_then(|s| s.to_str()) != Some("nyl.toml") {
return Err(NylError::Config(format!(
"Unsupported project configuration file '{}'. Use 'nyl.toml'.",
path.display()
)));
}
if path.extension().and_then(|s| s.to_str()) != Some("toml") {
return Err(NylError::Config(format!(
"Unsupported project configuration format for '{}'. Only TOML is supported.",
path.display()
)));
}
tracing::debug!("Reading configuration file: {}", path.display());
let contents = std::fs::read_to_string(path)?;
let mut project: ProjectFile =
toml::from_str(&contents).map_err(|e| NylError::Config(format!("Failed to parse TOML config: {}", e)))?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
project.project.components_search_paths = resolve_paths(&project.project.components_search_paths, base_dir);
project.project.helm_chart_search_paths = resolve_paths(&project.project.helm_chart_search_paths, base_dir);
Ok(Self {
file: Some(path.to_path_buf()),
config: project,
})
}
pub fn get_components_search_paths(&self) -> &[PathBuf] {
&self.config.project.components_search_paths
}
pub fn get_helm_chart_search_paths(&self) -> &[PathBuf] {
&self.config.project.helm_chart_search_paths
}
pub fn get_alias_target(&self, key: &str) -> Option<&str> {
self.config.project.aliases.get(key).map(String::as_str)
}
pub fn get_alias_target_for_kind(&self, api_version: &str, kind: &str) -> Option<&str> {
let key = format!("{}/{}", api_version, kind);
self.get_alias_target(&key)
}
pub fn get_strip_empty_metadata_labels_mode(&self) -> StripEmptyMetadataLabelsMode {
self.config.project.strip_empty_metadata_labels
}
pub fn has_profiles(&self) -> bool {
!self.config.profile.values.is_empty()
}
pub fn profile_names(&self) -> Vec<&str> {
self.config.profile.values.keys().map(String::as_str).collect()
}
pub fn get_profile_values(&self, name: &str) -> Option<&BTreeMap<String, serde_json::Value>> {
self.config.profile.values.get(name)
}
pub fn resolve_component_chart_dir(&self, kind: &str) -> Result<PathBuf> {
for root in self.get_components_search_paths() {
let chart_dir = root.join(kind);
if chart_dir.join("Chart.yaml").exists() {
return Ok(chart_dir);
}
}
Err(NylError::Config(format!(
"Component '{}' was not found in configured components_search_paths",
kind
)))
}
pub fn validate(&self) -> Vec<String> {
let mut warnings = Vec::new();
for path in self.get_components_search_paths() {
if !path.exists() {
warnings.push(format!("Components search path does not exist: {}", path.display()));
}
}
for path in self.get_helm_chart_search_paths() {
if !path.exists() {
warnings.push(format!("Helm chart search path does not exist: {}", path.display()));
}
}
for (key, target) in &self.config.project.aliases {
if !key.contains('/') {
warnings.push(format!(
"Alias key '{}' is invalid; expected '<apiVersion>/<kind>'",
key
));
}
if target.trim().is_empty() {
warnings.push(format!("Alias '{}' has an empty target", key));
}
}
warnings
}
}
#[cfg(test)]
#[allow(clippy::needless_raw_string_hashes)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_default_project_settings() {
let settings = ProjectSettings::default();
assert_eq!(settings.components_search_paths, vec![PathBuf::from("components")]);
assert_eq!(settings.helm_chart_search_paths, vec![PathBuf::from(".")]);
assert!(settings.aliases.is_empty());
assert_eq!(
settings.strip_empty_metadata_labels,
StripEmptyMetadataLabelsMode::Always
);
}
#[test]
fn test_default_profile_settings() {
let settings = ProfileSettings::default();
assert!(settings.values.is_empty());
}
#[test]
fn test_load_toml_config() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("nyl.toml");
let toml_content = r#"
[project]
components_search_paths = ["my-components"]
helm_chart_search_paths = ["lib", "vendor"]
strip_empty_metadata_labels = "argocd"
[project.aliases]
"myapi.io/v1/MyKind" = "oci://registry-1.docker.io/bitnamicharts/nginx@18.2.4"
"#;
fs::write(&config_path, toml_content).unwrap();
let config = ProjectConfig::load(Some(config_path.clone())).unwrap();
assert_eq!(config.file, Some(config_path.clone()));
assert_eq!(config.get_components_search_paths().len(), 1);
assert_eq!(config.get_helm_chart_search_paths().len(), 2);
assert_eq!(
config.get_alias_target_for_kind("myapi.io/v1", "MyKind"),
Some("oci://registry-1.docker.io/bitnamicharts/nginx@18.2.4")
);
assert_eq!(
config.get_strip_empty_metadata_labels_mode(),
StripEmptyMetadataLabelsMode::Argocd
);
assert!(config.get_components_search_paths()[0].is_absolute());
assert!(config.get_helm_chart_search_paths()[0].is_absolute());
assert!(config.get_helm_chart_search_paths()[1].is_absolute());
}
#[test]
fn test_load_no_config_returns_defaults() {
let temp = TempDir::new().unwrap();
let config = ProjectConfig::load_from_dir(None, Some(temp.path())).unwrap();
assert!(config.file.is_none());
assert_eq!(config.get_components_search_paths(), &[PathBuf::from("components")]);
assert_eq!(config.get_helm_chart_search_paths(), &[PathBuf::from(".")]);
assert!(config.config.project.aliases.is_empty());
assert_eq!(
config.get_strip_empty_metadata_labels_mode(),
StripEmptyMetadataLabelsMode::Always
);
assert!(!config.has_profiles());
}
#[test]
fn test_validate_aliases() {
let config = ProjectConfig {
file: None,
config: ProjectFile {
project: ProjectSettings {
aliases: BTreeMap::from([
("bad-key".to_string(), "oci://example.com/app@1.0.0".to_string()),
("good.io/v1/Empty".to_string(), " ".to_string()),
]),
..ProjectSettings::default()
},
profile: ProfileSettings::default(),
},
};
let warnings = config.validate();
assert!(warnings.iter().any(|w| w.contains("Alias key 'bad-key' is invalid")));
assert!(warnings.iter().any(|w| w.contains("has an empty target")));
}
#[test]
fn test_resolve_component_chart_dir() {
let temp = TempDir::new().unwrap();
let root1 = temp.path().join("comps1");
let root2 = temp.path().join("comps2");
fs::create_dir_all(root1.join("v1.example.io/WebApp")).unwrap();
fs::create_dir_all(root2.join("v1.example.io/WebApp")).unwrap();
fs::write(root2.join("v1.example.io/WebApp/Chart.yaml"), "apiVersion = \"v2\"").unwrap();
let config_path = temp.path().join("nyl.toml");
fs::write(
&config_path,
r#"[project]
components_search_paths = ["comps1", "comps2"]
"#,
)
.unwrap();
let config = ProjectConfig::load(Some(config_path)).unwrap();
let resolved = config.resolve_component_chart_dir("v1.example.io/WebApp").unwrap();
assert_eq!(resolved, root2.join("v1.example.io/WebApp"));
}
#[test]
fn test_find_uses_nyl_toml_only() {
let temp = TempDir::new().unwrap();
fs::write(temp.path().join("nyl-project.yaml"), "settings: {}").unwrap();
fs::write(temp.path().join("nyl.toml"), "[project]").unwrap();
let found = ProjectConfig::find(Some(temp.path())).unwrap();
assert_eq!(found, Some(temp.path().join("nyl.toml")));
}
#[test]
fn test_validate_missing_paths() {
let config = ProjectConfig {
file: None,
config: ProjectFile::default(),
};
let warnings = config.validate();
assert!(warnings
.iter()
.any(|w| w.contains("Components search path does not exist")));
}
#[test]
fn test_malformed_toml() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("nyl.toml");
fs::write(&config_path, "[project\ninvalid toml").unwrap();
let result = ProjectConfig::load(Some(config_path));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Failed to parse TOML"));
}
#[test]
fn test_reject_legacy_filename() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("nyl-project.toml");
fs::write(&config_path, "[project]").unwrap();
let result = ProjectConfig::load(Some(config_path));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported project configuration file"));
}
#[test]
fn test_load_profile_values_from_toml() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("nyl.toml");
let toml_content = r#"
[project]
[profile.values.dev]
replicas = 1
image_tag = "dev-latest"
[profile.values.prod]
replicas = 3
image_tag = "v1.0.0"
"#;
fs::write(&config_path, toml_content).unwrap();
let config = ProjectConfig::load(Some(config_path)).unwrap();
assert!(config.has_profiles());
assert_eq!(config.profile_names().len(), 2);
assert_eq!(
config.get_profile_values("dev").and_then(|v| v.get("replicas")),
Some(&serde_json::json!(1))
);
}
#[test]
fn test_profile_invalid_shape_rejected() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("nyl.toml");
let toml_content = r#"
[project]
[profile.dev]
replicas = 1
"#;
fs::write(&config_path, toml_content).unwrap();
let err = ProjectConfig::load(Some(config_path)).unwrap_err().to_string();
assert!(err.contains("Failed to parse TOML config"));
}
#[test]
fn test_invalid_strip_empty_metadata_labels_mode_rejected() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("nyl.toml");
let toml_content = r#"
[project]
strip_empty_metadata_labels = "sometimes"
"#;
fs::write(&config_path, toml_content).unwrap();
let err = ProjectConfig::load(Some(config_path)).unwrap_err().to_string();
assert!(err.contains("Failed to parse TOML config"));
assert!(err.contains("strip_empty_metadata_labels"));
}
}