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};
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    /// How long settled run worktrees remain available. `None` disables
65    /// automatic removal.
66    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    /// Repository-scoped exec-shaped agent adapters. Absent means the
73    /// repository has not configured an agent yet; queued work stays queued.
74    pub agent: Option<AgentConfig>,
75    /// Committed flow definitions plus the built-in `default` when it is not
76    /// overridden by a repository file.
77    pub flows: BTreeMap<String, Flow>,
78    pub default_flow: String,
79    /// The single test aftercare stage: an argv run in the worktree after a
80    /// successful exit. Absent means the run branch merges without a test
81    /// gate; an unchanged branch completes as a no-op.
82    pub aftercare_test_cmd: Option<Vec<String>>,
83    /// Repository-scoped prefixes for durable IDs stamped into committed
84    /// files. These deliberately do not inherit user defaults.
85    pub ticket_prefix: String,
86    pub project_prefix: String,
87    /// How long a ticket row stays stamped missing after its committed file
88    /// disappears before reconciliation deletes it.
89    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        // Evaluate real instants rather than constructing a local wall time:
131        // skipped and repeated DST minutes then follow the same `is_open`
132        // policy as every spawn decision. Forty-nine hours covers even a
133        // skipped local calendar day.
134        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    /// Validates only the repository configuration needed before contacting an
146    /// existing daemon. Runtime policy and authored definitions are owned by
147    /// the daemon snapshot and must not hold operational verbs hostage.
148    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
356/// Rejects `on_fail.target` overrides that do not name a configured agent
357/// target. Overrides without an explicit target resolve against the ticket's
358/// snapshot at spawn time, so they are left alone here.
359fn 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
661/// Parses durations like `45s`, `30m`, `12h`, `30d`, or `2w` into
662/// milliseconds.
663fn 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}