Skip to main content

rustex_project/
lib.rs

1use anyhow::{Context, Result};
2use camino::{Utf8Path, Utf8PathBuf};
3use serde::{Deserialize, Serialize};
4use tracing::debug;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct RustexConfig {
8    pub project_root: Utf8PathBuf,
9    pub convex_root: Utf8PathBuf,
10    pub out_dir: Utf8PathBuf,
11    #[serde(default = "default_emit")]
12    pub emit: Vec<String>,
13    #[serde(default)]
14    pub strict: bool,
15    #[serde(default)]
16    pub allow_inferred_returns: bool,
17    #[serde(default = "default_naming")]
18    pub naming_strategy: String,
19    #[serde(default = "default_id_style")]
20    pub id_style: String,
21    #[serde(default)]
22    pub custom_derives: Vec<String>,
23    #[serde(default)]
24    pub custom_attributes: Vec<String>,
25}
26
27fn default_emit() -> Vec<String> {
28    vec!["rust".into(), "manifest".into(), "ir".into()]
29}
30
31fn default_naming() -> String {
32    "safe".into()
33}
34
35fn default_id_style() -> String {
36    "newtype_per_table".into()
37}
38
39impl Default for RustexConfig {
40    fn default() -> Self {
41        Self {
42            project_root: Utf8PathBuf::from("."),
43            convex_root: Utf8PathBuf::from("./convex"),
44            out_dir: Utf8PathBuf::from("./generated/rustex"),
45            emit: default_emit(),
46            strict: false,
47            allow_inferred_returns: true,
48            naming_strategy: default_naming(),
49            id_style: default_id_style(),
50            custom_derives: Vec::new(),
51            custom_attributes: Vec::new(),
52        }
53    }
54}
55
56#[derive(Debug, Clone)]
57pub struct ProjectLayout {
58    pub root: Utf8PathBuf,
59    pub convex_root: Utf8PathBuf,
60    pub out_dir: Utf8PathBuf,
61    pub config_path: Utf8PathBuf,
62    pub discovered_convex_roots: Vec<Utf8PathBuf>,
63    pub component_roots: Vec<Utf8PathBuf>,
64}
65
66pub fn load_config(root: &Utf8Path) -> Result<(RustexConfig, ProjectLayout)> {
67    let _span = tracing::info_span!("rustex_project.load_config", root = %root).entered();
68    let config_path = root.join("rustex.toml");
69    let raw = std::fs::read_to_string(&config_path)
70        .with_context(|| format!("failed to read config at {config_path}"))?;
71    let config: RustexConfig =
72        toml::from_str(&raw).with_context(|| format!("failed to parse {config_path}"))?;
73
74    if !root.exists() {
75        anyhow::bail!("project root does not exist: {root}");
76    }
77
78    let discovered_convex_roots = discover_convex_roots(root);
79    let configured_convex_root = absolutize(root, &config.convex_root);
80    let convex_root = if configured_convex_root.exists() {
81        configured_convex_root
82    } else if discovered_convex_roots.len() == 1 {
83        discovered_convex_roots[0].clone()
84    } else {
85        configured_convex_root
86    };
87
88    let layout = ProjectLayout {
89        root: root.to_path_buf(),
90        convex_root: convex_root.clone(),
91        out_dir: absolutize(root, &config.out_dir),
92        config_path,
93        component_roots: discover_component_roots(&convex_root),
94        discovered_convex_roots,
95    };
96
97    validate_layout(&layout)?;
98    debug!(
99        convex_root = %display_path(&layout.convex_root, &layout.root),
100        out_dir = %display_path(&layout.out_dir, &layout.root),
101        "resolved project layout"
102    );
103
104    Ok((config, layout))
105}
106
107fn absolutize(root: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
108    if path.is_absolute() {
109        path.to_path_buf()
110    } else {
111        root.join(path)
112    }
113}
114
115fn display_path(path: &Utf8Path, root: &Utf8Path) -> String {
116    path.strip_prefix(root)
117        .map(Utf8Path::to_string)
118        .unwrap_or_else(|_| path.to_string())
119}
120
121fn validate_layout(layout: &ProjectLayout) -> Result<()> {
122    if !layout.root.is_dir() {
123        anyhow::bail!("project root is not a directory: {}", layout.root);
124    }
125
126    if !layout.config_path.is_file() {
127        anyhow::bail!("missing rustex config: {}", layout.config_path);
128    }
129
130    if !layout.convex_root.exists() {
131        let candidates = if layout.discovered_convex_roots.is_empty() {
132            String::new()
133        } else {
134            format!(
135                " discovered candidates: {}",
136                layout
137                    .discovered_convex_roots
138                    .iter()
139                    .map(ToString::to_string)
140                    .collect::<Vec<_>>()
141                    .join(", ")
142            )
143        };
144        anyhow::bail!(
145            "convex root does not exist: {}. rustex supports standard convex/ layouts and can auto-detect common monorepo locations.{}",
146            layout.convex_root,
147            candidates
148        );
149    }
150
151    if !layout.convex_root.is_dir() {
152        anyhow::bail!("convex root is not a directory: {}", layout.convex_root);
153    }
154
155    let schema_path = layout.convex_root.join("schema.ts");
156    let generated_dir = layout.convex_root.join("_generated");
157    if !schema_path.is_file() && !generated_dir.is_dir() {
158        anyhow::bail!(
159            "unsupported convex layout at {}: expected schema.ts or _generated/ metadata",
160            layout.convex_root
161        );
162    }
163
164    Ok(())
165}
166
167fn discover_convex_roots(root: &Utf8Path) -> Vec<Utf8PathBuf> {
168    let mut candidates = vec![root.join("convex")];
169    for base in ["apps", "packages"] {
170        let dir = root.join(base);
171        if let Ok(entries) = std::fs::read_dir(&dir) {
172            for entry in entries.flatten() {
173                let path = entry.path().join("convex");
174                if let Ok(path) = Utf8PathBuf::from_path_buf(path) {
175                    candidates.push(path);
176                }
177            }
178        }
179    }
180    candidates
181        .into_iter()
182        .filter(|path| path.is_dir())
183        .collect()
184}
185
186fn discover_component_roots(convex_root: &Utf8Path) -> Vec<Utf8PathBuf> {
187    let components_dir = convex_root.join("components");
188    let Ok(entries) = std::fs::read_dir(&components_dir) else {
189        return Vec::new();
190    };
191    entries
192        .flatten()
193        .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
194        .filter(|path| path.is_dir())
195        .collect()
196}