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