1mod catalog;
6mod codegen;
7pub mod discovery;
8mod emit;
9mod error;
10mod fetch;
11pub mod ir;
12mod manifest;
13
14pub use emit::{emit_service_rust, write_file as emit_write_file};
15
16pub use catalog::list_available_actions;
17pub use error::BuilderError;
18pub use fetch::{DiscoveryFetcher, HttpFetcher, MapFetcher};
19
20use std::path::{Path, PathBuf};
21
22use crate::discovery::RestDescription;
23use crate::emit::{
24 emit_serde_helpers, write_file, write_generation_manifest, write_mod_rs,
25};
26use crate::fetch::{read_cache, validate_api_identifier, write_cache};
27use crate::ir::{apply_filter, discovery_to_ir, resolve_service};
28use crate::manifest::{
29 entry_for_service, load as load_manifest, service_unchanged, sha256_json, GenerationManifest,
30};
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ActionFilter {
35 All,
37 Whitelist(Vec<String>),
39 Blacklist(Vec<String>),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ServiceSpec {
49 pub name: String,
50 pub version: String,
51 pub filter: ActionFilter,
52}
53
54impl ServiceSpec {
55 pub fn whitelist(
60 name: impl Into<String>,
61 version: impl Into<String>,
62 patterns: Vec<String>,
63 ) -> Result<Self, BuilderError> {
64 if patterns.is_empty() {
65 return Err(BuilderError::Resolution(
66 "whitelist patterns must not be empty: list APIs in BuilderConfig.services, then \
67 add one or more patterns per API (use list_available_actions() to discover them)"
68 .into(),
69 ));
70 }
71 Ok(Self {
72 name: name.into(),
73 version: version.into(),
74 filter: ActionFilter::Whitelist(patterns),
75 })
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum RegenerationPolicy {
82 Always,
84 #[default]
86 IfChanged,
87 IfMissing,
89 Never,
91}
92
93pub struct BuilderConfig {
95 pub services: Vec<ServiceSpec>,
96 pub out_dir: PathBuf,
97 pub regeneration: RegenerationPolicy,
98 pub fetcher: Option<Box<dyn DiscoveryFetcher>>,
99 pub cache_dir: Option<PathBuf>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ActionSummary {
106 pub service: String,
107 pub resource_path: String,
108 pub method: String,
109 pub id: String,
110 pub http_method: String,
111 pub description: String,
112 pub deprecated: bool,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct GenerationReport {
118 pub services_generated: Vec<String>,
119 pub services_skipped: Vec<String>,
120 pub actions_emitted: usize,
121 pub schemas_emitted: usize,
122}
123
124pub fn generate(config: BuilderConfig) -> Result<GenerationReport, BuilderError> {
126 for spec in &config.services {
127 validate_service_spec(spec)?;
128 }
129
130 let fetcher: Box<dyn DiscoveryFetcher> = config
131 .fetcher
132 .unwrap_or_else(|| Box::new(HttpFetcher::new()));
133
134 let manifest_path = manifest_path_for_out(&config.out_dir);
135 let existing = load_manifest(&manifest_path)?;
136
137 let mut report = GenerationReport {
138 services_generated: Vec::new(),
139 services_skipped: Vec::new(),
140 actions_emitted: 0,
141 schemas_emitted: 0,
142 };
143
144 let mut manifest = existing.unwrap_or_else(|| GenerationManifest {
145 gws_builder_version: env!("CARGO_PKG_VERSION").to_string(),
146 generated_at: String::new(),
147 services: std::collections::HashMap::new(),
148 });
149
150 for spec in &config.services {
151 validate_api_identifier(&spec.name)?;
152 validate_api_identifier(&spec.version)?;
153
154 let out_file = config.out_dir.join(format!("{}.rs", spec.name));
155
156 match config.regeneration {
157 RegenerationPolicy::Never => {
158 if !manifest_path.exists() || !out_file.exists() {
159 return Err(BuilderError::Resolution(format!(
160 "RegenerationPolicy::Never requires existing manifest and {}.rs",
161 spec.name
162 )));
163 }
164 report
165 .services_skipped
166 .push(format!("{}/{}", spec.name, spec.version));
167 continue;
168 }
169 RegenerationPolicy::IfMissing => {
170 if manifest.services.contains_key(&spec.name) && out_file.exists() {
171 report
172 .services_skipped
173 .push(format!("{}/{}", spec.name, spec.version));
174 continue;
175 }
176 }
177 _ => {}
178 }
179
180 let raw = match fetcher.fetch_document(&spec.name, &spec.version) {
181 Ok(s) => s,
182 Err(e) => {
183 if let Some(ref dir) = config.cache_dir {
184 if let Some(cached) = read_cache(dir, &spec.name, &spec.version) {
185 eprintln!(
186 "gws-builder: using cached Discovery JSON for {}/{} ({e})",
187 spec.name, spec.version
188 );
189 cached
190 } else {
191 return Err(e);
192 }
193 } else {
194 return Err(e);
195 }
196 }
197 };
198
199 if let Some(ref dir) = config.cache_dir {
200 write_cache(dir, &spec.name, &spec.version, &raw);
201 }
202
203 let doc: RestDescription = serde_json::from_str(&raw).map_err(|e| BuilderError::Parse {
204 service: spec.name.clone(),
205 source: e,
206 })?;
207
208 let revision = doc.revision.clone().unwrap_or_default();
209 let checksum = sha256_json(&raw);
210
211 if matches!(config.regeneration, RegenerationPolicy::IfChanged) {
212 if let Some(entry) = manifest.services.get(&spec.name) {
213 if service_unchanged(entry, &revision, &checksum, &spec.filter) && out_file.exists()
214 {
215 report
216 .services_skipped
217 .push(format!("{}/{}", spec.name, spec.version));
218 continue;
219 }
220 }
221 }
222
223 let mut ir = discovery_to_ir(&doc)?;
224 apply_filter(&mut ir, &spec.filter)?;
225 resolve_service(&mut ir)?;
226
227 let rust = emit_service_rust(&ir)?;
228 write_file(&out_file, &rust)?;
229
230 let actions = count_actions(&ir);
231 let schemas = ir.structs.len() + ir.enums.len();
232
233 manifest.services.insert(
234 spec.name.clone(),
235 entry_for_service(&raw, Some(&revision), actions, schemas, spec),
236 );
237
238 report.actions_emitted += actions;
239 report.schemas_emitted += schemas;
240 report.services_generated.push(spec.name.clone());
241 }
242
243 let helpers_path = config.out_dir.join("serde_helpers.rs");
244 write_file(&helpers_path, emit_serde_helpers())?;
245
246 let modules = list_existing_service_modules(&config.out_dir, &config.services);
247 write_mod_rs(&config.out_dir, &modules)?;
248
249 manifest.generated_at = now_iso8601();
250 write_generation_manifest(&manifest_path, &manifest)?;
251
252 Ok(report)
253}
254
255fn now_iso8601() -> String {
256 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
257}
258
259fn list_existing_service_modules(out_dir: &Path, specs: &[ServiceSpec]) -> Vec<String> {
260 specs
261 .iter()
262 .filter(|s| out_dir.join(format!("{}.rs", s.name)).exists())
263 .map(|s| s.name.clone())
264 .collect()
265}
266
267fn count_actions(service: &crate::ir::IrService) -> usize {
268 fn walk(res: &crate::ir::IrResource) -> usize {
269 let mut n = res.methods.len();
270 for s in &res.sub_resources {
271 n += walk(s);
272 }
273 n
274 }
275 service.resources.iter().map(walk).sum()
276}
277
278fn manifest_path_for_out(out_dir: &Path) -> PathBuf {
279 out_dir
280 .parent()
281 .unwrap_or(Path::new("."))
282 .join("generation_manifest.json")
283}
284
285fn validate_service_spec(spec: &ServiceSpec) -> Result<(), BuilderError> {
286 if let ActionFilter::Whitelist(p) = &spec.filter {
287 if p.is_empty() {
288 return Err(BuilderError::Resolution(format!(
289 "service `{}`: whitelist patterns must not be empty (remove the API or add patterns)",
290 spec.name
291 )));
292 }
293 }
294 Ok(())
295}