use std::collections::BTreeMap;
use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::flow::{DEFAULT_FLOW_NAME, Flow};
use crate::ids::{DEFAULT_PROJECT_PREFIX, DEFAULT_TICKET_PREFIX, valid_prefix};
use crate::sources::FlowSource;
use crate::sources::markdown::MarkdownFlowSource;
pub const CONFIG_VERSION: u32 = 1;
pub const DEFAULT_DELETE_MISSING_AFTER_MS: i64 = 30 * 24 * 60 * 60 * 1000;
pub const DEFAULT_WORKTREE_RETENTION_MS: i64 = 7 * 24 * 60 * 60 * 1000;
pub const DEFAULT_WORKTREE_DIR: &str = ".worktrees";
pub const DEFAULT_PROJECT_DIR: &str = ".agents/sloop/projects";
pub const DEFAULT_TICKET_DIR: &str = ".agents/sloop/tickets";
pub const DEFAULT_STALL_REPORT_AFTER_MS: i64 = 10 * 60 * 1000;
pub const DEFAULT_STALL_AFTER_MS: i64 = 2 * 60 * 60 * 1000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repository {
pub root: PathBuf,
pub config_path: PathBuf,
pub state_dir: PathBuf,
pub runtime_dir: PathBuf,
pub operator_socket: PathBuf,
pub lock_path: PathBuf,
pub daemon_log: PathBuf,
pub db_path: PathBuf,
}
impl Repository {
pub fn discover(start: &Path) -> Result<Self, ConfigError> {
let start = start.canonicalize().map_err(|source| ConfigError::Io {
path: start.to_path_buf(),
source,
})?;
for directory in start.ancestors() {
let config_path = directory.join(".agents/sloop/config.yaml");
if config_path.is_file() {
let paths = crate::paths::resolve(directory).map_err(ConfigError::Paths)?;
return Ok(Self {
root: directory.to_path_buf(),
config_path,
state_dir: paths.state_dir,
runtime_dir: paths.runtime_dir,
operator_socket: paths.operator_socket,
lock_path: paths.lock_path,
daemon_log: paths.daemon_log,
db_path: paths.db_path,
});
}
}
Err(ConfigError::RepositoryNotFound(start))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub worktree_dir: PathBuf,
pub worktree_retention_ms: Option<i64>,
pub project_dir: PathBuf,
pub ticket_dir: PathBuf,
pub ticket_source: TicketSourceConfig,
pub max_parallel_tasks: usize,
pub stall_report_after_ms: i64,
pub stall_after_ms: i64,
pub running_hours: Option<RunningHours>,
pub agent: Option<AgentConfig>,
pub flows: BTreeMap<String, Flow>,
pub default_flow: String,
pub flow_test_cmd: Option<Vec<String>>,
pub ticket_prefix: String,
pub project_prefix: String,
pub delete_missing_after_ms: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TicketSourceConfig {
Markdown,
Exec(Vec<String>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentConfig {
pub default_target: String,
pub targets: BTreeMap<String, AgentTarget>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentTarget {
pub cmd: Vec<String>,
pub model: Option<String>,
pub effort: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RunningHours {
pub start: String,
pub end: String,
start_minute: u16,
end_minute: u16,
}
impl RunningHours {
pub fn is_open(&self, local_minute: u16) -> bool {
if self.start_minute < self.end_minute {
(self.start_minute..self.end_minute).contains(&local_minute)
} else {
local_minute >= self.start_minute || local_minute < self.end_minute
}
}
pub fn next_opening_ms(&self, clock: &dyn crate::clock::Clock, now_ms: i64) -> i64 {
let mut candidate = (now_ms.div_euclid(60_000) + 1) * 60_000;
for _ in 0..=(49 * 60) {
if self.is_open(clock.local_minute(candidate)) {
return candidate;
}
candidate += 60_000;
}
candidate
}
}
impl Config {
pub fn validate_client_essentials(repository: &Repository) -> Result<(), ConfigError> {
let config = read_client_config(&repository.config_path)?;
validate_worktree_dir(
config
.worktree_dir
.as_deref()
.unwrap_or_else(|| Path::new(DEFAULT_WORKTREE_DIR)),
&repository.config_path,
)?;
Ok(())
}
pub fn load(repository: &Repository) -> Result<Self, ConfigError> {
let user_path = user_config_path().filter(|path| path.is_file());
let user = user_path
.as_ref()
.map(|path| read_config(path))
.transpose()?;
let config = read_config(&repository.config_path)?;
let worktree_dir = validate_worktree_dir(
config
.worktree_dir
.as_deref()
.unwrap_or_else(|| Path::new(DEFAULT_WORKTREE_DIR)),
&repository.config_path,
)?;
let project_dir = validate_repository_dir(
"project_dir",
config
.project_dir
.as_deref()
.unwrap_or_else(|| Path::new(DEFAULT_PROJECT_DIR)),
&repository.config_path,
)?;
let ticket_dir = validate_repository_dir(
"ticket_dir",
config
.ticket_dir
.as_deref()
.unwrap_or_else(|| Path::new(DEFAULT_TICKET_DIR)),
&repository.config_path,
)?;
if user.as_ref().is_some_and(|config| {
config.agent.is_some()
|| config
.defaults
.as_ref()
.is_some_and(|defaults| defaults.agent.is_some())
}) {
return Err(ConfigError::Invalid {
path: user_path.clone().expect("loaded user config has a path"),
message: "agent targets are repository-scoped; configure `agent.default_target` and `agent.targets` in .agents/sloop/config.yaml".into(),
});
}
if user.as_ref().is_some_and(|config| {
config.sources.is_some()
|| config
.defaults
.as_ref()
.is_some_and(|defaults| defaults.sources.is_some())
}) {
return Err(ConfigError::Invalid {
path: user_path.expect("loaded user config has a path"),
message: "sources are repository-scoped; configure `sources` in .agents/sloop/config.yaml".into(),
});
}
let ticket_source = match config
.sources
.as_ref()
.and_then(|sources| sources.tickets.as_ref())
.and_then(|tickets| tickets.exec.clone())
{
None => TicketSourceConfig::Markdown,
Some(argv) if argv.is_empty() => {
return Err(ConfigError::Invalid {
path: repository.config_path.clone(),
message: "sources.tickets.exec must name a command".into(),
});
}
Some(argv) => TicketSourceConfig::Exec(argv),
};
let defaults = user
.as_ref()
.and_then(|config| config.defaults.as_ref())
.and_then(|defaults| defaults.scheduler.as_ref());
let repository_scheduler = config.scheduler.as_ref();
let max_parallel_tasks = repository_scheduler
.and_then(|scheduler| scheduler.max_parallel_tasks)
.or_else(|| defaults.and_then(|scheduler| scheduler.max_parallel_tasks))
.unwrap_or(1);
if max_parallel_tasks == 0 {
return Err(ConfigError::Invalid {
path: repository.config_path.clone(),
message: "scheduler.max_parallel_tasks must be greater than zero".into(),
});
}
let stall_report_after_ms = repository_scheduler
.and_then(|scheduler| scheduler.stall_report_after.as_deref())
.or_else(|| defaults.and_then(|scheduler| scheduler.stall_report_after.as_deref()))
.map(|value| {
parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
path: repository.config_path.clone(),
message: format!("scheduler.stall_report_after: {message}"),
})
})
.transpose()?
.unwrap_or(DEFAULT_STALL_REPORT_AFTER_MS);
let stall_after_ms = repository_scheduler
.and_then(|scheduler| scheduler.stall_after.as_deref())
.or_else(|| defaults.and_then(|scheduler| scheduler.stall_after.as_deref()))
.map(|value| {
parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
path: repository.config_path.clone(),
message: format!("scheduler.stall_after: {message}"),
})
})
.transpose()?
.unwrap_or(DEFAULT_STALL_AFTER_MS);
if stall_after_ms <= stall_report_after_ms {
return Err(ConfigError::Invalid {
path: repository.config_path.clone(),
message: "scheduler.stall_after must be greater than scheduler.stall_report_after"
.into(),
});
}
let running_hours = repository_scheduler
.and_then(|scheduler| scheduler.running_hours.clone())
.or_else(|| defaults.and_then(|scheduler| scheduler.running_hours.clone()))
.map(|hours| validate_running_hours(hours, &repository.config_path))
.transpose()?;
let agent = config
.agent
.as_ref()
.map(|agent| validate_agent(agent, &repository.config_path))
.transpose()?;
let flow_test_cmd = config
.flow
.as_ref()
.or_else(|| {
user.as_ref()
.and_then(|config| config.defaults.as_ref())
.and_then(|defaults| defaults.flow.as_ref())
})
.and_then(|flow| flow.test_cmd.clone());
if let Some(cmd) = &flow_test_cmd
&& cmd.is_empty()
{
return Err(ConfigError::Invalid {
path: repository.config_path.clone(),
message: "flow.test_cmd must name a command".into(),
});
}
let ticket_prefix = config
.ids
.as_ref()
.and_then(|ids| ids.ticket_prefix.clone())
.unwrap_or_else(|| DEFAULT_TICKET_PREFIX.into());
validate_id_prefix("ids.ticket_prefix", &ticket_prefix, &repository.config_path)?;
let project_prefix = config
.ids
.as_ref()
.and_then(|ids| ids.project_prefix.clone())
.unwrap_or_else(|| DEFAULT_PROJECT_PREFIX.into());
validate_id_prefix(
"ids.project_prefix",
&project_prefix,
&repository.config_path,
)?;
let flows = load_flows(&repository.root)?;
validate_flow_targets(&flows, agent.as_ref(), &repository.config_path)?;
if flow_test_cmd.is_some()
&& let Some(flow) = flows
.values()
.find(|flow| flow.stages.iter().any(|stage| stage.name == "test"))
{
return Err(ConfigError::Invalid {
path: repository.config_path.clone(),
message: format!(
"flow.test_cmd conflicts with stage `test` in flow `{}`",
flow.name
),
});
}
let delete_missing_after_ms = config
.delete_missing_after
.as_deref()
.map(|value| {
parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
path: repository.config_path.clone(),
message: format!("delete_missing_after: {message}"),
})
})
.transpose()?
.unwrap_or(DEFAULT_DELETE_MISSING_AFTER_MS);
let worktree_retention_ms = match config.worktree_retention.as_deref() {
Some("never") => None,
Some(value) => Some(parse_duration_ms(value).map_err(|message| {
ConfigError::Invalid {
path: repository.config_path.clone(),
message: format!(
"worktree_retention: {message}; use a positive duration such as 7d, 24h, or 90m, or use `never`"
),
}
})?),
None => Some(DEFAULT_WORKTREE_RETENTION_MS),
};
Ok(Self {
worktree_dir,
worktree_retention_ms,
project_dir,
ticket_dir,
ticket_source,
max_parallel_tasks,
stall_report_after_ms,
stall_after_ms,
running_hours,
agent,
flows,
default_flow: DEFAULT_FLOW_NAME.into(),
flow_test_cmd,
ticket_prefix,
project_prefix,
delete_missing_after_ms,
})
}
}
fn validate_flow_targets(
flows: &BTreeMap<String, Flow>,
agent: Option<&AgentConfig>,
path: &Path,
) -> Result<(), ConfigError> {
let known = |target: &String| agent.is_some_and(|agent| agent.targets.contains_key(target));
let invalid = |flow: &Flow, stage: &str, position: &str, target: &str| ConfigError::Invalid {
path: path.to_path_buf(),
message: format!(
"flow `{}` stage `{stage}` {position} names unknown agent target `{target}`",
flow.name
),
};
for flow in flows.values() {
for stage in &flow.stages {
if let crate::flow::Check::Panel(panel) = &stage.result_check {
for reviewer in &panel.reviewers {
if !known(&reviewer.target) {
return Err(invalid(
flow,
&stage.name,
"panel reviewer",
&reviewer.target,
));
}
}
}
}
}
Ok(())
}
fn load_flows(root: &Path) -> Result<BTreeMap<String, Flow>, ConfigError> {
let mut flows = BTreeMap::from([(DEFAULT_FLOW_NAME.into(), crate::flow::built_in_default())]);
for flow in MarkdownFlowSource::new(root)
.pull()
.map_err(ConfigError::Source)?
{
flows.insert(flow.name.clone(), flow);
}
Ok(flows)
}
fn validate_worktree_dir(value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
validate_repository_dir("worktree_dir", value, path)
}
fn validate_repository_dir(key: &str, value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
use std::path::Component;
if value.is_absolute() {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("{key} must be repository-relative, not an absolute path"),
});
}
let mut normalized = PathBuf::new();
for component in value.components() {
match component {
Component::Normal(component) => normalized.push(component),
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("{key} must not escape the repository root with `..`"),
});
}
}
Component::RootDir | Component::Prefix(_) => {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("{key} must be repository-relative, not an absolute path"),
});
}
}
}
if normalized.as_os_str().is_empty() {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("{key} must name a directory below the repository root"),
});
}
Ok(normalized)
}
fn validate_agent(agent: &RawAgent, path: &Path) -> Result<AgentConfig, ConfigError> {
if agent.cmd.is_some() {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: "agent.cmd has been removed; use `agent.default_target` and `agent.targets`"
.into(),
});
}
let default_target = agent
.default_target
.as_ref()
.ok_or_else(|| ConfigError::Invalid {
path: path.to_path_buf(),
message: "agent.default_target is required".into(),
})?;
let targets = agent.targets.as_ref().ok_or_else(|| ConfigError::Invalid {
path: path.to_path_buf(),
message: "agent.targets is required".into(),
})?;
if !targets.contains_key(default_target) {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!(
"agent.default_target `{default_target}` does not name an entry in agent.targets"
),
});
}
let mut commands = BTreeMap::new();
for (name, target) in targets {
if target.cmd.is_empty() {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("agent.targets.{name}.cmd must name a command"),
});
}
let prompt_count = target
.cmd
.iter()
.map(|argument| argument.matches("{prompt}").count())
.sum::<usize>();
if prompt_count != 1 {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!(
"agent.targets.{name}.cmd must contain `{{prompt}}` exactly once (found {prompt_count})"
),
});
}
for (key, value) in [("model", &target.model), ("effort", &target.effort)] {
if let Some(value) = value
&& value.trim().is_empty()
{
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!("agent.targets.{name}.{key} must not be empty"),
});
}
}
commands.insert(
name.clone(),
AgentTarget {
cmd: target.cmd.clone(),
model: target.model.clone(),
effort: target.effort.clone(),
},
);
}
Ok(AgentConfig {
default_target: default_target.clone(),
targets: commands,
})
}
pub(crate) fn expand_agent_cmd(
target: &AgentTarget,
model: Option<&str>,
effort: Option<&str>,
prompt: &str,
) -> Result<Vec<String>, String> {
let model = model.or(target.model.as_deref());
let effort = effort.or(target.effort.as_deref());
target
.cmd
.iter()
.map(|argument| {
let argument = match (argument.contains("{model}"), model) {
(true, Some(model)) => argument.replace("{model}", model),
(true, None) => return Err("does not specify `model`".to_owned()),
(false, _) => argument.clone(),
};
let argument = match (argument.contains("{effort}"), effort) {
(true, Some(effort)) => argument.replace("{effort}", effort),
(true, None) => return Err("does not specify `effort`".to_owned()),
(false, _) => argument,
};
Ok(argument.replace("{prompt}", prompt))
})
.collect()
}
fn validate_id_prefix(key: &str, prefix: &str, path: &Path) -> Result<(), ConfigError> {
if valid_prefix(prefix) {
return Ok(());
}
Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: format!(
"{key} must be non-empty and contain only ASCII letters, digits, `-`, or `_`, with a letter or digit at each end"
),
})
}
fn user_config_path() -> Option<PathBuf> {
env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/sloop/config.yaml"))
}
fn read_config(path: &Path) -> Result<RawConfig, ConfigError> {
let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
let config: RawConfig =
serde_yaml::from_str(&contents).map_err(|source| ConfigError::Invalid {
path: path.to_path_buf(),
message: source.to_string(),
})?;
if config.version != CONFIG_VERSION {
return Err(ConfigError::UnsupportedVersion {
path: path.to_path_buf(),
version: config.version,
});
}
Ok(config)
}
fn read_client_config(path: &Path) -> Result<RawClientConfig, ConfigError> {
let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
path: path.to_path_buf(),
source,
})?;
let config: RawClientConfig =
serde_yaml::from_str(&contents).map_err(|source| ConfigError::Invalid {
path: path.to_path_buf(),
message: source.to_string(),
})?;
if config.version != CONFIG_VERSION {
return Err(ConfigError::UnsupportedVersion {
path: path.to_path_buf(),
version: config.version,
});
}
Ok(config)
}
fn validate_running_hours(
hours: RawRunningHours,
path: &Path,
) -> Result<RunningHours, ConfigError> {
let start_minute = parse_local_time(&hours.start);
let end_minute = parse_local_time(&hours.end);
let (Some(start_minute), Some(end_minute)) = (start_minute, end_minute) else {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: "scheduler.running_hours values must use a valid HH:MM time".into(),
});
};
if start_minute == end_minute {
return Err(ConfigError::Invalid {
path: path.to_path_buf(),
message: "scheduler.running_hours start and end must differ".into(),
});
}
Ok(RunningHours {
start: hours.start,
end: hours.end,
start_minute,
end_minute,
})
}
pub(crate) fn parse_local_time(value: &str) -> Option<u16> {
let (hour, minute) = value.split_once(':')?;
if hour.len() != 2 || minute.len() != 2 {
return None;
}
if !hour.bytes().all(|byte| byte.is_ascii_digit())
|| !minute.bytes().all(|byte| byte.is_ascii_digit())
{
return None;
}
let hour = hour.parse::<u16>().ok()?;
let minute = minute.parse::<u16>().ok()?;
(hour < 24 && minute < 60).then_some(hour * 60 + minute)
}
#[derive(Debug, Deserialize)]
struct RawConfig {
version: u32,
worktree_dir: Option<PathBuf>,
worktree_retention: Option<String>,
project_dir: Option<PathBuf>,
ticket_dir: Option<PathBuf>,
defaults: Option<RawDefaults>,
scheduler: Option<RawScheduler>,
agent: Option<RawAgent>,
sources: Option<RawSources>,
flow: Option<RawFlowDefaults>,
ids: Option<RawIds>,
delete_missing_after: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawClientConfig {
version: u32,
worktree_dir: Option<PathBuf>,
}
fn parse_duration_ms(value: &str) -> Result<i64, String> {
let value = value.trim();
let (digits, unit) = value.split_at(value.len().saturating_sub(1));
let scale: i64 = match unit {
"s" => 1000,
"m" => 60 * 1000,
"h" => 60 * 60 * 1000,
"d" => 24 * 60 * 60 * 1000,
"w" => 7 * 24 * 60 * 60 * 1000,
_ => {
return Err(format!(
"`{value}` must look like 30d, 12h, 30m, 45s, or 2w"
));
}
};
let count: i64 = digits
.parse()
.map_err(|_| format!("`{value}` must look like 30d, 12h, 30m, 45s, or 2w"))?;
if count <= 0 {
return Err(format!("`{value}` must be a positive duration"));
}
count
.checked_mul(scale)
.ok_or_else(|| format!("`{value}` is too large"))
}
#[derive(Debug, Deserialize)]
struct RawIds {
ticket_prefix: Option<String>,
project_prefix: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawDefaults {
scheduler: Option<RawScheduler>,
agent: Option<RawAgent>,
sources: Option<RawSources>,
flow: Option<RawFlowDefaults>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSources {
tickets: Option<RawTicketSource>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTicketSource {
exec: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct RawFlowDefaults {
test_cmd: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct RawAgent {
default_target: Option<String>,
targets: Option<BTreeMap<String, RawAgentTarget>>,
cmd: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct RawAgentTarget {
cmd: Vec<String>,
model: Option<String>,
effort: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawScheduler {
max_parallel_tasks: Option<usize>,
stall_report_after: Option<String>,
stall_after: Option<String>,
running_hours: Option<RawRunningHours>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawRunningHours {
start: String,
end: String,
}
#[derive(Debug)]
pub enum ConfigError {
RepositoryNotFound(PathBuf),
Paths(crate::paths::PathError),
Source(crate::sources::SourceError),
Io {
path: PathBuf,
source: std::io::Error,
},
Invalid {
path: PathBuf,
message: String,
},
UnsupportedVersion {
path: PathBuf,
version: u32,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::RepositoryNotFound(start) => write!(
formatter,
"no .agents/sloop/config.yaml found from {}",
start.display()
),
Self::Paths(error) => write!(formatter, "cannot resolve Sloop runtime paths: {error}"),
Self::Source(error) => error.fmt(formatter),
Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
Self::Invalid { path, message } => write!(formatter, "{}: {message}", path.display()),
Self::UnsupportedVersion { path, version } => write!(
formatter,
"{}: unsupported config version {version}",
path.display()
),
}
}
}
impl std::error::Error for ConfigError {}
#[cfg(test)]
mod tests {
use std::fs;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use tempfile::tempdir;
use super::{Config, ConfigError, Repository, RunningHours, TicketSourceConfig};
use crate::clock::Clock;
struct SpringForwardClock;
impl Clock for SpringForwardClock {
fn now_ms(&self) -> i64 {
30_000
}
fn local_minute(&self, timestamp_ms: i64) -> u16 {
if timestamp_ms < 60_000 { 119 } else { 180 }
}
fn sleep_until(&self, _deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(std::future::pending())
}
}
#[test]
fn discovers_the_nearest_repository_from_a_nested_directory() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.unwrap();
let nested = root.path().join("src/deep");
fs::create_dir_all(&nested).unwrap();
let repository = Repository::discover(&nested).unwrap();
assert_eq!(repository.root, root.path().canonicalize().unwrap());
}
#[test]
fn repository_scheduler_values_are_loaded() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
concat!(
"version: 1\n",
"scheduler:\n",
" max_parallel_tasks: 3\n",
" stall_report_after: 7m\n",
" stall_after: 3h\n",
" running_hours:\n",
" start: '22:00'\n",
" end: '06:00'\n"
),
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let config = Config::load(&repository).unwrap();
assert_eq!(config.max_parallel_tasks, 3);
assert_eq!(config.stall_report_after_ms, 7 * 60 * 1000);
assert_eq!(config.stall_after_ms, 3 * 60 * 60 * 1000);
assert_eq!(config.running_hours.unwrap().start, "22:00");
}
#[test]
fn stall_report_after_defaults_and_rejects_non_positive_durations() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
let path = root.path().join(".agents/sloop/config.yaml");
fs::write(&path, "version: 1\n").unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert_eq!(
Config::load(&repository).unwrap().stall_report_after_ms,
10 * 60 * 1000
);
assert_eq!(
Config::load(&repository).unwrap().stall_after_ms,
2 * 60 * 60 * 1000
);
for duration in ["0m", "-1m"] {
fs::write(
&path,
format!("version: 1\nscheduler:\n stall_report_after: {duration}\n"),
)
.unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("scheduler.stall_report_after"), "{error}");
assert!(error.contains("positive duration"), "{error}");
}
}
#[test]
fn stall_after_must_be_positive_and_greater_than_the_report_threshold() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
let path = root.path().join(".agents/sloop/config.yaml");
fs::write(&path, "version: 1\n").unwrap();
let repository = Repository::discover(root.path()).unwrap();
for duration in ["0m", "-1m"] {
fs::write(
&path,
format!("version: 1\nscheduler:\n stall_after: {duration}\n"),
)
.unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("scheduler.stall_after"), "{error}");
assert!(error.contains("positive duration"), "{error}");
}
fs::write(
&path,
"version: 1\nscheduler:\n stall_report_after: 30m\n stall_after: 30m\n",
)
.unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(
error.contains("stall_after must be greater than scheduler.stall_report_after"),
"{error}"
);
}
#[test]
fn worktree_dir_defaults_to_dot_worktrees() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert_eq!(
Config::load(&repository).unwrap().worktree_dir,
PathBuf::from(".worktrees")
);
}
#[test]
fn worktree_retention_defaults_to_seven_days_and_accepts_never() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
let path = root.path().join(".agents/sloop/config.yaml");
fs::write(&path, "version: 1\n").unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert_eq!(
Config::load(&repository).unwrap().worktree_retention_ms,
Some(7 * 24 * 60 * 60 * 1000)
);
fs::write(&path, "version: 1\nworktree_retention: never\n").unwrap();
assert_eq!(
Config::load(&repository).unwrap().worktree_retention_ms,
None
);
fs::write(&path, "version: 1\nworktree_retention: 90m\n").unwrap();
assert_eq!(
Config::load(&repository).unwrap().worktree_retention_ms,
Some(90 * 60 * 1000)
);
}
#[test]
fn invalid_worktree_retention_names_the_key_and_remedy() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nworktree_retention: tomorrow\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("worktree_retention"), "{error}");
assert!(error.contains("use a positive duration"), "{error}");
assert!(error.contains("`never`"), "{error}");
}
#[test]
fn content_directories_default_to_the_sloop_layout() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let config = Config::load(&repository).unwrap();
assert_eq!(config.project_dir, PathBuf::from(".agents/sloop/projects"));
assert_eq!(config.ticket_dir, PathBuf::from(".agents/sloop/tickets"));
}
#[test]
fn ticket_source_defaults_to_markdown() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert_eq!(
Config::load(&repository).unwrap().ticket_source,
TicketSourceConfig::Markdown
);
}
#[test]
fn exec_ticket_source_loads_from_repository_configuration() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nsources:\n tickets:\n exec: [ticket-source, --repo, .]\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert_eq!(
Config::load(&repository).unwrap().ticket_source,
TicketSourceConfig::Exec(vec!["ticket-source".into(), "--repo".into(), ".".into()])
);
}
#[test]
fn exec_ticket_source_command_must_be_nonempty() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nsources:\n tickets:\n exec: []\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("sources.tickets.exec"), "{error}");
assert!(error.contains("must name a command"), "{error}");
}
#[test]
fn content_directories_load_repository_relative_paths() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nproject_dir: planning/projects\nticket_dir: planning/tickets\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let config = Config::load(&repository).unwrap();
assert_eq!(config.project_dir, PathBuf::from("planning/projects"));
assert_eq!(config.ticket_dir, PathBuf::from("planning/tickets"));
}
#[test]
fn content_directories_must_stay_below_the_repository_root() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nproject_dir: /tmp/projects\nticket_dir: ../tickets\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("project_dir"), "{error}");
assert!(error.contains("repository-relative"), "{error}");
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nproject_dir: planning/projects\nticket_dir: ../tickets\n",
)
.unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("ticket_dir"), "{error}");
assert!(error.contains("repository root"), "{error}");
}
#[test]
fn worktree_dir_loads_a_repository_relative_path() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nworktree_dir: build/agent-worktrees\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert_eq!(
Config::load(&repository).unwrap().worktree_dir,
PathBuf::from("build/agent-worktrees")
);
}
#[test]
fn worktree_dir_rejects_an_absolute_path() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nworktree_dir: /tmp/sloop-worktrees\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("worktree_dir"), "{error}");
assert!(error.contains("repository-relative"), "{error}");
}
#[test]
fn worktree_dir_rejects_parent_traversal_outside_the_repository() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nworktree_dir: ../sloop-worktrees\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("worktree_dir"), "{error}");
assert!(error.contains("repository root"), "{error}");
}
#[test]
fn repository_agent_loads_multiple_named_targets() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
concat!(
"version: 1\n",
"agent:\n",
" default_target: claude\n",
" targets:\n",
" claude:\n",
" cmd: [claude, --model, '{model}', '{prompt}']\n",
" codex:\n",
" cmd: [codex, exec, --model, '{model}', '{prompt}']\n",
),
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let agent = Config::load(&repository).unwrap().agent.unwrap();
assert_eq!(agent.default_target, "claude");
assert_eq!(agent.targets.len(), 2);
assert_eq!(agent.targets["codex"].cmd[0], "codex");
}
#[test]
fn agent_targets_load_default_model_and_effort() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
concat!(
"version: 1\n",
"agent:\n",
" default_target: claude\n",
" targets:\n",
" claude:\n",
" model: opus\n",
" effort: high\n",
" cmd: [claude, --model, '{model}', --effort, '{effort}', '{prompt}']\n",
),
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let agent = Config::load(&repository).unwrap().agent.unwrap();
assert_eq!(agent.targets["claude"].model.as_deref(), Some("opus"));
assert_eq!(agent.targets["claude"].effort.as_deref(), Some("high"));
}
#[test]
fn agent_target_rejects_a_blank_default_model() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
concat!(
"version: 1\n",
"agent:\n",
" default_target: claude\n",
" targets:\n",
" claude:\n",
" model: ' '\n",
" cmd: [claude, --model, '{model}', '{prompt}']\n",
),
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("agent.targets.claude.model"), "{error}");
}
#[test]
fn agent_default_target_must_name_a_configured_target() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nagent:\n default_target: missing\n targets:\n fake:\n cmd: [fake]\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("agent.default_target `missing`"), "{error}");
assert!(error.contains("agent.targets"), "{error}");
}
#[test]
fn client_essentials_ignore_daemon_only_configuration_validation() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nagent:\n default_target: missing\n targets: not-a-map\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
Config::validate_client_essentials(&repository).unwrap();
assert!(Config::load(&repository).is_err());
}
#[test]
fn every_agent_target_command_must_be_nonempty() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nagent:\n default_target: fake\n targets:\n fake:\n cmd: []\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("agent.targets.fake.cmd"), "{error}");
assert!(error.contains("must name a command"), "{error}");
}
#[test]
fn every_agent_target_requires_prompt_exactly_once() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
let config_path = root.path().join(".agents/sloop/config.yaml");
fs::write(
&config_path,
"version: 1\nagent:\n default_target: missing_prompt\n targets:\n missing_prompt:\n cmd: [agent]\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(
error.contains("agent.targets.missing_prompt.cmd"),
"{error}"
);
assert!(error.contains("`{prompt}` exactly once"), "{error}");
assert!(error.contains("found 0"), "{error}");
fs::write(
config_path,
"version: 1\nagent:\n default_target: duplicate_prompt\n targets:\n duplicate_prompt:\n cmd: [agent, '{prompt}', 'again={prompt}']\n",
)
.unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(
error.contains("agent.targets.duplicate_prompt.cmd"),
"{error}"
);
assert!(error.contains("`{prompt}` exactly once"), "{error}");
assert!(error.contains("found 2"), "{error}");
}
#[test]
fn legacy_singular_agent_command_names_the_new_shape() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nagent:\n cmd: [fake]\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("agent.cmd has been removed"), "{error}");
assert!(error.contains("agent.default_target"), "{error}");
assert!(error.contains("agent.targets"), "{error}");
}
#[test]
fn id_prefixes_default_and_load_from_repository_configuration() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let defaults = Config::load(&repository).unwrap();
assert_eq!(defaults.ticket_prefix, "TICK");
assert_eq!(defaults.project_prefix, "PROJ");
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nids:\n ticket_prefix: WORK\n project_prefix: TEAM\n",
)
.unwrap();
let configured = Config::load(&repository).unwrap();
assert_eq!(configured.ticket_prefix, "WORK");
assert_eq!(configured.project_prefix, "TEAM");
}
#[test]
fn invalid_id_prefixes_are_clear_configuration_errors() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nids:\n ticket_prefix: ''\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("ids.ticket_prefix"), "{error}");
assert!(error.contains("non-empty"), "{error}");
}
#[test]
fn running_hours_are_start_inclusive_and_end_exclusive() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '17:00'\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let hours = Config::load(&repository).unwrap().running_hours.unwrap();
assert!(!hours.is_open(8 * 60 + 59));
assert!(hours.is_open(9 * 60));
assert!(hours.is_open(16 * 60 + 59));
assert!(!hours.is_open(17 * 60));
}
#[test]
fn running_hours_may_cross_midnight() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nscheduler:\n running_hours:\n start: '22:00'\n end: '06:00'\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let hours = Config::load(&repository).unwrap().running_hours.unwrap();
assert!(hours.is_open(23 * 60));
assert!(hours.is_open(5 * 60 + 59));
assert!(!hours.is_open(6 * 60));
assert!(!hours.is_open(12 * 60));
}
#[test]
fn equal_running_hour_boundaries_are_rejected() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '09:00'\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert!(matches!(
Config::load(&repository),
Err(ConfigError::Invalid { .. })
));
}
#[test]
fn next_opening_uses_the_first_open_instant_after_a_dst_skip() {
let hours = RunningHours {
start: "02:30".into(),
end: "04:00".into(),
start_minute: 150,
end_minute: 240,
};
assert_eq!(
hours.next_opening_ms(&SpringForwardClock, SpringForwardClock.now_ms()),
60_000
);
}
#[test]
fn unsupported_versions_are_rejected() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 2\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
assert!(matches!(
Config::load(&repository),
Err(ConfigError::UnsupportedVersion { version: 2, .. })
));
}
#[test]
fn durations_parse_with_single_letter_units() {
use super::parse_duration_ms;
assert_eq!(parse_duration_ms("45s").unwrap(), 45_000);
assert_eq!(parse_duration_ms("30m").unwrap(), 1_800_000);
assert_eq!(parse_duration_ms("12h").unwrap(), 43_200_000);
assert_eq!(parse_duration_ms("30d").unwrap(), 2_592_000_000);
assert_eq!(parse_duration_ms("2w").unwrap(), 1_209_600_000);
for invalid in ["", "30", "d", "0d", "-1d", "1.5h", "30 days"] {
assert!(parse_duration_ms(invalid).is_err(), "{invalid}");
}
}
#[test]
fn delete_missing_after_is_configurable_and_defaults_to_a_month() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
let config_path = root.path().join(".agents/sloop/config.yaml");
fs::write(&config_path, "version: 1\n").unwrap();
let repository = Repository::discover(root.path()).unwrap();
let config = Config::load(&repository).unwrap();
assert_eq!(
config.delete_missing_after_ms,
super::DEFAULT_DELETE_MISSING_AFTER_MS
);
fs::write(&config_path, "version: 1\ndelete_missing_after: 7d\n").unwrap();
let config = Config::load(&repository).unwrap();
assert_eq!(config.delete_missing_after_ms, 7 * 24 * 60 * 60 * 1000);
fs::write(&config_path, "version: 1\ndelete_missing_after: soon\n").unwrap();
let error = Config::load(&repository).unwrap_err();
assert!(
error.to_string().contains("delete_missing_after"),
"{error}"
);
}
#[test]
fn built_in_default_flow_does_not_reuse_agent_placeholders() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nagent:\n default_target: reviewer\n targets:\n reviewer:\n cmd: [review-agent, --model, '{model}', --effort, '{effort}', '{prompt}']\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let config = Config::load(&repository).unwrap();
assert_eq!(config.default_flow, "default");
assert_eq!(
config.flows["default"]
.stages
.iter()
.map(|stage| stage.name.as_str())
.collect::<Vec<_>>(),
["build", "merge"]
);
}
#[test]
fn flow_test_command_is_not_duplicated_in_the_built_in_default_flow() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nflow:\n test_cmd: [cargo, test]\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let config = Config::load(&repository).unwrap();
let flow = &config.flows["default"];
assert_eq!(
flow.stages
.iter()
.map(|stage| stage.name.as_str())
.collect::<Vec<_>>(),
["build", "merge"]
);
}
#[test]
fn committed_default_flow_overrides_the_built_in_flow() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nflow:\n test_cmd: [cargo, test]\n",
)
.unwrap();
fs::write(
root.path().join(".agents/sloop/flows/default.yaml"),
"- { name: build, action: agent }\n- { name: ship, action: { exec: [ship] } }\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let config = Config::load(&repository).unwrap();
assert_eq!(
config.flows["default"]
.stages
.iter()
.map(|stage| stage.name.as_str())
.collect::<Vec<_>>(),
["build", "ship"]
);
}
#[test]
fn panel_reviewers_must_name_configured_agent_targets() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\nagent:\n default_target: fake\n targets:\n fake:\n cmd: [fake, '{prompt}']\n",
)
.unwrap();
fs::write(
root.path().join(".agents/sloop/flows/default.yaml"),
"- name: build\n action: agent\n result_check:\n panel:\n prompt: prompts/review.md\n reviewers: [{ target: fake }, { target: ghost }]\n require: { quorum: 1 }\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("stage `build`"), "{error}");
assert!(error.contains("panel reviewer"), "{error}");
assert!(error.contains("unknown agent target `ghost`"), "{error}");
}
#[test]
fn invalid_flow_error_names_the_file_and_problem() {
let root = tempdir().unwrap();
fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
fs::write(
root.path().join(".agents/sloop/config.yaml"),
"version: 1\n",
)
.unwrap();
fs::write(
root.path().join(".agents/sloop/flows/broken.yaml"),
"- { name: build, action: agent }\n- { name: check, action: { exec: [] } }\n",
)
.unwrap();
let repository = Repository::discover(root.path()).unwrap();
let error = Config::load(&repository).unwrap_err().to_string();
assert!(error.contains("broken.yaml"), "{error}");
assert!(error.contains("non-empty command"), "{error}");
}
}