use crate::error::{FlecheError, Result};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
fn load_dotenv(project_path: &Path) -> HashMap<String, String> {
let dotenv_path = project_path.join(".env");
load_dotenv_from(&dotenv_path).unwrap_or_default()
}
fn load_dotenv_from(path: &Path) -> std::result::Result<HashMap<String, String>, String> {
let mut vars = HashMap::new();
match dotenvy::from_path_iter(path) {
Ok(iter) => {
for item in iter {
let (k, v) = item.map_err(|e| format!("{e}"))?;
vars.insert(k, v);
}
Ok(vars)
}
Err(dotenvy::Error::Io(_)) => Ok(vars), Err(e) => Err(format!("{e}")),
}
}
fn load_dotenv_strict(path: &Path) -> Result<HashMap<String, String>> {
if !path.exists() {
return Err(FlecheError::ConfigParse(format!(
"dotenv file not found: {}",
path.display()
)));
}
load_dotenv_from(path).map_err(|e| {
FlecheError::ConfigParse(format!(
"failed to parse dotenv file {}: {e}",
path.display()
))
})
}
fn expand_variables(
value: &str,
project_name: &str,
context: &IndexMap<String, String>,
dotenv: &HashMap<String, String>,
) -> Result<String> {
shellexpand::env_with_context(
value,
|var| -> std::result::Result<Option<Cow<'_, str>>, std::convert::Infallible> {
Ok(
if var == "PROJECT" {
Some(Cow::Owned(project_name.to_string()))
} else {
None
}
.or_else(|| context.get(var).map(|v| Cow::Borrowed(v.as_str())))
.or_else(|| std::env::var(var).ok().map(Cow::Owned))
.or_else(|| dotenv.get(var).map(|v| Cow::Owned(v.clone()))),
)
},
)
.map(std::borrow::Cow::into_owned)
.map_err(|e| FlecheError::ConfigParse(format!("variable expansion failed: {e}")))
}
fn expand_env_map(
env: IndexMap<String, String>,
project_name: &str,
dotenv: &HashMap<String, String>,
) -> Result<IndexMap<String, String>> {
let mut expanded = IndexMap::new();
for (key, value) in env {
let expanded_value = expand_variables(&value, project_name, &expanded, dotenv)?;
expanded.insert(key, expanded_value);
}
Ok(expanded)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectConfig {
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "Settings::default_list_limit")]
pub default_list_limit: usize,
#[serde(default = "Settings::default_retry_base_delay")]
pub retry_base_delay_secs: u64,
#[serde(default = "Settings::default_poll_interval_local")]
pub poll_interval_local_secs: u64,
#[serde(default = "Settings::default_poll_interval_remote")]
pub poll_interval_remote_secs: u64,
#[serde(default = "Settings::default_ssh_timeout")]
pub ssh_timeout_secs: u64,
#[serde(default = "Settings::default_ssh_connect_timeout")]
pub ssh_connect_timeout_secs: u64,
}
impl Default for Settings {
fn default() -> Self {
Self {
default_list_limit: Self::default_list_limit(),
retry_base_delay_secs: Self::default_retry_base_delay(),
poll_interval_local_secs: Self::default_poll_interval_local(),
poll_interval_remote_secs: Self::default_poll_interval_remote(),
ssh_timeout_secs: Self::default_ssh_timeout(),
ssh_connect_timeout_secs: Self::default_ssh_connect_timeout(),
}
}
}
impl Settings {
fn default_list_limit() -> usize {
20
}
fn default_retry_base_delay() -> u64 {
30
}
fn default_poll_interval_local() -> u64 {
2
}
fn default_poll_interval_remote() -> u64 {
5
}
fn default_ssh_timeout() -> u64 {
60
}
fn default_ssh_connect_timeout() -> u64 {
30
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteConfig {
pub host: String,
pub base_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SlurmConfig {
pub partition: Option<String>,
pub time: Option<String>,
pub gpus: Option<u32>,
pub cpus: Option<u32>,
pub memory: Option<String>,
pub constraint: Option<String>,
pub nodes: Option<u32>,
pub exclude: Option<String>,
}
impl SlurmConfig {
pub fn merge(&self, other: &SlurmConfig) -> SlurmConfig {
SlurmConfig {
partition: other.partition.clone().or_else(|| self.partition.clone()),
time: other.time.clone().or_else(|| self.time.clone()),
gpus: other.gpus.or(self.gpus),
cpus: other.cpus.or(self.cpus),
memory: other.memory.clone().or_else(|| self.memory.clone()),
constraint: other.constraint.clone().or_else(|| self.constraint.clone()),
nodes: other.nodes.or(self.nodes),
exclude: other.exclude.clone().or_else(|| self.exclude.clone()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct JobDefinition {
pub command: Option<String>,
#[serde(default)]
pub inputs: Vec<String>,
#[serde(default)]
pub outputs: Vec<String>,
#[serde(default)]
pub slurm: SlurmConfig,
#[serde(default)]
pub env: IndexMap<String, String>,
pub host: Option<String>,
#[serde(default)]
pub exec: Option<bool>,
#[serde(default)]
pub dotenv: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedJob {
pub name: String,
pub command: String,
pub inputs: Vec<String>,
pub outputs: Vec<String>,
pub slurm: SlurmConfig,
pub env: IndexMap<String, String>,
pub host: String,
#[serde(default)]
pub exec: bool,
}
#[derive(Debug, Clone)]
pub struct Config {
pub project_name: String,
pub project_path: PathBuf,
pub remote: RemoteConfig,
pub global_env: IndexMap<String, String>,
dotenv: HashMap<String, String>,
pub global_slurm: SlurmConfig,
pub jobs: HashMap<String, JobDefinition>,
pub settings: Settings,
global_dotenv: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
project: ProjectConfig,
remote: Option<RemoteConfig>,
#[serde(default)]
env: IndexMap<String, String>,
#[serde(default)]
slurm: SlurmConfig,
#[serde(default)]
jobs: HashMap<String, JobDefinition>,
#[serde(default)]
settings: Settings,
dotenv: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawJobFile {
command: Option<String>,
#[serde(default)]
inputs: Vec<String>,
#[serde(default)]
outputs: Vec<String>,
#[serde(default)]
slurm: SlurmConfig,
#[serde(default)]
env: IndexMap<String, String>,
host: Option<String>,
#[serde(default)]
exec: Option<bool>,
dotenv: Option<String>,
}
impl Config {
pub fn find_and_load() -> Result<Config> {
let config_path = find_config_file()?;
Self::load_from_path(&config_path)
}
pub fn load_from_path(config_path: &Path) -> Result<Config> {
let project_path = config_path
.parent()
.ok_or_else(|| FlecheError::ConfigParse("Invalid config path".to_string()))?
.to_path_buf();
let dotenv = load_dotenv(&project_path);
let content = std::fs::read_to_string(config_path)
.map_err(|e| FlecheError::ConfigParse(format!("Failed to read config: {e}")))?;
let raw: RawConfig = toml::from_str(&content)
.map_err(|e| FlecheError::ConfigParse(format!("Failed to parse TOML: {e}")))?;
let raw_remote = raw
.remote
.ok_or_else(|| FlecheError::MissingField("remote".to_string()))?;
let project_name = raw.project.name.unwrap_or_else(|| {
project_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed")
.to_string()
});
let expanded_global_env = expand_env_map(raw.env.clone(), &project_name, &dotenv)?;
let remote = RemoteConfig {
host: raw_remote.host,
base_path: expand_variables(
&raw_remote.base_path,
&project_name,
&expanded_global_env,
&dotenv,
)?,
};
let global_env = raw.env;
let mut jobs = raw.jobs;
let fleche_dir = project_path.join("fleche");
if fleche_dir.is_dir() {
load_jobs_from_dir(&fleche_dir, &fleche_dir, &mut jobs)?;
}
Ok(Config {
project_name,
project_path,
remote,
global_env,
dotenv,
global_slurm: raw.slurm,
jobs,
settings: raw.settings,
global_dotenv: raw.dotenv,
})
}
pub fn resolve_job(
&self,
job_name: Option<&str>,
command_override: Option<&str>,
env_overrides: &[(String, String)],
slurm_overrides: &SlurmConfig,
) -> Result<ResolvedJob> {
let (name, job_def) = if let Some(name) = job_name {
let job = self.jobs.get(name).ok_or_else(|| {
let available: Vec<_> = self.jobs.keys().cloned().collect();
FlecheError::JobNotFound(name.to_string(), available.join(", "))
})?;
(name.to_string(), job.clone())
} else {
if command_override.is_none() {
return Err(FlecheError::NoJobOrCommand);
}
("adhoc".to_string(), JobDefinition::default())
};
let merged_slurm = self.global_slurm.merge(&job_def.slurm);
let final_slurm = merged_slurm.merge(slurm_overrides);
let dotenv_path = job_def.dotenv.as_ref().or(self.global_dotenv.as_ref());
let dotenv_vars = match dotenv_path {
Some(path) => load_dotenv_strict(&self.project_path.join(path))?,
None => HashMap::new(),
};
let mut raw_env: IndexMap<String, String> = dotenv_vars.into_iter().collect();
raw_env.extend(self.global_env.clone());
raw_env.extend(job_def.env.clone());
for (k, v) in env_overrides {
raw_env.insert(k.clone(), v.clone());
}
let expanded_env = expand_env_map(raw_env, &self.project_name, &self.dotenv)?;
let raw_command = command_override
.map(std::string::ToString::to_string)
.or(job_def.command)
.ok_or_else(|| FlecheError::MissingField(format!("command for job '{name}'")))?;
let command = expand_variables(
&raw_command,
&self.project_name,
&expanded_env,
&self.dotenv,
)?;
let inputs = job_def
.inputs
.iter()
.map(|v| expand_variables(v, &self.project_name, &expanded_env, &self.dotenv))
.collect::<Result<Vec<_>>>()?;
let outputs = job_def
.outputs
.iter()
.map(|v| expand_variables(v, &self.project_name, &expanded_env, &self.dotenv))
.collect::<Result<Vec<_>>>()?;
reject_empty_path_entries(&name, "inputs", &job_def.inputs, &inputs)?;
reject_empty_path_entries(&name, "outputs", &job_def.outputs, &outputs)?;
let host = job_def.host.unwrap_or_else(|| self.remote.host.clone());
let exec = job_def.exec.unwrap_or(false);
Ok(ResolvedJob {
name,
command,
inputs,
outputs,
slurm: final_slurm,
env: expanded_env,
host,
exec,
})
}
pub fn dotenv_file(&self) -> Option<&str> {
self.global_dotenv.as_deref()
}
pub fn job_names(&self) -> Vec<String> {
let mut names: Vec<_> = self.jobs.keys().cloned().collect();
names.sort();
names
}
}
pub fn reject_empty_path_entries(
job: &str,
field: &str,
raw: &[String],
expanded: &[String],
) -> Result<()> {
for (index, value) in expanded.iter().enumerate() {
if value.trim().is_empty() {
return Err(FlecheError::EmptyPathEntry {
job: job.to_string(),
field: field.to_string(),
index,
raw: raw.get(index).cloned().unwrap_or_default(),
});
}
}
Ok(())
}
fn find_config_file() -> Result<PathBuf> {
let mut current = std::env::current_dir()
.map_err(|e| FlecheError::ConfigParse(format!("Failed to get current directory: {e}")))?;
loop {
let config_path = current.join("fleche.toml");
if config_path.exists() {
return Ok(config_path);
}
if !current.pop() {
return Err(FlecheError::ConfigNotFound);
}
}
}
fn load_jobs_from_dir(
base_dir: &Path,
current_dir: &Path,
jobs: &mut HashMap<String, JobDefinition>,
) -> Result<()> {
let entries = std::fs::read_dir(current_dir)
.map_err(|e| FlecheError::ConfigParse(format!("Failed to read fleche directory: {e}")))?;
for entry in entries {
let entry = entry.map_err(|e| {
FlecheError::ConfigParse(format!("Failed to read directory entry: {e}"))
})?;
let path = entry.path();
if path.is_dir() {
load_jobs_from_dir(base_dir, &path, jobs)?;
} else if let Some(ext) = path.extension()
&& ext == "toml"
{
let relative = path
.strip_prefix(base_dir)
.map_err(|e| FlecheError::ConfigParse(format!("Path error: {e}")))?;
let job_name = relative
.with_extension("")
.to_string_lossy()
.replace('\\', "/");
if jobs.contains_key(&job_name) {
return Err(FlecheError::DuplicateJob(
job_name,
format!("fleche.toml and {}", path.display()),
));
}
let content = std::fs::read_to_string(&path).map_err(|e| {
FlecheError::ConfigParse(format!("Failed to read {}: {}", path.display(), e))
})?;
let raw: RawJobFile = toml::from_str(&content).map_err(|e| {
FlecheError::ConfigParse(format!("Failed to parse {}: {}", path.display(), e))
})?;
jobs.insert(
job_name,
JobDefinition {
command: raw.command,
inputs: raw.inputs,
outputs: raw.outputs,
slurm: raw.slurm,
env: raw.env,
host: raw.host,
exec: raw.exec,
dotenv: raw.dotenv,
},
);
}
}
Ok(())
}
pub fn generate_init_config() -> &'static str {
r#"# dotenv = ".env" # Load .env file vars into job environments
[project]
# name = "my-project" # Optional, defaults to directory name
[remote]
host = "cluster" # SSH host (from ~/.ssh/config or full address)
base_path = "~/fleche" # Remote base directory for all projects
[env]
# Global environment variables for all jobs
# HF_HOME = "/scratch/cache/huggingface"
# PYTHONUNBUFFERED = "1"
[slurm]
# Global Slurm defaults (inherited by all jobs)
# partition = "gpu"
# time = "4:00:00"
# gpus = 1
# cpus = 8
# memory = "32G"
# [settings]
# Optional settings to override defaults
# default_list_limit = 20 # Jobs shown in `fleche status`
# retry_base_delay_secs = 30 # Base delay for --retry exponential backoff
# ssh_timeout_secs = 60 # SSH command timeout
# ssh_connect_timeout_secs = 30 # SSH connection timeout
# Example job definition:
# [jobs.train]
# command = "python train.py"
# inputs = ["data/"] # gitignored files to copy to workspace
# outputs = ["checkpoints/"] # files to download with `fleche download`
# exec = true # run directly via SSH instead of Slurm
#
# [jobs.train.slurm]
# time = "24:00:00"
# gpus = 4
# Jobs can also be defined in separate files: fleche/train.toml, fleche/eval.toml
"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slurm_merge() {
let base = SlurmConfig {
partition: Some("cpu".to_string()),
time: Some("1:00:00".to_string()),
gpus: None,
cpus: Some(4),
memory: None,
constraint: None,
nodes: None,
exclude: None,
};
let override_config = SlurmConfig {
partition: Some("gpu".to_string()),
time: None,
gpus: Some(1),
cpus: None,
memory: Some("32G".to_string()),
constraint: None,
nodes: None,
exclude: None,
};
let merged = base.merge(&override_config);
assert_eq!(merged.partition, Some("gpu".to_string()));
assert_eq!(merged.time, Some("1:00:00".to_string()));
assert_eq!(merged.gpus, Some(1));
assert_eq!(merged.cpus, Some(4));
assert_eq!(merged.memory, Some("32G".to_string()));
}
#[test]
fn test_expand_variables_from_system_env() {
let context = IndexMap::new();
let dotenv = HashMap::new();
let result = expand_variables("/home/${USER}", "test", &context, &dotenv).unwrap();
assert!(result.starts_with("/home/"));
assert!(!result.contains("${"));
}
#[test]
fn test_expand_variables_from_context() {
let mut context = IndexMap::new();
context.insert("CACHE".to_string(), "/scratch/cache".to_string());
let dotenv = HashMap::new();
let result = expand_variables("${CACHE}/data", "test", &context, &dotenv).unwrap();
assert_eq!(result, "/scratch/cache/data");
}
#[test]
fn test_expand_variables_context_takes_precedence() {
let mut context = IndexMap::new();
context.insert("USER".to_string(), "override_user".to_string());
let dotenv = HashMap::new();
let result = expand_variables("${USER}", "test", &context, &dotenv).unwrap();
assert_eq!(result, "override_user");
}
#[test]
fn test_expand_variables_with_default() {
let context = IndexMap::new();
let dotenv = HashMap::new();
let result =
expand_variables("${UNDEFINED_VAR:-default_value}", "test", &context, &dotenv).unwrap();
assert_eq!(result, "default_value");
}
#[test]
fn test_expand_env_map_ordering() {
let mut env = IndexMap::new();
env.insert("BASE".to_string(), "/scratch".to_string());
env.insert("CACHE".to_string(), "${BASE}/cache".to_string());
env.insert("UV_CACHE".to_string(), "${CACHE}/uv".to_string());
let dotenv = HashMap::new();
let expanded = expand_env_map(env, "test", &dotenv).unwrap();
assert_eq!(expanded.get("BASE").unwrap(), "/scratch");
assert_eq!(expanded.get("CACHE").unwrap(), "/scratch/cache");
assert_eq!(expanded.get("UV_CACHE").unwrap(), "/scratch/cache/uv");
}
#[test]
fn test_expand_variables_no_expansion_needed() {
let context = IndexMap::new();
let dotenv = HashMap::new();
let result = expand_variables("/plain/path/no/vars", "test", &context, &dotenv).unwrap();
assert_eq!(result, "/plain/path/no/vars");
}
#[test]
fn test_expand_variables_from_dotenv() {
let context = IndexMap::new();
let mut dotenv = HashMap::new();
dotenv.insert("MY_VAR".to_string(), "from_dotenv".to_string());
let result = expand_variables("${MY_VAR}", "test", &context, &dotenv).unwrap();
assert_eq!(result, "from_dotenv");
}
#[test]
fn test_expand_variables_system_env_beats_dotenv() {
let context = IndexMap::new();
let mut dotenv = HashMap::new();
dotenv.insert("USER".to_string(), "dotenv_user".to_string());
let result = expand_variables("${USER}", "test", &context, &dotenv).unwrap();
assert_ne!(result, "dotenv_user");
}
#[test]
fn test_expand_variables_context_beats_dotenv() {
let mut context = IndexMap::new();
context.insert("MY_VAR".to_string(), "from_context".to_string());
let mut dotenv = HashMap::new();
dotenv.insert("MY_VAR".to_string(), "from_dotenv".to_string());
let result = expand_variables("${MY_VAR}", "test", &context, &dotenv).unwrap();
assert_eq!(result, "from_context");
}
#[test]
fn test_expand_variables_project_builtin() {
let context = IndexMap::new();
let dotenv = HashMap::new();
let result = expand_variables("${PROJECT}", "myproject", &context, &dotenv).unwrap();
assert_eq!(result, "myproject");
}
#[test]
fn test_expand_variables_project_in_path() {
let context = IndexMap::new();
let dotenv = HashMap::new();
let result =
expand_variables("/scratch/${PROJECT}/.venv", "graphmind", &context, &dotenv).unwrap();
assert_eq!(result, "/scratch/graphmind/.venv");
}
#[test]
fn test_expand_variables_project_beats_all() {
let mut context = IndexMap::new();
context.insert("PROJECT".to_string(), "from_context".to_string());
let mut dotenv = HashMap::new();
dotenv.insert("PROJECT".to_string(), "from_dotenv".to_string());
let result = expand_variables("${PROJECT}", "builtin", &context, &dotenv).unwrap();
assert_eq!(result, "builtin");
}
#[test]
fn test_load_dotenv_strict_missing_file_errors() {
let path = Path::new("/tmp/fleche_test_nonexistent/.env");
let result = load_dotenv_strict(path);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("dotenv file not found"), "got: {err}");
}
#[test]
fn test_load_dotenv_strict_reads_file() {
let dir = tempfile::tempdir().unwrap();
let env_path = dir.path().join(".env");
std::fs::write(&env_path, "API_KEY=secret123\nDB_HOST=localhost\n").unwrap();
let vars = load_dotenv_strict(&env_path).unwrap();
assert_eq!(vars.get("API_KEY").unwrap(), "secret123");
assert_eq!(vars.get("DB_HOST").unwrap(), "localhost");
}
fn create_test_project(
toml_content: &str,
dotenv_files: &[(&str, &str)],
) -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("fleche.toml");
std::fs::write(&config_path, toml_content).unwrap();
for (name, content) in dotenv_files {
std::fs::write(dir.path().join(name), content).unwrap();
}
(dir, config_path)
}
#[test]
fn test_dotenv_injects_vars_into_job_env() {
let (_dir, config_path) = create_test_project(
r#"
dotenv = ".env"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
"#,
&[(".env", "INJECTED_VAR=hello_world\n")],
);
let config = Config::load_from_path(&config_path).unwrap();
let job = config
.resolve_job(Some("train"), None, &[], &SlurmConfig::default())
.unwrap();
assert_eq!(job.env.get("INJECTED_VAR").unwrap(), "hello_world");
}
#[test]
fn test_dotenv_global_env_overrides_dotenv_vars() {
let (_dir, config_path) = create_test_project(
r#"
dotenv = ".env"
[remote]
host = "cluster"
base_path = "~/fleche"
[env]
SHARED = "from_global"
[jobs.train]
command = "echo hi"
"#,
&[(".env", "SHARED=from_dotenv\n")],
);
let config = Config::load_from_path(&config_path).unwrap();
let job = config
.resolve_job(Some("train"), None, &[], &SlurmConfig::default())
.unwrap();
assert_eq!(job.env.get("SHARED").unwrap(), "from_global");
}
#[test]
fn test_dotenv_job_env_overrides_dotenv_vars() {
let (_dir, config_path) = create_test_project(
r#"
dotenv = ".env"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
[jobs.train.env]
SHARED = "from_job"
"#,
&[(".env", "SHARED=from_dotenv\n")],
);
let config = Config::load_from_path(&config_path).unwrap();
let job = config
.resolve_job(Some("train"), None, &[], &SlurmConfig::default())
.unwrap();
assert_eq!(job.env.get("SHARED").unwrap(), "from_job");
}
#[test]
fn test_dotenv_cli_env_overrides_dotenv_vars() {
let (_dir, config_path) = create_test_project(
r#"
dotenv = ".env"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
"#,
&[(".env", "SHARED=from_dotenv\n")],
);
let config = Config::load_from_path(&config_path).unwrap();
let overrides = vec![("SHARED".to_string(), "from_cli".to_string())];
let job = config
.resolve_job(Some("train"), None, &overrides, &SlurmConfig::default())
.unwrap();
assert_eq!(job.env.get("SHARED").unwrap(), "from_cli");
}
#[test]
fn test_dotenv_per_job_overrides_global() {
let (_dir, config_path) = create_test_project(
r#"
dotenv = ".env"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
dotenv = ".env.train"
"#,
&[
(".env", "SOURCE=global\nGLOBAL_ONLY=yes\n"),
(".env.train", "SOURCE=train\nTRAIN_ONLY=yes\n"),
],
);
let config = Config::load_from_path(&config_path).unwrap();
let job = config
.resolve_job(Some("train"), None, &[], &SlurmConfig::default())
.unwrap();
assert_eq!(job.env.get("SOURCE").unwrap(), "train");
assert_eq!(job.env.get("TRAIN_ONLY").unwrap(), "yes");
assert!(job.env.get("GLOBAL_ONLY").is_none());
}
#[test]
fn test_dotenv_missing_configured_file_errors() {
let (_dir, config_path) = create_test_project(
r#"
dotenv = ".env.missing"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
"#,
&[],
);
let config = Config::load_from_path(&config_path).unwrap();
let result = config.resolve_job(Some("train"), None, &[], &SlurmConfig::default());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("dotenv file not found"), "got: {err}");
}
#[test]
fn test_empty_input_entry_is_rejected() {
let (_dir, config_path) = create_test_project(
r#"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
inputs = ["data/real.txt", "${OPTIONAL}"]
[jobs.train.env]
OPTIONAL = ""
"#,
&[],
);
let config = Config::load_from_path(&config_path).unwrap();
let result = config.resolve_job(Some("train"), None, &[], &SlurmConfig::default());
assert!(result.is_err(), "expected empty input to be rejected");
let err = result.unwrap_err().to_string();
assert!(err.contains("inputs"), "got: {err}");
assert!(err.contains("empty"), "got: {err}");
assert!(err.contains("${OPTIONAL}"), "got: {err}");
}
#[test]
fn test_empty_output_entry_is_rejected() {
let (_dir, config_path) = create_test_project(
r#"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
outputs = ["${MISSING}"]
[jobs.train.env]
MISSING = ""
"#,
&[],
);
let config = Config::load_from_path(&config_path).unwrap();
let result = config.resolve_job(Some("train"), None, &[], &SlurmConfig::default());
assert!(result.is_err(), "expected empty output to be rejected");
let err = result.unwrap_err().to_string();
assert!(err.contains("outputs"), "got: {err}");
}
#[test]
fn test_whitespace_only_input_entry_is_rejected() {
let (_dir, config_path) = create_test_project(
r#"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
inputs = [" "]
"#,
&[],
);
let config = Config::load_from_path(&config_path).unwrap();
let result = config.resolve_job(Some("train"), None, &[], &SlurmConfig::default());
assert!(
result.is_err(),
"expected whitespace-only input to be rejected"
);
}
#[test]
fn test_reject_empty_path_entries_helper() {
assert!(reject_empty_path_entries("j", "inputs", &[], &[]).is_ok());
assert!(
reject_empty_path_entries(
"j",
"inputs",
&["data/x.txt".to_string()],
&["data/x.txt".to_string()],
)
.is_ok()
);
let raw = vec!["data/x.txt".to_string(), String::new()];
let err = reject_empty_path_entries("j", "inputs", &raw, &raw).unwrap_err();
assert!(err.to_string().contains("index 1"), "got: {err}");
let ws = vec![" ".to_string()];
assert!(reject_empty_path_entries("j", "inputs", &ws, &ws).is_err());
}
#[test]
fn test_non_empty_inputs_still_resolve() {
let (_dir, config_path) = create_test_project(
r#"
[remote]
host = "cluster"
base_path = "~/fleche"
[jobs.train]
command = "echo hi"
inputs = ["data/real.txt", "${OPTIONAL}"]
[jobs.train.env]
OPTIONAL = "extra/file.txt"
"#,
&[],
);
let config = Config::load_from_path(&config_path).unwrap();
let job = config
.resolve_job(Some("train"), None, &[], &SlurmConfig::default())
.unwrap();
assert_eq!(job.inputs, vec!["data/real.txt", "extra/file.txt"]);
}
#[test]
fn test_dotenv_accessor() {
let (_dir, config_path) = create_test_project(
r#"
dotenv = ".env"
[remote]
host = "cluster"
base_path = "~/fleche"
"#,
&[(".env", "")],
);
let config = Config::load_from_path(&config_path).unwrap();
assert_eq!(config.dotenv_file(), Some(".env"));
}
#[test]
fn test_dotenv_accessor_none_when_unset() {
let (_dir, config_path) = create_test_project(
r#"
[remote]
host = "cluster"
base_path = "~/fleche"
"#,
&[],
);
let config = Config::load_from_path(&config_path).unwrap();
assert_eq!(config.dotenv_file(), None);
}
}