Skip to main content

gws_builder/
lib.rs

1//! Static Rust codegen from Google Discovery documents (build-time / `build.rs`).
2//!
3//! See crate `docs/BUILD_PLAN.md` for design.
4
5mod 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/// Which API methods to include in generated code.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum ActionFilter {
35    /// Include all resources and methods.
36    All,
37    /// Include only methods matching these patterns (`files.*`, `files.list`, `users.**`).
38    Whitelist(Vec<String>),
39    /// Include everything except methods matching these patterns.
40    Blacklist(Vec<String>),
41}
42
43/// One Google API (e.g. Drive v3) to generate.
44///
45/// **Only APIs you list in [`BuilderConfig::services`] are codegenned.** To keep binaries small and
46/// scopes minimal, give each API a [`ActionFilter::Whitelist`] (see [`ServiceSpec::whitelist`]).
47#[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    /// Emit only methods matching these patterns; see [`ActionFilter`] and the crate README.
56    ///
57    /// Returns an error if `patterns` is empty — use [`list_available_actions`] to discover ids,
58    /// then add patterns such as `files.*` or `users.messages.list`.
59    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/// When to fetch Discovery docs and regenerate Rust sources.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum RegenerationPolicy {
82    /// Always fetch and regenerate.
83    Always,
84    /// Regenerate when revision or checksum (or filter) changes vs manifest.
85    #[default]
86    IfChanged,
87    /// Generate only when manifest or outputs are missing.
88    IfMissing,
89    /// Never fetch; require existing outputs.
90    Never,
91}
92
93/// Configuration for [`generate`].
94pub struct BuilderConfig {
95    pub services: Vec<ServiceSpec>,
96    pub out_dir: PathBuf,
97    pub regeneration: RegenerationPolicy,
98    pub fetcher: Option<Box<dyn DiscoveryFetcher>>,
99    /// Optional cache directory for raw Discovery JSON (used when fetch fails).
100    pub cache_dir: Option<PathBuf>,
101}
102
103/// Summary of one available REST method (for whitelisting).
104#[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/// Result of a [`generate`] run.
116#[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
124/// Run fetch → parse → filter → IR → codegen → emit for each configured service.
125pub 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}