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