1use std::collections::BTreeMap;
2use std::env;
3use std::fmt;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::flow::{DEFAULT_FLOW_NAME, Flow};
10use crate::ids::{DEFAULT_PROJECT_PREFIX, DEFAULT_TICKET_PREFIX, valid_prefix};
11
12pub const CONFIG_VERSION: u32 = 1;
13pub const DEFAULT_DELETE_MISSING_AFTER_MS: i64 = 30 * 24 * 60 * 60 * 1000;
14pub const DEFAULT_WORKTREE_DIR: &str = ".worktrees";
15pub const DEFAULT_PROJECT_DIR: &str = ".agents/sloop/projects";
16pub const DEFAULT_TICKET_DIR: &str = ".agents/sloop/tickets";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Project {
20 pub root: PathBuf,
21 pub config_path: PathBuf,
22 pub state_dir: PathBuf,
23 pub runtime_dir: PathBuf,
24 pub operator_socket: PathBuf,
25 pub lock_path: PathBuf,
26 pub daemon_log: PathBuf,
27 pub db_path: PathBuf,
28}
29
30impl Project {
31 pub fn discover(start: &Path) -> Result<Self, ConfigError> {
32 let start = start.canonicalize().map_err(|source| ConfigError::Io {
33 path: start.to_path_buf(),
34 source,
35 })?;
36
37 for directory in start.ancestors() {
38 let config_path = directory.join(".agents/sloop/config.yaml");
39 if config_path.is_file() {
40 let paths = crate::paths::resolve(directory).map_err(ConfigError::Paths)?;
41 return Ok(Self {
42 root: directory.to_path_buf(),
43 config_path,
44 state_dir: paths.state_dir,
45 runtime_dir: paths.runtime_dir,
46 operator_socket: paths.operator_socket,
47 lock_path: paths.lock_path,
48 daemon_log: paths.daemon_log,
49 db_path: paths.db_path,
50 });
51 }
52 }
53
54 Err(ConfigError::ProjectNotFound(start))
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Config {
60 pub worktree_dir: PathBuf,
61 pub project_dir: PathBuf,
62 pub ticket_dir: PathBuf,
63 pub max_parallel_tasks: usize,
64 pub running_hours: Option<RunningHours>,
65 pub agent: Option<AgentConfig>,
68 pub flows: BTreeMap<String, Flow>,
71 pub default_flow: String,
72 pub aftercare_test_cmd: Option<Vec<String>>,
76 pub ticket_prefix: String,
79 pub project_prefix: String,
80 pub delete_missing_after_ms: i64,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct AgentConfig {
87 pub default_target: String,
88 pub targets: BTreeMap<String, Vec<String>>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct RunningHours {
93 pub start: String,
94 pub end: String,
95 start_minute: u16,
96 end_minute: u16,
97}
98
99impl RunningHours {
100 pub fn is_open(&self, local_minute: u16) -> bool {
101 if self.start_minute < self.end_minute {
102 (self.start_minute..self.end_minute).contains(&local_minute)
103 } else {
104 local_minute >= self.start_minute || local_minute < self.end_minute
105 }
106 }
107
108 pub fn next_opening_ms(&self, clock: &dyn crate::clock::Clock, now_ms: i64) -> i64 {
109 let mut candidate = (now_ms.div_euclid(60_000) + 1) * 60_000;
110 for _ in 0..=(49 * 60) {
115 if self.is_open(clock.local_minute(candidate)) {
116 return candidate;
117 }
118 candidate += 60_000;
119 }
120 candidate
121 }
122}
123
124impl Config {
125 pub fn load(project: &Project) -> Result<Self, ConfigError> {
126 let user_path = user_config_path().filter(|path| path.is_file());
127 let user = user_path
128 .as_ref()
129 .map(|path| read_config(path))
130 .transpose()?;
131 let repository = read_config(&project.config_path)?;
132
133 let worktree_dir = validate_worktree_dir(
134 repository
135 .worktree_dir
136 .as_deref()
137 .unwrap_or_else(|| Path::new(DEFAULT_WORKTREE_DIR)),
138 &project.config_path,
139 )?;
140 let project_dir = validate_repository_dir(
141 "project_dir",
142 repository
143 .project_dir
144 .as_deref()
145 .unwrap_or_else(|| Path::new(DEFAULT_PROJECT_DIR)),
146 &project.config_path,
147 )?;
148 let ticket_dir = validate_repository_dir(
149 "ticket_dir",
150 repository
151 .ticket_dir
152 .as_deref()
153 .unwrap_or_else(|| Path::new(DEFAULT_TICKET_DIR)),
154 &project.config_path,
155 )?;
156
157 if user.as_ref().is_some_and(|config| {
158 config.agent.is_some()
159 || config
160 .defaults
161 .as_ref()
162 .is_some_and(|defaults| defaults.agent.is_some())
163 }) {
164 return Err(ConfigError::Invalid {
165 path: user_path.expect("loaded user config has a path"),
166 message: "agent targets are repository-scoped; configure `agent.default_target` and `agent.targets` in .agents/sloop/config.yaml".into(),
167 });
168 }
169
170 let defaults = user
171 .as_ref()
172 .and_then(|config| config.defaults.as_ref())
173 .and_then(|defaults| defaults.scheduler.as_ref());
174 let repository_scheduler = repository.scheduler.as_ref();
175
176 let max_parallel_tasks = repository_scheduler
177 .and_then(|scheduler| scheduler.max_parallel_tasks)
178 .or_else(|| defaults.and_then(|scheduler| scheduler.max_parallel_tasks))
179 .unwrap_or(1);
180 if max_parallel_tasks == 0 {
181 return Err(ConfigError::Invalid {
182 path: project.config_path.clone(),
183 message: "scheduler.max_parallel_tasks must be greater than zero".into(),
184 });
185 }
186
187 let running_hours = repository_scheduler
188 .and_then(|scheduler| scheduler.running_hours.clone())
189 .or_else(|| defaults.and_then(|scheduler| scheduler.running_hours.clone()))
190 .map(|hours| validate_running_hours(hours, &project.config_path))
191 .transpose()?;
192
193 let agent = repository
194 .agent
195 .as_ref()
196 .map(|agent| validate_agent(agent, &project.config_path))
197 .transpose()?;
198
199 let aftercare_test_cmd = repository
200 .aftercare
201 .as_ref()
202 .or_else(|| {
203 user.as_ref()
204 .and_then(|config| config.defaults.as_ref())
205 .and_then(|defaults| defaults.aftercare.as_ref())
206 })
207 .and_then(|aftercare| aftercare.test_cmd.clone());
208 if let Some(cmd) = &aftercare_test_cmd
209 && cmd.is_empty()
210 {
211 return Err(ConfigError::Invalid {
212 path: project.config_path.clone(),
213 message: "aftercare.test_cmd must name a command".into(),
214 });
215 }
216
217 let ticket_prefix = repository
218 .ids
219 .as_ref()
220 .and_then(|ids| ids.ticket_prefix.clone())
221 .unwrap_or_else(|| DEFAULT_TICKET_PREFIX.into());
222 validate_id_prefix("ids.ticket_prefix", &ticket_prefix, &project.config_path)?;
223 let project_prefix = repository
224 .ids
225 .as_ref()
226 .and_then(|ids| ids.project_prefix.clone())
227 .unwrap_or_else(|| DEFAULT_PROJECT_PREFIX.into());
228 validate_id_prefix("ids.project_prefix", &project_prefix, &project.config_path)?;
229
230 let default_agent_cmd = agent
231 .as_ref()
232 .map(|agent| agent.targets[&agent.default_target].as_slice());
233 let flows = load_flows(
234 &project.root,
235 default_agent_cmd,
236 aftercare_test_cmd.as_deref(),
237 )?;
238
239 let delete_missing_after_ms = repository
240 .delete_missing_after
241 .as_deref()
242 .map(|value| {
243 parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
244 path: project.config_path.clone(),
245 message: format!("delete_missing_after: {message}"),
246 })
247 })
248 .transpose()?
249 .unwrap_or(DEFAULT_DELETE_MISSING_AFTER_MS);
250
251 Ok(Self {
252 worktree_dir,
253 project_dir,
254 ticket_dir,
255 max_parallel_tasks,
256 running_hours,
257 agent,
258 flows,
259 default_flow: DEFAULT_FLOW_NAME.into(),
260 aftercare_test_cmd,
261 ticket_prefix,
262 project_prefix,
263 delete_missing_after_ms,
264 })
265 }
266}
267
268fn load_flows(
269 root: &Path,
270 default_agent_cmd: Option<&[String]>,
271 test_cmd: Option<&[String]>,
272) -> Result<BTreeMap<String, Flow>, ConfigError> {
273 let mut flows = BTreeMap::from([(
274 DEFAULT_FLOW_NAME.into(),
275 crate::flow::built_in_default(default_agent_cmd, test_cmd),
276 )]);
277 let directory = root.join(".agents/sloop/flows");
278 let entries = match fs::read_dir(&directory) {
279 Ok(entries) => entries,
280 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(flows),
281 Err(source) => {
282 return Err(ConfigError::Io {
283 path: directory,
284 source,
285 });
286 }
287 };
288
289 let mut paths = entries
290 .map(|entry| {
291 entry
292 .map(|entry| entry.path())
293 .map_err(|source| ConfigError::Io {
294 path: directory.clone(),
295 source,
296 })
297 })
298 .collect::<Result<Vec<_>, _>>()?;
299 paths.retain(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "yaml"));
300 paths.sort();
301
302 for path in paths {
303 let name = path
304 .file_stem()
305 .and_then(|stem| stem.to_str())
306 .ok_or_else(|| ConfigError::Invalid {
307 path: path.clone(),
308 message: "flow filename must be valid UTF-8".into(),
309 })?;
310 let contents = fs::read_to_string(&path).map_err(|source| ConfigError::Io {
311 path: path.clone(),
312 source,
313 })?;
314 let flow = crate::flow::parse(name, &contents).map_err(|message| ConfigError::Invalid {
315 path: path.clone(),
316 message,
317 })?;
318 flows.insert(name.into(), flow);
319 }
320 Ok(flows)
321}
322
323fn validate_worktree_dir(value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
324 validate_repository_dir("worktree_dir", value, path)
325}
326
327fn validate_repository_dir(key: &str, value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
328 use std::path::Component;
329
330 if value.is_absolute() {
331 return Err(ConfigError::Invalid {
332 path: path.to_path_buf(),
333 message: format!("{key} must be repository-relative, not an absolute path"),
334 });
335 }
336
337 let mut normalized = PathBuf::new();
338 for component in value.components() {
339 match component {
340 Component::Normal(component) => normalized.push(component),
341 Component::CurDir => {}
342 Component::ParentDir => {
343 if !normalized.pop() {
344 return Err(ConfigError::Invalid {
345 path: path.to_path_buf(),
346 message: format!("{key} must not escape the repository root with `..`"),
347 });
348 }
349 }
350 Component::RootDir | Component::Prefix(_) => {
351 return Err(ConfigError::Invalid {
352 path: path.to_path_buf(),
353 message: format!("{key} must be repository-relative, not an absolute path"),
354 });
355 }
356 }
357 }
358 if normalized.as_os_str().is_empty() {
359 return Err(ConfigError::Invalid {
360 path: path.to_path_buf(),
361 message: format!("{key} must name a directory below the repository root"),
362 });
363 }
364 Ok(normalized)
365}
366
367fn validate_agent(agent: &RawAgent, path: &Path) -> Result<AgentConfig, ConfigError> {
368 if agent.cmd.is_some() {
369 return Err(ConfigError::Invalid {
370 path: path.to_path_buf(),
371 message: "agent.cmd has been removed; use `agent.default_target` and `agent.targets`"
372 .into(),
373 });
374 }
375 let default_target = agent
376 .default_target
377 .as_ref()
378 .ok_or_else(|| ConfigError::Invalid {
379 path: path.to_path_buf(),
380 message: "agent.default_target is required".into(),
381 })?;
382 let targets = agent.targets.as_ref().ok_or_else(|| ConfigError::Invalid {
383 path: path.to_path_buf(),
384 message: "agent.targets is required".into(),
385 })?;
386 if !targets.contains_key(default_target) {
387 return Err(ConfigError::Invalid {
388 path: path.to_path_buf(),
389 message: format!(
390 "agent.default_target `{default_target}` does not name an entry in agent.targets"
391 ),
392 });
393 }
394
395 let mut commands = BTreeMap::new();
396 for (name, target) in targets {
397 if target.cmd.is_empty() {
398 return Err(ConfigError::Invalid {
399 path: path.to_path_buf(),
400 message: format!("agent.targets.{name}.cmd must name a command"),
401 });
402 }
403 let prompt_count = target
404 .cmd
405 .iter()
406 .map(|argument| argument.matches("{prompt}").count())
407 .sum::<usize>();
408 if prompt_count != 1 {
409 return Err(ConfigError::Invalid {
410 path: path.to_path_buf(),
411 message: format!(
412 "agent.targets.{name}.cmd must contain `{{prompt}}` exactly once (found {prompt_count})"
413 ),
414 });
415 }
416 commands.insert(name.clone(), target.cmd.clone());
417 }
418 Ok(AgentConfig {
419 default_target: default_target.clone(),
420 targets: commands,
421 })
422}
423
424pub(crate) fn expand_agent_cmd(
425 template: &[String],
426 model: Option<&str>,
427 effort: Option<&str>,
428 prompt: &str,
429) -> Result<Vec<String>, String> {
430 template
431 .iter()
432 .map(|argument| {
433 let argument = match (argument.contains("{model}"), model) {
434 (true, Some(model)) => argument.replace("{model}", model),
435 (true, None) => return Err("does not specify `model`".to_owned()),
436 (false, _) => argument.clone(),
437 };
438 let argument = match (argument.contains("{effort}"), effort) {
439 (true, Some(effort)) => argument.replace("{effort}", effort),
440 (true, None) => return Err("does not specify `effort`".to_owned()),
441 (false, _) => argument,
442 };
443 Ok(argument.replace("{prompt}", prompt))
444 })
445 .collect()
446}
447
448fn validate_id_prefix(key: &str, prefix: &str, path: &Path) -> Result<(), ConfigError> {
449 if valid_prefix(prefix) {
450 return Ok(());
451 }
452 Err(ConfigError::Invalid {
453 path: path.to_path_buf(),
454 message: format!(
455 "{key} must be non-empty and contain only ASCII letters, digits, `-`, or `_`, with a letter or digit at each end"
456 ),
457 })
458}
459
460fn user_config_path() -> Option<PathBuf> {
461 env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/sloop/config.yaml"))
462}
463
464fn read_config(path: &Path) -> Result<RawConfig, ConfigError> {
465 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
466 path: path.to_path_buf(),
467 source,
468 })?;
469 let config: RawConfig =
470 serde_yaml::from_str(&contents).map_err(|source| ConfigError::Invalid {
471 path: path.to_path_buf(),
472 message: source.to_string(),
473 })?;
474 if config.version != CONFIG_VERSION {
475 return Err(ConfigError::UnsupportedVersion {
476 path: path.to_path_buf(),
477 version: config.version,
478 });
479 }
480 Ok(config)
481}
482
483fn validate_running_hours(
484 hours: RawRunningHours,
485 path: &Path,
486) -> Result<RunningHours, ConfigError> {
487 let start_minute = parse_local_time(&hours.start);
488 let end_minute = parse_local_time(&hours.end);
489 let (Some(start_minute), Some(end_minute)) = (start_minute, end_minute) else {
490 return Err(ConfigError::Invalid {
491 path: path.to_path_buf(),
492 message: "scheduler.running_hours values must use a valid HH:MM time".into(),
493 });
494 };
495 if start_minute == end_minute {
496 return Err(ConfigError::Invalid {
497 path: path.to_path_buf(),
498 message: "scheduler.running_hours start and end must differ".into(),
499 });
500 }
501 Ok(RunningHours {
502 start: hours.start,
503 end: hours.end,
504 start_minute,
505 end_minute,
506 })
507}
508
509fn parse_local_time(value: &str) -> Option<u16> {
510 let (hour, minute) = value.split_once(':')?;
511 if hour.len() != 2 || minute.len() != 2 {
512 return None;
513 }
514 let hour = hour.parse::<u16>().ok()?;
515 let minute = minute.parse::<u16>().ok()?;
516 (hour < 24 && minute < 60).then_some(hour * 60 + minute)
517}
518
519#[derive(Debug, Deserialize)]
520struct RawConfig {
521 version: u32,
522 worktree_dir: Option<PathBuf>,
523 project_dir: Option<PathBuf>,
524 ticket_dir: Option<PathBuf>,
525 defaults: Option<RawDefaults>,
526 scheduler: Option<RawScheduler>,
527 agent: Option<RawAgent>,
528 aftercare: Option<RawAftercare>,
529 ids: Option<RawIds>,
530 delete_missing_after: Option<String>,
531}
532
533fn parse_duration_ms(value: &str) -> Result<i64, String> {
536 let value = value.trim();
537 let (digits, unit) = value.split_at(value.len().saturating_sub(1));
538 let scale: i64 = match unit {
539 "s" => 1000,
540 "m" => 60 * 1000,
541 "h" => 60 * 60 * 1000,
542 "d" => 24 * 60 * 60 * 1000,
543 "w" => 7 * 24 * 60 * 60 * 1000,
544 _ => {
545 return Err(format!(
546 "`{value}` must look like 30d, 12h, 30m, 45s, or 2w"
547 ));
548 }
549 };
550 let count: i64 = digits
551 .parse()
552 .map_err(|_| format!("`{value}` must look like 30d, 12h, 30m, 45s, or 2w"))?;
553 if count <= 0 {
554 return Err(format!("`{value}` must be a positive duration"));
555 }
556 count
557 .checked_mul(scale)
558 .ok_or_else(|| format!("`{value}` is too large"))
559}
560
561#[derive(Debug, Deserialize)]
562struct RawIds {
563 ticket_prefix: Option<String>,
564 project_prefix: Option<String>,
565}
566
567#[derive(Debug, Deserialize)]
568struct RawDefaults {
569 scheduler: Option<RawScheduler>,
570 agent: Option<RawAgent>,
571 aftercare: Option<RawAftercare>,
572}
573
574#[derive(Debug, Deserialize)]
575struct RawAftercare {
576 test_cmd: Option<Vec<String>>,
577}
578
579#[derive(Debug, Deserialize)]
580struct RawAgent {
581 default_target: Option<String>,
582 targets: Option<BTreeMap<String, RawAgentTarget>>,
583 cmd: Option<Vec<String>>,
584}
585
586#[derive(Debug, Deserialize)]
587struct RawAgentTarget {
588 cmd: Vec<String>,
589}
590
591#[derive(Debug, Deserialize)]
592struct RawScheduler {
593 max_parallel_tasks: Option<usize>,
594 running_hours: Option<RawRunningHours>,
595}
596
597#[derive(Debug, Clone, Deserialize)]
598struct RawRunningHours {
599 start: String,
600 end: String,
601}
602
603#[derive(Debug)]
604pub enum ConfigError {
605 ProjectNotFound(PathBuf),
606 Paths(crate::paths::PathError),
607 Io {
608 path: PathBuf,
609 source: std::io::Error,
610 },
611 Invalid {
612 path: PathBuf,
613 message: String,
614 },
615 UnsupportedVersion {
616 path: PathBuf,
617 version: u32,
618 },
619}
620
621impl fmt::Display for ConfigError {
622 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
623 match self {
624 Self::ProjectNotFound(start) => write!(
625 formatter,
626 "no .agents/sloop/config.yaml found from {}",
627 start.display()
628 ),
629 Self::Paths(error) => write!(formatter, "cannot resolve Sloop runtime paths: {error}"),
630 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
631 Self::Invalid { path, message } => write!(formatter, "{}: {message}", path.display()),
632 Self::UnsupportedVersion { path, version } => write!(
633 formatter,
634 "{}: unsupported config version {version}",
635 path.display()
636 ),
637 }
638 }
639}
640
641impl std::error::Error for ConfigError {}
642
643#[cfg(test)]
644mod tests {
645 use std::fs;
646 use std::future::Future;
647 use std::path::PathBuf;
648 use std::pin::Pin;
649
650 use tempfile::tempdir;
651
652 use super::{Config, ConfigError, Project, RunningHours};
653 use crate::clock::Clock;
654 use crate::flow::StageKind;
655
656 struct SpringForwardClock;
657
658 impl Clock for SpringForwardClock {
659 fn now_ms(&self) -> i64 {
660 30_000
661 }
662
663 fn local_minute(&self, timestamp_ms: i64) -> u16 {
664 if timestamp_ms < 60_000 { 119 } else { 180 }
665 }
666
667 fn sleep_until(&self, _deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
668 Box::pin(std::future::pending())
669 }
670 }
671
672 #[test]
673 fn discovers_the_nearest_project_from_a_nested_directory() {
674 let root = tempdir().unwrap();
675 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
676 fs::write(
677 root.path().join(".agents/sloop/config.yaml"),
678 "version: 1\n",
679 )
680 .unwrap();
681 let nested = root.path().join("src/deep");
682 fs::create_dir_all(&nested).unwrap();
683
684 let project = Project::discover(&nested).unwrap();
685 assert_eq!(project.root, root.path().canonicalize().unwrap());
686 }
687
688 #[test]
689 fn repository_scheduler_values_are_loaded() {
690 let root = tempdir().unwrap();
691 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
692 fs::write(
693 root.path().join(".agents/sloop/config.yaml"),
694 concat!(
695 "version: 1\n",
696 "scheduler:\n",
697 " max_parallel_tasks: 3\n",
698 " running_hours:\n",
699 " start: '22:00'\n",
700 " end: '06:00'\n"
701 ),
702 )
703 .unwrap();
704
705 let project = Project::discover(root.path()).unwrap();
706 let config = Config::load(&project).unwrap();
707 assert_eq!(config.max_parallel_tasks, 3);
708 assert_eq!(config.running_hours.unwrap().start, "22:00");
709 }
710
711 #[test]
712 fn worktree_dir_defaults_to_dot_worktrees() {
713 let root = tempdir().unwrap();
714 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
715 fs::write(
716 root.path().join(".agents/sloop/config.yaml"),
717 "version: 1\n",
718 )
719 .unwrap();
720
721 let project = Project::discover(root.path()).unwrap();
722 assert_eq!(
723 Config::load(&project).unwrap().worktree_dir,
724 PathBuf::from(".worktrees")
725 );
726 }
727
728 #[test]
729 fn content_directories_default_to_the_sloop_layout() {
730 let root = tempdir().unwrap();
731 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
732 fs::write(
733 root.path().join(".agents/sloop/config.yaml"),
734 "version: 1\n",
735 )
736 .unwrap();
737
738 let project = Project::discover(root.path()).unwrap();
739 let config = Config::load(&project).unwrap();
740 assert_eq!(config.project_dir, PathBuf::from(".agents/sloop/projects"));
741 assert_eq!(config.ticket_dir, PathBuf::from(".agents/sloop/tickets"));
742 }
743
744 #[test]
745 fn content_directories_load_repository_relative_paths() {
746 let root = tempdir().unwrap();
747 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
748 fs::write(
749 root.path().join(".agents/sloop/config.yaml"),
750 "version: 1\nproject_dir: planning/projects\nticket_dir: planning/tickets\n",
751 )
752 .unwrap();
753
754 let project = Project::discover(root.path()).unwrap();
755 let config = Config::load(&project).unwrap();
756 assert_eq!(config.project_dir, PathBuf::from("planning/projects"));
757 assert_eq!(config.ticket_dir, PathBuf::from("planning/tickets"));
758 }
759
760 #[test]
761 fn content_directories_must_stay_below_the_repository_root() {
762 let root = tempdir().unwrap();
763 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
764 fs::write(
765 root.path().join(".agents/sloop/config.yaml"),
766 "version: 1\nproject_dir: /tmp/projects\nticket_dir: ../tickets\n",
767 )
768 .unwrap();
769
770 let project = Project::discover(root.path()).unwrap();
771 let error = Config::load(&project).unwrap_err().to_string();
772 assert!(error.contains("project_dir"), "{error}");
773 assert!(error.contains("repository-relative"), "{error}");
774
775 fs::write(
776 root.path().join(".agents/sloop/config.yaml"),
777 "version: 1\nproject_dir: planning/projects\nticket_dir: ../tickets\n",
778 )
779 .unwrap();
780 let error = Config::load(&project).unwrap_err().to_string();
781 assert!(error.contains("ticket_dir"), "{error}");
782 assert!(error.contains("repository root"), "{error}");
783 }
784
785 #[test]
786 fn worktree_dir_loads_a_repository_relative_path() {
787 let root = tempdir().unwrap();
788 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
789 fs::write(
790 root.path().join(".agents/sloop/config.yaml"),
791 "version: 1\nworktree_dir: build/agent-worktrees\n",
792 )
793 .unwrap();
794
795 let project = Project::discover(root.path()).unwrap();
796 assert_eq!(
797 Config::load(&project).unwrap().worktree_dir,
798 PathBuf::from("build/agent-worktrees")
799 );
800 }
801
802 #[test]
803 fn worktree_dir_rejects_an_absolute_path() {
804 let root = tempdir().unwrap();
805 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
806 fs::write(
807 root.path().join(".agents/sloop/config.yaml"),
808 "version: 1\nworktree_dir: /tmp/sloop-worktrees\n",
809 )
810 .unwrap();
811
812 let project = Project::discover(root.path()).unwrap();
813 let error = Config::load(&project).unwrap_err().to_string();
814 assert!(error.contains("worktree_dir"), "{error}");
815 assert!(error.contains("repository-relative"), "{error}");
816 }
817
818 #[test]
819 fn worktree_dir_rejects_parent_traversal_outside_the_repository() {
820 let root = tempdir().unwrap();
821 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
822 fs::write(
823 root.path().join(".agents/sloop/config.yaml"),
824 "version: 1\nworktree_dir: ../sloop-worktrees\n",
825 )
826 .unwrap();
827
828 let project = Project::discover(root.path()).unwrap();
829 let error = Config::load(&project).unwrap_err().to_string();
830 assert!(error.contains("worktree_dir"), "{error}");
831 assert!(error.contains("repository root"), "{error}");
832 }
833
834 #[test]
835 fn repository_agent_loads_multiple_named_targets() {
836 let root = tempdir().unwrap();
837 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
838 fs::write(
839 root.path().join(".agents/sloop/config.yaml"),
840 concat!(
841 "version: 1\n",
842 "agent:\n",
843 " default_target: claude\n",
844 " targets:\n",
845 " claude:\n",
846 " cmd: [claude, --model, '{model}', '{prompt}']\n",
847 " codex:\n",
848 " cmd: [codex, exec, --model, '{model}', '{prompt}']\n",
849 ),
850 )
851 .unwrap();
852
853 let project = Project::discover(root.path()).unwrap();
854 let agent = Config::load(&project).unwrap().agent.unwrap();
855 assert_eq!(agent.default_target, "claude");
856 assert_eq!(agent.targets.len(), 2);
857 assert_eq!(agent.targets["codex"][0], "codex");
858 }
859
860 #[test]
861 fn agent_default_target_must_name_a_configured_target() {
862 let root = tempdir().unwrap();
863 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
864 fs::write(
865 root.path().join(".agents/sloop/config.yaml"),
866 "version: 1\nagent:\n default_target: missing\n targets:\n fake:\n cmd: [fake]\n",
867 )
868 .unwrap();
869
870 let project = Project::discover(root.path()).unwrap();
871 let error = Config::load(&project).unwrap_err().to_string();
872 assert!(error.contains("agent.default_target `missing`"), "{error}");
873 assert!(error.contains("agent.targets"), "{error}");
874 }
875
876 #[test]
877 fn every_agent_target_command_must_be_nonempty() {
878 let root = tempdir().unwrap();
879 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
880 fs::write(
881 root.path().join(".agents/sloop/config.yaml"),
882 "version: 1\nagent:\n default_target: fake\n targets:\n fake:\n cmd: []\n",
883 )
884 .unwrap();
885
886 let project = Project::discover(root.path()).unwrap();
887 let error = Config::load(&project).unwrap_err().to_string();
888 assert!(error.contains("agent.targets.fake.cmd"), "{error}");
889 assert!(error.contains("must name a command"), "{error}");
890 }
891
892 #[test]
893 fn every_agent_target_requires_prompt_exactly_once() {
894 let root = tempdir().unwrap();
895 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
896 let config_path = root.path().join(".agents/sloop/config.yaml");
897 fs::write(
898 &config_path,
899 "version: 1\nagent:\n default_target: missing_prompt\n targets:\n missing_prompt:\n cmd: [agent]\n",
900 )
901 .unwrap();
902
903 let project = Project::discover(root.path()).unwrap();
904 let error = Config::load(&project).unwrap_err().to_string();
905 assert!(
906 error.contains("agent.targets.missing_prompt.cmd"),
907 "{error}"
908 );
909 assert!(error.contains("`{prompt}` exactly once"), "{error}");
910 assert!(error.contains("found 0"), "{error}");
911
912 fs::write(
913 config_path,
914 "version: 1\nagent:\n default_target: duplicate_prompt\n targets:\n duplicate_prompt:\n cmd: [agent, '{prompt}', 'again={prompt}']\n",
915 )
916 .unwrap();
917 let error = Config::load(&project).unwrap_err().to_string();
918 assert!(
919 error.contains("agent.targets.duplicate_prompt.cmd"),
920 "{error}"
921 );
922 assert!(error.contains("`{prompt}` exactly once"), "{error}");
923 assert!(error.contains("found 2"), "{error}");
924 }
925
926 #[test]
927 fn legacy_singular_agent_command_names_the_new_shape() {
928 let root = tempdir().unwrap();
929 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
930 fs::write(
931 root.path().join(".agents/sloop/config.yaml"),
932 "version: 1\nagent:\n cmd: [fake]\n",
933 )
934 .unwrap();
935
936 let project = Project::discover(root.path()).unwrap();
937 let error = Config::load(&project).unwrap_err().to_string();
938 assert!(error.contains("agent.cmd has been removed"), "{error}");
939 assert!(error.contains("agent.default_target"), "{error}");
940 assert!(error.contains("agent.targets"), "{error}");
941 }
942
943 #[test]
944 fn id_prefixes_default_and_load_from_repository_configuration() {
945 let root = tempdir().unwrap();
946 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
947 fs::write(
948 root.path().join(".agents/sloop/config.yaml"),
949 "version: 1\n",
950 )
951 .unwrap();
952 let project = Project::discover(root.path()).unwrap();
953 let defaults = Config::load(&project).unwrap();
954 assert_eq!(defaults.ticket_prefix, "TICK");
955 assert_eq!(defaults.project_prefix, "PROJ");
956
957 fs::write(
958 root.path().join(".agents/sloop/config.yaml"),
959 "version: 1\nids:\n ticket_prefix: WORK\n project_prefix: TEAM\n",
960 )
961 .unwrap();
962 let configured = Config::load(&project).unwrap();
963 assert_eq!(configured.ticket_prefix, "WORK");
964 assert_eq!(configured.project_prefix, "TEAM");
965 }
966
967 #[test]
968 fn invalid_id_prefixes_are_clear_configuration_errors() {
969 let root = tempdir().unwrap();
970 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
971 fs::write(
972 root.path().join(".agents/sloop/config.yaml"),
973 "version: 1\nids:\n ticket_prefix: ''\n",
974 )
975 .unwrap();
976
977 let project = Project::discover(root.path()).unwrap();
978 let error = Config::load(&project).unwrap_err().to_string();
979 assert!(error.contains("ids.ticket_prefix"), "{error}");
980 assert!(error.contains("non-empty"), "{error}");
981 }
982
983 #[test]
984 fn running_hours_are_start_inclusive_and_end_exclusive() {
985 let root = tempdir().unwrap();
986 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
987 fs::write(
988 root.path().join(".agents/sloop/config.yaml"),
989 "version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '17:00'\n",
990 )
991 .unwrap();
992
993 let project = Project::discover(root.path()).unwrap();
994 let hours = Config::load(&project).unwrap().running_hours.unwrap();
995 assert!(!hours.is_open(8 * 60 + 59));
996 assert!(hours.is_open(9 * 60));
997 assert!(hours.is_open(16 * 60 + 59));
998 assert!(!hours.is_open(17 * 60));
999 }
1000
1001 #[test]
1002 fn running_hours_may_cross_midnight() {
1003 let root = tempdir().unwrap();
1004 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1005 fs::write(
1006 root.path().join(".agents/sloop/config.yaml"),
1007 "version: 1\nscheduler:\n running_hours:\n start: '22:00'\n end: '06:00'\n",
1008 )
1009 .unwrap();
1010
1011 let project = Project::discover(root.path()).unwrap();
1012 let hours = Config::load(&project).unwrap().running_hours.unwrap();
1013 assert!(hours.is_open(23 * 60));
1014 assert!(hours.is_open(5 * 60 + 59));
1015 assert!(!hours.is_open(6 * 60));
1016 assert!(!hours.is_open(12 * 60));
1017 }
1018
1019 #[test]
1020 fn equal_running_hour_boundaries_are_rejected() {
1021 let root = tempdir().unwrap();
1022 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1023 fs::write(
1024 root.path().join(".agents/sloop/config.yaml"),
1025 "version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '09:00'\n",
1026 )
1027 .unwrap();
1028
1029 let project = Project::discover(root.path()).unwrap();
1030 assert!(matches!(
1031 Config::load(&project),
1032 Err(ConfigError::Invalid { .. })
1033 ));
1034 }
1035
1036 #[test]
1037 fn next_opening_uses_the_first_open_instant_after_a_dst_skip() {
1038 let hours = RunningHours {
1039 start: "02:30".into(),
1040 end: "04:00".into(),
1041 start_minute: 150,
1042 end_minute: 240,
1043 };
1044
1045 assert_eq!(
1046 hours.next_opening_ms(&SpringForwardClock, SpringForwardClock.now_ms()),
1047 60_000
1048 );
1049 }
1050
1051 #[test]
1052 fn unsupported_versions_are_rejected() {
1053 let root = tempdir().unwrap();
1054 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1055 fs::write(
1056 root.path().join(".agents/sloop/config.yaml"),
1057 "version: 2\n",
1058 )
1059 .unwrap();
1060
1061 let project = Project::discover(root.path()).unwrap();
1062 assert!(matches!(
1063 Config::load(&project),
1064 Err(ConfigError::UnsupportedVersion { version: 2, .. })
1065 ));
1066 }
1067
1068 #[test]
1069 fn durations_parse_with_single_letter_units() {
1070 use super::parse_duration_ms;
1071 assert_eq!(parse_duration_ms("45s").unwrap(), 45_000);
1072 assert_eq!(parse_duration_ms("30m").unwrap(), 1_800_000);
1073 assert_eq!(parse_duration_ms("12h").unwrap(), 43_200_000);
1074 assert_eq!(parse_duration_ms("30d").unwrap(), 2_592_000_000);
1075 assert_eq!(parse_duration_ms("2w").unwrap(), 1_209_600_000);
1076 for invalid in ["", "30", "d", "0d", "-1d", "1.5h", "30 days"] {
1077 assert!(parse_duration_ms(invalid).is_err(), "{invalid}");
1078 }
1079 }
1080
1081 #[test]
1082 fn delete_missing_after_is_configurable_and_defaults_to_a_month() {
1083 let root = tempdir().unwrap();
1084 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1085 let config_path = root.path().join(".agents/sloop/config.yaml");
1086
1087 fs::write(&config_path, "version: 1\n").unwrap();
1088 let project = Project::discover(root.path()).unwrap();
1089 let config = Config::load(&project).unwrap();
1090 assert_eq!(
1091 config.delete_missing_after_ms,
1092 super::DEFAULT_DELETE_MISSING_AFTER_MS
1093 );
1094
1095 fs::write(&config_path, "version: 1\ndelete_missing_after: 7d\n").unwrap();
1096 let config = Config::load(&project).unwrap();
1097 assert_eq!(config.delete_missing_after_ms, 7 * 24 * 60 * 60 * 1000);
1098
1099 fs::write(&config_path, "version: 1\ndelete_missing_after: soon\n").unwrap();
1100 let error = Config::load(&project).unwrap_err();
1101 assert!(
1102 error.to_string().contains("delete_missing_after"),
1103 "{error}"
1104 );
1105 }
1106
1107 #[test]
1108 fn built_in_default_flow_uses_the_default_agent_target() {
1109 let root = tempdir().unwrap();
1110 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1111 fs::write(
1112 root.path().join(".agents/sloop/config.yaml"),
1113 "version: 1\nagent:\n default_target: reviewer\n targets:\n reviewer:\n cmd: [review-agent, --batch, '{prompt}']\n",
1114 )
1115 .unwrap();
1116
1117 let project = Project::discover(root.path()).unwrap();
1118 let config = Config::load(&project).unwrap();
1119 assert_eq!(config.default_flow, "default");
1120 assert_eq!(
1121 config.flows["default"]
1122 .stages
1123 .iter()
1124 .map(|stage| stage.name.as_str())
1125 .collect::<Vec<_>>(),
1126 ["build", "review", "merge"]
1127 );
1128 let StageKind::Exec { cmd } = &config.flows["default"].stages[1].kind else {
1129 panic!("review stage must be exec");
1130 };
1131 assert_eq!(&cmd[..2], ["review-agent", "--batch"]);
1132 assert!(cmd.last().unwrap().contains("Review the completed work"));
1133 }
1134
1135 #[test]
1136 fn aftercare_test_command_is_added_to_the_built_in_default_flow() {
1137 let root = tempdir().unwrap();
1138 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1139 fs::write(
1140 root.path().join(".agents/sloop/config.yaml"),
1141 "version: 1\naftercare:\n test_cmd: [cargo, test]\n",
1142 )
1143 .unwrap();
1144
1145 let project = Project::discover(root.path()).unwrap();
1146 let config = Config::load(&project).unwrap();
1147 let flow = &config.flows["default"];
1148 assert_eq!(
1149 flow.stages
1150 .iter()
1151 .map(|stage| stage.name.as_str())
1152 .collect::<Vec<_>>(),
1153 ["build", "review", "test", "merge"]
1154 );
1155 assert_eq!(
1156 flow.stages[2].kind,
1157 StageKind::Exec {
1158 cmd: vec!["cargo".into(), "test".into()]
1159 }
1160 );
1161 }
1162
1163 #[test]
1164 fn committed_default_flow_overrides_the_built_in_flow() {
1165 let root = tempdir().unwrap();
1166 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1167 fs::write(
1168 root.path().join(".agents/sloop/config.yaml"),
1169 "version: 1\naftercare:\n test_cmd: [cargo, test]\n",
1170 )
1171 .unwrap();
1172 fs::write(
1173 root.path().join(".agents/sloop/flows/default.yaml"),
1174 "- { name: build, kind: build }\n- { name: ship, kind: exec, cmd: [ship] }\n",
1175 )
1176 .unwrap();
1177
1178 let project = Project::discover(root.path()).unwrap();
1179 let config = Config::load(&project).unwrap();
1180 assert_eq!(
1181 config.flows["default"]
1182 .stages
1183 .iter()
1184 .map(|stage| stage.name.as_str())
1185 .collect::<Vec<_>>(),
1186 ["build", "ship"]
1187 );
1188 }
1189
1190 #[test]
1191 fn invalid_flow_error_names_the_file_and_problem() {
1192 let root = tempdir().unwrap();
1193 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1194 fs::write(
1195 root.path().join(".agents/sloop/config.yaml"),
1196 "version: 1\n",
1197 )
1198 .unwrap();
1199 fs::write(
1200 root.path().join(".agents/sloop/flows/broken.yaml"),
1201 "- { name: build, kind: build }\n- { name: check, kind: exec, cmd: [] }\n",
1202 )
1203 .unwrap();
1204
1205 let project = Project::discover(root.path()).unwrap();
1206 let error = Config::load(&project).unwrap_err().to_string();
1207 assert!(error.contains("broken.yaml"), "{error}");
1208 assert!(error.contains("non-empty `cmd`"), "{error}");
1209 }
1210}