Skip to main content

sloop/
config.rs

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    /// Repository-scoped exec-shaped agent adapters. Absent means the
66    /// repository has not configured an agent yet; queued work stays queued.
67    pub agent: Option<AgentConfig>,
68    /// Committed flow definitions plus the built-in `default` when it is not
69    /// overridden by a repository file.
70    pub flows: BTreeMap<String, Flow>,
71    pub default_flow: String,
72    /// The single test aftercare stage: an argv run in the worktree after a
73    /// successful exit with commits. Absent means committed work merges
74    /// without a test gate.
75    pub aftercare_test_cmd: Option<Vec<String>>,
76    /// Repository-scoped prefixes for durable IDs stamped into committed
77    /// files. These deliberately do not inherit user defaults.
78    pub ticket_prefix: String,
79    pub project_prefix: String,
80    /// How long a ticket row stays stamped missing after its committed file
81    /// disappears before reconciliation deletes it.
82    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        // Evaluate real instants rather than constructing a local wall time:
111        // skipped and repeated DST minutes then follow the same `is_open`
112        // policy as every spawn decision. Forty-nine hours covers even a
113        // skipped local calendar day.
114        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
533/// Parses durations like `45s`, `30m`, `12h`, `30d`, or `2w` into
534/// milliseconds.
535fn 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}