use crate::constants::defaults::DEFAULT_ID_WIDTH;
use crate::constants::identity::{GLOBAL_CONFIG_DIR, PROJECT_RUNTIME_DIR};
use crate::constants::queue::{DEFAULT_DONE_FILE, DEFAULT_ID_PREFIX, DEFAULT_QUEUE_FILE};
use crate::contracts::Config;
use crate::fsutil;
use crate::prompts_internal::validate_instruction_file_paths;
use anyhow::{Context, Result, bail};
use std::env;
use std::path::{Path, PathBuf};
use super::Resolved;
use super::layer::{ConfigLayer, apply_layer, load_layer};
use super::trust::load_repo_trust;
use super::validation::{
validate_config, validate_project_execution_trust, validate_queue_done_file_override,
validate_queue_file_override, validate_queue_id_prefix_override,
validate_queue_id_width_override,
};
const CONFIG_FILE_NAME: &str = "config.jsonc";
const QUEUE_FILE_NAME: &str = "queue.jsonc";
const DONE_FILE_NAME: &str = "done.jsonc";
const TRUST_FILE_NAME: &str = "trust.jsonc";
const OLD_QUEUE_JSON_FILE_NAME: &str = "queue.json";
const OLD_DONE_JSON_FILE_NAME: &str = "done.json";
const OLD_CONFIG_JSON_FILE_NAME: &str = "config.json";
const RUNTIME_MARKER_FILES: &[&str] = &[
QUEUE_FILE_NAME,
DONE_FILE_NAME,
CONFIG_FILE_NAME,
TRUST_FILE_NAME,
OLD_QUEUE_JSON_FILE_NAME,
OLD_DONE_JSON_FILE_NAME,
OLD_CONFIG_JSON_FILE_NAME,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectRuntimeLayout {
Current,
Uninitialized,
}
impl ProjectRuntimeLayout {
fn directory_name(self) -> &'static str {
match self {
Self::Current | Self::Uninitialized => PROJECT_RUNTIME_DIR,
}
}
}
pub fn resolve_from_cwd() -> Result<Resolved> {
resolve_from_cwd_internal(true, true, None)
}
pub fn resolve_from_cwd_skipping_project_execution_trust() -> Result<Resolved> {
resolve_from_cwd_internal(true, false, None)
}
pub fn resolve_from_cwd_with_profile(profile: Option<&str>) -> Result<Resolved> {
resolve_from_cwd_internal(true, true, profile)
}
pub fn resolve_from_cwd_for_doctor() -> Result<Resolved> {
resolve_from_cwd_internal(false, false, None)
}
fn resolve_from_cwd_internal(
validate_instruction_files: bool,
validate_execution_trust: bool,
profile: Option<&str>,
) -> Result<Resolved> {
let cwd = env::current_dir().context("resolve current working directory")?;
log::debug!("resolving configuration from cwd: {}", cwd.display());
let repo_root = find_repo_root(&cwd);
let global_path = global_config_path();
let global_layer_paths = global_config_layer_paths();
let project_path = project_config_path(&repo_root);
let repo_trust = load_repo_trust(&repo_root)?;
let mut cfg = Config::default();
let mut project_layer: Option<ConfigLayer> = None;
let mut queue_file_explicit = false;
let mut done_file_explicit = false;
for path in &global_layer_paths {
log::debug!("checking global config at: {}", path.display());
if path.exists() {
log::debug!("loading global config: {}", path.display());
let layer = load_layer(path)
.with_context(|| format!("load global config {}", path.display()))?;
queue_file_explicit |= layer.queue.file.is_some();
done_file_explicit |= layer.queue.done_file.is_some();
cfg = apply_layer(cfg, layer)
.with_context(|| format!("apply global config {}", path.display()))?;
}
}
log::debug!("checking project config at: {}", project_path.display());
if project_path.exists() {
log::debug!("loading project config: {}", project_path.display());
let layer = load_layer(&project_path)
.with_context(|| format!("load project config {}", project_path.display()))?;
queue_file_explicit |= layer.queue.file.is_some();
done_file_explicit |= layer.queue.done_file.is_some();
project_layer = Some(layer.clone());
cfg = apply_layer(cfg, layer)
.with_context(|| format!("apply project config {}", project_path.display()))?;
}
if validate_execution_trust {
validate_project_execution_trust(project_layer.as_ref(), &repo_trust)?;
}
validate_config(&cfg)?;
if let Some(name) = profile {
apply_profile_patch(&mut cfg, name)?;
validate_config(&cfg)?;
}
if validate_instruction_files {
validate_instruction_file_paths(&repo_root, &cfg)
.with_context(|| "validate instruction_files from config")?;
}
let id_prefix = resolve_id_prefix(&cfg)?;
let id_width = resolve_id_width(&cfg)?;
let queue_path = resolve_queue_path_with_source(&repo_root, &cfg, queue_file_explicit)?;
let done_path = resolve_done_path_with_source(&repo_root, &cfg, done_file_explicit)?;
let resolved_global_path = global_layer_paths
.iter()
.rev()
.find(|path| path.exists())
.cloned()
.or(global_path);
log::debug!("resolved repo_root: {}", repo_root.display());
log::debug!("resolved queue_path: {}", queue_path.display());
log::debug!("resolved done_path: {}", done_path.display());
Ok(Resolved {
config: cfg,
repo_root,
queue_path,
done_path,
id_prefix,
id_width,
global_config_path: resolved_global_path,
project_config_path: Some(project_path),
})
}
fn apply_profile_patch(cfg: &mut Config, name: &str) -> Result<()> {
let name = name.trim();
if name.is_empty() {
bail!("Invalid --profile: name cannot be empty");
}
let patch =
crate::agent::resolve_profile_patch(name, cfg.profiles.as_ref()).ok_or_else(|| {
let names = crate::agent::all_profile_names(cfg.profiles.as_ref());
if names.is_empty() {
anyhow::anyhow!(
"Unknown profile: {name:?}. No profiles are configured. Define profiles under the `profiles` key in .cueloop/config.jsonc."
)
} else {
anyhow::anyhow!(
"Unknown profile: {name:?}. Available configured profiles: {}",
names.into_iter().collect::<Vec<_>>().join(", ")
)
}
})?;
cfg.agent.merge_from(patch);
Ok(())
}
pub fn resolve_id_prefix(cfg: &Config) -> Result<String> {
validate_queue_id_prefix_override(cfg.queue.id_prefix.as_deref())?;
let raw = cfg.queue.id_prefix.as_deref().unwrap_or(DEFAULT_ID_PREFIX);
Ok(raw.trim().to_uppercase())
}
pub fn resolve_id_width(cfg: &Config) -> Result<usize> {
validate_queue_id_width_override(cfg.queue.id_width)?;
Ok(cfg.queue.id_width.unwrap_or(DEFAULT_ID_WIDTH as u8) as usize)
}
pub fn resolve_queue_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
resolve_queue_path_with_source(repo_root, cfg, false)
}
fn resolve_queue_path_with_source(
repo_root: &Path,
cfg: &Config,
queue_file_explicit: bool,
) -> Result<PathBuf> {
validate_queue_file_override(cfg.queue.file.as_deref())?;
let raw = default_aware_runtime_path(
repo_root,
cfg.queue.file.as_deref(),
queue_file_explicit,
QUEUE_FILE_NAME,
DEFAULT_QUEUE_FILE,
);
resolve_repo_path(repo_root, &raw)
}
pub fn resolve_done_path(repo_root: &Path, cfg: &Config) -> Result<PathBuf> {
resolve_done_path_with_source(repo_root, cfg, false)
}
fn resolve_done_path_with_source(
repo_root: &Path,
cfg: &Config,
done_file_explicit: bool,
) -> Result<PathBuf> {
validate_queue_done_file_override(cfg.queue.done_file.as_deref())?;
let raw = default_aware_runtime_path(
repo_root,
cfg.queue.done_file.as_deref(),
done_file_explicit,
DONE_FILE_NAME,
DEFAULT_DONE_FILE,
);
resolve_repo_path(repo_root, &raw)
}
fn default_aware_runtime_path(
repo_root: &Path,
configured: Option<&Path>,
configured_explicitly: bool,
file_name: &str,
current_default: &str,
) -> PathBuf {
match configured {
Some(path) if !configured_explicitly && path == Path::new(current_default) => {
default_runtime_relative_path(repo_root, file_name)
}
Some(path) => path.to_path_buf(),
None => default_runtime_relative_path(repo_root, file_name),
}
}
fn resolve_repo_path(repo_root: &Path, raw: &Path) -> Result<PathBuf> {
let value = fsutil::expand_tilde(raw);
Ok(if value.is_absolute() {
value
} else {
repo_root.join(value)
})
}
fn default_runtime_relative_path(repo_root: &Path, file_name: &str) -> PathBuf {
PathBuf::from(project_runtime_layout(repo_root).directory_name()).join(file_name)
}
pub fn global_config_path() -> Option<PathBuf> {
config_base_dir().map(|base| base.join(GLOBAL_CONFIG_DIR).join(CONFIG_FILE_NAME))
}
fn global_config_layer_paths() -> Vec<PathBuf> {
global_config_path().into_iter().collect()
}
fn config_base_dir() -> Option<PathBuf> {
if let Some(value) = env::var_os("XDG_CONFIG_HOME") {
Some(PathBuf::from(value))
} else {
let home = env::var_os("HOME")?;
Some(PathBuf::from(home).join(".config"))
}
}
pub fn project_config_path(repo_root: &Path) -> PathBuf {
project_runtime_dir(repo_root).join(CONFIG_FILE_NAME)
}
pub fn project_runtime_dir(repo_root: &Path) -> PathBuf {
repo_root.join(project_runtime_layout(repo_root).directory_name())
}
pub fn project_runtime_layout(repo_root: &Path) -> ProjectRuntimeLayout {
let current_dir = repo_root.join(PROJECT_RUNTIME_DIR);
if has_runtime_marker(¤t_dir) {
ProjectRuntimeLayout::Current
} else {
ProjectRuntimeLayout::Uninitialized
}
}
fn has_runtime_marker(runtime_dir: &Path) -> bool {
runtime_dir.is_dir()
&& RUNTIME_MARKER_FILES
.iter()
.any(|name| runtime_dir.join(name).is_file())
}
pub fn find_repo_root(start: &Path) -> PathBuf {
log::debug!("searching for repo root starting from: {}", start.display());
for dir in start.ancestors() {
log::debug!("checking directory: {}", dir.display());
let current_dir = dir.join(PROJECT_RUNTIME_DIR);
if has_runtime_marker(¤t_dir) {
log::debug!(
"found repo root at: {} (via {PROJECT_RUNTIME_DIR}/)",
dir.display()
);
return dir.to_path_buf();
}
if dir.join(".git").exists() {
log::debug!("found repo root at: {} (via .git/)", dir.display());
return dir.to_path_buf();
}
}
log::debug!(
"no repo root found, using start directory: {}",
start.display()
);
start.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn default_runtime_relative_path_matches_current_default_queue_constant() {
let dir = TempDir::new().expect("temp dir");
assert_eq!(
default_runtime_relative_path(dir.path(), QUEUE_FILE_NAME),
PathBuf::from(crate::constants::queue::DEFAULT_QUEUE_FILE)
);
}
}