Skip to main content

agent_launch/
config.rs

1//! launch.yaml loader + strict deserialization.
2
3use std::path::Path;
4
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum LaunchConfigError {
11    #[error("failed to read or parse {path}: {source}")]
12    Io {
13        path: String,
14        #[source]
15        source: std::io::Error,
16    },
17    #[error("failed to parse {path}: {source}")]
18    Yaml {
19        path: String,
20        #[source]
21        source: serde_yaml::Error,
22    },
23    #[error("invalid launch.yaml: {0}")]
24    Schema(String),
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields, tag = "kind", rename_all = "lowercase")]
29pub enum Platform {
30    Hn { pattern: HnPattern },
31    Reddit { subreddit: String },
32    X { handle: String },
33    Mastodon { instance: String, handle: String },
34    Linkedin,
35}
36
37impl Platform {
38    pub fn kind(&self) -> &'static str {
39        match self {
40            Platform::Hn { .. } => "hn",
41            Platform::Reddit { .. } => "reddit",
42            Platform::X { .. } => "x",
43            Platform::Mastodon { .. } => "mastodon",
44            Platform::Linkedin => "linkedin",
45        }
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub enum HnPattern {
52    ShowHn,
53    AskHn,
54    Regular,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Project {
60    pub name: String,
61    pub oneliner: String,
62    pub audience: String,
63    pub hooks: Vec<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct Context {
69    pub repo: String,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub manifest: Option<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct LaunchConfig {
77    pub version: u8,
78    pub project: Project,
79    pub platforms: Vec<Platform>,
80    pub context: Context,
81}
82
83/// Load a launch.yaml file and run strict schema validation.
84pub fn load_launch_config(path: &Path) -> Result<LaunchConfig, LaunchConfigError> {
85    let raw = std::fs::read_to_string(path).map_err(|source| LaunchConfigError::Io {
86        path: path.display().to_string(),
87        source,
88    })?;
89    let cfg: LaunchConfig =
90        serde_yaml::from_str(&raw).map_err(|source| LaunchConfigError::Yaml {
91            path: path.display().to_string(),
92            source,
93        })?;
94
95    validate(&cfg)?;
96    Ok(cfg)
97}
98
99fn validate(cfg: &LaunchConfig) -> Result<(), LaunchConfigError> {
100    if cfg.version != 1 {
101        return Err(LaunchConfigError::Schema(format!(
102            "version: expected 1, got {}",
103            cfg.version
104        )));
105    }
106    let name_re = Regex::new(r"^[a-z0-9][a-z0-9-]*$").unwrap();
107    if !name_re.is_match(&cfg.project.name) {
108        return Err(LaunchConfigError::Schema(
109            "project.name: must match /^[a-z0-9][a-z0-9-]*$/".into(),
110        ));
111    }
112    if cfg.project.oneliner.is_empty() || cfg.project.oneliner.chars().count() > 120 {
113        return Err(LaunchConfigError::Schema(
114            "project.oneliner: 1..=120 chars".into(),
115        ));
116    }
117    if cfg.project.audience.is_empty() || cfg.project.audience.chars().count() > 200 {
118        return Err(LaunchConfigError::Schema(
119            "project.audience: 1..=200 chars".into(),
120        ));
121    }
122    if cfg.project.hooks.is_empty() {
123        return Err(LaunchConfigError::Schema(
124            "project.hooks: at least 1 required".into(),
125        ));
126    }
127    if cfg.project.hooks.len() > 5 {
128        return Err(LaunchConfigError::Schema(
129            "project.hooks: at most 5 allowed".into(),
130        ));
131    }
132    if cfg.project.hooks.iter().any(|h| h.is_empty()) {
133        return Err(LaunchConfigError::Schema(
134            "project.hooks: items must be non-empty".into(),
135        ));
136    }
137    if cfg.platforms.is_empty() {
138        return Err(LaunchConfigError::Schema(
139            "platforms: at least 1 required".into(),
140        ));
141    }
142    let repo_re = Regex::new(r#"^[^/]+/[^/]+$"#).unwrap();
143    if !repo_re.is_match(&cfg.context.repo) {
144        return Err(LaunchConfigError::Schema(
145            "context.repo: must be \"owner/name\"".into(),
146        ));
147    }
148    let subreddit_re = Regex::new(r"^[a-zA-Z0-9_]{2,21}$").unwrap();
149    for p in &cfg.platforms {
150        match p {
151            Platform::Reddit { subreddit } => {
152                if !subreddit_re.is_match(subreddit) {
153                    return Err(LaunchConfigError::Schema(format!(
154                        "reddit subreddit '{subreddit}' must match /^[a-zA-Z0-9_]{{2,21}}$/ (no leading r/)"
155                    )));
156                }
157            }
158            Platform::X { handle } => {
159                if handle.is_empty() {
160                    return Err(LaunchConfigError::Schema("x.handle: non-empty".into()));
161                }
162            }
163            Platform::Mastodon { instance, handle } => {
164                if instance.is_empty() {
165                    return Err(LaunchConfigError::Schema(
166                        "mastodon.instance: non-empty".into(),
167                    ));
168                }
169                if handle.is_empty() {
170                    return Err(LaunchConfigError::Schema(
171                        "mastodon.handle: non-empty".into(),
172                    ));
173                }
174            }
175            _ => {}
176        }
177    }
178    Ok(())
179}