1use crate::agent::Agent;
4use crate::config::Config;
5use crate::schema::{Field, FieldAdmin, FieldType, OnDelete, Resource};
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
11pub struct App {
12 pub root: PathBuf,
14 pub config: Config,
15 pub resources: BTreeMap<String, Resource>,
17 pub agents: BTreeMap<String, Agent>,
19 pub tls: Option<TlsPaths>,
21 pub functions_dir: PathBuf,
23}
24
25#[derive(Debug, Clone)]
27pub struct TlsPaths {
28 pub cert: PathBuf,
29 pub key: PathBuf,
30}
31
32impl App {
33 pub fn load(root: impl AsRef<Path>) -> crate::Result<App> {
36 let root = root.as_ref().to_path_buf();
37 let config = Config::load(&root)?;
38
39 let mut resources = BTreeMap::new();
40 let mut agents = BTreeMap::new();
41
42 for (name, src) in crate::defaults::builtins() {
44 resources.insert(name.to_string(), crate::defaults::parse_builtin(src));
45 }
46
47 if config.payments.enabled() {
52 for (name, src) in crate::defaults::billing_builtins() {
53 resources.insert(name.to_string(), crate::defaults::parse_builtin(src));
54 }
55 }
56
57 let agents_dir = root.join("agents");
60 if agents_dir.is_dir() {
61 for entry in std::fs::read_dir(&agents_dir).map_err(|e| crate::Error::Io {
62 path: agents_dir.clone(),
63 source: e,
64 })? {
65 let entry = entry.map_err(|e| crate::Error::Io {
66 path: agents_dir.clone(),
67 source: e,
68 })?;
69 let path = entry.path();
70 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
71 continue;
72 }
73 let agent = Agent::load(&path)?;
74 for (name, resource) in agent.storage_resources()? {
75 resources.insert(name, resource);
76 }
77 tracing::info!(agent = %agent.meta.name, "loaded agent");
78 agents.insert(agent.meta.name.clone(), agent);
79 }
80 }
81
82 let models_dir = root.join("models");
84 if models_dir.is_dir() {
85 for entry in std::fs::read_dir(&models_dir).map_err(|e| crate::Error::Io {
86 path: models_dir.clone(),
87 source: e,
88 })? {
89 let entry = entry.map_err(|e| crate::Error::Io {
90 path: models_dir.clone(),
91 source: e,
92 })?;
93 let path = entry.path();
94 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
95 continue;
96 }
97 let resource = Resource::load(&path)?;
98 tracing::info!(resource = %resource.meta.name, "loaded model");
99 resources.insert(resource.meta.name.clone(), resource);
100 }
101 }
102
103 for resource in resources.values_mut() {
107 if resource.is_org_scoped() && !resource.fields.contains_key("organization_id") {
108 resource.fields.insert(
109 "organization_id".to_string(),
110 Field {
111 ty: FieldType::Reference,
112 references: Some("organization".to_string()),
113 required: true,
114 unique: false,
115 hidden: false,
116 default: None,
117 max_length: None,
118 on_delete: Some(OnDelete::Cascade),
119 admin: FieldAdmin {
122 visible: false,
123 ..FieldAdmin::default()
124 },
125 },
126 );
127 }
128 }
129
130 let tls = Self::detect_tls(&root);
132 if tls.is_some() {
133 tracing::info!("https/ directory found — serving over TLS");
134 }
135
136 Ok(App {
137 functions_dir: root.join("functions"),
138 root,
139 config,
140 resources,
141 agents,
142 tls,
143 })
144 }
145
146 fn detect_tls(root: &Path) -> Option<TlsPaths> {
148 let dir = root.join("https");
149 if !dir.is_dir() {
150 return None;
151 }
152 let cert = ["cert.pem", "fullchain.pem", "certificate.pem", "server.crt"]
153 .iter()
154 .map(|f| dir.join(f))
155 .find(|p| p.exists())?;
156 let key = ["key.pem", "privkey.pem", "server.key", "private.pem"]
157 .iter()
158 .map(|f| dir.join(f))
159 .find(|p| p.exists())?;
160 Some(TlsPaths { cert, key })
161 }
162
163 pub fn display_name(&self) -> String {
171 self.config
172 .app
173 .name
174 .as_deref()
175 .map(str::trim)
176 .filter(|name| !name.is_empty())
177 .map(str::to_string)
178 .unwrap_or_else(|| {
179 self.root
180 .file_name()
181 .and_then(|name| name.to_str())
182 .unwrap_or("apiplant app")
183 .to_string()
184 })
185 }
186
187 pub fn docs_title(&self) -> String {
190 self.config
191 .docs
192 .title
193 .as_deref()
194 .map(str::trim)
195 .filter(|title| !title.is_empty())
196 .map(str::to_string)
197 .unwrap_or_else(|| self.display_name())
198 }
199
200 pub fn resources_in_dependency_order(&self) -> Vec<&Resource> {
203 let mut ordered: Vec<&Resource> = Vec::new();
204 let mut placed: std::collections::HashSet<&str> = std::collections::HashSet::new();
205
206 let mut remaining: Vec<&Resource> = self.resources.values().collect();
208 while !remaining.is_empty() {
209 let mut progressed = false;
210 remaining.retain(|r| {
211 let deps_ready = r.fields.values().all(|f| match &f.references {
212 Some(target) => placed.contains(target.as_str()) || target == &r.meta.name,
213 None => true,
214 });
215 if deps_ready {
216 ordered.push(r);
217 placed.insert(r.meta.name.as_str());
218 progressed = true;
219 false
220 } else {
221 true
222 }
223 });
224 if !progressed {
225 ordered.append(&mut remaining);
227 }
228 }
229 ordered
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::fs;
237 use std::time::{SystemTime, UNIX_EPOCH};
238
239 fn temp_app_dir(label: &str) -> PathBuf {
240 let mut dir = std::env::temp_dir();
241 let stamp = SystemTime::now()
242 .duration_since(UNIX_EPOCH)
243 .unwrap()
244 .as_nanos();
245 dir.push(format!(
246 "apiplant-app-{label}-{}-{stamp}",
247 std::process::id()
248 ));
249 fs::create_dir_all(dir.join("models")).unwrap();
250 dir
251 }
252
253 #[test]
256 fn display_name_and_docs_title_share_one_source() {
257 let dir = temp_app_dir("naming");
258 let directory = dir.file_name().unwrap().to_str().unwrap().to_string();
259
260 let unnamed = App::load(&dir).unwrap();
261 assert_eq!(unnamed.display_name(), directory);
262 assert_eq!(unnamed.docs_title(), directory);
263
264 fs::write(dir.join("main.toml"), "[app]\nname = \"Acme Logistics\"\n").unwrap();
265 let named = App::load(&dir).unwrap();
266 assert_eq!(named.display_name(), "Acme Logistics");
267 assert_eq!(named.docs_title(), "Acme Logistics");
268
269 fs::write(dir.join("main.toml"), "[app]\nname = \" \"\n").unwrap();
271 assert_eq!(App::load(&dir).unwrap().display_name(), directory);
272
273 fs::write(
276 dir.join("main.toml"),
277 "[app]\nname = \"Acme Logistics\"\n\n[docs]\ntitle = \"Acme Freight API\"\n",
278 )
279 .unwrap();
280 let split = App::load(&dir).unwrap();
281 assert_eq!(split.display_name(), "Acme Logistics");
282 assert_eq!(split.docs_title(), "Acme Freight API");
283
284 fs::remove_dir_all(&dir).unwrap();
285 }
286
287 #[test]
288 fn empty_app_loads_builtins_and_no_tls() {
289 let dir = temp_app_dir("empty");
290 let app = App::load(&dir).unwrap();
291
292 assert!(app.resources.contains_key("organization"));
293 assert!(app.resources.contains_key("membership"));
294 assert!(app.resources.contains_key("user"));
295 assert!(app.resources.contains_key("api_key"));
296 assert!(app.resources.contains_key("oauth_connection"));
297 assert!(app.agents.is_empty());
298 assert!(app.tls.is_none());
299 assert_eq!(app.functions_dir, dir.join("functions"));
300
301 fs::remove_dir_all(dir).unwrap();
302 }
303
304 #[test]
305 fn org_scoped_resources_get_organization_id_injected() {
306 let dir = temp_app_dir("org-scope");
307 fs::write(
308 dir.join("models/post.toml"),
309 r#"
310[resource]
311name = "post"
312
313[fields.title]
314type = "string"
315required = true
316"#,
317 )
318 .unwrap();
319 fs::write(
320 dir.join("models/plan.toml"),
321 r#"
322[resource]
323name = "plan"
324scope = "global"
325
326[fields.name]
327type = "string"
328"#,
329 )
330 .unwrap();
331
332 let app = App::load(&dir).unwrap();
333 let post = app.resources.get("post").unwrap();
334 let plan = app.resources.get("plan").unwrap();
335
336 let org = post.fields.get("organization_id").unwrap();
337 assert_eq!(org.ty, FieldType::Reference);
338 assert_eq!(org.references.as_deref(), Some("organization"));
339 assert!(org.required);
340 assert_eq!(org.on_delete, Some(OnDelete::Cascade));
341 assert!(!plan.fields.contains_key("organization_id"));
342
343 fs::remove_dir_all(dir).unwrap();
344 }
345
346 #[test]
347 fn same_named_model_replaces_builtin_resource() {
348 let dir = temp_app_dir("override-user");
349 fs::write(
350 dir.join("models/users.toml"),
351 r#"
352[resource]
353name = "user"
354scope = "global"
355
356[auth]
357identity_field = "username"
358password_field = "password_hash"
359
360[fields.username]
361type = "string"
362required = true
363
364[fields.password_hash]
365type = "string"
366hidden = true
367"#,
368 )
369 .unwrap();
370
371 let app = App::load(&dir).unwrap();
372 let user = app.resources.get("user").unwrap();
373
374 assert!(user.fields.contains_key("username"));
375 assert!(!user.fields.contains_key("email"));
376 assert_eq!(user.auth.as_ref().unwrap().identity_field, "username");
377
378 fs::remove_dir_all(dir).unwrap();
379 }
380
381 #[test]
382 fn tls_detection_accepts_common_cert_and_key_names() {
383 let dir = temp_app_dir("tls");
384 fs::create_dir_all(dir.join("https")).unwrap();
385 fs::write(dir.join("https/fullchain.pem"), "cert").unwrap();
386 fs::write(dir.join("https/privkey.pem"), "key").unwrap();
387
388 let app = App::load(&dir).unwrap();
389 let tls = app.tls.unwrap();
390 assert_eq!(tls.cert, dir.join("https/fullchain.pem"));
391 assert_eq!(tls.key, dir.join("https/privkey.pem"));
392
393 fs::remove_dir_all(dir).unwrap();
394 }
395
396 #[test]
397 fn dependency_order_places_parents_before_children() {
398 let dir = temp_app_dir("deps");
399 fs::write(
400 dir.join("models/post.toml"),
401 r#"
402[resource]
403name = "post"
404
405[fields.owner_id]
406type = "reference"
407references = "user"
408
409[fields.title]
410type = "string"
411"#,
412 )
413 .unwrap();
414 fs::write(
415 dir.join("models/comment.toml"),
416 r#"
417[resource]
418name = "comment"
419
420[fields.post_id]
421type = "reference"
422references = "post"
423
424[fields.owner_id]
425type = "reference"
426references = "user"
427
428[fields.body]
429type = "text"
430"#,
431 )
432 .unwrap();
433
434 let app = App::load(&dir).unwrap();
435 let order: Vec<_> = app
436 .resources_in_dependency_order()
437 .into_iter()
438 .map(|r| r.meta.name.as_str())
439 .collect();
440
441 let user_idx = order.iter().position(|name| *name == "user").unwrap();
442 let post_idx = order.iter().position(|name| *name == "post").unwrap();
443 let comment_idx = order.iter().position(|name| *name == "comment").unwrap();
444
445 assert!(user_idx < post_idx);
446 assert!(post_idx < comment_idx);
447
448 fs::remove_dir_all(dir).unwrap();
449 }
450
451 #[test]
452 fn agents_are_loaded_and_seed_history_resources() {
453 let dir = temp_app_dir("agent");
454 fs::create_dir_all(dir.join("agents")).unwrap();
455 fs::write(
456 dir.join("agents/coach.toml"),
457 r#"
458[agent]
459name = "coach"
460system = "Be helpful."
461storage.enabled = true
462
463[permissions]
464chat = "authenticated"
465history = "owner"
466"#,
467 )
468 .unwrap();
469
470 let app = App::load(&dir).unwrap();
471 assert!(app.agents.contains_key("coach"));
472 assert!(app.resources.contains_key("ai_coach_thread"));
473 assert!(app.resources.contains_key("ai_coach_message"));
474
475 fs::remove_dir_all(dir).unwrap();
476 }
477}