1use 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
83pub 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}