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