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}