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