Skip to main content

vercel_rpc_cli/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7pub const CONFIG_FILE_NAME: &str = "rpc.config.toml";
8
9#[derive(Debug, Default, Deserialize)]
10#[serde(default)]
11pub struct RpcConfig {
12    pub input: InputConfig,
13    pub output: OutputConfig,
14    pub codegen: CodegenConfig,
15    pub watch: WatchConfig,
16}
17
18#[derive(Debug, Deserialize)]
19#[serde(default)]
20pub struct InputConfig {
21    pub dir: PathBuf,
22    pub include: Vec<String>,
23    pub exclude: Vec<String>,
24}
25
26#[derive(Debug, Deserialize)]
27#[serde(default)]
28pub struct OutputConfig {
29    pub types: PathBuf,
30    pub client: PathBuf,
31    pub svelte: Option<PathBuf>,
32    pub react: Option<PathBuf>,
33    pub vue: Option<PathBuf>,
34    pub solid: Option<PathBuf>,
35    pub imports: ImportsConfig,
36}
37
38#[derive(Debug, Deserialize)]
39#[serde(default)]
40pub struct ImportsConfig {
41    pub types_path: String,
42    pub extension: String,
43}
44
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, clap::ValueEnum)]
46pub enum FieldNaming {
47    #[default]
48    #[serde(rename = "preserve")]
49    #[value(name = "preserve")]
50    Preserve,
51    #[serde(rename = "camelCase")]
52    #[value(name = "camelCase")]
53    CamelCase,
54}
55
56#[derive(Debug, Default, Deserialize)]
57#[serde(default)]
58pub struct NamingConfig {
59    pub fields: FieldNaming,
60}
61
62#[derive(Debug, Default, Deserialize)]
63#[serde(default)]
64pub struct CodegenConfig {
65    pub preserve_docs: bool,
66    pub branded_newtypes: bool,
67    pub naming: NamingConfig,
68    pub type_overrides: HashMap<String, String>,
69    pub bigint_types: Vec<String>,
70}
71
72#[derive(Debug, Deserialize)]
73#[serde(default)]
74pub struct WatchConfig {
75    pub debounce_ms: u64,
76    pub clear_screen: bool,
77}
78
79impl Default for InputConfig {
80    fn default() -> Self {
81        Self {
82            dir: PathBuf::from("api"),
83            include: vec!["**/*.rs".into()],
84            exclude: vec![],
85        }
86    }
87}
88
89impl Default for OutputConfig {
90    fn default() -> Self {
91        Self {
92            types: PathBuf::from("src/lib/rpc-types.ts"),
93            client: PathBuf::from("src/lib/rpc-client.ts"),
94            svelte: None,
95            react: None,
96            vue: None,
97            solid: None,
98            imports: ImportsConfig::default(),
99        }
100    }
101}
102
103impl Default for ImportsConfig {
104    fn default() -> Self {
105        Self {
106            types_path: "./rpc-types".to_string(),
107            extension: String::new(),
108        }
109    }
110}
111
112impl ImportsConfig {
113    /// Returns the full import specifier: `types_path` + `extension`.
114    pub fn types_specifier(&self) -> String {
115        format!("{}{}", self.types_path, self.extension)
116    }
117}
118
119impl Default for WatchConfig {
120    fn default() -> Self {
121        Self {
122            debounce_ms: 200,
123            clear_screen: false,
124        }
125    }
126}
127
128/// Walk up from `start` looking for `rpc.config.toml`.
129/// Returns `None` if not found.
130pub fn discover(start: &Path) -> Option<PathBuf> {
131    let mut dir = start;
132    loop {
133        let candidate = dir.join(CONFIG_FILE_NAME);
134        if candidate.is_file() {
135            return Some(candidate);
136        }
137        dir = dir.parent()?;
138    }
139}
140
141/// Read and parse a config file at the given path.
142pub fn load(path: &Path) -> Result<RpcConfig> {
143    let content = std::fs::read_to_string(path)
144        .with_context(|| format!("Failed to read config file {}", path.display()))?;
145    let config: RpcConfig =
146        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
147    Ok(config)
148}
149
150/// CLI overrides that can be applied on top of a loaded config.
151#[derive(Default)]
152pub struct CliOverrides {
153    pub config: Option<PathBuf>,
154    pub no_config: bool,
155    // input
156    pub dir: Option<PathBuf>,
157    pub include: Vec<String>,
158    pub exclude: Vec<String>,
159    // output
160    pub output: Option<PathBuf>,
161    pub client_output: Option<PathBuf>,
162    pub svelte_output: Option<PathBuf>,
163    pub react_output: Option<PathBuf>,
164    pub vue_output: Option<PathBuf>,
165    pub solid_output: Option<PathBuf>,
166    pub types_import: Option<String>,
167    pub extension: Option<String>,
168    // codegen
169    pub preserve_docs: bool,
170    pub branded_newtypes: Option<bool>,
171    pub fields: Option<FieldNaming>,
172    pub type_overrides: Vec<(String, String)>,
173    pub bigint_types: Vec<String>,
174    // watch
175    pub debounce_ms: Option<u64>,
176    pub clear_screen: bool,
177}
178
179/// Resolve config: discover/load the file, then apply CLI overrides.
180pub fn resolve(cli: CliOverrides) -> Result<RpcConfig> {
181    let mut config = if cli.no_config {
182        RpcConfig::default()
183    } else if let Some(path) = &cli.config {
184        load(path)?
185    } else {
186        let cwd = std::env::current_dir().context("Failed to get current directory")?;
187        match discover(&cwd) {
188            Some(path) => load(&path)?,
189            None => RpcConfig::default(),
190        }
191    };
192
193    // Apply CLI overrides (move values instead of cloning)
194    if let Some(dir) = cli.dir {
195        config.input.dir = dir;
196    }
197    if !cli.include.is_empty() {
198        config.input.include = cli.include;
199    }
200    if !cli.exclude.is_empty() {
201        config.input.exclude = cli.exclude;
202    }
203    if let Some(output) = cli.output {
204        config.output.types = output;
205    }
206    if let Some(client_output) = cli.client_output {
207        config.output.client = client_output;
208    }
209    if let Some(svelte_output) = cli.svelte_output {
210        config.output.svelte = Some(svelte_output);
211    }
212    if let Some(react_output) = cli.react_output {
213        config.output.react = Some(react_output);
214    }
215    if let Some(vue_output) = cli.vue_output {
216        config.output.vue = Some(vue_output);
217    }
218    if let Some(solid_output) = cli.solid_output {
219        config.output.solid = Some(solid_output);
220    }
221    if let Some(types_import) = cli.types_import {
222        config.output.imports.types_path = types_import;
223    }
224    if let Some(extension) = cli.extension {
225        config.output.imports.extension = extension;
226    }
227    if cli.preserve_docs {
228        config.codegen.preserve_docs = true;
229    }
230    if let Some(branded) = cli.branded_newtypes {
231        config.codegen.branded_newtypes = branded;
232    }
233    if let Some(fields) = cli.fields {
234        config.codegen.naming.fields = fields;
235    }
236    for (key, value) in cli.type_overrides {
237        config.codegen.type_overrides.insert(key, value);
238    }
239    if !cli.bigint_types.is_empty() {
240        config.codegen.bigint_types = cli.bigint_types;
241    }
242    if let Some(debounce_ms) = cli.debounce_ms {
243        config.watch.debounce_ms = debounce_ms;
244    }
245    if cli.clear_screen {
246        config.watch.clear_screen = true;
247    }
248
249    Ok(config)
250}