use std::path::{Path, PathBuf};
use super::schema::ProjectConfig;
pub fn resolve_cluster_dispatch(chain: &[Option<bool>]) -> bool {
chain.iter().rev().find_map(|x| *x).unwrap_or(false)
}
pub fn cluster_dispatch_enabled(project: &ProjectConfig, chain: &[Option<bool>]) -> bool {
project.cluster.is_some() && resolve_cluster_dispatch(chain)
}
pub(super) const CONFIG_NAMES: &[&str] = &["fdl.yaml", "fdl.yml", "fdl.json"];
pub(super) const EXAMPLE_SUFFIXES: &[&str] = &[".example", ".dist"];
pub fn find_config(start: &Path) -> Option<PathBuf> {
let mut dir = start.to_path_buf();
loop {
for name in CONFIG_NAMES {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
for name in CONFIG_NAMES {
for suffix in EXAMPLE_SUFFIXES {
let example = dir.join(format!("{name}{suffix}"));
if example.is_file() {
let target = dir.join(name);
if try_copy_example(&example, &target) {
return Some(target);
}
return Some(example);
}
}
}
if !dir.pop() {
return None;
}
}
}
pub fn find_config_in(dir: &Path) -> Option<PathBuf> {
CONFIG_NAMES
.iter()
.map(|n| dir.join(n))
.find(|c| c.is_file())
}
pub fn find_project_config(start: &Path) -> Option<PathBuf> {
const COMMAND_MARKERS: &[&str] = &["entry", "compile", "docker", "run", "append"];
let mut dir = start.to_path_buf();
loop {
if let Some(candidate) = find_config_in(&dir) {
let is_command_shaped = std::fs::read_to_string(&candidate)
.ok()
.and_then(|raw| serde_yaml_ng::from_str::<serde_yaml_ng::Value>(&raw).ok())
.and_then(|v| v.as_mapping().cloned())
.is_some_and(|map| {
COMMAND_MARKERS
.iter()
.any(|k| map.contains_key(serde_yaml_ng::Value::String((*k).into())))
});
if !is_command_shaped {
return Some(candidate);
}
}
if !dir.pop() {
return None;
}
}
}
pub(super) fn try_copy_example(example: &Path, target: &Path) -> bool {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return false;
}
let example_name = example.file_name().unwrap_or_default().to_string_lossy();
let target_name = target.file_name().unwrap_or_default().to_string_lossy();
eprintln!(
"fdl: found {example_name} but no {target_name}. \
Copy it to create your local config? [Y/n] "
);
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
let answer = input.trim().to_lowercase();
if answer.is_empty() || answer == "y" || answer == "yes" {
match std::fs::copy(example, target) {
Ok(_) => {
eprintln!("fdl: created {target_name} (edit to customize)");
true
}
Err(e) => {
eprintln!("fdl: failed to copy: {e}");
false
}
}
} else {
false
}
}
pub fn load_project(path: &Path) -> Result<ProjectConfig, String> {
load_project_with_env(path, None)
}
pub fn load_project_with_env(base_path: &Path, env: Option<&str>) -> Result<ProjectConfig, String> {
let layers = resolve_config_layers(base_path, env)?;
let merged =
crate::overlay::merge_layers(layers.iter().map(|(_, v)| v.clone()).collect::<Vec<_>>());
if merged.is_null() {
return Ok(ProjectConfig::default());
}
let merged_str = serde_yaml_ng::to_string(&merged).map_err(|e| {
format!(
"{}: failed to re-serialize merged YAML for diagnostics: {e}",
base_path.display()
)
})?;
let cfg = serde_yaml_ng::from_str::<ProjectConfig>(&merged_str).map_err(|e| {
let names: Vec<String> = layers
.iter()
.map(|(p, _)| {
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?")
.to_string()
})
.collect();
let env_hint = env.map(|n| format!(" {n}")).unwrap_or_default();
let (loc_str, context) = match e.location() {
Some(loc) => (
format!(" at merged-view line {}, col {}", loc.line(), loc.column()),
extract_context(&merged_str, loc.line()),
),
None => (String::new(), String::new()),
};
format!(
"{} (layers: {}){}: {}{}\n inspect merged view: fdl{} config show",
base_path.display(),
names.join(" + "),
loc_str,
e,
context,
env_hint
)
})?;
reject_user_ranks(&cfg, base_path)?;
validate_gpu_ram_shares(&cfg, base_path)?;
Ok(cfg)
}
fn validate_gpu_ram_shares(cfg: &ProjectConfig, base_path: &Path) -> Result<(), String> {
let bad = |s: Option<f64>| s.is_some_and(|f| !f.is_finite() || f < 0.0);
let err = |key: &str, got: f64| {
Err(format!(
"{}: {key} must be a non-negative fraction of host RAM \
(e.g. 0.5), got {got}",
base_path.display(),
))
};
if let Some(cluster) = &cfg.cluster {
if bad(cluster.gpu_ram_share) {
return err("cluster.gpu_ram_share", cluster.gpu_ram_share.unwrap());
}
for (i, w) in cluster.workers.iter().enumerate() {
if bad(w.gpu_ram_share) {
return err(
&format!("cluster.workers[{i}] ({:?}) gpu_ram_share", w.host),
w.gpu_ram_share.unwrap(),
);
}
}
}
if let Some(join) = &cfg.join
&& bad(join.gpu_ram_share)
{
return err("join.gpu_ram_share", join.gpu_ram_share.unwrap());
}
Ok(())
}
fn reject_user_ranks(cfg: &ProjectConfig, base_path: &Path) -> Result<(), String> {
let Some(cluster) = &cfg.cluster else {
return Ok(());
};
for (i, w) in cluster.workers.iter().enumerate() {
if !w.ranks.is_empty() {
return Err(format!(
"{}: cluster.workers[{i}] ({:?}) declares `ranks:`, which is \
not user configuration; ranks are computed from probed device \
counts at launch. Remove the key.",
base_path.display(),
w.host,
));
}
}
Ok(())
}
fn extract_context(text: &str, line_no: usize) -> String {
if line_no == 0 {
return String::new();
}
let lines: Vec<&str> = text.lines().collect();
if line_no > lines.len() {
return String::new();
}
let start = line_no.saturating_sub(2).max(1);
let end = (line_no + 1).min(lines.len());
let mut out = String::from("\n");
for n in start..=end {
let marker = if n == line_no { ">>" } else { " " };
out.push_str(&format!(" {marker} {n:>4}: {}\n", lines[n - 1]));
}
out
}
pub fn load_merged_value(
base_path: &Path,
env: Option<&str>,
) -> Result<serde_yaml_ng::Value, String> {
let layers = resolve_config_layers(base_path, env)?;
Ok(crate::overlay::merge_layers(
layers.into_iter().map(|(_, v)| v).collect::<Vec<_>>(),
))
}
pub fn resolve_config_layers(
base_path: &Path,
env: Option<&str>,
) -> Result<Vec<(PathBuf, serde_yaml_ng::Value)>, String> {
let mut layers = crate::overlay::resolve_chain(base_path)?;
if let Some(name) = env {
match crate::overlay::find_env_file(base_path, name) {
Some(p) => {
let env_chain = crate::overlay::resolve_chain(&p)?;
layers.extend(env_chain);
}
None => {
return Err(format!(
"environment `{name}` not found (expected fdl.{name}.yml next to {})",
base_path.display()
));
}
}
}
let mut seen = std::collections::HashSet::new();
layers.retain(|(path, _)| seen.insert(path.clone()));
Ok(layers)
}
pub fn config_layer_sources(base_path: &Path, env: Option<&str>) -> Vec<PathBuf> {
resolve_config_layers(base_path, env)
.map(|ls| ls.into_iter().map(|(p, _)| p).collect())
.unwrap_or_else(|_| vec![base_path.to_path_buf()])
}