ript_config/
lib.rs

1//! The single place for `ript.toml` configuration.
2//!
3//! Any crates that want to read the config file can do so here to prevent one place having
4//! to load the config and pass it around everywhere.
5
6use std::collections::HashMap;
7use std::fs::File;
8use std::io::{self, Write};
9use std::path::PathBuf;
10use std::str::FromStr;
11use std::{env, fs};
12
13use anyhow::Context;
14use serde::{Deserialize, Serialize};
15
16/// The config file itself.
17#[derive(Deserialize, Serialize)]
18pub struct RiptDotToml {
19    /// The path to the TypeScript file that will be generated by the compiler driver.
20    #[serde(default = "default_outfile")]
21    outfile: PathBuf,
22
23    /// Types are already pascal case, and fields have casings determined by serde.
24    /// Namespace names come from module names, which are always snake case.
25    /// Setting this to true will automatically camelize the namespace names.
26    #[serde(default = "default_camelize_namespaces")]
27    camelize_namespaces: bool,
28
29    /// Override a output with a typescript keyword type. In the future there should definitely be
30    /// a more detailed way to do this, but this works well for now.
31    ///
32    /// The key should be a fully qualified rust path; for example: `my_crate::my_module::MyType`
33    #[serde(default = "default_overrides")]
34    overrides: HashMap<String, TsKeywordType>,
35
36    /// The package name of the InertiaJS wrapper. We have an officially supported one, but this can be
37    /// overridden to use a different base.
38    #[serde(default = "default_wrapper_package_import")]
39    wrapper_package_import: String,
40}
41
42impl FromStr for RiptDotToml {
43    type Err = toml::de::Error;
44
45    fn from_str(s: &str) -> Result<Self, Self::Err> {
46        toml::from_str(s)
47    }
48}
49
50fn default_outfile() -> PathBuf {
51    PathBuf::from("target/inertia.ts")
52}
53
54fn default_camelize_namespaces() -> bool {
55    true
56}
57
58fn default_overrides() -> HashMap<String, TsKeywordType> {
59    HashMap::new()
60}
61
62fn default_wrapper_package_import() -> String {
63    "ript".to_string()
64}
65
66impl Default for RiptDotToml {
67    fn default() -> Self {
68        Self {
69            outfile: default_outfile(),
70            camelize_namespaces: default_camelize_namespaces(),
71            overrides: default_overrides(),
72            wrapper_package_import: default_wrapper_package_import(),
73        }
74    }
75}
76
77impl RiptDotToml {
78    /// Provision the config file if it doesn't exist and return its path.
79    ///
80    /// This deliberately does not create an instance of `Self`, as we don't want to read the source files
81    /// yet. Instead, the driver is responsible for creating them through the compiler sess. We could technically
82    /// do that here and bring in the driver as a dep, but that would prevent `ript-cli` from using this,
83    /// and we want the CLI to remain usable on stable hence the reason for this existing as a separate crate
84    /// and offloading the reading / writing to consumers.
85    pub fn provision() -> anyhow::Result<PathBuf> {
86        let path = ript_dot_doml()?;
87
88        if !path.exists() {
89            std::fs::write(&path, toml::to_string(&Self::default())?)?;
90        }
91
92        Ok(path)
93    }
94
95    /// Get a writer for the generated TypeScript file
96    pub fn outfile_writer(&self) -> anyhow::Result<TsWriter> {
97        TsWriter::new(&self.outfile)
98    }
99
100    /// Returns the generated TS path. This should not be used directly
101    /// by the compiler driver for output, but should be used by the driver
102    /// to add this file to the sourcemap so that changing it will trigger
103    /// rebuilds.
104    pub fn outfile(&self) -> &PathBuf {
105        &self.outfile
106    }
107
108    // /// The path of the opened config file itself.
109    // pub fn path(&self) -> &PathBuf {
110    //     &self.path
111    // }
112
113    /// Whether to camelize the namespace names.
114    pub fn camelize_namespaces(&self) -> bool {
115        self.camelize_namespaces
116    }
117
118    pub fn overrides(&self) -> &HashMap<String, TsKeywordType> {
119        &self.overrides
120    }
121
122    pub fn wrapper_package_import(&self) -> &str {
123        &self.wrapper_package_import
124    }
125}
126
127fn ript_dot_doml() -> anyhow::Result<PathBuf> {
128    let workspace_root = detect_workspace_root()?;
129    Ok(workspace_root.join("ript.toml"))
130}
131
132/// Attempt to find the workspace root, or just the crate directory itself
133/// if running in a non-workspace
134pub fn detect_workspace_root() -> anyhow::Result<PathBuf> {
135    let mut current_dir =
136        env::current_dir().context("failed to determine the current working directory")?;
137
138    let mut last_cargo_toml: Option<PathBuf> = None;
139
140    loop {
141        let cargo_toml_path = current_dir.join("Cargo.toml");
142        if cargo_toml_path.exists() {
143            // record this Cargo.toml in case we don't find a `[workspace]` anywhere
144            last_cargo_toml = Some(cargo_toml_path.clone());
145
146            // Naive check for `[workspace]` in the contents
147            if let Ok(contents) = fs::read_to_string(&cargo_toml_path) {
148                // If any line, trimmed of leading space, starts with "[workspace]" we assume it's a workspace root
149                if contents
150                    .lines()
151                    .any(|line| line.trim_start().starts_with("[workspace]"))
152                {
153                    return Ok(current_dir);
154                }
155            }
156        }
157
158        // move one directory up; if we can't then we're done searching
159        if !current_dir.pop() {
160            break;
161        }
162    }
163
164    // if we found at least one Cargo.toml return its directory
165    if let Some(path) = last_cargo_toml {
166        return Ok(path.parent().context("no parent")?.to_path_buf());
167    }
168
169    // otherwise just fall back to the current working directory
170    // env::current_dir()
171
172    anyhow::bail!("could not find a Cargo.toml in any parent directories")
173}
174
175/// A writer for the generated TypeScript file that implements std::io::Write
176pub struct TsWriter {
177    file: File,
178}
179
180impl TsWriter {
181    /// Create a new TsWriter from a path
182    fn new(path: &PathBuf) -> anyhow::Result<Self> {
183        // create required directories for the generated ts if they don't exist
184        if let Some(parent_dir) = path.parent() {
185            fs::create_dir_all(parent_dir)?;
186        }
187
188        Ok(Self {
189            file: File::create(path)?,
190        })
191    }
192}
193
194impl Write for TsWriter {
195    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
196        self.file.write(buf)
197    }
198
199    fn flush(&mut self) -> io::Result<()> {
200        self.file.flush()
201    }
202}
203
204/// A configurable TS keyword type
205#[derive(Deserialize, Serialize, Clone, Copy)]
206#[serde(rename_all = "camelCase")]
207pub enum TsKeywordType {
208    /// A string
209    String,
210    /// A number
211    Number,
212}