use anyhow::Context;
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
use std::time::Duration;
use tokio::sync::broadcast;
static CACHE: std::sync::LazyLock<RwLock<HashMap<PathBuf, serde_norway::Value>>> =
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
static WATCHER: std::sync::LazyLock<RwLock<HashMap<PathBuf, ()>>> =
std::sync::LazyLock::new(|| RwLock::new(HashMap::new()));
static FILE_WATCHER: std::sync::LazyLock<Arc<Mutex<Option<RecommendedWatcher>>>> =
std::sync::LazyLock::new(|| Arc::new(Mutex::new(None)));
static CHANGE_TX: std::sync::LazyLock<broadcast::Sender<PathBuf>> =
std::sync::LazyLock::new(|| {
let (tx, _) = broadcast::channel(16);
tx
});
pub fn subscribe() -> broadcast::Receiver<PathBuf> {
CHANGE_TX.subscribe()
}
pub async fn warm_cache() -> anyhow::Result<()> {
let global_path = global_config_path()?;
if global_path.exists() {
match tokio::fs::read_to_string(&global_path).await {
Ok(content) => {
if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
if let Ok(mut cache) = CACHE.write() {
cache.insert(global_path.clone(), parsed);
}
watch_file(&global_path);
}
}
Err(e) => tracing::warn!("failed to warm global config cache: {e}"),
}
}
let project_path = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".orch.yml");
if project_path.exists() {
match tokio::fs::read_to_string(&project_path).await {
Ok(content) => {
if let Ok(parsed) = serde_norway::from_str::<serde_norway::Value>(&content) {
if let Ok(mut cache) = CACHE.write() {
cache.insert(project_path.clone(), parsed);
}
watch_file(&project_path);
}
}
Err(e) => tracing::warn!("failed to warm project config cache: {e}"),
}
}
Ok(())
}
#[cfg(test)]
pub fn clear_test_cache() {
if let Ok(mut cache) = CACHE.write() {
cache.clear();
}
}
fn invalidate_cache(path: &PathBuf) {
if let Ok(mut cache) = CACHE.write() {
cache.remove(path);
tracing::debug!("config cache invalidated for: {}", path.display());
}
let _ = CHANGE_TX.send(path.clone());
}
fn ensure_watcher() {
let mut watcher_guard = FILE_WATCHER.lock().unwrap_or_else(|e| e.into_inner());
if watcher_guard.is_some() {
return;
}
let watcher = RecommendedWatcher::new(
move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
for path in event.paths {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if filename == "config.yml" || filename == ".orch.yml" {
invalidate_cache(&path);
}
}
}
},
Config::default().with_poll_interval(Duration::from_secs(2)),
);
if let Ok(w) = watcher {
*watcher_guard = Some(w);
}
}
fn watch_file(path: &PathBuf) {
ensure_watcher();
if let Ok(mut watched) = WATCHER.write() {
if !watched.contains_key(path) {
watched.insert(path.clone(), ());
if let Ok(mut guard) = FILE_WATCHER.lock() {
if let Some(ref mut watcher) = *guard {
match watcher.watch(path, RecursiveMode::NonRecursive) {
Ok(()) => tracing::debug!("now watching config file: {}", path.display()),
Err(e) => tracing::warn!(
"failed to register config file watcher for {}: {}",
path.display(),
e
),
}
}
}
}
}
}
fn read_file_to_string(path: &Path) -> anyhow::Result<String> {
match tokio::runtime::Handle::try_current() {
Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
Ok(tokio::task::block_in_place(|| {
std::fs::read_to_string(path)
})?)
}
_ => Ok(std::fs::read_to_string(path)?),
}
}
fn load_cached_root(path: &PathBuf) -> anyhow::Result<serde_norway::Value> {
if let Ok(cache) = CACHE.read() {
if let Some(cached) = cache.get(path) {
return Ok(cached.clone());
}
}
let content =
read_file_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let parsed: serde_norway::Value =
serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
if let Ok(mut cache) = CACHE.write() {
cache.insert(path.clone(), parsed.clone());
}
Ok(parsed)
}
fn global_config_path() -> anyhow::Result<PathBuf> {
crate::home::config_path()
}
pub fn get_list(key: &str) -> anyhow::Result<Vec<String>> {
let project_path = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".orch.yml");
if project_path.exists() {
if let Ok(val) = resolve_list(&project_path, key) {
if !val.is_empty() {
return Ok(val);
}
}
}
let global_path = global_config_path()?;
if global_path.exists() {
return resolve_list(&global_path, key);
}
Ok(vec![])
}
fn resolve_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
watch_file(path);
let root = load_cached_root(path)?;
extract_list(&root, key)
}
fn extract_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
let mut current = root;
for part in key.split('.') {
match current.get(part) {
Some(v) => current = v,
None => return Ok(vec![]),
}
}
match current {
serde_norway::Value::Sequence(seq) => Ok(seq
.iter()
.filter_map(|item| item.as_str().map(String::from))
.collect()),
serde_norway::Value::String(s) => {
Ok(s.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
_ => Ok(vec![]),
}
}
pub fn get(key: &str) -> anyhow::Result<String> {
let project_path = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".orch.yml");
if project_path.exists() {
if let Ok(val) = resolve_key(&project_path, key) {
return Ok(val);
}
}
let global_path = global_config_path()?;
if global_path.exists() {
return resolve_key(&global_path, key);
}
anyhow::bail!("config key not found: {key}")
}
pub fn get_project_paths() -> anyhow::Result<Vec<String>> {
let global_path = global_config_path()?;
if global_path.exists() {
if let Ok(paths) = resolve_projects_list(&global_path, "projects") {
if !paths.is_empty() {
return Ok(paths);
}
}
}
Ok(vec![])
}
pub fn get_projects_with_paths() -> anyhow::Result<Vec<(String, std::path::PathBuf)>> {
let paths = get_project_paths()?;
if !paths.is_empty() {
let mut results = Vec::new();
for path_str in &paths {
let path = std::path::PathBuf::from(path_str);
let orch_yml = path.join(".orch.yml");
let config_file = if orch_yml.exists() {
orch_yml
} else {
let legacy = path.join(".orchestrator.yml");
if legacy.exists() {
legacy
} else {
continue;
}
};
if let Ok(content) = std::fs::read_to_string(&config_file) {
if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
if let Some(repo) = doc
.get("gh")
.and_then(|gh| gh.get("repo"))
.and_then(|r| r.as_str())
{
results.push((repo.to_string(), path));
}
}
}
}
if !results.is_empty() {
return Ok(results);
}
}
if let Ok(repo) = get("gh.repo") {
if !repo.is_empty() {
return Ok(vec![(repo, std::path::PathBuf::from("."))]);
}
}
Ok(vec![])
}
pub fn get_repo_for_project(project_path: &std::path::Path) -> anyhow::Result<String> {
let orch_yml = project_path.join(".orch.yml");
let config_file = if orch_yml.exists() {
orch_yml
} else {
let legacy = project_path.join(".orchestrator.yml");
if legacy.exists() {
legacy
} else {
anyhow::bail!("no .orch.yml found in {}", project_path.display());
}
};
let content = std::fs::read_to_string(&config_file)?;
let doc: serde_norway::Value = serde_norway::from_str(&content)?;
doc.get("gh")
.and_then(|gh| gh.get("repo"))
.and_then(|r| r.as_str())
.map(String::from)
.ok_or_else(|| anyhow::anyhow!("gh.repo not set in {}", config_file.display()))
}
pub fn find_project_root() -> anyhow::Result<std::path::PathBuf> {
let mut dir = std::env::current_dir()?;
loop {
if dir.join(".orch.yml").exists() || dir.join(".orchestrator.yml").exists() {
return Ok(dir);
}
if !dir.pop() {
break;
}
}
anyhow::bail!("no .orch.yml found in current directory or parents — run `orch init`")
}
pub fn get_current_repo() -> anyhow::Result<String> {
if let Ok(project_root) = find_project_root() {
if let Ok(repo) = get_repo_for_project(&project_root) {
return Ok(repo);
}
}
if let Ok(repo) = get("gh.repo") {
if !repo.is_empty() {
return Ok(repo);
}
}
if let Ok(repo) = get("repo") {
if !repo.is_empty() {
return Ok(repo);
}
}
anyhow::bail!("could not determine repo — ensure .orch.yml has gh.repo set")
}
#[derive(Debug, Clone)]
pub struct SkillConfig {
pub repo: String,
}
pub fn get_skills() -> anyhow::Result<Vec<SkillConfig>> {
let global_path = global_config_path()?;
if !global_path.exists() {
return Ok(vec![]);
}
let root = load_cached_root(&global_path)?;
extract_skills_list(&root)
}
fn extract_skills_list(root: &serde_norway::Value) -> anyhow::Result<Vec<SkillConfig>> {
let Some(serde_norway::Value::Sequence(seq)) = root.get("skills") else {
return Ok(vec![]);
};
let mut skills = Vec::new();
for item in seq {
if let serde_norway::Value::Mapping(map) = item {
let repo = map
.get("repo")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default();
if !repo.is_empty() {
skills.push(SkillConfig { repo });
}
}
}
Ok(skills)
}
fn resolve_projects_list(path: &PathBuf, key: &str) -> anyhow::Result<Vec<String>> {
watch_file(path);
let root = load_cached_root(path)?;
match extract_projects_list(&root, key) {
Ok(projects) => Ok(projects),
Err(_) => Ok(vec![]),
}
}
fn extract_projects_list(root: &serde_norway::Value, key: &str) -> anyhow::Result<Vec<String>> {
let mut current = root;
for part in key.split('.') {
let next = match current.get(part) {
Some(v) => v,
None => return Ok(vec![]),
};
current = next;
}
match current {
serde_norway::Value::Sequence(seq) => {
let mut projects = Vec::new();
for item in seq {
if let serde_norway::Value::String(s) = item {
projects.push(s.clone());
} else if let serde_norway::Value::Mapping(map) = item {
if let Some(serde_norway::Value::String(repo)) = map.get("repo") {
projects.push(repo.clone());
}
}
}
Ok(projects)
}
_ => Ok(vec![]),
}
}
fn resolve_key(path: &PathBuf, key: &str) -> anyhow::Result<String> {
watch_file(path);
let root = load_cached_root(path)?;
extract_value(&root, key)
}
fn extract_value(root: &serde_norway::Value, key: &str) -> anyhow::Result<String> {
let mut current = root;
for part in key.split('.') {
current = current
.get(part)
.with_context(|| format!("key not found: {key}"))?;
}
match current {
serde_norway::Value::String(s) => Ok(s.clone()),
serde_norway::Value::Number(n) => Ok(n.to_string()),
serde_norway::Value::Bool(b) => Ok(b.to_string()),
serde_norway::Value::Null => Ok(String::new()),
_ => Ok(serde_norway::to_string(current)?),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_yaml(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
path
}
#[test]
fn resolve_simple_key() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
let val = resolve_key(&path, "repo").unwrap();
assert_eq!(val, "owner/repo");
}
#[test]
fn resolve_nested_key() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(
dir.path(),
"config.yml",
"agents:\n claude:\n model: opus\n",
);
let val = resolve_key(&path, "agents.claude.model").unwrap();
assert_eq!(val, "opus");
}
#[test]
fn resolve_boolean_value() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(dir.path(), "config.yml", "enabled: true\n");
let val = resolve_key(&path, "enabled").unwrap();
assert_eq!(val, "true");
}
#[test]
fn resolve_number_value() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(dir.path(), "config.yml", "timeout: 300\n");
let val = resolve_key(&path, "timeout").unwrap();
assert_eq!(val, "300");
}
#[test]
fn resolve_missing_key_errors() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(dir.path(), "config.yml", "repo: owner/repo\n");
let result = resolve_key(&path, "missing.key");
assert!(result.is_err());
}
#[test]
fn resolve_missing_file_errors() {
let path = PathBuf::from("/nonexistent/config.yml");
let result = resolve_key(&path, "repo");
assert!(result.is_err());
}
#[test]
fn resolve_projects_list_string_array() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(
dir.path(),
"config.yml",
"projects:\n - owner/repo1\n - owner/repo2\n",
);
let projects = resolve_projects_list(&path, "projects").unwrap();
assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
}
#[test]
fn resolve_projects_list_map_array() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(
dir.path(),
"config.yml",
"projects:\n - repo: owner/repo1\n project_dir: /path/to/repo1\n - repo: owner/repo2\n project_dir: /path/to/repo2\n",
);
let projects = resolve_projects_list(&path, "projects").unwrap();
assert_eq!(projects, vec!["owner/repo1", "owner/repo2"]);
}
#[test]
fn resolve_projects_list_fallback_to_repo() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(dir.path(), "config.yml", "repo: owner/single-repo\n");
let projects = resolve_projects_list(&path, "projects").unwrap();
assert!(projects.is_empty());
}
#[test]
fn extract_projects_list_from_sequence() {
let yaml =
serde_norway::from_str::<serde_norway::Value>("projects:\n - a\n - b\n - c\n")
.unwrap();
let projects = extract_projects_list(&yaml, "projects").unwrap();
assert_eq!(projects, vec!["a", "b", "c"]);
}
#[test]
fn extract_projects_list_from_mapping_sequence() {
let yaml =
serde_norway::from_str::<serde_norway::Value>("projects:\n - repo: a\n - repo: b\n")
.unwrap();
let projects = extract_projects_list(&yaml, "projects").unwrap();
assert_eq!(projects, vec!["a", "b"]);
}
#[test]
fn extract_projects_list_empty_for_missing() {
let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
let projects = extract_projects_list(&yaml, "projects").unwrap();
assert!(projects.is_empty());
}
#[test]
fn subscribe_receives_change_notifications() {
let mut rx = subscribe();
let path = PathBuf::from(format!("/tmp/test-config-{}.yml", std::process::id()));
invalidate_cache(&path);
loop {
match rx.try_recv() {
Ok(received_path) if received_path == path => break,
Ok(_) => continue, Err(e) => panic!("expected config change notification, got error: {e:?}"),
}
}
}
#[test]
fn subscribe_multiple_receivers() {
let mut rx1 = subscribe();
let mut rx2 = subscribe();
let path = PathBuf::from(format!("/tmp/test-config-multi-{}.yml", std::process::id()));
invalidate_cache(&path);
let find_path = |rx: &mut tokio::sync::broadcast::Receiver<PathBuf>, expected: &PathBuf| loop {
match rx.try_recv() {
Ok(p) if &p == expected => return,
Ok(_) => continue,
Err(e) => panic!("expected config change notification, got error: {e:?}"),
}
};
find_path(&mut rx1, &path);
find_path(&mut rx2, &path);
}
#[test]
fn resolve_list_from_sequence() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(
dir.path(),
"config.yml",
"workflow:\n allowed_tools:\n - Edit\n - Write\n - git\n - npm\n",
);
let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
}
#[test]
fn resolve_list_from_comma_string() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(
dir.path(),
"config.yml",
"workflow:\n allowed_tools: \"Edit,Write,git,npm\"\n",
);
let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
assert_eq!(list, vec!["Edit", "Write", "git", "npm"]);
}
#[test]
fn resolve_list_missing_key_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let path = write_yaml(dir.path(), "config.yml", "foo: bar\n");
let list = resolve_list(&path, "workflow.allowed_tools").unwrap();
assert!(list.is_empty());
}
#[test]
fn extract_skills_list_from_sequence() {
let yaml = serde_norway::from_str::<serde_norway::Value>(
"skills:\n - repo: owner/skills1\n - repo: owner/skills2\n",
)
.unwrap();
let skills = extract_skills_list(&yaml).unwrap();
assert_eq!(skills.len(), 2);
assert_eq!(skills[0].repo, "owner/skills1");
assert_eq!(skills[1].repo, "owner/skills2");
}
#[test]
fn extract_skills_list_single_repo() {
let yaml =
serde_norway::from_str::<serde_norway::Value>("skills:\n - repo: owner/skills\n")
.unwrap();
let skills = extract_skills_list(&yaml).unwrap();
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].repo, "owner/skills");
}
#[test]
fn extract_skills_list_empty_for_missing() {
let yaml = serde_norway::from_str::<serde_norway::Value>("foo: bar").unwrap();
let skills = extract_skills_list(&yaml).unwrap();
assert!(skills.is_empty());
}
}