use super::merge::deep_merge_all;
use super::types::{Config, Prompts};
use anyhow::Result;
use serde_json::Value;
use std::path::{Path, PathBuf};
use tracing::warn;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ConfigTier {
Defaults = 0,
Project = 1,
User = 2,
Environment = 3,
}
impl std::fmt::Display for ConfigTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigTier::Defaults => write!(f, "defaults"),
ConfigTier::Project => write!(f, "project"),
ConfigTier::User => write!(f, "user"),
ConfigTier::Environment => write!(f, "environment"),
}
}
}
#[derive(Debug, Clone)]
pub struct ConfigPaths {
pub defaults_dir: Option<PathBuf>,
pub install_dir: Option<PathBuf>,
pub project_dir: Option<PathBuf>,
pub project_dir_deprecated: Option<PathBuf>,
pub user_dir: Option<PathBuf>,
}
impl Default for ConfigPaths {
fn default() -> Self {
Self::discover()
}
}
impl ConfigPaths {
pub fn discover() -> Self {
let user_dir = std::env::var("TASK_GRAPH_USER_DIR")
.ok()
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".task-graph")));
let project_dir = std::env::var("TASK_GRAPH_PROJECT_DIR")
.ok()
.map(PathBuf::from)
.or_else(|| Some(PathBuf::from("task-graph")));
let project_dir_deprecated = Some(PathBuf::from(".task-graph"));
let install_dir = std::env::var("TASK_GRAPH_INSTALL_DIR")
.ok()
.map(PathBuf::from)
.or_else(|| Some(PathBuf::from("config")));
Self {
defaults_dir: None, install_dir,
project_dir,
project_dir_deprecated,
user_dir,
}
}
pub fn with_dirs(project_dir: Option<PathBuf>, user_dir: Option<PathBuf>) -> Self {
Self {
defaults_dir: None,
install_dir: None, project_dir,
project_dir_deprecated: Some(PathBuf::from(".task-graph")),
user_dir,
}
}
pub fn with_all_dirs(
install_dir: Option<PathBuf>,
project_dir: Option<PathBuf>,
user_dir: Option<PathBuf>,
) -> Self {
Self {
defaults_dir: None,
install_dir,
project_dir,
project_dir_deprecated: Some(PathBuf::from(".task-graph")),
user_dir,
}
}
pub fn effective_project_dir(&self) -> Option<&Path> {
if let Some(ref dir) = self.project_dir
&& dir.exists()
{
return Some(dir);
}
if let Some(ref dir) = self.project_dir_deprecated
&& dir.exists()
{
return Some(dir);
}
self.project_dir.as_deref()
}
pub fn is_using_deprecated(&self) -> bool {
if let Some(ref new_dir) = self.project_dir
&& new_dir.exists()
{
return false;
}
if let Some(ref dep_dir) = self.project_dir_deprecated {
return dep_dir.exists();
}
false
}
}
#[derive(Debug, Clone)]
pub struct ConfigLoader {
pub paths: ConfigPaths,
config: Config,
config_path: Option<PathBuf>,
using_deprecated: bool,
}
impl ConfigLoader {
pub fn load() -> Result<Self> {
Self::load_with_paths(ConfigPaths::discover())
}
pub fn load_with_paths(paths: ConfigPaths) -> Result<Self> {
let using_deprecated = paths.is_using_deprecated();
if using_deprecated {
warn!(
"Using deprecated config directory '.task-graph/'. \
Run 'task-graph migrate' to move to 'task-graph/'."
);
}
if let Ok(explicit_path) = std::env::var("TASK_GRAPH_CONFIG_PATH") {
let path = PathBuf::from(&explicit_path);
let config = Config::load(&path)?;
return Ok(Self {
paths,
config,
config_path: Some(path),
using_deprecated,
});
}
let mut configs: Vec<Value> = Vec::new();
let default_config = Config::default();
if let Ok(default_json) = serde_json::to_value(&default_config) {
configs.push(default_json);
}
let mut project_config_path = None;
if let Some(project_dir) = paths.effective_project_dir() {
let config_file = project_dir.join("config.yaml");
if config_file.exists()
&& let Ok(content) = std::fs::read_to_string(&config_file)
&& let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
{
configs.push(yaml_value);
project_config_path = Some(config_file);
}
}
if let Some(ref user_dir) = paths.user_dir {
let config_file = user_dir.join("config.yaml");
if config_file.exists()
&& let Ok(content) = std::fs::read_to_string(&config_file)
&& let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
{
configs.push(yaml_value);
}
}
let merged = deep_merge_all(configs);
let mut config: Config = serde_json::from_value(merged)?;
Self::apply_env_overrides(&mut config);
Ok(Self {
paths,
config,
config_path: project_config_path,
using_deprecated,
})
}
fn apply_env_overrides(config: &mut Config) {
if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
config.server.db_path = PathBuf::from(db_path);
}
if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
config.server.media_dir = PathBuf::from(media_dir);
}
if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
config.server.log_dir = PathBuf::from(log_dir);
}
if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
config.server.skills_dir = PathBuf::from(skills_dir);
}
}
pub fn load_prompts(&self) -> Prompts {
let mut prompts_configs: Vec<Value> = Vec::new();
if let Ok(default_json) = serde_json::to_value(Prompts::default()) {
prompts_configs.push(default_json);
}
if let Some(project_dir) = self.paths.effective_project_dir() {
let prompts_file = project_dir.join("prompts.yaml");
if prompts_file.exists()
&& let Ok(content) = std::fs::read_to_string(&prompts_file)
&& let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
{
prompts_configs.push(yaml_value);
}
}
if let Some(ref user_dir) = self.paths.user_dir {
let prompts_file = user_dir.join("prompts.yaml");
if prompts_file.exists()
&& let Ok(content) = std::fs::read_to_string(&prompts_file)
&& let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
{
prompts_configs.push(yaml_value);
}
}
let merged = deep_merge_all(prompts_configs);
serde_json::from_value(merged).unwrap_or_default()
}
pub fn load_workflows(&self) -> super::workflows::WorkflowsConfig {
let mut workflows_configs: Vec<Value> = Vec::new();
if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
{
workflows_configs.push(default_json);
}
if let Some(project_dir) = self.paths.effective_project_dir() {
let workflows_file = project_dir.join("workflows.yaml");
if workflows_file.exists()
&& let Ok(content) = std::fs::read_to_string(&workflows_file)
&& let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
{
workflows_configs.push(yaml_value);
}
}
if let Some(ref user_dir) = self.paths.user_dir {
let workflows_file = user_dir.join("workflows.yaml");
if workflows_file.exists()
&& let Ok(content) = std::fs::read_to_string(&workflows_file)
&& let Ok(yaml_value) = serde_yaml::from_str::<Value>(&content)
{
workflows_configs.push(yaml_value);
}
}
let merged = deep_merge_all(workflows_configs);
serde_json::from_value(merged).unwrap_or_default()
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn config_mut(&mut self) -> &mut Config {
&mut self.config
}
pub fn into_config(self) -> Config {
self.config
}
pub fn config_path(&self) -> Option<&Path> {
self.config_path.as_deref()
}
pub fn is_using_deprecated(&self) -> bool {
self.using_deprecated
}
pub fn project_dir(&self) -> Option<&Path> {
self.paths.effective_project_dir()
}
pub fn user_dir(&self) -> Option<&Path> {
self.paths.user_dir.as_deref()
}
pub fn skills_dir(&self) -> PathBuf {
if let Ok(skills_dir) = std::env::var("TASK_GRAPH_SKILLS_DIR") {
return PathBuf::from(skills_dir);
}
if let Some(project_dir) = self.paths.effective_project_dir() {
let skills_dir = project_dir.join("skills");
if skills_dir.exists() {
return skills_dir;
}
}
self.config.server.skills_dir.clone()
}
pub fn load_workflow_by_name(&self, name: &str) -> Result<super::workflows::WorkflowsConfig> {
let filename = format!("workflow-{}.yaml", name);
if let Some(ref user_dir) = self.paths.user_dir {
let workflow_file = user_dir.join(&filename);
if workflow_file.exists() {
return self.load_workflow_from_path(&workflow_file);
}
}
if let Some(project_dir) = self.paths.effective_project_dir() {
let workflow_file = project_dir.join(&filename);
if workflow_file.exists() {
return self.load_workflow_from_path(&workflow_file);
}
}
if let Some(ref install_dir) = self.paths.install_dir {
let workflow_file = install_dir.join(&filename);
if workflow_file.exists() {
return self.load_workflow_from_path(&workflow_file);
}
}
Err(anyhow::anyhow!(
"Workflow '{}' not found. Searched for '{}' in user, project, and install directories.",
name,
filename
))
}
fn load_workflow_from_path(&self, path: &Path) -> Result<super::workflows::WorkflowsConfig> {
let content = std::fs::read_to_string(path)?;
let yaml_value: Value = serde_yaml::from_str(&content)?;
let mut configs: Vec<Value> = Vec::new();
if let Ok(default_json) = serde_json::to_value(super::workflows::WorkflowsConfig::default())
{
configs.push(default_json);
}
configs.push(yaml_value);
let merged = deep_merge_all(configs);
let mut workflow: super::workflows::WorkflowsConfig = serde_json::from_value(merged)?;
workflow.source_file = Some(path.to_path_buf());
Ok(workflow)
}
pub fn list_workflows(&self) -> Vec<String> {
let mut workflows = Vec::new();
if let Some(ref user_dir) = self.paths.user_dir
&& let Ok(entries) = std::fs::read_dir(user_dir)
{
for entry in entries.filter_map(|e| e.ok()) {
if let Some(name) = Self::extract_workflow_name(&entry.path())
&& !workflows.contains(&name)
{
workflows.push(name);
}
}
}
if let Some(project_dir) = self.paths.effective_project_dir()
&& let Ok(entries) = std::fs::read_dir(project_dir)
{
for entry in entries.filter_map(|e| e.ok()) {
if let Some(name) = Self::extract_workflow_name(&entry.path())
&& !workflows.contains(&name)
{
workflows.push(name);
}
}
}
if let Some(ref install_dir) = self.paths.install_dir
&& let Ok(entries) = std::fs::read_dir(install_dir)
{
for entry in entries.filter_map(|e| e.ok()) {
if let Some(name) = Self::extract_workflow_name(&entry.path())
&& !workflows.contains(&name)
{
workflows.push(name);
}
}
}
workflows.sort();
workflows
}
fn extract_workflow_name(path: &Path) -> Option<String> {
let filename = path.file_name()?.to_str()?;
if filename.starts_with("workflow-") && filename.ends_with(".yaml") {
let name = filename.strip_prefix("workflow-")?.strip_suffix(".yaml")?;
if !name.is_empty() {
return Some(name.to_string());
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_paths_discover() {
let paths = ConfigPaths::discover();
assert!(paths.project_dir.is_some());
}
#[test]
fn test_load_defaults_only() {
let temp = TempDir::new().unwrap();
let paths = ConfigPaths::with_dirs(
Some(temp.path().join("project")),
Some(temp.path().join("user")),
);
let loader = ConfigLoader::load_with_paths(paths).unwrap();
let config = loader.config();
assert_eq!(config.server.claim_limit, 5);
assert_eq!(config.server.stale_timeout_seconds, 900);
}
#[test]
fn test_project_config_overrides_defaults() {
let temp = TempDir::new().unwrap();
let project_dir = temp.path().join("task-graph");
std::fs::create_dir_all(&project_dir).unwrap();
let config_content = r#"
server:
claim_limit: 10
"#;
std::fs::write(project_dir.join("config.yaml"), config_content).unwrap();
let paths = ConfigPaths::with_dirs(Some(project_dir), Some(temp.path().join("user")));
let loader = ConfigLoader::load_with_paths(paths).unwrap();
let config = loader.config();
assert_eq!(config.server.claim_limit, 10);
assert_eq!(config.server.stale_timeout_seconds, 900);
}
#[test]
fn test_user_config_overrides_project() {
let temp = TempDir::new().unwrap();
let project_dir = temp.path().join("task-graph");
let user_dir = temp.path().join("user");
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::create_dir_all(&user_dir).unwrap();
let project_config = r#"
server:
claim_limit: 10
stale_timeout_seconds: 600
"#;
std::fs::write(project_dir.join("config.yaml"), project_config).unwrap();
let user_config = r#"
server:
claim_limit: 20
"#;
std::fs::write(user_dir.join("config.yaml"), user_config).unwrap();
let paths = ConfigPaths::with_dirs(Some(project_dir), Some(user_dir));
let loader = ConfigLoader::load_with_paths(paths).unwrap();
let config = loader.config();
assert_eq!(config.server.claim_limit, 20);
assert_eq!(config.server.stale_timeout_seconds, 600);
}
}