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};
11use crate::sources::FlowSource;
12use crate::sources::markdown::MarkdownFlowSource;
13
14pub const CONFIG_VERSION: u32 = 1;
15pub const DEFAULT_DELETE_MISSING_AFTER_MS: i64 = 30 * 24 * 60 * 60 * 1000;
16pub const DEFAULT_WORKTREE_RETENTION_MS: i64 = 7 * 24 * 60 * 60 * 1000;
17pub const DEFAULT_WORKTREE_DIR: &str = ".worktrees";
18pub const DEFAULT_PROJECT_DIR: &str = ".agents/sloop/projects";
19pub const DEFAULT_TICKET_DIR: &str = ".agents/sloop/tickets";
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Repository {
23 pub root: PathBuf,
24 pub config_path: PathBuf,
25 pub state_dir: PathBuf,
26 pub runtime_dir: PathBuf,
27 pub operator_socket: PathBuf,
28 pub lock_path: PathBuf,
29 pub daemon_log: PathBuf,
30 pub db_path: PathBuf,
31}
32
33impl Repository {
34 pub fn discover(start: &Path) -> Result<Self, ConfigError> {
35 let start = start.canonicalize().map_err(|source| ConfigError::Io {
36 path: start.to_path_buf(),
37 source,
38 })?;
39
40 for directory in start.ancestors() {
41 let config_path = directory.join(".agents/sloop/config.yaml");
42 if config_path.is_file() {
43 let paths = crate::paths::resolve(directory).map_err(ConfigError::Paths)?;
44 return Ok(Self {
45 root: directory.to_path_buf(),
46 config_path,
47 state_dir: paths.state_dir,
48 runtime_dir: paths.runtime_dir,
49 operator_socket: paths.operator_socket,
50 lock_path: paths.lock_path,
51 daemon_log: paths.daemon_log,
52 db_path: paths.db_path,
53 });
54 }
55 }
56
57 Err(ConfigError::RepositoryNotFound(start))
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Config {
63 pub worktree_dir: PathBuf,
64 pub worktree_retention_ms: Option<i64>,
67 pub project_dir: PathBuf,
68 pub ticket_dir: PathBuf,
69 pub ticket_source: TicketSourceConfig,
70 pub max_parallel_tasks: usize,
71 pub running_hours: Option<RunningHours>,
72 pub agent: Option<AgentConfig>,
75 pub flows: BTreeMap<String, Flow>,
78 pub default_flow: String,
79 pub aftercare_test_cmd: Option<Vec<String>>,
83 pub ticket_prefix: String,
86 pub project_prefix: String,
87 pub delete_missing_after_ms: i64,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum TicketSourceConfig {
94 Markdown,
95 Exec(Vec<String>),
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct AgentConfig {
100 pub default_target: String,
101 pub targets: BTreeMap<String, AgentTarget>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct AgentTarget {
106 pub cmd: Vec<String>,
107 pub model: Option<String>,
108 pub effort: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct RunningHours {
113 pub start: String,
114 pub end: String,
115 start_minute: u16,
116 end_minute: u16,
117}
118
119impl RunningHours {
120 pub fn is_open(&self, local_minute: u16) -> bool {
121 if self.start_minute < self.end_minute {
122 (self.start_minute..self.end_minute).contains(&local_minute)
123 } else {
124 local_minute >= self.start_minute || local_minute < self.end_minute
125 }
126 }
127
128 pub fn next_opening_ms(&self, clock: &dyn crate::clock::Clock, now_ms: i64) -> i64 {
129 let mut candidate = (now_ms.div_euclid(60_000) + 1) * 60_000;
130 for _ in 0..=(49 * 60) {
135 if self.is_open(clock.local_minute(candidate)) {
136 return candidate;
137 }
138 candidate += 60_000;
139 }
140 candidate
141 }
142}
143
144impl Config {
145 pub fn validate_client_essentials(repository: &Repository) -> Result<(), ConfigError> {
149 let config = read_client_config(&repository.config_path)?;
150 validate_worktree_dir(
151 config
152 .worktree_dir
153 .as_deref()
154 .unwrap_or_else(|| Path::new(DEFAULT_WORKTREE_DIR)),
155 &repository.config_path,
156 )?;
157 Ok(())
158 }
159
160 pub fn load(repository: &Repository) -> Result<Self, ConfigError> {
161 let user_path = user_config_path().filter(|path| path.is_file());
162 let user = user_path
163 .as_ref()
164 .map(|path| read_config(path))
165 .transpose()?;
166 let config = read_config(&repository.config_path)?;
167
168 let worktree_dir = validate_worktree_dir(
169 config
170 .worktree_dir
171 .as_deref()
172 .unwrap_or_else(|| Path::new(DEFAULT_WORKTREE_DIR)),
173 &repository.config_path,
174 )?;
175 let project_dir = validate_repository_dir(
176 "project_dir",
177 config
178 .project_dir
179 .as_deref()
180 .unwrap_or_else(|| Path::new(DEFAULT_PROJECT_DIR)),
181 &repository.config_path,
182 )?;
183 let ticket_dir = validate_repository_dir(
184 "ticket_dir",
185 config
186 .ticket_dir
187 .as_deref()
188 .unwrap_or_else(|| Path::new(DEFAULT_TICKET_DIR)),
189 &repository.config_path,
190 )?;
191
192 if user.as_ref().is_some_and(|config| {
193 config.agent.is_some()
194 || config
195 .defaults
196 .as_ref()
197 .is_some_and(|defaults| defaults.agent.is_some())
198 }) {
199 return Err(ConfigError::Invalid {
200 path: user_path.clone().expect("loaded user config has a path"),
201 message: "agent targets are repository-scoped; configure `agent.default_target` and `agent.targets` in .agents/sloop/config.yaml".into(),
202 });
203 }
204 if user.as_ref().is_some_and(|config| {
205 config.sources.is_some()
206 || config
207 .defaults
208 .as_ref()
209 .is_some_and(|defaults| defaults.sources.is_some())
210 }) {
211 return Err(ConfigError::Invalid {
212 path: user_path.expect("loaded user config has a path"),
213 message: "sources are repository-scoped; configure `sources` in .agents/sloop/config.yaml".into(),
214 });
215 }
216
217 let ticket_source = match config
218 .sources
219 .as_ref()
220 .and_then(|sources| sources.tickets.as_ref())
221 .and_then(|tickets| tickets.exec.clone())
222 {
223 None => TicketSourceConfig::Markdown,
224 Some(argv) if argv.is_empty() => {
225 return Err(ConfigError::Invalid {
226 path: repository.config_path.clone(),
227 message: "sources.tickets.exec must name a command".into(),
228 });
229 }
230 Some(argv) => TicketSourceConfig::Exec(argv),
231 };
232
233 let defaults = user
234 .as_ref()
235 .and_then(|config| config.defaults.as_ref())
236 .and_then(|defaults| defaults.scheduler.as_ref());
237 let repository_scheduler = config.scheduler.as_ref();
238
239 let max_parallel_tasks = repository_scheduler
240 .and_then(|scheduler| scheduler.max_parallel_tasks)
241 .or_else(|| defaults.and_then(|scheduler| scheduler.max_parallel_tasks))
242 .unwrap_or(1);
243 if max_parallel_tasks == 0 {
244 return Err(ConfigError::Invalid {
245 path: repository.config_path.clone(),
246 message: "scheduler.max_parallel_tasks must be greater than zero".into(),
247 });
248 }
249
250 let running_hours = repository_scheduler
251 .and_then(|scheduler| scheduler.running_hours.clone())
252 .or_else(|| defaults.and_then(|scheduler| scheduler.running_hours.clone()))
253 .map(|hours| validate_running_hours(hours, &repository.config_path))
254 .transpose()?;
255
256 let agent = config
257 .agent
258 .as_ref()
259 .map(|agent| validate_agent(agent, &repository.config_path))
260 .transpose()?;
261
262 let aftercare_test_cmd = config
263 .aftercare
264 .as_ref()
265 .or_else(|| {
266 user.as_ref()
267 .and_then(|config| config.defaults.as_ref())
268 .and_then(|defaults| defaults.aftercare.as_ref())
269 })
270 .and_then(|aftercare| aftercare.test_cmd.clone());
271 if let Some(cmd) = &aftercare_test_cmd
272 && cmd.is_empty()
273 {
274 return Err(ConfigError::Invalid {
275 path: repository.config_path.clone(),
276 message: "aftercare.test_cmd must name a command".into(),
277 });
278 }
279
280 let ticket_prefix = config
281 .ids
282 .as_ref()
283 .and_then(|ids| ids.ticket_prefix.clone())
284 .unwrap_or_else(|| DEFAULT_TICKET_PREFIX.into());
285 validate_id_prefix("ids.ticket_prefix", &ticket_prefix, &repository.config_path)?;
286 let project_prefix = config
287 .ids
288 .as_ref()
289 .and_then(|ids| ids.project_prefix.clone())
290 .unwrap_or_else(|| DEFAULT_PROJECT_PREFIX.into());
291 validate_id_prefix(
292 "ids.project_prefix",
293 &project_prefix,
294 &repository.config_path,
295 )?;
296
297 let flows = load_flows(&repository.root)?;
298 validate_on_fail_targets(&flows, agent.as_ref(), &repository.config_path)?;
299 if aftercare_test_cmd.is_some()
300 && let Some(flow) = flows
301 .values()
302 .find(|flow| flow.stages.iter().any(|stage| stage.name == "test"))
303 {
304 return Err(ConfigError::Invalid {
305 path: repository.config_path.clone(),
306 message: format!(
307 "aftercare.test_cmd conflicts with stage `test` in flow `{}`",
308 flow.name
309 ),
310 });
311 }
312
313 let delete_missing_after_ms = config
314 .delete_missing_after
315 .as_deref()
316 .map(|value| {
317 parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
318 path: repository.config_path.clone(),
319 message: format!("delete_missing_after: {message}"),
320 })
321 })
322 .transpose()?
323 .unwrap_or(DEFAULT_DELETE_MISSING_AFTER_MS);
324 let worktree_retention_ms = match config.worktree_retention.as_deref() {
325 Some("never") => None,
326 Some(value) => Some(parse_duration_ms(value).map_err(|message| {
327 ConfigError::Invalid {
328 path: repository.config_path.clone(),
329 message: format!(
330 "worktree_retention: {message}; use a positive duration such as 7d, 24h, or 90m, or use `never`"
331 ),
332 }
333 })?),
334 None => Some(DEFAULT_WORKTREE_RETENTION_MS),
335 };
336
337 Ok(Self {
338 worktree_dir,
339 worktree_retention_ms,
340 project_dir,
341 ticket_dir,
342 ticket_source,
343 max_parallel_tasks,
344 running_hours,
345 agent,
346 flows,
347 default_flow: DEFAULT_FLOW_NAME.into(),
348 aftercare_test_cmd,
349 ticket_prefix,
350 project_prefix,
351 delete_missing_after_ms,
352 })
353 }
354}
355
356fn validate_on_fail_targets(
360 flows: &BTreeMap<String, Flow>,
361 agent: Option<&AgentConfig>,
362 path: &Path,
363) -> Result<(), ConfigError> {
364 for flow in flows.values() {
365 for stage in &flow.stages {
366 let Some(target) = stage
367 .on_fail
368 .as_ref()
369 .and_then(|on_fail| on_fail.target.as_ref())
370 else {
371 continue;
372 };
373 let known = agent.is_some_and(|agent| agent.targets.contains_key(target));
374 if !known {
375 return Err(ConfigError::Invalid {
376 path: path.to_path_buf(),
377 message: format!(
378 "flow `{}` stage `{}` on_fail names unknown agent target `{target}`",
379 flow.name, stage.name
380 ),
381 });
382 }
383 }
384 }
385 Ok(())
386}
387
388fn load_flows(root: &Path) -> Result<BTreeMap<String, Flow>, ConfigError> {
389 let mut flows = BTreeMap::from([(DEFAULT_FLOW_NAME.into(), crate::flow::built_in_default())]);
390 for flow in MarkdownFlowSource::new(root)
391 .pull()
392 .map_err(ConfigError::Source)?
393 {
394 flows.insert(flow.name.clone(), flow);
395 }
396 Ok(flows)
397}
398
399fn validate_worktree_dir(value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
400 validate_repository_dir("worktree_dir", value, path)
401}
402
403fn validate_repository_dir(key: &str, value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
404 use std::path::Component;
405
406 if value.is_absolute() {
407 return Err(ConfigError::Invalid {
408 path: path.to_path_buf(),
409 message: format!("{key} must be repository-relative, not an absolute path"),
410 });
411 }
412
413 let mut normalized = PathBuf::new();
414 for component in value.components() {
415 match component {
416 Component::Normal(component) => normalized.push(component),
417 Component::CurDir => {}
418 Component::ParentDir => {
419 if !normalized.pop() {
420 return Err(ConfigError::Invalid {
421 path: path.to_path_buf(),
422 message: format!("{key} must not escape the repository root with `..`"),
423 });
424 }
425 }
426 Component::RootDir | Component::Prefix(_) => {
427 return Err(ConfigError::Invalid {
428 path: path.to_path_buf(),
429 message: format!("{key} must be repository-relative, not an absolute path"),
430 });
431 }
432 }
433 }
434 if normalized.as_os_str().is_empty() {
435 return Err(ConfigError::Invalid {
436 path: path.to_path_buf(),
437 message: format!("{key} must name a directory below the repository root"),
438 });
439 }
440 Ok(normalized)
441}
442
443fn validate_agent(agent: &RawAgent, path: &Path) -> Result<AgentConfig, ConfigError> {
444 if agent.cmd.is_some() {
445 return Err(ConfigError::Invalid {
446 path: path.to_path_buf(),
447 message: "agent.cmd has been removed; use `agent.default_target` and `agent.targets`"
448 .into(),
449 });
450 }
451 let default_target = agent
452 .default_target
453 .as_ref()
454 .ok_or_else(|| ConfigError::Invalid {
455 path: path.to_path_buf(),
456 message: "agent.default_target is required".into(),
457 })?;
458 let targets = agent.targets.as_ref().ok_or_else(|| ConfigError::Invalid {
459 path: path.to_path_buf(),
460 message: "agent.targets is required".into(),
461 })?;
462 if !targets.contains_key(default_target) {
463 return Err(ConfigError::Invalid {
464 path: path.to_path_buf(),
465 message: format!(
466 "agent.default_target `{default_target}` does not name an entry in agent.targets"
467 ),
468 });
469 }
470
471 let mut commands = BTreeMap::new();
472 for (name, target) in targets {
473 if target.cmd.is_empty() {
474 return Err(ConfigError::Invalid {
475 path: path.to_path_buf(),
476 message: format!("agent.targets.{name}.cmd must name a command"),
477 });
478 }
479 let prompt_count = target
480 .cmd
481 .iter()
482 .map(|argument| argument.matches("{prompt}").count())
483 .sum::<usize>();
484 if prompt_count != 1 {
485 return Err(ConfigError::Invalid {
486 path: path.to_path_buf(),
487 message: format!(
488 "agent.targets.{name}.cmd must contain `{{prompt}}` exactly once (found {prompt_count})"
489 ),
490 });
491 }
492 for (key, value) in [("model", &target.model), ("effort", &target.effort)] {
493 if let Some(value) = value
494 && value.trim().is_empty()
495 {
496 return Err(ConfigError::Invalid {
497 path: path.to_path_buf(),
498 message: format!("agent.targets.{name}.{key} must not be empty"),
499 });
500 }
501 }
502 commands.insert(
503 name.clone(),
504 AgentTarget {
505 cmd: target.cmd.clone(),
506 model: target.model.clone(),
507 effort: target.effort.clone(),
508 },
509 );
510 }
511 Ok(AgentConfig {
512 default_target: default_target.clone(),
513 targets: commands,
514 })
515}
516
517pub(crate) fn expand_agent_cmd(
518 target: &AgentTarget,
519 model: Option<&str>,
520 effort: Option<&str>,
521 prompt: &str,
522) -> Result<Vec<String>, String> {
523 let model = model.or(target.model.as_deref());
524 let effort = effort.or(target.effort.as_deref());
525 target
526 .cmd
527 .iter()
528 .map(|argument| {
529 let argument = match (argument.contains("{model}"), model) {
530 (true, Some(model)) => argument.replace("{model}", model),
531 (true, None) => return Err("does not specify `model`".to_owned()),
532 (false, _) => argument.clone(),
533 };
534 let argument = match (argument.contains("{effort}"), effort) {
535 (true, Some(effort)) => argument.replace("{effort}", effort),
536 (true, None) => return Err("does not specify `effort`".to_owned()),
537 (false, _) => argument,
538 };
539 Ok(argument.replace("{prompt}", prompt))
540 })
541 .collect()
542}
543
544fn validate_id_prefix(key: &str, prefix: &str, path: &Path) -> Result<(), ConfigError> {
545 if valid_prefix(prefix) {
546 return Ok(());
547 }
548 Err(ConfigError::Invalid {
549 path: path.to_path_buf(),
550 message: format!(
551 "{key} must be non-empty and contain only ASCII letters, digits, `-`, or `_`, with a letter or digit at each end"
552 ),
553 })
554}
555
556fn user_config_path() -> Option<PathBuf> {
557 env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/sloop/config.yaml"))
558}
559
560fn read_config(path: &Path) -> Result<RawConfig, ConfigError> {
561 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
562 path: path.to_path_buf(),
563 source,
564 })?;
565 let config: RawConfig =
566 serde_yaml::from_str(&contents).map_err(|source| ConfigError::Invalid {
567 path: path.to_path_buf(),
568 message: source.to_string(),
569 })?;
570 if config.version != CONFIG_VERSION {
571 return Err(ConfigError::UnsupportedVersion {
572 path: path.to_path_buf(),
573 version: config.version,
574 });
575 }
576 Ok(config)
577}
578
579fn read_client_config(path: &Path) -> Result<RawClientConfig, ConfigError> {
580 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
581 path: path.to_path_buf(),
582 source,
583 })?;
584 let config: RawClientConfig =
585 serde_yaml::from_str(&contents).map_err(|source| ConfigError::Invalid {
586 path: path.to_path_buf(),
587 message: source.to_string(),
588 })?;
589 if config.version != CONFIG_VERSION {
590 return Err(ConfigError::UnsupportedVersion {
591 path: path.to_path_buf(),
592 version: config.version,
593 });
594 }
595 Ok(config)
596}
597
598fn validate_running_hours(
599 hours: RawRunningHours,
600 path: &Path,
601) -> Result<RunningHours, ConfigError> {
602 let start_minute = parse_local_time(&hours.start);
603 let end_minute = parse_local_time(&hours.end);
604 let (Some(start_minute), Some(end_minute)) = (start_minute, end_minute) else {
605 return Err(ConfigError::Invalid {
606 path: path.to_path_buf(),
607 message: "scheduler.running_hours values must use a valid HH:MM time".into(),
608 });
609 };
610 if start_minute == end_minute {
611 return Err(ConfigError::Invalid {
612 path: path.to_path_buf(),
613 message: "scheduler.running_hours start and end must differ".into(),
614 });
615 }
616 Ok(RunningHours {
617 start: hours.start,
618 end: hours.end,
619 start_minute,
620 end_minute,
621 })
622}
623
624pub(crate) fn parse_local_time(value: &str) -> Option<u16> {
625 let (hour, minute) = value.split_once(':')?;
626 if hour.len() != 2 || minute.len() != 2 {
627 return None;
628 }
629 if !hour.bytes().all(|byte| byte.is_ascii_digit())
630 || !minute.bytes().all(|byte| byte.is_ascii_digit())
631 {
632 return None;
633 }
634 let hour = hour.parse::<u16>().ok()?;
635 let minute = minute.parse::<u16>().ok()?;
636 (hour < 24 && minute < 60).then_some(hour * 60 + minute)
637}
638
639#[derive(Debug, Deserialize)]
640struct RawConfig {
641 version: u32,
642 worktree_dir: Option<PathBuf>,
643 worktree_retention: Option<String>,
644 project_dir: Option<PathBuf>,
645 ticket_dir: Option<PathBuf>,
646 defaults: Option<RawDefaults>,
647 scheduler: Option<RawScheduler>,
648 agent: Option<RawAgent>,
649 sources: Option<RawSources>,
650 aftercare: Option<RawAftercare>,
651 ids: Option<RawIds>,
652 delete_missing_after: Option<String>,
653}
654
655#[derive(Debug, Deserialize)]
656struct RawClientConfig {
657 version: u32,
658 worktree_dir: Option<PathBuf>,
659}
660
661fn parse_duration_ms(value: &str) -> Result<i64, String> {
664 let value = value.trim();
665 let (digits, unit) = value.split_at(value.len().saturating_sub(1));
666 let scale: i64 = match unit {
667 "s" => 1000,
668 "m" => 60 * 1000,
669 "h" => 60 * 60 * 1000,
670 "d" => 24 * 60 * 60 * 1000,
671 "w" => 7 * 24 * 60 * 60 * 1000,
672 _ => {
673 return Err(format!(
674 "`{value}` must look like 30d, 12h, 30m, 45s, or 2w"
675 ));
676 }
677 };
678 let count: i64 = digits
679 .parse()
680 .map_err(|_| format!("`{value}` must look like 30d, 12h, 30m, 45s, or 2w"))?;
681 if count <= 0 {
682 return Err(format!("`{value}` must be a positive duration"));
683 }
684 count
685 .checked_mul(scale)
686 .ok_or_else(|| format!("`{value}` is too large"))
687}
688
689#[derive(Debug, Deserialize)]
690struct RawIds {
691 ticket_prefix: Option<String>,
692 project_prefix: Option<String>,
693}
694
695#[derive(Debug, Deserialize)]
696struct RawDefaults {
697 scheduler: Option<RawScheduler>,
698 agent: Option<RawAgent>,
699 sources: Option<RawSources>,
700 aftercare: Option<RawAftercare>,
701}
702
703#[derive(Debug, Deserialize)]
704#[serde(deny_unknown_fields)]
705struct RawSources {
706 tickets: Option<RawTicketSource>,
707}
708
709#[derive(Debug, Deserialize)]
710#[serde(deny_unknown_fields)]
711struct RawTicketSource {
712 exec: Option<Vec<String>>,
713}
714
715#[derive(Debug, Deserialize)]
716struct RawAftercare {
717 test_cmd: Option<Vec<String>>,
718}
719
720#[derive(Debug, Deserialize)]
721struct RawAgent {
722 default_target: Option<String>,
723 targets: Option<BTreeMap<String, RawAgentTarget>>,
724 cmd: Option<Vec<String>>,
725}
726
727#[derive(Debug, Deserialize)]
728struct RawAgentTarget {
729 cmd: Vec<String>,
730 model: Option<String>,
731 effort: Option<String>,
732}
733
734#[derive(Debug, Deserialize)]
735struct RawScheduler {
736 max_parallel_tasks: Option<usize>,
737 running_hours: Option<RawRunningHours>,
738}
739
740#[derive(Debug, Clone, Deserialize)]
741struct RawRunningHours {
742 start: String,
743 end: String,
744}
745
746#[derive(Debug)]
747pub enum ConfigError {
748 RepositoryNotFound(PathBuf),
749 Paths(crate::paths::PathError),
750 Source(crate::sources::SourceError),
751 Io {
752 path: PathBuf,
753 source: std::io::Error,
754 },
755 Invalid {
756 path: PathBuf,
757 message: String,
758 },
759 UnsupportedVersion {
760 path: PathBuf,
761 version: u32,
762 },
763}
764
765impl fmt::Display for ConfigError {
766 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
767 match self {
768 Self::RepositoryNotFound(start) => write!(
769 formatter,
770 "no .agents/sloop/config.yaml found from {}",
771 start.display()
772 ),
773 Self::Paths(error) => write!(formatter, "cannot resolve Sloop runtime paths: {error}"),
774 Self::Source(error) => error.fmt(formatter),
775 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
776 Self::Invalid { path, message } => write!(formatter, "{}: {message}", path.display()),
777 Self::UnsupportedVersion { path, version } => write!(
778 formatter,
779 "{}: unsupported config version {version}",
780 path.display()
781 ),
782 }
783 }
784}
785
786impl std::error::Error for ConfigError {}
787
788#[cfg(test)]
789mod tests {
790 use std::fs;
791 use std::future::Future;
792 use std::path::PathBuf;
793 use std::pin::Pin;
794
795 use tempfile::tempdir;
796
797 use super::{Config, ConfigError, Repository, RunningHours, TicketSourceConfig};
798 use crate::clock::Clock;
799
800 struct SpringForwardClock;
801
802 impl Clock for SpringForwardClock {
803 fn now_ms(&self) -> i64 {
804 30_000
805 }
806
807 fn local_minute(&self, timestamp_ms: i64) -> u16 {
808 if timestamp_ms < 60_000 { 119 } else { 180 }
809 }
810
811 fn sleep_until(&self, _deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
812 Box::pin(std::future::pending())
813 }
814 }
815
816 #[test]
817 fn discovers_the_nearest_repository_from_a_nested_directory() {
818 let root = tempdir().unwrap();
819 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
820 fs::write(
821 root.path().join(".agents/sloop/config.yaml"),
822 "version: 1\n",
823 )
824 .unwrap();
825 let nested = root.path().join("src/deep");
826 fs::create_dir_all(&nested).unwrap();
827
828 let repository = Repository::discover(&nested).unwrap();
829 assert_eq!(repository.root, root.path().canonicalize().unwrap());
830 }
831
832 #[test]
833 fn repository_scheduler_values_are_loaded() {
834 let root = tempdir().unwrap();
835 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
836 fs::write(
837 root.path().join(".agents/sloop/config.yaml"),
838 concat!(
839 "version: 1\n",
840 "scheduler:\n",
841 " max_parallel_tasks: 3\n",
842 " running_hours:\n",
843 " start: '22:00'\n",
844 " end: '06:00'\n"
845 ),
846 )
847 .unwrap();
848
849 let repository = Repository::discover(root.path()).unwrap();
850 let config = Config::load(&repository).unwrap();
851 assert_eq!(config.max_parallel_tasks, 3);
852 assert_eq!(config.running_hours.unwrap().start, "22:00");
853 }
854
855 #[test]
856 fn worktree_dir_defaults_to_dot_worktrees() {
857 let root = tempdir().unwrap();
858 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
859 fs::write(
860 root.path().join(".agents/sloop/config.yaml"),
861 "version: 1\n",
862 )
863 .unwrap();
864
865 let repository = Repository::discover(root.path()).unwrap();
866 assert_eq!(
867 Config::load(&repository).unwrap().worktree_dir,
868 PathBuf::from(".worktrees")
869 );
870 }
871
872 #[test]
873 fn worktree_retention_defaults_to_seven_days_and_accepts_never() {
874 let root = tempdir().unwrap();
875 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
876 let path = root.path().join(".agents/sloop/config.yaml");
877 fs::write(&path, "version: 1\n").unwrap();
878 let repository = Repository::discover(root.path()).unwrap();
879 assert_eq!(
880 Config::load(&repository).unwrap().worktree_retention_ms,
881 Some(7 * 24 * 60 * 60 * 1000)
882 );
883
884 fs::write(&path, "version: 1\nworktree_retention: never\n").unwrap();
885 assert_eq!(
886 Config::load(&repository).unwrap().worktree_retention_ms,
887 None
888 );
889 fs::write(&path, "version: 1\nworktree_retention: 90m\n").unwrap();
890 assert_eq!(
891 Config::load(&repository).unwrap().worktree_retention_ms,
892 Some(90 * 60 * 1000)
893 );
894 }
895
896 #[test]
897 fn invalid_worktree_retention_names_the_key_and_remedy() {
898 let root = tempdir().unwrap();
899 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
900 fs::write(
901 root.path().join(".agents/sloop/config.yaml"),
902 "version: 1\nworktree_retention: tomorrow\n",
903 )
904 .unwrap();
905 let repository = Repository::discover(root.path()).unwrap();
906 let error = Config::load(&repository).unwrap_err().to_string();
907 assert!(error.contains("worktree_retention"), "{error}");
908 assert!(error.contains("use a positive duration"), "{error}");
909 assert!(error.contains("`never`"), "{error}");
910 }
911
912 #[test]
913 fn content_directories_default_to_the_sloop_layout() {
914 let root = tempdir().unwrap();
915 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
916 fs::write(
917 root.path().join(".agents/sloop/config.yaml"),
918 "version: 1\n",
919 )
920 .unwrap();
921
922 let repository = Repository::discover(root.path()).unwrap();
923 let config = Config::load(&repository).unwrap();
924 assert_eq!(config.project_dir, PathBuf::from(".agents/sloop/projects"));
925 assert_eq!(config.ticket_dir, PathBuf::from(".agents/sloop/tickets"));
926 }
927
928 #[test]
929 fn ticket_source_defaults_to_markdown() {
930 let root = tempdir().unwrap();
931 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
932 fs::write(
933 root.path().join(".agents/sloop/config.yaml"),
934 "version: 1\n",
935 )
936 .unwrap();
937
938 let repository = Repository::discover(root.path()).unwrap();
939 assert_eq!(
940 Config::load(&repository).unwrap().ticket_source,
941 TicketSourceConfig::Markdown
942 );
943 }
944
945 #[test]
946 fn exec_ticket_source_loads_from_repository_configuration() {
947 let root = tempdir().unwrap();
948 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
949 fs::write(
950 root.path().join(".agents/sloop/config.yaml"),
951 "version: 1\nsources:\n tickets:\n exec: [ticket-source, --repo, .]\n",
952 )
953 .unwrap();
954
955 let repository = Repository::discover(root.path()).unwrap();
956 assert_eq!(
957 Config::load(&repository).unwrap().ticket_source,
958 TicketSourceConfig::Exec(vec!["ticket-source".into(), "--repo".into(), ".".into()])
959 );
960 }
961
962 #[test]
963 fn exec_ticket_source_command_must_be_nonempty() {
964 let root = tempdir().unwrap();
965 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
966 fs::write(
967 root.path().join(".agents/sloop/config.yaml"),
968 "version: 1\nsources:\n tickets:\n exec: []\n",
969 )
970 .unwrap();
971
972 let repository = Repository::discover(root.path()).unwrap();
973 let error = Config::load(&repository).unwrap_err().to_string();
974 assert!(error.contains("sources.tickets.exec"), "{error}");
975 assert!(error.contains("must name a command"), "{error}");
976 }
977
978 #[test]
979 fn content_directories_load_repository_relative_paths() {
980 let root = tempdir().unwrap();
981 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
982 fs::write(
983 root.path().join(".agents/sloop/config.yaml"),
984 "version: 1\nproject_dir: planning/projects\nticket_dir: planning/tickets\n",
985 )
986 .unwrap();
987
988 let repository = Repository::discover(root.path()).unwrap();
989 let config = Config::load(&repository).unwrap();
990 assert_eq!(config.project_dir, PathBuf::from("planning/projects"));
991 assert_eq!(config.ticket_dir, PathBuf::from("planning/tickets"));
992 }
993
994 #[test]
995 fn content_directories_must_stay_below_the_repository_root() {
996 let root = tempdir().unwrap();
997 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
998 fs::write(
999 root.path().join(".agents/sloop/config.yaml"),
1000 "version: 1\nproject_dir: /tmp/projects\nticket_dir: ../tickets\n",
1001 )
1002 .unwrap();
1003
1004 let repository = Repository::discover(root.path()).unwrap();
1005 let error = Config::load(&repository).unwrap_err().to_string();
1006 assert!(error.contains("project_dir"), "{error}");
1007 assert!(error.contains("repository-relative"), "{error}");
1008
1009 fs::write(
1010 root.path().join(".agents/sloop/config.yaml"),
1011 "version: 1\nproject_dir: planning/projects\nticket_dir: ../tickets\n",
1012 )
1013 .unwrap();
1014 let error = Config::load(&repository).unwrap_err().to_string();
1015 assert!(error.contains("ticket_dir"), "{error}");
1016 assert!(error.contains("repository root"), "{error}");
1017 }
1018
1019 #[test]
1020 fn worktree_dir_loads_a_repository_relative_path() {
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\nworktree_dir: build/agent-worktrees\n",
1026 )
1027 .unwrap();
1028
1029 let repository = Repository::discover(root.path()).unwrap();
1030 assert_eq!(
1031 Config::load(&repository).unwrap().worktree_dir,
1032 PathBuf::from("build/agent-worktrees")
1033 );
1034 }
1035
1036 #[test]
1037 fn worktree_dir_rejects_an_absolute_path() {
1038 let root = tempdir().unwrap();
1039 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1040 fs::write(
1041 root.path().join(".agents/sloop/config.yaml"),
1042 "version: 1\nworktree_dir: /tmp/sloop-worktrees\n",
1043 )
1044 .unwrap();
1045
1046 let repository = Repository::discover(root.path()).unwrap();
1047 let error = Config::load(&repository).unwrap_err().to_string();
1048 assert!(error.contains("worktree_dir"), "{error}");
1049 assert!(error.contains("repository-relative"), "{error}");
1050 }
1051
1052 #[test]
1053 fn worktree_dir_rejects_parent_traversal_outside_the_repository() {
1054 let root = tempdir().unwrap();
1055 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1056 fs::write(
1057 root.path().join(".agents/sloop/config.yaml"),
1058 "version: 1\nworktree_dir: ../sloop-worktrees\n",
1059 )
1060 .unwrap();
1061
1062 let repository = Repository::discover(root.path()).unwrap();
1063 let error = Config::load(&repository).unwrap_err().to_string();
1064 assert!(error.contains("worktree_dir"), "{error}");
1065 assert!(error.contains("repository root"), "{error}");
1066 }
1067
1068 #[test]
1069 fn repository_agent_loads_multiple_named_targets() {
1070 let root = tempdir().unwrap();
1071 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1072 fs::write(
1073 root.path().join(".agents/sloop/config.yaml"),
1074 concat!(
1075 "version: 1\n",
1076 "agent:\n",
1077 " default_target: claude\n",
1078 " targets:\n",
1079 " claude:\n",
1080 " cmd: [claude, --model, '{model}', '{prompt}']\n",
1081 " codex:\n",
1082 " cmd: [codex, exec, --model, '{model}', '{prompt}']\n",
1083 ),
1084 )
1085 .unwrap();
1086
1087 let repository = Repository::discover(root.path()).unwrap();
1088 let agent = Config::load(&repository).unwrap().agent.unwrap();
1089 assert_eq!(agent.default_target, "claude");
1090 assert_eq!(agent.targets.len(), 2);
1091 assert_eq!(agent.targets["codex"].cmd[0], "codex");
1092 }
1093
1094 #[test]
1095 fn agent_targets_load_default_model_and_effort() {
1096 let root = tempdir().unwrap();
1097 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1098 fs::write(
1099 root.path().join(".agents/sloop/config.yaml"),
1100 concat!(
1101 "version: 1\n",
1102 "agent:\n",
1103 " default_target: claude\n",
1104 " targets:\n",
1105 " claude:\n",
1106 " model: opus\n",
1107 " effort: high\n",
1108 " cmd: [claude, --model, '{model}', --effort, '{effort}', '{prompt}']\n",
1109 ),
1110 )
1111 .unwrap();
1112
1113 let repository = Repository::discover(root.path()).unwrap();
1114 let agent = Config::load(&repository).unwrap().agent.unwrap();
1115 assert_eq!(agent.targets["claude"].model.as_deref(), Some("opus"));
1116 assert_eq!(agent.targets["claude"].effort.as_deref(), Some("high"));
1117 }
1118
1119 #[test]
1120 fn agent_target_rejects_a_blank_default_model() {
1121 let root = tempdir().unwrap();
1122 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1123 fs::write(
1124 root.path().join(".agents/sloop/config.yaml"),
1125 concat!(
1126 "version: 1\n",
1127 "agent:\n",
1128 " default_target: claude\n",
1129 " targets:\n",
1130 " claude:\n",
1131 " model: ' '\n",
1132 " cmd: [claude, --model, '{model}', '{prompt}']\n",
1133 ),
1134 )
1135 .unwrap();
1136
1137 let repository = Repository::discover(root.path()).unwrap();
1138 let error = Config::load(&repository).unwrap_err().to_string();
1139 assert!(error.contains("agent.targets.claude.model"), "{error}");
1140 }
1141
1142 #[test]
1143 fn agent_default_target_must_name_a_configured_target() {
1144 let root = tempdir().unwrap();
1145 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1146 fs::write(
1147 root.path().join(".agents/sloop/config.yaml"),
1148 "version: 1\nagent:\n default_target: missing\n targets:\n fake:\n cmd: [fake]\n",
1149 )
1150 .unwrap();
1151
1152 let repository = Repository::discover(root.path()).unwrap();
1153 let error = Config::load(&repository).unwrap_err().to_string();
1154 assert!(error.contains("agent.default_target `missing`"), "{error}");
1155 assert!(error.contains("agent.targets"), "{error}");
1156 }
1157
1158 #[test]
1159 fn client_essentials_ignore_daemon_only_configuration_validation() {
1160 let root = tempdir().unwrap();
1161 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1162 fs::write(
1163 root.path().join(".agents/sloop/config.yaml"),
1164 "version: 1\nagent:\n default_target: missing\n targets: not-a-map\n",
1165 )
1166 .unwrap();
1167
1168 let repository = Repository::discover(root.path()).unwrap();
1169 Config::validate_client_essentials(&repository).unwrap();
1170 assert!(Config::load(&repository).is_err());
1171 }
1172
1173 #[test]
1174 fn every_agent_target_command_must_be_nonempty() {
1175 let root = tempdir().unwrap();
1176 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1177 fs::write(
1178 root.path().join(".agents/sloop/config.yaml"),
1179 "version: 1\nagent:\n default_target: fake\n targets:\n fake:\n cmd: []\n",
1180 )
1181 .unwrap();
1182
1183 let repository = Repository::discover(root.path()).unwrap();
1184 let error = Config::load(&repository).unwrap_err().to_string();
1185 assert!(error.contains("agent.targets.fake.cmd"), "{error}");
1186 assert!(error.contains("must name a command"), "{error}");
1187 }
1188
1189 #[test]
1190 fn every_agent_target_requires_prompt_exactly_once() {
1191 let root = tempdir().unwrap();
1192 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1193 let config_path = root.path().join(".agents/sloop/config.yaml");
1194 fs::write(
1195 &config_path,
1196 "version: 1\nagent:\n default_target: missing_prompt\n targets:\n missing_prompt:\n cmd: [agent]\n",
1197 )
1198 .unwrap();
1199
1200 let repository = Repository::discover(root.path()).unwrap();
1201 let error = Config::load(&repository).unwrap_err().to_string();
1202 assert!(
1203 error.contains("agent.targets.missing_prompt.cmd"),
1204 "{error}"
1205 );
1206 assert!(error.contains("`{prompt}` exactly once"), "{error}");
1207 assert!(error.contains("found 0"), "{error}");
1208
1209 fs::write(
1210 config_path,
1211 "version: 1\nagent:\n default_target: duplicate_prompt\n targets:\n duplicate_prompt:\n cmd: [agent, '{prompt}', 'again={prompt}']\n",
1212 )
1213 .unwrap();
1214 let error = Config::load(&repository).unwrap_err().to_string();
1215 assert!(
1216 error.contains("agent.targets.duplicate_prompt.cmd"),
1217 "{error}"
1218 );
1219 assert!(error.contains("`{prompt}` exactly once"), "{error}");
1220 assert!(error.contains("found 2"), "{error}");
1221 }
1222
1223 #[test]
1224 fn legacy_singular_agent_command_names_the_new_shape() {
1225 let root = tempdir().unwrap();
1226 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1227 fs::write(
1228 root.path().join(".agents/sloop/config.yaml"),
1229 "version: 1\nagent:\n cmd: [fake]\n",
1230 )
1231 .unwrap();
1232
1233 let repository = Repository::discover(root.path()).unwrap();
1234 let error = Config::load(&repository).unwrap_err().to_string();
1235 assert!(error.contains("agent.cmd has been removed"), "{error}");
1236 assert!(error.contains("agent.default_target"), "{error}");
1237 assert!(error.contains("agent.targets"), "{error}");
1238 }
1239
1240 #[test]
1241 fn id_prefixes_default_and_load_from_repository_configuration() {
1242 let root = tempdir().unwrap();
1243 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1244 fs::write(
1245 root.path().join(".agents/sloop/config.yaml"),
1246 "version: 1\n",
1247 )
1248 .unwrap();
1249 let repository = Repository::discover(root.path()).unwrap();
1250 let defaults = Config::load(&repository).unwrap();
1251 assert_eq!(defaults.ticket_prefix, "TICK");
1252 assert_eq!(defaults.project_prefix, "PROJ");
1253
1254 fs::write(
1255 root.path().join(".agents/sloop/config.yaml"),
1256 "version: 1\nids:\n ticket_prefix: WORK\n project_prefix: TEAM\n",
1257 )
1258 .unwrap();
1259 let configured = Config::load(&repository).unwrap();
1260 assert_eq!(configured.ticket_prefix, "WORK");
1261 assert_eq!(configured.project_prefix, "TEAM");
1262 }
1263
1264 #[test]
1265 fn invalid_id_prefixes_are_clear_configuration_errors() {
1266 let root = tempdir().unwrap();
1267 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1268 fs::write(
1269 root.path().join(".agents/sloop/config.yaml"),
1270 "version: 1\nids:\n ticket_prefix: ''\n",
1271 )
1272 .unwrap();
1273
1274 let repository = Repository::discover(root.path()).unwrap();
1275 let error = Config::load(&repository).unwrap_err().to_string();
1276 assert!(error.contains("ids.ticket_prefix"), "{error}");
1277 assert!(error.contains("non-empty"), "{error}");
1278 }
1279
1280 #[test]
1281 fn running_hours_are_start_inclusive_and_end_exclusive() {
1282 let root = tempdir().unwrap();
1283 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1284 fs::write(
1285 root.path().join(".agents/sloop/config.yaml"),
1286 "version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '17:00'\n",
1287 )
1288 .unwrap();
1289
1290 let repository = Repository::discover(root.path()).unwrap();
1291 let hours = Config::load(&repository).unwrap().running_hours.unwrap();
1292 assert!(!hours.is_open(8 * 60 + 59));
1293 assert!(hours.is_open(9 * 60));
1294 assert!(hours.is_open(16 * 60 + 59));
1295 assert!(!hours.is_open(17 * 60));
1296 }
1297
1298 #[test]
1299 fn running_hours_may_cross_midnight() {
1300 let root = tempdir().unwrap();
1301 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1302 fs::write(
1303 root.path().join(".agents/sloop/config.yaml"),
1304 "version: 1\nscheduler:\n running_hours:\n start: '22:00'\n end: '06:00'\n",
1305 )
1306 .unwrap();
1307
1308 let repository = Repository::discover(root.path()).unwrap();
1309 let hours = Config::load(&repository).unwrap().running_hours.unwrap();
1310 assert!(hours.is_open(23 * 60));
1311 assert!(hours.is_open(5 * 60 + 59));
1312 assert!(!hours.is_open(6 * 60));
1313 assert!(!hours.is_open(12 * 60));
1314 }
1315
1316 #[test]
1317 fn equal_running_hour_boundaries_are_rejected() {
1318 let root = tempdir().unwrap();
1319 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1320 fs::write(
1321 root.path().join(".agents/sloop/config.yaml"),
1322 "version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '09:00'\n",
1323 )
1324 .unwrap();
1325
1326 let repository = Repository::discover(root.path()).unwrap();
1327 assert!(matches!(
1328 Config::load(&repository),
1329 Err(ConfigError::Invalid { .. })
1330 ));
1331 }
1332
1333 #[test]
1334 fn next_opening_uses_the_first_open_instant_after_a_dst_skip() {
1335 let hours = RunningHours {
1336 start: "02:30".into(),
1337 end: "04:00".into(),
1338 start_minute: 150,
1339 end_minute: 240,
1340 };
1341
1342 assert_eq!(
1343 hours.next_opening_ms(&SpringForwardClock, SpringForwardClock.now_ms()),
1344 60_000
1345 );
1346 }
1347
1348 #[test]
1349 fn unsupported_versions_are_rejected() {
1350 let root = tempdir().unwrap();
1351 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1352 fs::write(
1353 root.path().join(".agents/sloop/config.yaml"),
1354 "version: 2\n",
1355 )
1356 .unwrap();
1357
1358 let repository = Repository::discover(root.path()).unwrap();
1359 assert!(matches!(
1360 Config::load(&repository),
1361 Err(ConfigError::UnsupportedVersion { version: 2, .. })
1362 ));
1363 }
1364
1365 #[test]
1366 fn durations_parse_with_single_letter_units() {
1367 use super::parse_duration_ms;
1368 assert_eq!(parse_duration_ms("45s").unwrap(), 45_000);
1369 assert_eq!(parse_duration_ms("30m").unwrap(), 1_800_000);
1370 assert_eq!(parse_duration_ms("12h").unwrap(), 43_200_000);
1371 assert_eq!(parse_duration_ms("30d").unwrap(), 2_592_000_000);
1372 assert_eq!(parse_duration_ms("2w").unwrap(), 1_209_600_000);
1373 for invalid in ["", "30", "d", "0d", "-1d", "1.5h", "30 days"] {
1374 assert!(parse_duration_ms(invalid).is_err(), "{invalid}");
1375 }
1376 }
1377
1378 #[test]
1379 fn delete_missing_after_is_configurable_and_defaults_to_a_month() {
1380 let root = tempdir().unwrap();
1381 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1382 let config_path = root.path().join(".agents/sloop/config.yaml");
1383
1384 fs::write(&config_path, "version: 1\n").unwrap();
1385 let repository = Repository::discover(root.path()).unwrap();
1386 let config = Config::load(&repository).unwrap();
1387 assert_eq!(
1388 config.delete_missing_after_ms,
1389 super::DEFAULT_DELETE_MISSING_AFTER_MS
1390 );
1391
1392 fs::write(&config_path, "version: 1\ndelete_missing_after: 7d\n").unwrap();
1393 let config = Config::load(&repository).unwrap();
1394 assert_eq!(config.delete_missing_after_ms, 7 * 24 * 60 * 60 * 1000);
1395
1396 fs::write(&config_path, "version: 1\ndelete_missing_after: soon\n").unwrap();
1397 let error = Config::load(&repository).unwrap_err();
1398 assert!(
1399 error.to_string().contains("delete_missing_after"),
1400 "{error}"
1401 );
1402 }
1403
1404 #[test]
1405 fn built_in_default_flow_does_not_reuse_agent_placeholders() {
1406 let root = tempdir().unwrap();
1407 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1408 fs::write(
1409 root.path().join(".agents/sloop/config.yaml"),
1410 "version: 1\nagent:\n default_target: reviewer\n targets:\n reviewer:\n cmd: [review-agent, --model, '{model}', --effort, '{effort}', '{prompt}']\n",
1411 )
1412 .unwrap();
1413
1414 let repository = Repository::discover(root.path()).unwrap();
1415 let config = Config::load(&repository).unwrap();
1416 assert_eq!(config.default_flow, "default");
1417 assert_eq!(
1418 config.flows["default"]
1419 .stages
1420 .iter()
1421 .map(|stage| stage.name.as_str())
1422 .collect::<Vec<_>>(),
1423 ["build", "merge"]
1424 );
1425 }
1426
1427 #[test]
1428 fn aftercare_test_command_is_not_duplicated_in_the_built_in_default_flow() {
1429 let root = tempdir().unwrap();
1430 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1431 fs::write(
1432 root.path().join(".agents/sloop/config.yaml"),
1433 "version: 1\naftercare:\n test_cmd: [cargo, test]\n",
1434 )
1435 .unwrap();
1436
1437 let repository = Repository::discover(root.path()).unwrap();
1438 let config = Config::load(&repository).unwrap();
1439 let flow = &config.flows["default"];
1440 assert_eq!(
1441 flow.stages
1442 .iter()
1443 .map(|stage| stage.name.as_str())
1444 .collect::<Vec<_>>(),
1445 ["build", "merge"]
1446 );
1447 }
1448
1449 #[test]
1450 fn committed_default_flow_overrides_the_built_in_flow() {
1451 let root = tempdir().unwrap();
1452 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1453 fs::write(
1454 root.path().join(".agents/sloop/config.yaml"),
1455 "version: 1\naftercare:\n test_cmd: [cargo, test]\n",
1456 )
1457 .unwrap();
1458 fs::write(
1459 root.path().join(".agents/sloop/flows/default.yaml"),
1460 "- { name: build, kind: build }\n- { name: ship, kind: exec, cmd: [ship] }\n",
1461 )
1462 .unwrap();
1463
1464 let repository = Repository::discover(root.path()).unwrap();
1465 let config = Config::load(&repository).unwrap();
1466 assert_eq!(
1467 config.flows["default"]
1468 .stages
1469 .iter()
1470 .map(|stage| stage.name.as_str())
1471 .collect::<Vec<_>>(),
1472 ["build", "ship"]
1473 );
1474 }
1475
1476 #[test]
1477 fn on_fail_target_must_name_a_configured_agent_target() {
1478 let root = tempdir().unwrap();
1479 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1480 fs::write(
1481 root.path().join(".agents/sloop/config.yaml"),
1482 "version: 1\nagent:\n default_target: fake\n targets:\n fake:\n cmd: [fake, '{prompt}']\n",
1483 )
1484 .unwrap();
1485 fs::write(
1486 root.path().join(".agents/sloop/flows/default.yaml"),
1487 "- { name: build, kind: agent }\n- name: check\n kind: exec\n cmd: ['true']\n on_fail:\n agent: fix it\n target: ghost\n- { name: merge, kind: merge }\n",
1488 )
1489 .unwrap();
1490
1491 let repository = Repository::discover(root.path()).unwrap();
1492 let error = Config::load(&repository).unwrap_err().to_string();
1493 assert!(error.contains("stage `check`"), "{error}");
1494 assert!(error.contains("unknown agent target `ghost`"), "{error}");
1495 }
1496
1497 #[test]
1498 fn on_fail_without_a_target_override_loads_without_an_agent() {
1499 let root = tempdir().unwrap();
1500 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1501 fs::write(
1502 root.path().join(".agents/sloop/config.yaml"),
1503 "version: 1\n",
1504 )
1505 .unwrap();
1506 fs::write(
1507 root.path().join(".agents/sloop/flows/default.yaml"),
1508 "- { name: build, kind: agent }\n- name: check\n kind: exec\n cmd: ['true']\n on_fail:\n agent: fix it\n- { name: merge, kind: merge }\n",
1509 )
1510 .unwrap();
1511
1512 let repository = Repository::discover(root.path()).unwrap();
1513 let config = Config::load(&repository).unwrap();
1514 assert_eq!(
1515 config.flows["default"].stages[1]
1516 .on_fail
1517 .as_ref()
1518 .unwrap()
1519 .agent,
1520 "fix it"
1521 );
1522 }
1523
1524 #[test]
1525 fn invalid_flow_error_names_the_file_and_problem() {
1526 let root = tempdir().unwrap();
1527 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1528 fs::write(
1529 root.path().join(".agents/sloop/config.yaml"),
1530 "version: 1\n",
1531 )
1532 .unwrap();
1533 fs::write(
1534 root.path().join(".agents/sloop/flows/broken.yaml"),
1535 "- { name: build, kind: build }\n- { name: check, kind: exec, cmd: [] }\n",
1536 )
1537 .unwrap();
1538
1539 let repository = Repository::discover(root.path()).unwrap();
1540 let error = Config::load(&repository).unwrap_err().to_string();
1541 assert!(error.contains("broken.yaml"), "{error}");
1542 assert!(error.contains("non-empty `cmd`"), "{error}");
1543 }
1544}