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