use std::path::PathBuf;
use crate::config::WorkflowConfig;
use crate::error::{CruiseError, Result};
#[derive(Debug, Clone)]
pub enum ConfigSource {
Explicit(PathBuf),
EnvVar(PathBuf),
Local(PathBuf),
UserDir(PathBuf),
Builtin,
}
impl ConfigSource {
#[must_use]
pub fn display_string(&self) -> String {
match self {
Self::Builtin => "config: (builtin default)".to_string(),
Self::Explicit(p) | Self::EnvVar(p) | Self::Local(p) | Self::UserDir(p) => {
format!("config: {}", p.display())
}
}
}
#[must_use]
pub fn path(&self) -> Option<&PathBuf> {
match self {
Self::Explicit(p) | Self::EnvVar(p) | Self::Local(p) | Self::UserDir(p) => Some(p),
Self::Builtin => None,
}
}
}
pub fn resolve_config(explicit: Option<&str>) -> Result<(String, ConfigSource)> {
use std::io::IsTerminal;
let cwd = std::env::current_dir()
.map_err(|e| CruiseError::Other(format!("failed to get current directory: {e}")))?;
if std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
resolve_config_in_dir_with_interactive(explicit, &cwd, true)
} else {
resolve_config_in_dir(explicit, &cwd)
}
}
pub fn resolve_config_in_dir(
explicit: Option<&str>,
cwd: &std::path::Path,
) -> Result<(String, ConfigSource)> {
resolve_config_in_dir_with_interactive(explicit, cwd, false)
}
#[derive(Debug)]
struct ConfigCandidate {
label: String,
source: CandidateKind,
}
impl std::fmt::Display for ConfigCandidate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.label)
}
}
#[derive(Debug)]
enum CandidateKind {
EnvVar(PathBuf),
Local(PathBuf),
UserDir(PathBuf),
Builtin,
}
fn push_yaml_dir_candidates(
candidates: &mut Vec<ConfigCandidate>,
dir: &PathBuf,
kind: impl Fn(PathBuf) -> CandidateKind,
) {
for file in collect_yaml_files(dir) {
let file = to_absolute(file);
let filename = file
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let label = format!("{filename} ({})", file.display());
candidates.push(ConfigCandidate {
label,
source: kind(file),
});
}
}
fn collect_candidates(
cwd: &std::path::Path,
env_val: Option<String>,
) -> Result<Vec<ConfigCandidate>> {
let mut candidates = Vec::new();
if let Some(env_path) = env_val {
let buf = PathBuf::from(&env_path);
match std::fs::metadata(&buf) {
Ok(_) => {
let abs = to_absolute(buf);
let label = format!("CRUISE_CONFIG → {}", abs.display());
candidates.push(ConfigCandidate {
label,
source: CandidateKind::EnvVar(abs),
});
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(CruiseError::ConfigNotFound(env_path));
}
Err(e) => {
return Err(CruiseError::Other(format!(
"failed to access '{}': {e}",
buf.display()
)));
}
}
}
for name in &["cruise.yaml", "cruise.yml", ".cruise.yaml", ".cruise.yml"] {
let path = cwd.join(name);
if path.is_file() {
let abs = to_absolute(path.clone());
let label = format!("{name} ({})", abs.display());
candidates.push(ConfigCandidate {
label,
source: CandidateKind::Local(abs),
});
}
}
let local_dir = cwd.join(".cruise");
if local_dir.is_dir() {
push_yaml_dir_candidates(&mut candidates, &local_dir, CandidateKind::Local);
}
if let Ok(config_dir) = crate::paths::config_dir() {
push_yaml_dir_candidates(&mut candidates, &config_dir, CandidateKind::UserDir);
}
candidates.push(ConfigCandidate {
label: "Built-in default".to_string(),
source: CandidateKind::Builtin,
});
Ok(candidates)
}
fn resolve_config_in_dir_with_interactive(
explicit: Option<&str>,
cwd: &std::path::Path,
interactive: bool,
) -> 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(to_absolute(buf))));
}
let env_val = std::env::var("CRUISE_CONFIG").ok();
let candidates = collect_candidates(cwd, env_val)?;
let chosen = if !interactive
|| matches!(
candidates.first().map(|c| &c.source),
Some(CandidateKind::EnvVar(_))
) {
candidates.into_iter().next().ok_or_else(|| {
CruiseError::Other("internal error: candidate list was empty".to_string())
})?
} else {
let real: Vec<ConfigCandidate> = candidates
.into_iter()
.filter(|c| !matches!(c.source, CandidateKind::Builtin))
.collect();
if real.is_empty() {
ConfigCandidate {
label: "Built-in default".to_string(),
source: CandidateKind::Builtin,
}
} else if real.len() == 1 {
real.into_iter().next().ok_or_else(|| {
CruiseError::Other(
"internal error: filtered candidate list became empty".to_string(),
)
})?
} else {
prompt_select_among_candidates(real)?
}
};
materialize_candidate(chosen)
}
fn materialize_candidate(candidate: ConfigCandidate) -> Result<(String, ConfigSource)> {
match candidate.source {
CandidateKind::EnvVar(path) => {
let yaml = read_config_file(&path)?;
Ok((yaml, ConfigSource::EnvVar(path)))
}
CandidateKind::Local(path) => {
let yaml = read_config_file(&path)?;
Ok((yaml, ConfigSource::Local(path)))
}
CandidateKind::UserDir(path) => {
let yaml = read_config_file(&path)?;
Ok((yaml, ConfigSource::UserDir(path)))
}
CandidateKind::Builtin => {
let yaml = serde_yaml::to_string(&WorkflowConfig::default_builtin()).map_err(|e| {
CruiseError::Other(format!("failed to serialize built-in config: {e}"))
})?;
Ok((yaml, ConfigSource::Builtin))
}
}
}
fn read_config_file(path: &std::path::Path) -> Result<String> {
std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
CruiseError::ConfigNotFound(path.display().to_string())
} else {
CruiseError::Other(format!("failed to read '{}': {e}", path.display()))
}
})
}
fn prompt_select_among_candidates(candidates: Vec<ConfigCandidate>) -> Result<ConfigCandidate> {
match inquire::Select::new("Select a workflow config", candidates)
.with_starting_cursor(0)
.prompt()
{
Ok(candidate) => Ok(candidate),
Err(
inquire::InquireError::OperationCanceled | inquire::InquireError::OperationInterrupted,
) => Err(CruiseError::Other("config selection cancelled".to_string())),
Err(e) => Err(CruiseError::Other(e.to_string())),
}
}
fn to_absolute(path: PathBuf) -> PathBuf {
if path.is_absolute() {
return path;
}
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
}
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| {
if p.is_dir() {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(name, "sessions" | "worktrees" | "clones") {
return false;
}
}
p.is_file() && matches!(p.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"))
})
.collect();
files.sort_by_key(|p| p.file_name().unwrap_or_default().to_os_string());
files
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::path::PathBuf;
struct DirGuard {
prev: PathBuf,
_lock: crate::test_support::ProcessLock,
}
impl DirGuard {
fn new() -> Self {
let lock = crate::test_support::lock_process();
Self {
prev: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
_lock: lock,
}
}
}
impl Drop for DirGuard {
fn drop(&mut self) {
if std::env::set_current_dir(&self.prev).is_err() {
let _ = std::env::set_current_dir("/");
}
}
}
use crate::test_support::EnvGuard;
#[test]
fn test_resolve_explicit_ok() {
let mut tmp = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
writeln!(tmp, "command: [echo]\nsteps:\n s:\n command: echo")
.unwrap_or_else(|e| panic!("{e:?}"));
let path = tmp
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"))
.to_string();
let (yaml, source) = resolve_config(Some(&path)).unwrap_or_else(|e| panic!("{e:?}"));
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_local() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let config_path = tmp_dir.path().join("cruise.yaml");
std::fs::write(
&config_path,
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config(None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("echo"));
assert!(matches!(source, ConfigSource::Local(_)));
}
#[test]
fn test_resolve_local_yml() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yml"),
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config(None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("echo"));
assert!(matches!(source, ConfigSource::Local(_)));
}
#[test]
fn test_resolve_hidden_cruise_yaml() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join(".cruise.yaml"),
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config(None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("echo"));
assert!(matches!(source, ConfigSource::Local(_)));
}
#[test]
fn test_resolve_hidden_cruise_yml() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join(".cruise.yml"),
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config(None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("echo"));
assert!(matches!(source, ConfigSource::Local(_)));
}
#[test]
fn test_resolve_env_var_ok() {
let mut tmp = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
writeln!(tmp, "command: [echo]\nsteps:\n s:\n command: echo")
.unwrap_or_else(|e| panic!("{e:?}"));
let path = tmp
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"));
let _dir_guard = DirGuard::new();
let _env_guard = EnvGuard::set("CRUISE_CONFIG", std::ffi::OsStr::new(path));
let (yaml, source) = resolve_config(None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("echo"));
assert!(matches!(source, ConfigSource::EnvVar(_)));
}
#[test]
fn test_resolve_env_var_missing_file() {
let _dir_guard = DirGuard::new();
let _env_guard = EnvGuard::set(
"CRUISE_CONFIG",
std::ffi::OsStr::new("/nonexistent/env/cruise.yaml"),
);
let result = resolve_config(None);
assert!(result.is_err());
}
#[test]
fn test_env_var_takes_priority_over_local() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yaml"),
"command: [local]\nsteps:\n s:\n command: local",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let mut env_tmp = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
writeln!(
env_tmp,
"command: [envvar]\nsteps:\n s:\n command: envvar"
)
.unwrap_or_else(|e| panic!("{e:?}"));
let env_path = env_tmp
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"));
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
let _env_guard = EnvGuard::set("CRUISE_CONFIG", std::ffi::OsStr::new(env_path));
let (yaml, source) = resolve_config(None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("envvar"));
assert!(matches!(source, ConfigSource::EnvVar(_)));
}
#[test]
fn test_local_takes_priority_over_hidden() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yaml"),
"command: [visible]\nsteps:\n s:\n command: visible",
)
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join(".cruise.yaml"),
"command: [hidden]\nsteps:\n s:\n command: hidden",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _dir_guard = DirGuard::new();
std::env::set_current_dir(tmp_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, _source) = resolve_config(None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("visible"));
}
#[test]
fn test_collect_yaml_files_sorted() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(tmp_dir.path().join("b.yaml"), "").unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(tmp_dir.path().join("a.yml"), "").unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(tmp_dir.path().join("c.yaml"), "").unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(tmp_dir.path().join("d.txt"), "").unwrap_or_else(|e| panic!("{e:?}"));
let files = collect_yaml_files(&tmp_dir.path().to_path_buf());
let names: Vec<&str> = files
.iter()
.map(|p| {
p.file_name()
.unwrap_or_else(|| panic!("unexpected None"))
.to_str()
.unwrap_or_else(|| panic!("unexpected None"))
})
.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_or_else(|e| panic!("{e:?}"));
let files = collect_yaml_files(&tmp_dir.path().to_path_buf());
assert!(files.is_empty());
}
#[test]
fn test_resolve_in_dir_local_config_beats_user_dir() {
let repo_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
repo_dir.path().join("cruise.yaml"),
"command: [local]\nsteps:\n s:\n command: local",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let config_cruise = fake_home.path().join(".config").join("cruise");
std::fs::create_dir_all(&config_cruise).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
config_cruise.join("default.yaml"),
"command: [userdir]\nsteps:\n s:\n command: userdir",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _dir_guard = DirGuard::new();
let _home_guard = EnvGuard::set("HOME", fake_home.path().as_os_str());
let _xdg_guard = EnvGuard::remove("XDG_CONFIG_HOME");
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) =
resolve_config_in_dir(None, repo_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
assert!(yaml.contains("local"), "expected local config, got: {yaml}");
assert!(
matches!(source, ConfigSource::Local(_)),
"expected Local, got: {source:?}"
);
if let ConfigSource::Local(p) = source {
assert_eq!(p, repo_dir.path().join("cruise.yaml"));
}
}
#[test]
fn test_resolve_in_dir_explicit_path_bypasses_dir() {
let repo_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
repo_dir.path().join("cruise.yaml"),
"command: [local]\nsteps:\n s:\n command: local",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let explicit_file = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
explicit_file.path(),
"command: [explicit]\nsteps:\n s:\n command: explicit",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let explicit_path = explicit_file
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"))
.to_string();
let _dir_guard = DirGuard::new();
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config_in_dir(Some(&explicit_path), repo_dir.path())
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("explicit"),
"expected explicit config, got: {yaml}"
);
assert!(
matches!(source, ConfigSource::Explicit(_)),
"expected Explicit, got: {source:?}"
);
}
#[test]
fn test_resolve_in_dir_env_var_bypasses_dir() {
let repo_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
repo_dir.path().join("cruise.yaml"),
"command: [local]\nsteps:\n s:\n command: local",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let env_file = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
env_file.path(),
"command: [envvar]\nsteps:\n s:\n command: envvar",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let env_path = env_file
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"));
let _dir_guard = DirGuard::new();
let _env_guard = EnvGuard::set("CRUISE_CONFIG", std::ffi::OsStr::new(env_path));
let (yaml, source) =
resolve_config_in_dir(None, repo_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("envvar"),
"expected envvar config, got: {yaml}"
);
assert!(
matches!(source, ConfigSource::EnvVar(_)),
"expected EnvVar, got: {source:?}"
);
}
#[test]
fn test_resolve_in_dir_falls_back_to_user_dir() {
let repo_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let config_cruise = fake_home.path().join(".config").join("cruise");
std::fs::create_dir_all(&config_cruise).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
config_cruise.join("myconf.yaml"),
"command: [userdir]\nsteps:\n s:\n command: userdir",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _dir_guard = DirGuard::new();
let home_var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
let _home_guard = EnvGuard::set(home_var, fake_home.path().as_os_str());
let _xdg_guard = EnvGuard::remove("XDG_CONFIG_HOME");
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) =
resolve_config_in_dir(None, repo_dir.path()).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("userdir"),
"expected userdir config, got: {yaml}"
);
assert!(
matches!(source, ConfigSource::UserDir(_)),
"expected UserDir, got: {source:?}"
);
}
#[test]
fn test_collect_candidates_only_builtin_when_nothing_exists() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let candidates =
collect_candidates(tmp_dir.path(), None).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
candidates.len(),
1,
"expected only Builtin, got {candidates:?}"
);
assert!(
matches!(candidates[0].source, CandidateKind::Builtin),
"expected Builtin, got {:?}",
candidates[0].source
);
}
#[test]
fn test_collect_candidates_builtin_always_last() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yaml"),
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let candidates =
collect_candidates(tmp_dir.path(), None).unwrap_or_else(|e| panic!("{e:?}"));
assert!(!candidates.is_empty(), "candidates should not be empty");
assert!(
matches!(
candidates
.last()
.unwrap_or_else(|| panic!("unexpected empty"))
.source,
CandidateKind::Builtin
),
"last candidate must be Builtin, got: {candidates:?}"
);
}
#[test]
fn test_collect_candidates_env_is_first() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yaml"),
"command: [local]\nsteps:\n s:\n command: local",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let env_file = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
let env_path = env_file
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"))
.to_string();
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let candidates =
collect_candidates(tmp_dir.path(), Some(env_path)).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
candidates.len() >= 2,
"expected at least EnvVar + Local, got {candidates:?}"
);
assert!(
matches!(candidates[0].source, CandidateKind::EnvVar(_)),
"first candidate must be EnvVar, got: {:?}",
candidates[0].source
);
assert!(
matches!(candidates[1].source, CandidateKind::Local(_)),
"second candidate must be Local, got: {:?}",
candidates[1].source
);
}
#[test]
fn test_collect_candidates_local_cruise_yaml_before_cruise_yml() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yaml"),
"command: [yaml]\nsteps:\n s:\n command: yaml",
)
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yml"),
"command: [yml]\nsteps:\n s:\n command: yml",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let candidates =
collect_candidates(tmp_dir.path(), None).unwrap_or_else(|e| panic!("{e:?}"));
let local_candidates: Vec<&ConfigCandidate> = candidates
.iter()
.filter(|c| matches!(c.source, CandidateKind::Local(_)))
.collect();
assert!(
local_candidates.len() >= 2,
"expected at least 2 local candidates, got {local_candidates:?}"
);
let first_name = match &local_candidates[0].source {
CandidateKind::Local(p) => p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
_ => panic!("expected Local"),
};
assert_eq!(
first_name, "cruise.yaml",
"cruise.yaml must precede cruise.yml"
);
}
#[test]
fn test_collect_candidates_env_missing_file_returns_error() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let result =
collect_candidates(tmp_dir.path(), Some("/nonexistent/cruise.yaml".to_string()));
assert!(
result.is_err(),
"expected error for missing env file, got Ok"
);
}
#[test]
fn test_collect_candidates_user_dir_in_ascii_order() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let config_cruise = fake_home.path().join(".config").join("cruise");
std::fs::create_dir_all(&config_cruise).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(config_cruise.join("b.yaml"), "").unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(config_cruise.join("a.yaml"), "").unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let candidates =
collect_candidates(tmp_dir.path(), None).unwrap_or_else(|e| panic!("{e:?}"));
let user_dir_candidates: Vec<&ConfigCandidate> = candidates
.iter()
.filter(|c| matches!(c.source, CandidateKind::UserDir(_)))
.collect();
assert_eq!(
user_dir_candidates.len(),
2,
"expected 2 user-dir candidates, got {user_dir_candidates:?}"
);
let first_name = match &user_dir_candidates[0].source {
CandidateKind::UserDir(p) => p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
_ => panic!("expected UserDir"),
};
assert_eq!(
first_name, "a.yaml",
"user-dir candidates must be ASCII-sorted"
);
}
#[test]
fn test_collect_candidates_label_contains_kind_prefix() {
let env_file = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
let env_path = env_file
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"))
.to_string();
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let candidates =
collect_candidates(tmp_dir.path(), Some(env_path)).unwrap_or_else(|e| panic!("{e:?}"));
let env_candidate = candidates
.iter()
.find(|c| matches!(c.source, CandidateKind::EnvVar(_)))
.unwrap_or_else(|| panic!("expected EnvVar candidate"));
assert!(
env_candidate.label.contains("CRUISE_CONFIG"),
"env label must include 'CRUISE_CONFIG', got: {}",
env_candidate.label
);
let builtin_candidate = candidates
.iter()
.find(|c| matches!(c.source, CandidateKind::Builtin))
.unwrap_or_else(|| panic!("expected Builtin candidate"));
let lower = builtin_candidate.label.to_lowercase();
assert!(
lower.contains("builtin") || lower.contains("default"),
"builtin label must indicate it is a default, got: {}",
builtin_candidate.label
);
}
#[test]
fn test_interactive_false_cruise_yaml_beats_cruise_yml() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yaml"),
"command: [yaml]\nsteps:\n s:\n command: yaml",
)
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yml"),
"command: [yml]\nsteps:\n s:\n command: yml",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config_in_dir_with_interactive(None, tmp_dir.path(), false)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("yaml") && !yaml.contains("yml\n"),
"expected cruise.yaml content, got: {yaml}"
);
if let ConfigSource::Local(ref p) = source {
assert_eq!(
p.file_name().unwrap_or_default().to_str().unwrap_or(""),
"cruise.yaml",
"resolved path must be cruise.yaml"
);
} else {
panic!("expected Local, got: {source:?}");
}
}
#[test]
fn test_interactive_false_user_dir_multiple_files_picks_ascii_first() {
let repo_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let config_cruise = fake_home.path().join(".config").join("cruise");
std::fs::create_dir_all(&config_cruise).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
config_cruise.join("b.yaml"),
"command: [beta]\nsteps:\n s:\n command: beta",
)
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
config_cruise.join("a.yaml"),
"command: [alpha]\nsteps:\n s:\n command: alpha",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config_in_dir_with_interactive(None, repo_dir.path(), false)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("alpha"),
"expected a.yaml (alpha) content, got: {yaml}"
);
if let ConfigSource::UserDir(ref p) = source {
assert_eq!(
p.file_name().unwrap_or_default().to_str().unwrap_or(""),
"a.yaml",
"must pick ASCII-first file when non-interactive"
);
} else {
panic!("expected UserDir, got: {source:?}");
}
}
#[test]
fn test_interactive_false_nothing_returns_builtin() {
let repo_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (_yaml, source) = resolve_config_in_dir_with_interactive(None, repo_dir.path(), false)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
matches!(source, ConfigSource::Builtin),
"expected Builtin, got: {source:?}"
);
}
#[test]
fn test_interactive_true_explicit_path_bypasses_selector() {
let explicit_file = tempfile::NamedTempFile::new().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
explicit_file.path(),
"command: [explicit]\nsteps:\n s:\n command: explicit",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let explicit_path = explicit_file
.path()
.to_str()
.unwrap_or_else(|| panic!("unexpected None"))
.to_string();
let repo_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) =
resolve_config_in_dir_with_interactive(Some(&explicit_path), repo_dir.path(), true)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("explicit"),
"expected explicit config content, got: {yaml}"
);
assert!(
matches!(source, ConfigSource::Explicit(_)),
"expected Explicit, got: {source:?}"
);
}
#[test]
fn test_resolve_local_cruise_dir_yaml() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let cruise_dir = tmp_dir.path().join(".cruise");
std::fs::create_dir_all(&cruise_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
cruise_dir.join("foo.yaml"),
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config_in_dir_with_interactive(None, tmp_dir.path(), false)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("echo"),
"expected foo.yaml content, got: {yaml}"
);
assert!(
matches!(source, ConfigSource::Local(_)),
"expected Local, got: {source:?}"
);
if let ConfigSource::Local(p) = source {
assert_eq!(
p,
cruise_dir.join("foo.yaml"),
"resolved path must point to .cruise/foo.yaml"
);
}
}
#[test]
fn test_collect_candidates_local_dir_after_single_files_before_userdir() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("cruise.yaml"),
"command: [top]\nsteps:\n s:\n command: top",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let cruise_dir = tmp_dir.path().join(".cruise");
std::fs::create_dir_all(&cruise_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
cruise_dir.join("team.yaml"),
"command: [team]\nsteps:\n s:\n command: team",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let config_cruise = fake_home.path().join(".config").join("cruise");
std::fs::create_dir_all(&config_cruise).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
config_cruise.join("global.yaml"),
"command: [global]\nsteps:\n s:\n command: global",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let candidates =
collect_candidates(tmp_dir.path(), None).unwrap_or_else(|e| panic!("{e:?}"));
let local: Vec<&ConfigCandidate> = candidates
.iter()
.filter(|c| matches!(c.source, CandidateKind::Local(_)))
.collect();
let user_dir: Vec<&ConfigCandidate> = candidates
.iter()
.filter(|c| matches!(c.source, CandidateKind::UserDir(_)))
.collect();
assert!(!local.is_empty(), "expected at least one Local candidate");
assert!(
!user_dir.is_empty(),
"expected at least one UserDir candidate"
);
let top_idx = candidates
.iter()
.position(|c| match &c.source {
CandidateKind::Local(p) => p.file_name().unwrap_or_default() == "cruise.yaml",
_ => false,
})
.unwrap_or_else(|| panic!("cruise.yaml candidate not found"));
let team_idx = candidates
.iter()
.position(|c| match &c.source {
CandidateKind::Local(p) => p.file_name().unwrap_or_default() == "team.yaml",
_ => false,
})
.unwrap_or_else(|| panic!(".cruise/team.yaml candidate not found"));
let user_idx = candidates
.iter()
.position(|c| matches!(c.source, CandidateKind::UserDir(_)))
.unwrap_or_else(|| panic!("UserDir candidate not found"));
assert!(
top_idx < team_idx,
"cruise.yaml (idx {top_idx}) must precede .cruise/team.yaml (idx {team_idx})"
);
assert!(
team_idx < user_idx,
".cruise/team.yaml (idx {team_idx}) must precede user-dir (idx {user_idx})"
);
}
#[test]
fn test_collect_candidates_local_dir_multiple_files_ascii_sorted() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let cruise_dir = tmp_dir.path().join(".cruise");
std::fs::create_dir_all(&cruise_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
cruise_dir.join("b.yaml"),
"command: [beta]\nsteps:\n s:\n command: beta",
)
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
cruise_dir.join("a.yml"),
"command: [alpha]\nsteps:\n s:\n command: alpha",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let candidates =
collect_candidates(tmp_dir.path(), None).unwrap_or_else(|e| panic!("{e:?}"));
let dir_locals: Vec<&ConfigCandidate> = candidates
.iter()
.filter(|c| match &c.source {
CandidateKind::Local(p) => p
.parent()
.and_then(|d| d.file_name())
.is_some_and(|n| n == ".cruise"),
_ => false,
})
.collect();
assert_eq!(
dir_locals.len(),
2,
"expected 2 .cruise/ candidates, got {dir_locals:?}"
);
let first_name = match &dir_locals[0].source {
CandidateKind::Local(p) => p
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
_ => panic!("expected Local"),
};
assert_eq!(
first_name, "a.yml",
".cruise/ candidates must be ASCII-sorted (a.yml before b.yaml)"
);
}
#[test]
fn test_collect_yaml_files_excludes_data_subdirs() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let sessions_dir = tmp_dir.path().join("sessions");
let worktrees_dir = tmp_dir.path().join("worktrees");
let clones_dir = tmp_dir.path().join("clones");
std::fs::create_dir_all(&sessions_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::create_dir_all(&worktrees_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::create_dir_all(&clones_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(sessions_dir.join("x.yaml"), "").unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(worktrees_dir.join("y.yaml"), "").unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(clones_dir.join("z.yaml"), "").unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
tmp_dir.path().join("valid.yaml"),
"command: [echo]\nsteps:\n s:\n command: echo",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let files = collect_yaml_files(&tmp_dir.path().to_path_buf());
let names: Vec<String> = files
.iter()
.map(|p| {
p.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
.collect();
assert_eq!(
names,
vec!["valid.yaml"],
"only valid.yaml should be returned, got: {names:?}"
);
}
#[test]
fn test_interactive_false_local_dir_picks_ascii_first() {
let tmp_dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let cruise_dir = tmp_dir.path().join(".cruise");
std::fs::create_dir_all(&cruise_dir).unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
cruise_dir.join("b.yaml"),
"command: [beta]\nsteps:\n s:\n command: beta",
)
.unwrap_or_else(|e| panic!("{e:?}"));
std::fs::write(
cruise_dir.join("a.yaml"),
"command: [alpha]\nsteps:\n s:\n command: alpha",
)
.unwrap_or_else(|e| panic!("{e:?}"));
let fake_home = tempfile::tempdir().unwrap_or_else(|e| panic!("{e:?}"));
let _guard = DirGuard::new();
let _home_guards = crate::test_support::set_fake_home(fake_home.path());
let _env_guard = EnvGuard::remove("CRUISE_CONFIG");
let (yaml, source) = resolve_config_in_dir_with_interactive(None, tmp_dir.path(), false)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
yaml.contains("alpha"),
"expected a.yaml (alpha) content, got: {yaml}"
);
if let ConfigSource::Local(ref p) = source {
assert_eq!(
p.file_name().unwrap_or_default().to_str().unwrap_or(""),
"a.yaml",
"must pick ASCII-first .cruise/ file when non-interactive"
);
} else {
panic!("expected Local, got: {source:?}");
}
}
}