ok some changes to locations for semantic models

This commit is contained in:
dal 2025-05-06 12:15:34 -06:00
parent d33c798f63
commit 79a2d7cb04
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
4 changed files with 246 additions and 132 deletions

View File

@ -223,7 +223,6 @@ fn check_excluded_tags(
exclude_tags: Some(exclude_tags.to_vec()),
model_paths: None,
projects: None,
semantic_models_file: None,
};
let manager = ExclusionManager::new(&temp_config)?;
@ -390,103 +389,124 @@ pub async fn deploy(path: Option<&str>, dry_run: bool, recursive: bool) -> Resul
}
};
// Determine the base directory for resolving relative paths (where buster.yml is or would be)
let effective_buster_config_dir = BusterConfig::base_dir(&buster_config_load_dir.join("buster.yml")).unwrap_or(buster_config_load_dir.clone());
let mut deploy_requests_final: Vec<DeployDatasetsRequest> = Vec::new();
let mut model_mappings_final: Vec<ModelMapping> = Vec::new();
let mut processed_models_from_spec = false;
// --- PRIMARY PATH: Use semantic_models_file from BusterConfig if available ---
// --- PRIMARY PATH: Iterate through projects and use semantic_models_file if available ---
if let Some(ref cfg) = buster_config {
if let Some(ref semantic_models_file_str) = cfg.semantic_models_file {
println!(" Using semantic_models_file from buster.yml: {}", semantic_models_file_str.cyan());
let semantic_spec_path = effective_buster_config_dir.join(semantic_models_file_str);
if let Some(ref projects) = cfg.projects {
for project_ctx in projects {
if let Some(ref semantic_models_file_str) = project_ctx.semantic_models_file {
println!(
" Using semantic_models_file for project '{}': {}",
project_ctx.identifier().cyan(),
semantic_models_file_str.cyan()
);
let semantic_spec_path = effective_buster_config_dir.join(semantic_models_file_str);
if !semantic_spec_path.exists() {
return Err(anyhow!("Specified semantic_models_file not found: {}", semantic_spec_path.display()));
}
progress.current_file = semantic_spec_path.to_string_lossy().into_owned();
progress.status = "Loading main semantic layer specification...".to_string();
progress.log_progress();
let spec = match parse_semantic_layer_spec(&semantic_spec_path) {
Ok(s) => s,
Err(e) => {
progress.log_error(&format!("Failed to parse semantic layer spec: {}", e));
result.failures.push((progress.current_file.clone(), "spec_level".to_string(), vec![e.to_string()]));
// Decide if to bail out or proceed to fallback
// For now, let's assume if semantic_models_file is specified and fails, we stop.
progress.log_summary(&result);
return Err(anyhow!("Failed to process specified semantic_models_file."));
}
};
progress.total_files = spec.models.len();
// Resolve configurations for all models in the spec
// For a single spec file, the "project context" is effectively the global one or the first one defined.
let primary_project_context = cfg.projects.as_ref().and_then(|p| p.first());
let models_with_context: Vec<(Model, Option<&ProjectContext>)> = spec.models.into_iter()
.map(|m| (m, primary_project_context))
.collect();
let resolved_models = match resolve_model_configurations(models_with_context, cfg) {
Ok(models) => models,
Err(e) => {
progress.log_error(&format!("Configuration resolution failed for spec: {}", e));
result.failures.push((progress.current_file.clone(), "spec_config_resolution".to_string(), vec![e.to_string()]));
progress.log_summary(&result);
return Err(anyhow!("Configuration resolution failed for models in semantic_models_file."));
}
};
for model in resolved_models {
progress.processed += 1;
progress.current_file = format!("{} (from {})", model.name, semantic_spec_path.file_name().unwrap_or_default().to_string_lossy());
progress.status = format!("Processing model '{}'", model.name);
progress.log_progress();
let sql_content = match get_sql_content_for_model(&model, &effective_buster_config_dir, &semantic_spec_path) {
Ok(content) => content,
Err(e) => {
progress.log_error(&format!("Failed to get SQL for model {}: {}", model.name, e));
result.failures.push((progress.current_file.clone(),model.name.clone(),vec![e.to_string()]));
continue;
if !semantic_spec_path.exists() {
// Log error for this specific project and continue to next or fallback
let error_msg = format!("Specified semantic_models_file not found for project '{}': {}", project_ctx.identifier(), semantic_spec_path.display());
eprintln!("{}", error_msg.red());
result.failures.push((
semantic_spec_path.to_string_lossy().into_owned(),
format!("project_{}", project_ctx.identifier()),
vec![format!("File not found: {}", semantic_spec_path.display())]
));
continue; // Continue to the next project or fallback if this was the last one
}
};
model_mappings_final.push(ModelMapping {
file: semantic_spec_path.file_name().unwrap_or_default().to_string_lossy().into_owned(), // Use spec filename
model_name: model.name.clone()
});
deploy_requests_final.push(to_deploy_request(&model, sql_content));
progress.log_success();
}
} else {
println!(" No semantic_models_file specified in buster.yml. Falling back to scanning for individual .yml files.");
deploy_individual_yml_files(
buster_config.as_ref(),
&effective_buster_config_dir,
recursive,
&mut progress,
&mut result,
&mut deploy_requests_final,
&mut model_mappings_final
).await?;
progress.current_file = semantic_spec_path.to_string_lossy().into_owned();
progress.status = format!("Loading semantic layer specification for project '{}'...", project_ctx.identifier());
progress.log_progress();
let spec = match parse_semantic_layer_spec(&semantic_spec_path) {
Ok(s) => s,
Err(e) => {
progress.log_error(&format!("Failed to parse semantic layer spec for project '{}': {}", project_ctx.identifier(), e));
result.failures.push((
progress.current_file.clone(),
format!("project_{}_spec_level", project_ctx.identifier()),
vec![e.to_string()]
));
continue; // Continue to the next project or fallback
}
};
progress.total_files += spec.models.len(); // Accumulate total files
processed_models_from_spec = true;
// Resolve configurations for all models in the spec using the current project_ctx
let models_with_context: Vec<(Model, Option<&ProjectContext>)> = spec.models.into_iter()
.map(|m| (m, Some(project_ctx)))
.collect();
let resolved_models = match resolve_model_configurations(models_with_context, cfg) { // cfg is the global BusterConfig
Ok(models) => models,
Err(e) => {
progress.log_error(&format!("Configuration resolution failed for spec in project '{}': {}", project_ctx.identifier(), e));
result.failures.push((
progress.current_file.clone(),
format!("project_{}_config_resolution", project_ctx.identifier()),
vec![e.to_string()]
));
continue; // Continue to the next project or fallback
}
};
for model in resolved_models {
progress.processed += 1;
progress.current_file = format!("{} (from {} in project '{}')", model.name, semantic_spec_path.file_name().unwrap_or_default().to_string_lossy(), project_ctx.identifier());
progress.status = format!("Processing model '{}'", model.name);
progress.log_progress();
let sql_content = match get_sql_content_for_model(&model, &effective_buster_config_dir, &semantic_spec_path) {
Ok(content) => content,
Err(e) => {
progress.log_error(&format!("Failed to get SQL for model {}: {}", model.name, e));
result.failures.push((progress.current_file.clone(),model.name.clone(),vec![e.to_string()]));
continue;
}
};
model_mappings_final.push(ModelMapping {
file: semantic_spec_path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
model_name: model.name.clone()
});
deploy_requests_final.push(to_deploy_request(&model, sql_content));
progress.log_success();
}
}
}
}
} else {
// No BusterConfig loaded at all
println!(" No buster.yml loaded. Scanning current/target directory for individual .yml files.");
}
// --- FALLBACK or ADDITIONAL: Scan for individual .yml files ---
// This runs if no semantic_models_file was processed from any project,
// or to supplement if specific logic allows (currently, it runs if processed_models_from_spec is false).
if !processed_models_from_spec {
if buster_config.as_ref().map_or(false, |cfg| cfg.projects.as_ref().map_or(false, |p| p.iter().any(|pc| pc.semantic_models_file.is_some()))) {
// This case means semantic_models_file was specified in some project but all failed to load/process.
println!("⚠️ A semantic_models_file was specified in buster.yml project(s) but failed to process. Now attempting to scan for individual .yml files.");
} else if buster_config.is_some() {
println!(" No semantic_models_file specified in any project in buster.yml. Falling back to scanning for individual .yml files.");
} else {
println!(" No buster.yml loaded. Scanning current/target directory for individual .yml files.");
}
deploy_individual_yml_files(
None,
&buster_config_load_dir, // Use the initial load directory as base if no config
buster_config.as_ref(),
&effective_buster_config_dir, // Use effective_buster_config_dir as the base for resolving model_paths
recursive,
&mut progress,
&mut result,
&mut deploy_requests_final,
&mut model_mappings_final
).await?;
} else {
println!(" Processed models from semantic_models_file(s) specified in buster.yml. Skipping scan for individual .yml files.");
}
@ -538,34 +558,61 @@ async fn deploy_individual_yml_files(
ExclusionManager::empty()
};
let yml_files_to_process = if let Some(cfg) = buster_config {
let effective_paths = cfg.resolve_effective_model_paths(base_search_dir);
if !effective_paths.is_empty() {
// Collect all files to process, associating them with their project context if found.
let mut files_to_process_with_context: Vec<(PathBuf, Option<&ProjectContext>)> = Vec::new();
if let Some(cfg) = buster_config {
let effective_paths_with_contexts = cfg.resolve_effective_model_paths(base_search_dir);
if !effective_paths_with_contexts.is_empty() {
println!(" Using effective model paths for individual .yml scan:");
// ... (logging paths) ...
let mut all_files = Vec::new();
for (path, _project_ctx) in effective_paths {
for (path, project_ctx_opt) in effective_paths_with_contexts {
// Log the path and its associated project context identifier if available
let context_identifier = project_ctx_opt.map_or_else(|| "Global/Default".to_string(), |ctx| ctx.identifier());
println!(" - Path: {}, Context: {}", path.display(), context_identifier.dimmed());
if path.is_dir() {
all_files.extend(find_yml_files(&path, recursive, &exclusion_manager, Some(progress))?);
match find_yml_files(&path, recursive, &exclusion_manager, Some(progress)) {
Ok(files_in_dir) => {
for f in files_in_dir {
files_to_process_with_context.push((f, project_ctx_opt));
}
},
Err(e) => eprintln!("Error finding YML files in {}: {}", path.display(), format!("{}", e).red()),
}
} else if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("yml") {
if path.file_name().and_then(|name| name.to_str()) != Some("buster.yml") {
all_files.push(path);
files_to_process_with_context.push((path.clone(), project_ctx_opt));
}
}
}
all_files
} else {
find_yml_files(base_search_dir, recursive, &exclusion_manager, Some(progress))?
// No effective paths from config, scan base_search_dir with no specific project context.
match find_yml_files(base_search_dir, recursive, &exclusion_manager, Some(progress)) {
Ok(files_in_dir) => {
for f in files_in_dir {
files_to_process_with_context.push((f, None));
}
},
Err(e) => eprintln!("Error finding YML files in {}: {}", base_search_dir.display(), format!("{}", e).red()),
}
}
} else {
find_yml_files(base_search_dir, recursive, &exclusion_manager, Some(progress))?
// No buster_config at all, scan base_search_dir with no project context.
match find_yml_files(base_search_dir, recursive, &exclusion_manager, Some(progress)) {
Ok(files_in_dir) => {
for f in files_in_dir {
files_to_process_with_context.push((f, None));
}
},
Err(e) => eprintln!("Error finding YML files in {}: {}", base_search_dir.display(), format!("{}", e).red()),
}
};
println!("Found {} individual model .yml files to process.", yml_files_to_process.len());
progress.total_files = yml_files_to_process.len(); // Reset total files for this phase
println!("Found {} individual model .yml files to process.", files_to_process_with_context.len());
progress.total_files = files_to_process_with_context.len(); // Reset total files for this phase
progress.processed = 0; // Reset processed for this phase
for yml_path in yml_files_to_process {
for (yml_path, project_ctx_opt) in files_to_process_with_context {
progress.processed += 1;
progress.current_file = yml_path.strip_prefix(base_search_dir).unwrap_or(&yml_path).to_string_lossy().into_owned();
progress.status = "Loading individual model file...".to_string();
@ -580,12 +627,8 @@ async fn deploy_individual_yml_files(
}
};
let project_context_opt = if let Some(cfg) = buster_config {
yml_path.parent().and_then(|p| cfg.get_context_for_path(&yml_path, p))
} else { None };
let models_with_context: Vec<(Model, Option<&ProjectContext>)> = parsed_models.into_iter()
.map(|m| (m, project_context_opt))
.map(|m| (m, project_ctx_opt))
.collect();
let resolved_models = match resolve_model_configurations(models_with_context, buster_config.unwrap_or(&BusterConfig::default())) {
@ -836,6 +879,7 @@ models:
exclude_tags: None,
model_paths: None,
name: Some("Test Project".to_string()),
semantic_models_file: None,
};
let global_config = BusterConfig {
@ -846,7 +890,6 @@ models:
exclude_tags: None,
model_paths: None,
projects: None,
semantic_models_file: None,
};
let models_with_context = vec![

View File

@ -46,8 +46,8 @@ pub async fn generate_semantic_models_command(
// 3. Determine target semantic YAML file path
let semantic_models_file_path_str = match target_semantic_file_arg {
Some(path_str) => path_str,
None => match buster_config.semantic_models_file {
Some(path_str) => path_str,
None => match buster_config.projects.as_ref().and_then(|projects| projects.first()) {
Some(project) => project.semantic_models_file.clone().unwrap_or_else(|| "models.yml".to_string()),
None => {
return Err(anyhow!(
"No target semantic model file specified and 'semantic_models_file' not set in buster.yml. \nPlease use the --output-file option or configure buster.yml via 'buster init'."

View File

@ -490,30 +490,58 @@ pub async fn init(destination_path: Option<&str>) -> Result<()> {
.with_default(true)
.prompt()?
{
// Default directory for semantic models:
// Try to use the first model_path from the first project context, if available.
let default_semantic_models_dir = current_buster_config.projects.as_ref()
.and_then(|projs| projs.first())
.and_then(|proj| proj.model_paths.as_ref())
.and_then(|paths| paths.first())
.map(|p| Path::new(p).parent().unwrap_or_else(|| Path::new(p)).to_string_lossy().into_owned()) // Use parent of first model path, or the path itself
.unwrap_or_else(|| "./buster_semantic_models".to_string());
let semantic_models_dir_str = Text::new("Enter directory for generated semantic model YAML files:")
.with_default("./buster_semantic_models")
.with_help_message("Example: ./semantic_layer or ./models_config")
.with_default(&default_semantic_models_dir)
.with_help_message("Example: ./semantic_layer or ./models")
.prompt()?;
let semantic_models_filename_str = Text::new("Enter filename for the main semantic models YAML file:")
.with_default("models.yml")
.with_default("models.yml") // Keep models.yml as a common default name
.with_help_message("Example: main_spec.yml or buster_models.yml")
.prompt()?;
let semantic_output_path = PathBuf::from(&semantic_models_dir_str).join(&semantic_models_filename_str);
// Ensure path is relative to buster.yml location for portability
// Ensure the output directory exists
if let Some(parent_dir) = semantic_output_path.parent() {
fs::create_dir_all(parent_dir).map_err(|e| {
anyhow!("Failed to create directory for semantic models YAML '{}': {}", parent_dir.display(), e)
})?;
println!("{} {}", "".green(), format!("Ensured directory exists: {}", parent_dir.display()).dimmed());
}
let relative_semantic_path = match pathdiff::diff_paths(&semantic_output_path, &dest_path) {
Some(p) => p.to_string_lossy().into_owned(),
None => {
// If paths are on different prefixes (e.g. Windows C: vs D:), or one is not prefix of other.
// Default to the absolute path in this rare case, or use semantic_output_path directly.
eprintln!("{}", "Could not determine relative path for semantic models file. Using absolute path.".yellow());
semantic_output_path.to_string_lossy().into_owned()
}
};
current_buster_config.semantic_models_file = Some(relative_semantic_path);
// Store in the first project context
if let Some(projects) = current_buster_config.projects.as_mut() {
if let Some(first_project) = projects.first_mut() {
first_project.semantic_models_file = Some(relative_semantic_path.clone());
} else {
// This case should ideally not happen if create_buster_config_file always creates a project
eprintln!("{}", "Warning: No project contexts found in buster.yml to store semantic_models_file path.".yellow());
// Optionally, create a default project here if necessary, or rely on create_buster_config_file to have done its job
}
} else {
eprintln!("{}", "Warning: 'projects' array is None in buster.yml. Cannot store semantic_models_file path.".yellow());
}
current_buster_config.save(&config_path).map_err(|e| anyhow!("Failed to save buster.yml with semantic model path: {}", e))?;
println!("{} {} {}", "".green(), "Updated buster.yml with semantic_models_file path:".green(), current_buster_config.semantic_models_file.as_ref().unwrap().cyan());
println!("{} {} {}", "".green(), "Updated buster.yml with semantic_models_file path in the first project:".green(), relative_semantic_path.cyan());
generate_semantic_models_from_dbt_catalog(&current_buster_config, &config_path, &dest_path).await?;
}
@ -527,14 +555,35 @@ async fn generate_semantic_models_flow(buster_config: &mut BusterConfig, config_
let default_dir = "./buster_semantic_models";
let default_file = "models.yml";
// Try to get defaults from the first project context's semantic_models_file
let (initial_dir, initial_file) = buster_config.projects.as_ref()
.and_then(|projs| projs.first())
.and_then(|proj| proj.semantic_models_file.as_ref())
.map(|p_str| {
let pth = Path::new(p_str);
let dir = pth.parent().and_then(|pp| pp.to_str()).unwrap_or(default_dir);
let file = pth.file_name().and_then(|f| f.to_str()).unwrap_or(default_file);
(dir.to_string(), file.to_string())
})
.unwrap_or((default_dir.to_string(), default_file.to_string()));
let semantic_models_dir_str = Text::new("Enter directory for generated semantic model YAML files:")
.with_default(buster_config.semantic_models_file.as_ref().and_then(|p| Path::new(p).parent().and_then(|pp| pp.to_str())).unwrap_or(default_dir))
.with_default(&initial_dir)
.prompt()?;
let semantic_models_filename_str = Text::new("Enter filename for the main semantic models YAML file:")
.with_default(buster_config.semantic_models_file.as_ref().and_then(|p| Path::new(p).file_name().and_then(|f| f.to_str())).unwrap_or(default_file))
.with_default(&initial_file)
.prompt()?;
let semantic_output_path = PathBuf::from(&semantic_models_dir_str).join(&semantic_models_filename_str);
// Ensure the output directory exists
if let Some(parent_dir) = semantic_output_path.parent() {
fs::create_dir_all(parent_dir).map_err(|e| {
anyhow!("Failed to create directory for semantic models YAML '{}': {}", parent_dir.display(), e)
})?;
println!("{} {}", "".green(), format!("Ensured directory exists: {}", parent_dir.display()).dimmed());
}
let relative_semantic_path = match pathdiff::diff_paths(&semantic_output_path, buster_config_dir) {
Some(p) => p.to_string_lossy().into_owned(),
None => {
@ -543,9 +592,19 @@ async fn generate_semantic_models_flow(buster_config: &mut BusterConfig, config_
}
};
buster_config.semantic_models_file = Some(relative_semantic_path);
// Store in the first project context
if let Some(projects) = buster_config.projects.as_mut() {
if let Some(first_project) = projects.first_mut() {
first_project.semantic_models_file = Some(relative_semantic_path.clone());
} else {
eprintln!("{}", "Warning: No project contexts found in buster.yml to update semantic_models_file path.".yellow());
}
} else {
eprintln!("{}", "Warning: 'projects' array is None in buster.yml. Cannot update semantic_models_file path.".yellow());
}
buster_config.save(config_path).map_err(|e| anyhow!("Failed to save buster.yml with semantic model path: {}", e))?;
println!("{} {} {}", "".green(), "Updated buster.yml with semantic_models_file path:".green(), buster_config.semantic_models_file.as_ref().unwrap().cyan());
println!("{} {} {}", "".green(), "Updated buster.yml with semantic_models_file path in the first project:".green(), relative_semantic_path.cyan());
generate_semantic_models_from_dbt_catalog(buster_config, config_path, buster_config_dir).await
}
@ -554,11 +613,19 @@ async fn generate_semantic_models_flow(buster_config: &mut BusterConfig, config_
// Placeholder for the main logic function
async fn generate_semantic_models_from_dbt_catalog(
buster_config: &BusterConfig,
config_path: &Path, // Path to buster.yml
_config_path: &Path, // Path to buster.yml (config_path is not directly used for choosing semantic_models_file anymore)
buster_config_dir: &Path, // Directory containing buster.yml, assumed dbt project root
) -> Result<()> {
println!("{}", "Starting semantic model generation from dbt catalog...".dimmed());
// Get semantic_models_file from the first project context
let semantic_output_path_str = buster_config.projects.as_ref()
.and_then(|projs| projs.first())
.and_then(|proj| proj.semantic_models_file.as_ref())
.ok_or_else(|| anyhow!("Semantic models file path not set in any project context within BusterConfig. This should have been prompted."))?;
let semantic_output_path = buster_config_dir.join(semantic_output_path_str);
let dbt_project_path = buster_config_dir;
let catalog_json_path = dbt_project_path.join("target").join("catalog.json");
@ -645,15 +712,15 @@ async fn generate_semantic_models_from_dbt_catalog(
let mut yaml_models: Vec<YamlModel> = Vec::new();
let primary_project_context = buster_config.projects.as_ref().and_then(|p| p.first());
// These defaults are now primarily for the model properties themselves if not set in dbt,
// data_source_name should come from the project context more directly.
let default_data_source_name = primary_project_context
.and_then(|pc| pc.data_source_name.as_ref())
.or(buster_config.data_source_name.as_ref());
.and_then(|pc| pc.data_source_name.as_ref());
let default_database = primary_project_context
.and_then(|pc| pc.database.as_ref())
.or(buster_config.database.as_ref());
.and_then(|pc| pc.database.as_ref());
let default_schema = primary_project_context
.and_then(|pc| pc.schema.as_ref())
.or(buster_config.schema.as_ref());
.and_then(|pc| pc.schema.as_ref());
for (_node_id, node) in dbt_catalog.nodes.iter().filter(|(_id, n)| n.resource_type == "model") {
let original_file_path_abs = buster_config_dir.join(&node.original_file_path);
@ -718,15 +785,17 @@ async fn generate_semantic_models_from_dbt_catalog(
}
let semantic_spec = YamlSemanticLayerSpec { models: yaml_models };
let yaml_output_path_str = buster_config
.semantic_models_file
.as_ref()
.ok_or_else(|| anyhow!("Semantic models file path not set in BusterConfig"))?;
let semantic_output_path = buster_config_dir.join(yaml_output_path_str);
// The semantic_output_path is already determined above using project context's semantic_models_file
// let yaml_output_path_str = buster_config
// .semantic_models_file // This top-level field is removed
// .as_ref()
// .ok_or_else(|| anyhow!("Semantic models file path not set in BusterConfig"))?;
// let semantic_output_path = buster_config_dir.join(yaml_output_path_str);
if let Some(parent_dir) = semantic_output_path.parent() {
fs::create_dir_all(parent_dir).map_err(|e| {
anyhow!("Failed to create directory for semantic models YAML: {}", e)
anyhow!("Failed to create directory for semantic models YAML '{}': {}", parent_dir.display(), e)
})?;
}
@ -868,18 +937,19 @@ fn create_buster_config_file(
model_paths: model_paths_vec,
exclude_files: None,
exclude_tags: None,
semantic_models_file: None, // Initialized as None, will be set later if user opts in
});
}
let config = BusterConfig {
data_source_name: None,
data_source_name: None, // Top-level fields are for fallback or specific global settings not tied to projects
schema: None,
database: None,
exclude_files: None,
exclude_tags: None,
model_paths: None, // This top-level field is superseded by 'projects'
projects: Some(project_contexts),
semantic_models_file: None,
// semantic_models_file: None, // Removed from top-level
};
config.save(path)?;
@ -957,6 +1027,7 @@ fn build_contexts_recursive(
model_paths: if model_globs_for_context.is_empty() { None } else { Some(model_globs_for_context) },
exclude_files: None,
exclude_tags: None,
semantic_models_file: None, // Initialized as None for contexts derived from dbt_project.yml
});
println!("Generated project context: {} (Schema: {}, DB: {})",
context_name.cyan(),

View File

@ -22,6 +22,8 @@ pub struct ProjectContext {
pub model_paths: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>, // Optional name for the project
#[serde(skip_serializing_if = "Option::is_none")]
pub semantic_models_file: Option<String>, // Path to the semantic layer YAML for this project
}
impl ProjectContext {
@ -81,8 +83,6 @@ pub struct BusterConfig {
// --- New multi-project structure ---
#[serde(default, skip_serializing_if = "Option::is_none")] // Allows files without 'projects' key to parse and skips serializing if None
pub projects: Option<Vec<ProjectContext>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub semantic_models_file: Option<String>, // Path to the generated semantic layer YAML file
}
impl BusterConfig {