use std::path::PathBuf;
use crate::error::{CruiseError, Result};
const DEFAULT_CONFIG: &str = include_str!("default_config.yaml");
#[derive(Debug, Clone)]
pub enum ConfigSource {
Explicit(PathBuf),
Local(PathBuf),
UserDir(PathBuf),
Builtin,
}
impl ConfigSource {
pub fn display_string(&self) -> String {
match self {
Self::Builtin => "config: (builtin default)".to_string(),
Self::Explicit(p) | Self::Local(p) | Self::UserDir(p) => {
format!("config: {}", p.display())
}
}
}
}
pub fn resolve_config(explicit: Option<&str>) -> Result<(String, ConfigSource)> {
if let Some(path) = explicit {
let buf = PathBuf::from(path);
let yaml = std::fs::read_to_string(&buf).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CruiseError::ConfigNotFound(path.to_string())
} else {
CruiseError::Other(format!("failed to read '{}': {}", path, e))
}
})?;
return Ok((yaml, ConfigSource::Explicit(buf)));
}
let local = PathBuf::from("cruise.yaml");
match std::fs::read_to_string(&local) {
Ok(yaml) => return Ok((yaml, ConfigSource::Local(local))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
return Err(CruiseError::Other(format!(
"failed to read 'cruise.yaml': {}",
e
)));
}
}
if let Ok(home) = std::env::var("HOME") {
let cruise_dir = PathBuf::from(home).join(".cruise");
let files = collect_yaml_files(&cruise_dir);
if !files.is_empty() {
let path = if files.len() == 1 {
files.into_iter().next().unwrap()
} else {
prompt_select_config(&files)?
};
let yaml = std::fs::read_to_string(&path).map_err(|e| {
CruiseError::Other(format!("failed to read '{}': {}", path.display(), e))
})?;
return Ok((yaml, ConfigSource::UserDir(path)));
}
}
Ok((DEFAULT_CONFIG.to_string(), ConfigSource::Builtin))
}
fn collect_yaml_files(dir: &PathBuf) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return vec![];
};
let mut files: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.is_file()
&& matches!(
p.extension().and_then(|e| e.to_str()),
Some("yaml") | Some("yml")
)
})
.collect();
files.sort_by_key(|p| p.file_name().unwrap_or_default().to_os_string());
files
}
fn prompt_select_config(files: &[PathBuf]) -> Result<PathBuf> {
let names: Vec<String> = files
.iter()
.map(|p| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
.collect();
let selection = dialoguer::Select::new()
.with_prompt("Select a workflow config")
.items(&names)
.default(0)
.interact_opt()
.map_err(|e| CruiseError::Other(e.to_string()))?
.ok_or_else(|| CruiseError::Other("config selection cancelled".to_string()))?;
Ok(files[selection].clone())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
static GLOBAL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct DirGuard {
prev: PathBuf,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl DirGuard {
fn new() -> Self {
let lock = GLOBAL_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
Self {
prev: std::env::current_dir().unwrap(),
_lock: lock,
}
}
}
impl Drop for DirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.prev);
}
}
struct EnvGuard {
key: &'static str,
prev: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &std::ffi::OsStr) -> Self {
let prev = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self { key, prev }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
if let Some(ref v) = self.prev {
std::env::set_var(self.key, v);
} else {
std::env::remove_var(self.key);
}
}
}
}
#[test]
fn test_resolve_explicit_ok() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "command: [echo]\nsteps:\n s:\n command: echo").unwrap();
let path = tmp.path().to_str().unwrap().to_string();
let (yaml, source) = resolve_config(Some(&path)).unwrap();
assert!(yaml.contains("echo"));
assert!(matches!(source, ConfigSource::Explicit(_)));
}
#[test]
fn test_resolve_explicit_missing() {
let result = resolve_config(Some("/nonexistent/path/cruise.yaml"));
assert!(result.is_err());
}
#[test]
fn test_resolve_builtin_fallback() {
let tmp_dir = tempfile::tempdir().unwrap();
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap();
let fake_home = tempfile::tempdir().unwrap();
let _home_guard = EnvGuard::set("HOME", fake_home.path().as_os_str());
let (yaml, source) = resolve_config(None).unwrap();
assert!(yaml.contains("steps"));
assert!(matches!(source, ConfigSource::Builtin));
}
#[test]
fn test_resolve_local() {
let tmp_dir = tempfile::tempdir().unwrap();
let config_path = tmp_dir.path().join("cruise.yaml");
std::fs::write(
&config_path,
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap();
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap();
let (yaml, source) = resolve_config(None).unwrap();
assert!(yaml.contains("echo"));
assert!(matches!(source, ConfigSource::Local(_)));
}
#[test]
fn test_collect_yaml_files_sorted() {
let tmp_dir = tempfile::tempdir().unwrap();
std::fs::write(tmp_dir.path().join("b.yaml"), "").unwrap();
std::fs::write(tmp_dir.path().join("a.yml"), "").unwrap();
std::fs::write(tmp_dir.path().join("c.yaml"), "").unwrap();
std::fs::write(tmp_dir.path().join("d.txt"), "").unwrap();
let files = collect_yaml_files(&tmp_dir.path().to_path_buf());
let names: Vec<&str> = files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["a.yml", "b.yaml", "c.yaml"]);
}
#[test]
fn test_collect_yaml_files_empty_dir() {
let tmp_dir = tempfile::tempdir().unwrap();
let files = collect_yaml_files(&tmp_dir.path().to_path_buf());
assert!(files.is_empty());
}
#[test]
fn test_builtin_yaml_parses() {
use crate::config::WorkflowConfig;
let config = WorkflowConfig::from_yaml(DEFAULT_CONFIG).unwrap();
assert!(!config.steps.is_empty());
}
}