use std::path::Path;
use arcature_build::uag::{SCHEMA_VERSION, Uag};
use crate::process::{ProcessSpec, run_capture};
use crate::project::ProjectConfig;
use super::uag_schema::SchemaError;
const MANIFEST_RELATIVE_PATH: &str = ".arcature/app-manifest.json";
pub(crate) fn load_uag(project: &ProjectConfig) -> Result<Uag, SchemaError> {
let manifest_path = project.root().join(MANIFEST_RELATIVE_PATH);
if manifest_path.is_file() {
load_uag_from_file(&manifest_path)
} else {
load_uag_fresh(project)
}
}
pub(crate) fn load_uag_from_file(path: &Path) -> Result<Uag, SchemaError> {
let bytes = std::fs::read(path).map_err(|source| SchemaError::ReadManifest {
path: path.to_path_buf(),
source,
})?;
let uag: Uag = serde_json::from_slice(&bytes).map_err(|source| SchemaError::ParseManifest {
path: path.to_path_buf(),
source,
})?;
if uag.schema_version != SCHEMA_VERSION {
return Err(SchemaError::IncompatibleSchema {
found: uag.schema_version,
expected: SCHEMA_VERSION,
});
}
Ok(uag)
}
pub(crate) fn load_uag_fresh(project: &ProjectConfig) -> Result<Uag, SchemaError> {
let output = run_capture(&ProcessSpec::new("cargo", project.root()).args([
"run",
"--quiet",
"--package",
&project.backend_package,
"--bin",
"arcature-metadata",
]))
.map_err(|error| SchemaError::RunMetadata(error.to_string()))?;
let uag: Uag =
serde_json::from_slice(&output).map_err(|source| SchemaError::ParseManifest {
path: project.root().join(MANIFEST_RELATIVE_PATH),
source,
})?;
if uag.schema_version != SCHEMA_VERSION {
return Err(SchemaError::IncompatibleSchema {
found: uag.schema_version,
expected: SCHEMA_VERSION,
});
}
Ok(uag)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::uag_schema::tests as schema_tests;
use arcature_build::uag::SCHEMA_VERSION;
use arcature_build::uag::schema::{
CadenceEntry, CommandEntry, JobEntry, ListenerEntry, ModuleEntry, RouteEntry,
ScheduleEntry, ServiceEntry,
};
use std::collections::BTreeMap;
use tempfile::tempdir;
fn fixture_uag() -> Uag {
let mut modules = BTreeMap::new();
modules.insert(
"Links".to_owned(),
ModuleEntry {
name: "Links".to_owned(),
imports: vec!["Accounts".to_owned()],
exports: vec![],
controllers: vec!["LinksController".to_owned()],
services: vec![],
policies: vec![],
routes: vec![RouteEntry {
method: "get".to_owned(),
path: "/links".to_owned(),
name: "links.index".to_owned(),
handler: "LinksController::index".to_owned(),
pages: vec![],
action_fields: vec![],
action_type: String::new(),
query_fields: vec![],
query_type: String::new(),
query_array: false,
query_string_fields: vec![],
query_string_type: String::new(),
}],
listeners: vec![ListenerEntry {
event: "LinkCreated".to_owned(),
listener: "log_creation".to_owned(),
}],
jobs: vec![JobEntry {
kind: "check_links".to_owned(),
version: 1,
handler: "handle_check_links".to_owned(),
}],
commands: vec![CommandEntry {
name: "links:prune".to_owned(),
function: "prune_links".to_owned(),
}],
schedules: vec![ScheduleEntry {
job: "cleanup_sessions".to_owned(),
version: 1,
cadence: CadenceEntry::Every { seconds: 300 },
}],
},
);
Uag {
schema_version: SCHEMA_VERSION,
application: "App".to_owned(),
framework_version: "2026.1.0".to_owned(),
modules,
routes: vec![RouteEntry {
method: "get".to_owned(),
path: "/links/{link}".to_owned(),
name: "links.show".to_owned(),
handler: "LinksController::show".to_owned(),
pages: vec![],
action_fields: vec![],
action_type: String::new(),
query_fields: vec![],
query_type: String::new(),
query_array: false,
query_string_fields: vec![],
query_string_type: String::new(),
}],
services: vec![ServiceEntry {
name: "LinkService".to_owned(),
deps: vec!["Db".to_owned(), "Cache".to_owned()],
}],
pages: vec![],
}
}
#[test]
fn load_uag_from_file_reads_and_validates_schema() {
let dir = tempdir().expect("temp dir");
let path = dir.path().join("app-manifest.json");
let uag = fixture_uag();
let json = serde_json::to_string(&uag).expect("serialize");
std::fs::write(&path, json).expect("write");
let loaded = load_uag_from_file(&path).expect("load");
assert_eq!(loaded, uag);
assert_eq!(loaded.schema_version, SCHEMA_VERSION);
assert_eq!(loaded.application, "App");
assert_eq!(loaded.routes.len(), 1);
assert_eq!(loaded.modules.len(), 1);
}
#[test]
fn load_uag_from_file_rejects_incompatible_schema() {
let dir = tempdir().expect("temp dir");
let path = dir.path().join("app-manifest.json");
let mut uag = fixture_uag();
uag.schema_version = SCHEMA_VERSION + 1;
let json = serde_json::to_string(&uag).expect("serialize");
std::fs::write(&path, json).expect("write");
let err = load_uag_from_file(&path).expect_err("should reject");
assert!(matches!(err, SchemaError::IncompatibleSchema { .. }));
}
#[test]
fn load_uag_from_file_rejects_missing_file() {
let dir = tempdir().expect("temp dir");
let path = dir.path().join("does-not-exist.json");
let err = load_uag_from_file(&path).expect_err("should fail");
assert!(matches!(err, SchemaError::ReadManifest { .. }));
}
#[test]
fn load_uag_from_file_rejects_malformed_json() {
let dir = tempdir().expect("temp dir");
let path = dir.path().join("app-manifest.json");
std::fs::write(&path, b"{ not valid json").expect("write");
let err = load_uag_from_file(&path).expect_err("should fail");
assert!(matches!(err, SchemaError::ParseManifest { .. }));
}
#[test]
fn schema_error_display_is_stable() {
schema_tests::assert_display_stable();
}
}