Skip to main content

pharos_runtime_compiler/
features.rs

1//! Compilation and lookup for coherent declarative application features.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use pharos_runtime_core::application_ir::{CONTRACT_VERSION, ContractId};
8use serde_json::{Map, Value};
9use sha2::{Digest, Sha256};
10
11const MAX_FEATURE_FILES: usize = 128;
12const MAX_TEMPLATE_FILES: usize = 4_096;
13const MAX_TEMPLATE_DEPTH: usize = 32;
14const INTERACTION_CLIENT: &str = include_str!("../assets/interaction-v1.js");
15
16#[derive(Debug, Clone, Eq, PartialEq)]
17/// A bounded feature compilation failure that prevents an ambiguous artifact.
18///
19/// # Variants
20///
21/// - `InvalidDocument`: Supported `FeatureCompileError` variant.
22/// - `DuplicateId`: Supported `FeatureCompileError` variant.
23/// - `MissingReference`: Supported `FeatureCompileError` variant.
24/// - `UnsafePath`: Supported `FeatureCompileError` variant.
25/// - `Io`: Supported `FeatureCompileError` variant.
26///
27/// # Examples
28///
29/// ```rust,ignore
30/// let _ = pharos_runtime_compiler::features::FeatureCompileError::InvalidDocument;
31/// ```
32///
33/// # Related
34///
35/// - [`crate::features`] for constructors, helpers, and neighboring runtime types.
36pub enum FeatureCompileError {
37    InvalidDocument(String),
38    DuplicateId(String),
39    MissingReference(String),
40    UnsafePath(String),
41    Io(String),
42}
43
44/// Returns a validated portable declaration identifier.
45fn declaration_id(value: &str) -> Result<String, FeatureCompileError> {
46    ContractId::parse(value)
47        .map(|id| id.as_str().to_owned())
48        .map_err(|_| FeatureCompileError::InvalidDocument(value.to_owned()))
49}
50
51/// Returns a safe absolute artifact path for one declared relative path.
52fn safe_artifact_path(root: &Path, relative: &str) -> Result<PathBuf, FeatureCompileError> {
53    let path = Path::new(relative);
54    if relative.is_empty()
55        || path.is_absolute()
56        || path
57            .components()
58            .any(|component| !matches!(component, std::path::Component::Normal(_)))
59    {
60        return Err(FeatureCompileError::UnsafePath(relative.to_owned()));
61    }
62    Ok(root.join(path))
63}
64
65/// Resolves an app-owned feature template without permitting boundary escape.
66///
67/// # Parameters
68///
69/// - `root`: Input consumed by [`crate::features::safe_feature_template_path`].
70/// - `relative`: Input consumed by [`crate::features::safe_feature_template_path`].
71///
72/// # Returns
73///
74/// Returns [`Some`] when [`crate::features::safe_feature_template_path`] can produce a value and [`None`] otherwise.
75///
76/// # Examples
77///
78/// ```rust,ignore
79/// let _result = pharos_runtime_compiler::features::safe_feature_template_path(std::path::Path::new("."), todo!());
80/// ```
81///
82/// # Related
83///
84/// - [`crate::features`] for adjacent helpers that share the same runtime surface.
85pub fn safe_feature_template_path(root: &Path, relative: &str) -> Option<PathBuf> {
86    let path = safe_artifact_path(root, relative).ok()?;
87    (relative.starts_with("templates/") && path.is_file()).then_some(path)
88}
89
90/// Loads a required JSON object from an artifact-local file.
91fn load_json_object(path: &Path) -> Result<Value, FeatureCompileError> {
92    let text = fs::read_to_string(path)
93        .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
94    let value: Value = serde_json::from_str(&text)
95        .map_err(|_| FeatureCompileError::InvalidDocument(path.to_string_lossy().into_owned()))?;
96    if !value.is_object() {
97        return Err(FeatureCompileError::InvalidDocument(
98            path.to_string_lossy().into_owned(),
99        ));
100    }
101    Ok(value)
102}
103
104/// Loads an existing lower-level contract or creates its empty canonical shape.
105fn load_contract_or_empty(
106    artifact_root: &Path,
107    name: &str,
108    array_key: &str,
109) -> Result<Value, FeatureCompileError> {
110    let path = artifact_root.join(format!("data/contracts/{name}.json"));
111    if path.is_file() {
112        return load_json_object(&path);
113    }
114    Ok(serde_json::json!({
115        "contract_version": CONTRACT_VERSION,
116        array_key: [],
117    }))
118}
119
120/// Writes one generated JSON document inside the artifact boundary.
121fn write_json(
122    artifact_root: &Path,
123    relative: &str,
124    value: &Value,
125) -> Result<(), FeatureCompileError> {
126    let path = safe_artifact_path(artifact_root, relative)?;
127    let parent = path
128        .parent()
129        .ok_or_else(|| FeatureCompileError::UnsafePath(relative.to_owned()))?;
130    fs::create_dir_all(parent)
131        .map_err(|_| FeatureCompileError::Io(parent.to_string_lossy().into_owned()))?;
132    let encoded = serde_json::to_string_pretty(value)
133        .map_err(|_| FeatureCompileError::InvalidDocument(relative.to_owned()))?;
134    pharos_runtime_core::fs::write_file_within(artifact_root, &path, encoded.as_bytes(), 0o644)
135        .map_err(|_| FeatureCompileError::Io(relative.to_owned()))
136}
137
138/// Collects sorted, non-symlinked feature source documents.
139fn feature_paths(artifact_root: &Path) -> Result<Vec<PathBuf>, FeatureCompileError> {
140    let directory = artifact_root.join("data/features");
141    let mut paths = Vec::new();
142    if directory.is_dir() {
143        for entry in fs::read_dir(&directory)
144            .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?
145        {
146            let entry = entry
147                .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?;
148            let path = entry.path();
149            let metadata = fs::symlink_metadata(&path)
150                .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
151            if metadata.file_type().is_symlink() || metadata.is_dir() {
152                return Err(FeatureCompileError::UnsafePath(
153                    path.to_string_lossy().into_owned(),
154                ));
155            }
156            if metadata.is_file()
157                && path.extension().and_then(|value| value.to_str()) == Some("json")
158            {
159                paths.push(path);
160            }
161            if paths.len() > MAX_FEATURE_FILES {
162                return Err(FeatureCompileError::InvalidDocument(
163                    "too many feature files".to_owned(),
164                ));
165            }
166        }
167    }
168    if paths.is_empty() {
169        let contract_path = artifact_root.join("data/contracts/features.json");
170        if contract_path.is_file() {
171            let metadata = fs::symlink_metadata(&contract_path).map_err(|_| {
172                FeatureCompileError::Io(contract_path.to_string_lossy().into_owned())
173            })?;
174            if metadata.file_type().is_symlink() || !metadata.is_file() {
175                return Err(FeatureCompileError::UnsafePath(
176                    contract_path.to_string_lossy().into_owned(),
177                ));
178            }
179            paths.push(contract_path);
180        }
181    }
182    paths.sort();
183    Ok(paths)
184}
185
186fn feature_source_documents(
187    path: &Path,
188    document: Value,
189) -> Result<Vec<Value>, FeatureCompileError> {
190    if document.get("feature_id").and_then(Value::as_str).is_some() {
191        return Ok(vec![document]);
192    }
193    if let Some(features) = document.get("features").and_then(Value::as_array) {
194        if features.len() > MAX_FEATURE_FILES {
195            return Err(FeatureCompileError::InvalidDocument(
196                path.to_string_lossy().into_owned(),
197            ));
198        }
199        return Ok(features.clone());
200    }
201    Err(FeatureCompileError::InvalidDocument(
202        path.to_string_lossy().into_owned(),
203    ))
204}
205
206/// Validates an HTTP route path without permitting queries, fragments, or controls.
207fn route_path(value: &str) -> Result<String, FeatureCompileError> {
208    if !value.starts_with('/')
209        || value.len() > 512
210        || value.contains(['?', '#', '\r', '\n', '\0'])
211        || value.split('/').any(|segment| segment == "..")
212    {
213        return Err(FeatureCompileError::UnsafePath(value.to_owned()));
214    }
215    Ok(value.to_owned())
216}
217
218/// Resolves a bounded map from one optional declaration section.
219fn declaration_map<'a>(
220    feature: &'a Value,
221    key: &str,
222) -> Result<&'a Map<String, Value>, FeatureCompileError> {
223    match feature.get(key) {
224        None => {
225            static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
226            Ok(EMPTY.get_or_init(Map::new))
227        }
228        Some(value) => value
229            .as_object()
230            .ok_or_else(|| FeatureCompileError::InvalidDocument(key.to_owned())),
231    }
232}
233
234/// Returns one optional top-level bundle declaration map.
235fn bundle_map(feature: &Value) -> Result<&Map<String, Value>, FeatureCompileError> {
236    declaration_map(feature, "bundles")
237}
238
239/// Returns one required bundle object.
240fn bundle_object<'a>(
241    bundle_id: &str,
242    bundle: &'a Value,
243) -> Result<&'a Map<String, Value>, FeatureCompileError> {
244    bundle
245        .as_object()
246        .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}")))
247}
248
249/// Returns one required nested declaration object within a bundle.
250fn bundle_section<'a>(
251    bundle_id: &str,
252    bundle: &'a Map<String, Value>,
253    section: &str,
254) -> Result<&'a Map<String, Value>, FeatureCompileError> {
255    bundle
256        .get(section)
257        .and_then(Value::as_object)
258        .ok_or_else(|| {
259            FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{section}"))
260        })
261}
262
263/// Inserts one default scalar or array field when the declaration omitted it.
264fn insert_default_field(object: &mut Map<String, Value>, key: &str, value: Option<Value>) {
265    if !object.contains_key(key)
266        && let Some(value) = value
267    {
268        object.insert(key.to_owned(), value);
269    }
270}
271
272/// Inserts one default response field inside a declaration-owned response object.
273fn insert_default_response_field(object: &mut Map<String, Value>, key: &str, value: Option<Value>) {
274    if value.is_none() {
275        return;
276    }
277    if !object.contains_key("response") {
278        object.insert("response".to_owned(), Value::Object(Map::new()));
279    }
280    if let Some(response) = object.get_mut("response").and_then(Value::as_object_mut)
281        && !response.contains_key(key)
282        && let Some(value) = value
283    {
284        response.insert(key.to_owned(), value);
285    }
286}
287
288/// Expands one bundle page declaration into an ordinary feature page contract.
289fn expand_bundle_page(
290    bundle_id: &str,
291    role: &str,
292    bundle: &Map<String, Value>,
293) -> Result<(String, Value, Value), FeatureCompileError> {
294    let mut page = bundle_section(bundle_id, bundle, role)?.clone();
295    let page_id = declaration_id(
296        page.get("page_id")
297            .and_then(Value::as_str)
298            .unwrap_or(&format!("{bundle_id}_{role}")),
299    )?;
300    insert_default_field(&mut page, "layout", bundle.get("layout").cloned());
301    insert_default_field(&mut page, "policy", bundle.get("policy").cloned());
302    insert_default_field(&mut page, "auth", bundle.get("auth").cloned());
303    let page_queries = page
304        .get("queries")
305        .cloned()
306        .or_else(|| Some(Value::Array(Vec::new())));
307    insert_default_response_field(&mut page, "target", bundle.get("target").cloned());
308    insert_default_response_field(&mut page, "history", bundle.get("history").cloned());
309    insert_default_response_field(&mut page, "queries", page_queries);
310    let summary = serde_json::json!({
311        "role": role,
312        "page_id": page_id,
313        "route_id": page.get("route_id").and_then(Value::as_str).map(ToOwned::to_owned),
314        "path": page.get("path").and_then(Value::as_str).map(ToOwned::to_owned),
315        "target": page.get("response").and_then(|value| value.get("target")).and_then(Value::as_str).map(ToOwned::to_owned),
316        "queries": page.get("response").and_then(|value| value.get("queries")).cloned().unwrap_or_else(|| Value::Array(Vec::new())),
317    });
318    Ok((page_id, Value::Object(page), summary))
319}
320
321/// Compiles one shorthand fragment response into the ordinary action response contract.
322fn expand_bundle_action_response(
323    bundle_id: &str,
324    role: &str,
325    bundle: &Map<String, Value>,
326    action: &Map<String, Value>,
327) -> Result<Value, FeatureCompileError> {
328    if let Some(response) = action.get("response") {
329        let mut response = response
330            .as_object()
331            .ok_or_else(|| {
332                FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.response"))
333            })?
334            .clone();
335        insert_default_field(&mut response, "target", bundle.get("target").cloned());
336        insert_default_field(
337            &mut response,
338            "fallback",
339            bundle
340                .get("fallback_redirect")
341                .cloned()
342                .map(|redirect| serde_json::json!({"redirect": redirect})),
343        );
344        return Ok(Value::Object(response));
345    }
346    let render = declaration_id(action.get("render").and_then(Value::as_str).ok_or_else(
347        || FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.render")),
348    )?)?;
349    let target = declaration_id(
350        action
351            .get("target")
352            .and_then(Value::as_str)
353            .or_else(|| bundle.get("target").and_then(Value::as_str))
354            .ok_or_else(|| {
355                FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.target"))
356            })?,
357    )?;
358    let fallback = route_path(
359        action
360            .get("fallback")
361            .and_then(Value::as_str)
362            .or_else(|| bundle.get("fallback_redirect").and_then(Value::as_str))
363            .ok_or_else(|| {
364                FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.{role}.fallback"))
365            })?,
366    )?;
367    let status = action
368        .get("status")
369        .cloned()
370        .unwrap_or_else(|| Value::from(200));
371    Ok(serde_json::json!({
372        "render": render,
373        "target": target,
374        "queries": action.get("queries").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
375        "fallback": {"redirect": fallback},
376        "status": status,
377    }))
378}
379
380/// Expands one bundle action declaration into an ordinary feature action contract.
381fn expand_bundle_action(
382    bundle_id: &str,
383    role: &str,
384    bundle: &Map<String, Value>,
385) -> Result<(String, Value, Value), FeatureCompileError> {
386    let section = bundle_section(bundle_id, bundle, role)?;
387    let mut action = section.clone();
388    let action_id = declaration_id(
389        action
390            .get("action_id")
391            .and_then(Value::as_str)
392            .unwrap_or(&format!("{bundle_id}_{role}")),
393    )?;
394    insert_default_field(
395        &mut action,
396        "method",
397        Some(Value::String("POST".to_owned())),
398    );
399    insert_default_field(&mut action, "policy", bundle.get("policy").cloned());
400    if !action.contains_key("response") {
401        action.insert(
402            "response".to_owned(),
403            expand_bundle_action_response(bundle_id, role, bundle, section)?,
404        );
405    } else if let Some(response) = action.get_mut("response").and_then(Value::as_object_mut) {
406        insert_default_field(response, "target", bundle.get("target").cloned());
407        insert_default_field(
408            response,
409            "fallback",
410            bundle
411                .get("fallback_redirect")
412                .cloned()
413                .map(|redirect| serde_json::json!({"redirect": redirect})),
414        );
415    }
416    let summary = serde_json::json!({
417        "role": role,
418        "action_id": action_id,
419        "route_id": action.get("route_id").and_then(Value::as_str).map(ToOwned::to_owned),
420        "path": action.get("path").and_then(Value::as_str).map(ToOwned::to_owned),
421        "method": action.get("method").and_then(Value::as_str).map(ToOwned::to_owned),
422        "target": action.get("response").and_then(|value| value.get("target")).and_then(Value::as_str).map(ToOwned::to_owned),
423        "queries": action.get("response").and_then(|value| value.get("queries")).cloned().unwrap_or_else(|| Value::Array(Vec::new())),
424        "render": action.get("response").and_then(|value| value.get("render")).and_then(Value::as_str).map(ToOwned::to_owned),
425        "fallback": action.get("response").and_then(|value| value.get("fallback")).and_then(|value| value.get("redirect")).and_then(Value::as_str).map(ToOwned::to_owned),
426    });
427    Ok((action_id, Value::Object(action), summary))
428}
429
430/// Expands one bundle family into ordinary pages and actions plus an inspectable summary.
431type ExpandedFeatureBundles = (Map<String, Value>, Map<String, Value>, Vec<Value>);
432
433/// Fully expanded pages/actions plus bundle provenance, shared by compilation and planning.
434pub type ExpandedFeatureDeclarations = (Map<String, Value>, Map<String, Value>, Vec<Value>);
435
436/// Resolves explicit declarations and bundles once, rejecting collisions in either namespace.
437pub fn feature_pages_and_actions(
438    feature_id: &str,
439    feature: &Value,
440) -> Result<ExpandedFeatureDeclarations, FeatureCompileError> {
441    declaration_id(feature_id)?;
442    let (bundle_pages, bundle_actions, summaries) = expand_feature_bundles(feature_id, feature)?;
443    let mut pages = declaration_map(feature, "pages")?.clone();
444    let mut actions = declaration_map(feature, "actions")?.clone();
445    for (id, page) in bundle_pages {
446        if pages.insert(id, page).is_some() {
447            return Err(FeatureCompileError::DuplicateId(format!(
448                "{feature_id}.pages"
449            )));
450        }
451    }
452    for (id, action) in bundle_actions {
453        if actions.insert(id, action).is_some() {
454            return Err(FeatureCompileError::DuplicateId(format!(
455                "{feature_id}.actions"
456            )));
457        }
458    }
459    Ok((pages, actions, summaries))
460}
461
462/// Expands feature bundles.
463fn expand_feature_bundles(
464    feature_id: &str,
465    feature: &Value,
466) -> Result<ExpandedFeatureBundles, FeatureCompileError> {
467    let mut pages = Map::new();
468    let mut actions = Map::new();
469    let mut bundles = Vec::new();
470    for (bundle_id, bundle_value) in bundle_map(feature)? {
471        let bundle_id = declaration_id(bundle_id)?;
472        let bundle = bundle_object(&bundle_id, bundle_value)?;
473        let kind = bundle.get("kind").and_then(Value::as_str).ok_or_else(|| {
474            FeatureCompileError::InvalidDocument(format!("bundles.{bundle_id}.kind"))
475        })?;
476        let (page_roles, action_roles): (&[&str], &[&str]) = match kind {
477            "list_detail" => (&["list", "detail"], &[]),
478            "list_create" => (&["list"], &["create"]),
479            "detail_edit" => (&["detail", "edit"], &["update"]),
480            "crud" => (&["list", "detail", "edit"], &["create", "update"]),
481            _ => {
482                return Err(FeatureCompileError::InvalidDocument(format!(
483                    "bundles.{bundle_id}.kind"
484                )));
485            }
486        };
487        let mut bundle_pages = Vec::new();
488        let mut bundle_actions = Vec::new();
489        for role in page_roles {
490            let (page_id, page, summary) = expand_bundle_page(&bundle_id, role, bundle)?;
491            if pages.insert(page_id.clone(), page).is_some() {
492                return Err(FeatureCompileError::DuplicateId(page_id));
493            }
494            bundle_pages.push(summary);
495        }
496        for role in action_roles {
497            let (action_id, action, summary) = expand_bundle_action(&bundle_id, role, bundle)?;
498            if actions.insert(action_id.clone(), action).is_some() {
499                return Err(FeatureCompileError::DuplicateId(action_id));
500            }
501            bundle_actions.push(summary);
502        }
503        bundles.push(serde_json::json!({
504            "feature_id": feature_id,
505            "bundle_id": bundle_id,
506            "bundle_kind": kind,
507            "target": bundle.get("target"),
508            "history": bundle.get("history"),
509            "fallback_redirect": bundle.get("fallback_redirect"),
510            "pages": bundle_pages,
511            "actions": bundle_actions,
512        }));
513    }
514    Ok((pages, actions, bundles))
515}
516
517/// Validates that response query names exist in the owning feature.
518fn validate_response_queries(
519    feature_id: &str,
520    queries: &Map<String, Value>,
521    response: &Value,
522) -> Result<(), FeatureCompileError> {
523    if let Some(variants) = response.get("variants").and_then(Value::as_object) {
524        if variants.is_empty() || variants.len() > 16 {
525            return Err(FeatureCompileError::InvalidDocument(format!(
526                "{feature_id}.response.variants"
527            )));
528        }
529        for (variant_id, variant) in variants {
530            declaration_id(variant_id)?;
531            validate_response_queries(feature_id, queries, variant)?;
532        }
533        return Ok(());
534    }
535    for query in response
536        .get("queries")
537        .and_then(Value::as_array)
538        .into_iter()
539        .flatten()
540    {
541        let query_id = query
542            .as_str()
543            .ok_or_else(|| FeatureCompileError::InvalidDocument(feature_id.to_owned()))?;
544        declaration_id(query_id)?;
545        if !queries.contains_key(query_id) {
546            return Err(FeatureCompileError::MissingReference(format!(
547                "{feature_id}:{query_id}"
548            )));
549        }
550    }
551    Ok(())
552}
553
554/// Finds a unique app-owned HTML template by its file stem.
555fn find_template_by_stem(artifact_root: &Path, stem: &str) -> Result<PathBuf, FeatureCompileError> {
556    declaration_id(stem)?;
557    let templates = artifact_root.join("templates");
558    let mut pending = vec![(templates, 0usize)];
559    let mut visited = 0usize;
560    let mut found = None;
561    while let Some((directory, depth)) = pending.pop() {
562        if depth > MAX_TEMPLATE_DEPTH {
563            return Err(FeatureCompileError::InvalidDocument(
564                "template depth exceeded".to_owned(),
565            ));
566        }
567        let mut entries = fs::read_dir(&directory)
568            .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?
569            .collect::<Result<Vec<_>, _>>()
570            .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?;
571        entries.sort_by_key(|entry| entry.file_name());
572        for entry in entries {
573            let path = entry.path();
574            let metadata = fs::symlink_metadata(&path)
575                .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
576            if metadata.file_type().is_symlink() {
577                return Err(FeatureCompileError::UnsafePath(
578                    path.to_string_lossy().into_owned(),
579                ));
580            }
581            if metadata.is_dir() {
582                pending.push((path, depth + 1));
583                continue;
584            }
585            if !metadata.is_file() {
586                continue;
587            }
588            visited += 1;
589            if visited > MAX_TEMPLATE_FILES {
590                return Err(FeatureCompileError::InvalidDocument(
591                    "too many template files".to_owned(),
592                ));
593            }
594            if path.extension().and_then(|value| value.to_str()) == Some("html")
595                && path.file_stem().and_then(|value| value.to_str()) == Some(stem)
596                && found.replace(path).is_some()
597            {
598                return Err(FeatureCompileError::DuplicateId(stem.to_owned()));
599            }
600        }
601    }
602    found.ok_or_else(|| FeatureCompileError::MissingReference(stem.to_owned()))
603}
604
605/// Adds one operation's closed entity effects to the owning transaction sets.
606fn collect_transaction_effect(
607    operation: &str,
608    value: &Value,
609    reads: &mut BTreeSet<String>,
610    writes: &mut BTreeSet<String>,
611) -> Result<(), FeatureCompileError> {
612    let entity = value
613        .get("entity")
614        .and_then(Value::as_str)
615        .map(declaration_id)
616        .transpose()?;
617    match operation {
618        "require" | "select" | "bind_inserted" => {
619            if let Some(entity) = entity {
620                reads.insert(format!("entity:{entity}"));
621            } else if value.get("query").and_then(Value::as_str).is_none() {
622                return Err(FeatureCompileError::InvalidDocument(operation.to_owned()));
623            }
624        }
625        "insert" | "insert_from" | "update" | "delete" | "upsert" | "allocate_sequence"
626        | "reserve" | "confirm" | "release" => {
627            let entity =
628                entity.ok_or_else(|| FeatureCompileError::InvalidDocument(operation.to_owned()))?;
629            reads.insert(format!("entity:{entity}"));
630            writes.insert(format!("entity:{entity}"));
631            if operation == "allocate_sequence" {
632                let counter = value
633                    .get("counter")
634                    .and_then(Value::as_object)
635                    .ok_or_else(|| {
636                        FeatureCompileError::InvalidDocument("allocate_sequence.counter".to_owned())
637                    })?;
638                let counter_entity =
639                    counter
640                        .get("entity")
641                        .and_then(Value::as_str)
642                        .ok_or_else(|| {
643                            FeatureCompileError::InvalidDocument("counter.entity".to_owned())
644                        })?;
645                let counter_entity = declaration_id(counter_entity)?;
646                reads.insert(format!("entity:{counter_entity}"));
647                writes.insert(format!("entity:{counter_entity}"));
648                if let Some(seed) = counter.get("seed") {
649                    let seed_entity =
650                        seed.get("entity").and_then(Value::as_str).ok_or_else(|| {
651                            FeatureCompileError::InvalidDocument("counter.seed.entity".to_owned())
652                        })?;
653                    reads.insert(format!("entity:{}", declaration_id(seed_entity)?));
654                }
655            }
656        }
657        "assert" | "values" | "idempotency" => {}
658        _ => {
659            return Err(FeatureCompileError::InvalidDocument(format!(
660                "transaction.{operation}"
661            )));
662        }
663    }
664    Ok(())
665}
666
667/// Collects the statically declared read and write entity sets for one transaction.
668fn transaction_effects(transaction: &Value) -> Result<Value, FeatureCompileError> {
669    if let Some(variants) = transaction.get("variants").and_then(Value::as_object) {
670        let selector = transaction
671            .get("select")
672            .and_then(Value::as_str)
673            .ok_or_else(|| FeatureCompileError::InvalidDocument("transaction.select".to_owned()))?;
674        let selector_field = selector
675            .strip_prefix("input.")
676            .ok_or_else(|| FeatureCompileError::InvalidDocument("transaction.select".to_owned()))?;
677        declaration_id(selector_field)?;
678        if variants.is_empty() || variants.len() > 16 {
679            return Err(FeatureCompileError::InvalidDocument(
680                "transaction.variants".to_owned(),
681            ));
682        }
683        let mut reads = BTreeSet::new();
684        let mut writes = BTreeSet::new();
685        for (variant_id, variant) in variants {
686            declaration_id(variant_id)?;
687            let effects = transaction_effects(variant)?;
688            for (key, output) in [("reads", &mut reads), ("writes", &mut writes)] {
689                for effect in effects
690                    .get(key)
691                    .and_then(Value::as_array)
692                    .into_iter()
693                    .flatten()
694                {
695                    output.insert(
696                        effect
697                            .as_str()
698                            .ok_or_else(|| {
699                                FeatureCompileError::InvalidDocument(
700                                    "transaction.effects".to_owned(),
701                                )
702                            })?
703                            .to_owned(),
704                    );
705                }
706            }
707        }
708        return Ok(serde_json::json!({"reads": reads, "writes": writes}));
709    }
710    let mut reads = BTreeSet::new();
711    let mut writes = BTreeSet::new();
712    let object = transaction
713        .as_object()
714        .ok_or_else(|| FeatureCompileError::InvalidDocument("transaction".to_owned()))?;
715    if let Some(steps) = object.get("steps").and_then(Value::as_array) {
716        if steps.is_empty() || steps.len() > 64 {
717            return Err(FeatureCompileError::InvalidDocument(
718                "transaction.steps".to_owned(),
719            ));
720        }
721        for step in steps {
722            let step = step.as_object().ok_or_else(|| {
723                FeatureCompileError::InvalidDocument("transaction.step".to_owned())
724            })?;
725            if step.len() != 1 {
726                return Err(FeatureCompileError::InvalidDocument(
727                    "transaction.step".to_owned(),
728                ));
729            }
730            let (operation, value) = step.iter().next().expect("one transaction step");
731            collect_transaction_effect(operation, value, &mut reads, &mut writes)?;
732        }
733    } else {
734        for (operation, value) in object {
735            collect_transaction_effect(operation, value, &mut reads, &mut writes)?;
736        }
737    }
738    Ok(serde_json::json!({
739        "reads": reads,
740        "writes": writes,
741    }))
742}
743
744/// Compiles one concrete action response into its fragment contract.
745fn compile_response_variant(
746    artifact_root: &Path,
747    feature_id: &str,
748    action_id: &str,
749    response: &Value,
750    queries: &Map<String, Value>,
751    inherited_target: Option<&str>,
752) -> Result<Value, FeatureCompileError> {
753    validate_response_queries(feature_id, queries, response)?;
754    let status = response
755        .get("status")
756        .and_then(Value::as_u64)
757        .unwrap_or(200);
758    if !(200..=599).contains(&status) {
759        return Err(FeatureCompileError::InvalidDocument(format!(
760            "{action_id}.response.status"
761        )));
762    }
763    if response.get("response_kind").and_then(Value::as_str) == Some("redirect") {
764        let redirect = route_path(
765            response
766                .get("fallback")
767                .and_then(|value| value.get("redirect"))
768                .and_then(Value::as_str)
769                .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
770        )?;
771        return Ok(serde_json::json!({
772            "response_kind": "redirect",
773            "fallback": {"redirect": redirect},
774            "status": status,
775        }));
776    }
777    if response.get("response_kind").and_then(Value::as_str) == Some("json") {
778        let surface = declaration_id(
779            response
780                .get("surface")
781                .and_then(Value::as_str)
782                .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
783        )?;
784        let payload_query = declaration_id(
785            response
786                .get("payload_query")
787                .and_then(Value::as_str)
788                .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
789        )?;
790        if !queries.contains_key(&payload_query) {
791            return Err(FeatureCompileError::MissingReference(format!(
792                "{feature_id}:{payload_query}"
793            )));
794        }
795        let mut compiled = serde_json::json!({
796            "response_kind": "json",
797            "surface": surface,
798            "payload_query": payload_query,
799            "status": status,
800        });
801        if let Some(payload_query_before) =
802            response.get("payload_query_before").and_then(Value::as_str)
803        {
804            let payload_query_before = declaration_id(payload_query_before)?;
805            if !queries.contains_key(&payload_query_before) {
806                return Err(FeatureCompileError::MissingReference(format!(
807                    "{feature_id}:{payload_query_before}"
808                )));
809            }
810            compiled["payload_query_before"] = Value::String(payload_query_before);
811        }
812        return Ok(compiled);
813    }
814    let render = declaration_id(
815        response
816            .get("render")
817            .and_then(Value::as_str)
818            .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
819    )?;
820    let fragment_path = find_template_by_stem(artifact_root, &render)?;
821    let fragment_relative = fragment_path
822        .strip_prefix(artifact_root)
823        .map_err(|_| FeatureCompileError::UnsafePath(render.clone()))?
824        .to_string_lossy()
825        .into_owned();
826    let target = declaration_id(
827        response
828            .get("target")
829            .and_then(Value::as_str)
830            .or(inherited_target)
831            .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
832    )?;
833    let fallback = route_path(
834        response
835            .get("fallback")
836            .and_then(|value| value.get("redirect"))
837            .and_then(Value::as_str)
838            .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.to_owned()))?,
839    )?;
840    Ok(serde_json::json!({
841        "response_kind": "fragment",
842        "render": render,
843        "template": fragment_relative,
844        "target": target,
845        "queries": response.get("queries").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
846        "fallback": {"redirect": fallback},
847        "status": status,
848    }))
849}
850
851/// Compiles a direct or validated-input-selected action response contract.
852fn compile_action_response(
853    artifact_root: &Path,
854    feature_id: &str,
855    action_id: &str,
856    response: &Value,
857    queries: &Map<String, Value>,
858) -> Result<Value, FeatureCompileError> {
859    let Some(variants) = response.get("variants").and_then(Value::as_object) else {
860        return compile_response_variant(
861            artifact_root,
862            feature_id,
863            action_id,
864            response,
865            queries,
866            None,
867        );
868    };
869    let selector = response
870        .get("select")
871        .and_then(Value::as_str)
872        .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.response")))?;
873    let selector_field = selector
874        .strip_prefix("input.")
875        .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.response")))?;
876    declaration_id(selector_field)?;
877    let target = declaration_id(
878        response
879            .get("target")
880            .and_then(Value::as_str)
881            .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.response")))?,
882    )?;
883    if variants.is_empty() || variants.len() > 16 {
884        return Err(FeatureCompileError::InvalidDocument(format!(
885            "{action_id}.response.variants"
886        )));
887    }
888    let mut compiled = Map::new();
889    for (variant_id, variant) in variants {
890        declaration_id(variant_id)?;
891        compiled.insert(
892            variant_id.clone(),
893            compile_response_variant(
894                artifact_root,
895                feature_id,
896                action_id,
897                variant,
898                queries,
899                Some(&target),
900            )?,
901        );
902    }
903    Ok(serde_json::json!({
904        "select": selector,
905        "target": target,
906        "variants": compiled,
907    }))
908}
909
910/// Ensures every selected contract variant is reachable from one validated enum input.
911fn validate_action_variants(
912    action_id: &str,
913    action: &Value,
914    contract: &Value,
915) -> Result<(), FeatureCompileError> {
916    let Some(variants) = contract.get("variants").and_then(Value::as_object) else {
917        return Ok(());
918    };
919    let selector = contract
920        .get("select")
921        .and_then(Value::as_str)
922        .and_then(|value| value.strip_prefix("input."))
923        .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.select")))?;
924    declaration_id(selector)?;
925    let rules = action
926        .get("input")
927        .and_then(Value::as_object)
928        .and_then(|fields| fields.get(selector))
929        .ok_or_else(|| FeatureCompileError::MissingReference(format!("{action_id}:{selector}")))?;
930    if rules.get("type").and_then(Value::as_str) != Some("enum") {
931        return Err(FeatureCompileError::InvalidDocument(format!(
932            "{action_id}.{selector}"
933        )));
934    }
935    let values = rules
936        .get("values")
937        .and_then(Value::as_array)
938        .ok_or_else(|| FeatureCompileError::InvalidDocument(format!("{action_id}.{selector}")))?;
939    let declared = values
940        .iter()
941        .map(|value| {
942            value
943                .as_str()
944                .ok_or_else(|| {
945                    FeatureCompileError::InvalidDocument(format!("{action_id}.{selector}"))
946                })
947                .and_then(declaration_id)
948        })
949        .collect::<Result<BTreeSet<_>, _>>()?;
950    let variant_ids = variants.keys().cloned().collect::<BTreeSet<_>>();
951    if declared != variant_ids {
952        return Err(FeatureCompileError::InvalidDocument(format!(
953            "{action_id}.{selector}.variants"
954        )));
955    }
956    Ok(())
957}
958
959/// Compiles one page declaration into a route and generated page specification.
960fn compile_page(
961    artifact_root: &Path,
962    feature_id: &str,
963    page_id: &str,
964    page: &Value,
965    queries: &Map<String, Value>,
966) -> Result<(Value, Value), FeatureCompileError> {
967    let page_id = declaration_id(page_id)?;
968    let path = route_path(
969        page.get("path")
970            .and_then(Value::as_str)
971            .ok_or_else(|| FeatureCompileError::InvalidDocument(page_id.clone()))?,
972    )?;
973    let template = page
974        .get("template")
975        .and_then(Value::as_str)
976        .ok_or_else(|| FeatureCompileError::InvalidDocument(page_id.clone()))?;
977    let template_path = safe_artifact_path(artifact_root, template)?;
978    if !template.starts_with("templates/") || !template_path.is_file() {
979        return Err(FeatureCompileError::MissingReference(template.to_owned()));
980    }
981    let policy = declaration_id(
982        page.get("policy")
983            .and_then(Value::as_str)
984            .unwrap_or("public"),
985    )?;
986    let auth = page
987        .get("auth")
988        .and_then(Value::as_str)
989        .unwrap_or(if policy == "public" {
990            "anonymous_or_session"
991        } else {
992            "session_required"
993        });
994    let response = page
995        .get("response")
996        .cloned()
997        .unwrap_or_else(|| serde_json::json!({}));
998    validate_response_queries(feature_id, queries, &response)?;
999    if let Some(target) = response.get("target").and_then(Value::as_str) {
1000        declaration_id(target)?;
1001    }
1002    let default_route_id = format!("{feature_id}_{page_id}");
1003    let route_id = declaration_id(
1004        page.get("route_id")
1005            .and_then(Value::as_str)
1006            .unwrap_or(&default_route_id),
1007    )?;
1008    let page_spec = serde_json::json!({
1009        "page_id": page_id,
1010        "title": page.get("title").and_then(Value::as_str).unwrap_or(&page_id),
1011        "layout_id": page.get("layout").and_then(Value::as_str).unwrap_or("default_shell"),
1012        "template_file": template,
1013        "template_role": "page_body",
1014        "feature_id": feature_id,
1015    });
1016    let route = serde_json::json!({
1017        "route_id": route_id,
1018        "path": path,
1019        "kind": "page",
1020        "method": "GET",
1021        "page_id": page_id,
1022        "layout_id": page_spec.get("layout_id").cloned().unwrap_or(Value::Null),
1023        "auth_gate": auth,
1024        "policy_gate": policy,
1025        "feature_id": feature_id,
1026        "feature_page": page_id,
1027        "queries": page.get("queries").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
1028        "response_contract": response,
1029    });
1030    Ok((route, page_spec))
1031}
1032
1033/// Compiles one action declaration into a route and lower-level action contract.
1034fn compile_action(
1035    artifact_root: &Path,
1036    feature_id: &str,
1037    action_id: &str,
1038    action: &Value,
1039    queries: &Map<String, Value>,
1040) -> Result<(Value, Value), FeatureCompileError> {
1041    let action_id = declaration_id(action_id)?;
1042    let path = route_path(
1043        action
1044            .get("path")
1045            .and_then(Value::as_str)
1046            .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.clone()))?,
1047    )?;
1048    let method = action
1049        .get("method")
1050        .and_then(Value::as_str)
1051        .unwrap_or("POST")
1052        .to_ascii_uppercase();
1053    if !matches!(method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") {
1054        return Err(FeatureCompileError::InvalidDocument(method));
1055    }
1056    let policy = declaration_id(
1057        action
1058            .get("policy")
1059            .and_then(Value::as_str)
1060            .unwrap_or("public"),
1061    )?;
1062    let response = action
1063        .get("response")
1064        .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.clone()))?;
1065    let transaction = action
1066        .get("transaction")
1067        .ok_or_else(|| FeatureCompileError::InvalidDocument(action_id.clone()))?;
1068    validate_action_variants(&action_id, action, transaction)?;
1069    validate_action_variants(&action_id, action, response)?;
1070    let compiled_response =
1071        compile_action_response(artifact_root, feature_id, &action_id, response, queries)?;
1072    let effects = transaction_effects(transaction)?;
1073    let default_route_id = format!("{feature_id}_{action_id}");
1074    let route_id = declaration_id(
1075        action
1076            .get("route_id")
1077            .and_then(Value::as_str)
1078            .unwrap_or(&default_route_id),
1079    )?;
1080    let route = serde_json::json!({
1081        "route_id": route_id,
1082        "path": path,
1083        "kind": "action",
1084        "method": method,
1085        "action_id": action_id,
1086        "auth_gate": if policy == "public" { "anonymous_or_session" } else { "session_required" },
1087        "policy_gate": policy,
1088        "feature_id": feature_id,
1089        "request_hooks": action
1090            .get("request_hooks")
1091            .cloned()
1092            .unwrap_or_else(|| Value::Array(Vec::new())),
1093    });
1094    let contract = serde_json::json!({
1095        "action_id": action_id,
1096        "policy_gate": policy,
1097        "feature_id": feature_id,
1098        "input_contract": {
1099            "inline_fields": action.get("input").cloned().unwrap_or_else(|| serde_json::json!({})),
1100            "reject_unknown": true,
1101        },
1102        "transaction_contract": transaction,
1103        "response_contract": compiled_response,
1104        "effect_contract": effects,
1105        "guard_contract": {
1106            "guards": action
1107                .get("guards")
1108                .cloned()
1109                .unwrap_or_else(|| Value::Array(Vec::new()))
1110        },
1111        "hook_contract": action
1112            .get("hook_contract")
1113            .cloned()
1114            .unwrap_or_else(|| serde_json::json!({})),
1115    });
1116    Ok((route, contract))
1117}
1118
1119/// Rewrites stable Pharos form attributes into progressive native/HTMX attributes.
1120fn compile_form_attributes(
1121    html: &str,
1122    actions: &BTreeMap<String, (String, String, String)>,
1123    base_path: &str,
1124) -> Result<String, FeatureCompileError> {
1125    let mut output = html.to_owned();
1126    for (action_id, (method, path, _target)) in actions {
1127        let action_attribute = format!(" pharos-action=\"{action_id}\"");
1128        let method_lower = method.to_ascii_lowercase();
1129        let scoped_path = if base_path.is_empty() || base_path == "/" {
1130            path.clone()
1131        } else {
1132            format!("{}{}", base_path.trim_end_matches('/'), path)
1133        };
1134        let replacement = format!(
1135            " method=\"{method_lower}\" action=\"{scoped_path}\" data-pharos-action=\"{scoped_path}\""
1136        );
1137        output = output.replace(&action_attribute, &replacement);
1138    }
1139    output = compile_form_target_attributes(&output)?;
1140    if output.contains(" pharos-action=") || output.contains(" pharos-target=") {
1141        return Err(FeatureCompileError::MissingReference(
1142            "unresolved Pharos form attribute".to_owned(),
1143        ));
1144    }
1145    Ok(output)
1146}
1147
1148/// Rewrites declared Pharos form targets after validating their target IDs.
1149fn compile_form_target_attributes(html: &str) -> Result<String, FeatureCompileError> {
1150    const MARKER: &str = " pharos-target=\"";
1151    let mut remaining = html;
1152    let mut output = String::with_capacity(html.len());
1153    while let Some(position) = remaining.find(MARKER) {
1154        output.push_str(&remaining[..position]);
1155        remaining = &remaining[position + MARKER.len()..];
1156        let Some(end) = remaining.find('"') else {
1157            return Err(FeatureCompileError::MissingReference(
1158                "unresolved Pharos form attribute".to_owned(),
1159            ));
1160        };
1161        let target = &remaining[..end];
1162        let target_id = declaration_id(target)?;
1163        output.push_str(" data-pharos-target=\"");
1164        output.push_str(&target_id);
1165        output.push('"');
1166        remaining = &remaining[end + 1..];
1167    }
1168    output.push_str(remaining);
1169    Ok(output)
1170}
1171
1172/// Rewrites stable Pharos navigation attributes into framework client hooks.
1173fn compile_navigation_attributes(html: &str) -> Result<String, FeatureCompileError> {
1174    let mut output = html.to_owned();
1175    for (source, compiled) in [
1176        (" pharos-nav=\"", " data-pharos-nav=\""),
1177        (" pharos-get=\"", " data-pharos-get=\""),
1178    ] {
1179        output = output.replace(source, compiled);
1180    }
1181    for marker in ["data-pharos-nav=\"", "data-pharos-get=\""] {
1182        for suffix in output.split(marker).skip(1) {
1183            let target = suffix.split('"').next().unwrap_or_default();
1184            declaration_id(target)?;
1185        }
1186    }
1187    Ok(output)
1188}
1189
1190/// Compiles Pharos form attributes in all app-owned artifact templates.
1191fn compile_template_tree(
1192    artifact_root: &Path,
1193    actions: &BTreeMap<String, (String, String, String)>,
1194) -> Result<(), FeatureCompileError> {
1195    let base_path = pharos_runtime_core::config::app_local_conf_path(artifact_root)
1196        .and_then(|path| pharos_runtime_core::config::dynamic_config_lookup_raw(&path, "base_path"))
1197        .filter(|value| value.starts_with('/') && !value.contains(['\r', '\n']))
1198        .unwrap_or_default();
1199    write_json_asset(
1200        artifact_root,
1201        "static/pharos/interaction-v1.js",
1202        INTERACTION_CLIENT.as_bytes(),
1203    )?;
1204    let interaction_version = Sha256::digest(INTERACTION_CLIENT.as_bytes())
1205        .iter()
1206        .take(8)
1207        .map(|byte| format!("{byte:02x}"))
1208        .collect::<String>();
1209    let templates = artifact_root.join("templates");
1210    let mut pending = vec![(templates, 0usize)];
1211    let mut visited = 0usize;
1212    while let Some((directory, depth)) = pending.pop() {
1213        if depth > MAX_TEMPLATE_DEPTH {
1214            return Err(FeatureCompileError::InvalidDocument(
1215                "template depth exceeded".to_owned(),
1216            ));
1217        }
1218        for entry in fs::read_dir(&directory)
1219            .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?
1220        {
1221            let entry = entry
1222                .map_err(|_| FeatureCompileError::Io(directory.to_string_lossy().into_owned()))?;
1223            let path = entry.path();
1224            let metadata = fs::symlink_metadata(&path)
1225                .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
1226            if metadata.file_type().is_symlink() {
1227                return Err(FeatureCompileError::UnsafePath(
1228                    path.to_string_lossy().into_owned(),
1229                ));
1230            }
1231            if metadata.is_dir() {
1232                pending.push((path, depth + 1));
1233                continue;
1234            }
1235            if !metadata.is_file()
1236                || path.extension().and_then(|value| value.to_str()) != Some("html")
1237            {
1238                continue;
1239            }
1240            visited += 1;
1241            if visited > MAX_TEMPLATE_FILES {
1242                return Err(FeatureCompileError::InvalidDocument(
1243                    "too many template files".to_owned(),
1244                ));
1245            }
1246            let source = fs::read_to_string(&path)
1247                .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
1248            let mut compiled = compile_form_attributes(&source, actions, &base_path)?;
1249            compiled = compile_navigation_attributes(&compiled)?;
1250            if path
1251                .components()
1252                .any(|component| component.as_os_str() == "layout")
1253                && !compiled.contains("/static/pharos/interaction-v1.js")
1254                && let Some(position) = compiled.rfind("</body>")
1255            {
1256                let source = format!(
1257                    "{}/static/pharos/interaction-v1.js?v={interaction_version}",
1258                    base_path.trim_end_matches('/'),
1259                );
1260                compiled.insert_str(
1261                    position,
1262                    &format!("  <script src=\"{source}\" defer></script>\n"),
1263                );
1264            }
1265            if compiled != source {
1266                pharos_runtime_core::fs::write_file_within(
1267                    artifact_root,
1268                    &path,
1269                    compiled.as_bytes(),
1270                    0o644,
1271                )
1272                .map_err(|_| FeatureCompileError::Io(path.to_string_lossy().into_owned()))?;
1273            }
1274        }
1275    }
1276    Ok(())
1277}
1278
1279/// Writes one framework-owned browser asset inside the built artifact.
1280fn write_json_asset(
1281    artifact_root: &Path,
1282    relative: &str,
1283    bytes: &[u8],
1284) -> Result<(), FeatureCompileError> {
1285    let path = safe_artifact_path(artifact_root, relative)?;
1286    let parent = path
1287        .parent()
1288        .ok_or_else(|| FeatureCompileError::UnsafePath(relative.to_owned()))?;
1289    fs::create_dir_all(parent)
1290        .map_err(|_| FeatureCompileError::Io(parent.to_string_lossy().into_owned()))?;
1291    pharos_runtime_core::fs::write_file_within(artifact_root, &path, bytes, 0o644)
1292        .map_err(|_| FeatureCompileError::Io(relative.to_owned()))
1293}
1294
1295/// Compiles coherent feature sources into validated runtime contracts.
1296///
1297/// # Parameters
1298///
1299/// - `artifact_root`: Input consumed by [`crate::features::compile_feature_sources`].
1300///
1301/// # Returns
1302///
1303/// Returns the successful value produced by [`crate::features::compile_feature_sources`] or an error when the operation fails.
1304///
1305/// # Errors
1306///
1307/// Returns an error value when [`crate::features::compile_feature_sources`] cannot complete successfully.
1308///
1309/// # Performance
1310///
1311/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::features::compile_feature_sources`].
1312///
1313/// # Examples
1314///
1315/// ```rust,ignore
1316/// let _result = pharos_runtime_compiler::features::compile_feature_sources(todo!());
1317/// ```
1318///
1319/// # Related
1320///
1321/// - [`crate::features`] for adjacent helpers that share the same runtime surface.
1322pub fn compile_feature_sources(artifact_root: &Path) -> Result<usize, FeatureCompileError> {
1323    let paths = feature_paths(artifact_root)?;
1324    write_json_asset(
1325        artifact_root,
1326        "static/pharos/editable-grouping-v1.css",
1327        include_bytes!("../assets/editable-grouping-v1.css"),
1328    )?;
1329    if paths.is_empty() {
1330        return Ok(0);
1331    }
1332    let mut routes = load_contract_or_empty(artifact_root, "routes", "routes")?;
1333    let mut actions = load_contract_or_empty(artifact_root, "actions", "actions")?;
1334    let route_rows = routes
1335        .get_mut("routes")
1336        .and_then(Value::as_array_mut)
1337        .ok_or_else(|| FeatureCompileError::InvalidDocument("routes".to_owned()))?;
1338    let action_rows = actions
1339        .get_mut("actions")
1340        .and_then(Value::as_array_mut)
1341        .ok_or_else(|| FeatureCompileError::InvalidDocument("actions".to_owned()))?;
1342    let mut route_ids = route_rows
1343        .iter()
1344        .filter_map(|row| row.get("route_id").and_then(Value::as_str))
1345        .map(ToOwned::to_owned)
1346        .collect::<BTreeSet<_>>();
1347    let mut action_ids = action_rows
1348        .iter()
1349        .filter_map(|row| row.get("action_id").and_then(Value::as_str))
1350        .map(ToOwned::to_owned)
1351        .collect::<BTreeSet<_>>();
1352    let mut feature_ids = BTreeSet::new();
1353    let mut compiled_features = Vec::new();
1354    let mut compiled_bundles = Vec::new();
1355    let mut form_actions = BTreeMap::new();
1356    let mut protected_assets = Vec::new();
1357
1358    for route in route_rows.iter() {
1359        let Some(action_id) = route.get("action_id").and_then(Value::as_str) else {
1360            continue;
1361        };
1362        let method = route
1363            .get("method")
1364            .and_then(Value::as_str)
1365            .unwrap_or("POST")
1366            .to_owned();
1367        let path = route
1368            .get("path")
1369            .and_then(Value::as_str)
1370            .unwrap_or_default()
1371            .to_owned();
1372        form_actions.insert(action_id.to_owned(), (method, path, String::new()));
1373    }
1374
1375    for path in paths {
1376        let feature_documents = feature_source_documents(&path, load_json_object(&path)?)?;
1377        for feature in feature_documents {
1378            if feature.get("contract_version").and_then(Value::as_str) != Some(CONTRACT_VERSION) {
1379                return Err(FeatureCompileError::InvalidDocument(
1380                    path.to_string_lossy().into_owned(),
1381                ));
1382            }
1383            let feature_id = declaration_id(
1384                feature
1385                    .get("feature_id")
1386                    .and_then(Value::as_str)
1387                    .ok_or_else(|| {
1388                        FeatureCompileError::InvalidDocument(path.to_string_lossy().into_owned())
1389                    })?,
1390            )?;
1391            if !feature_ids.insert(feature_id.clone()) {
1392                return Err(FeatureCompileError::DuplicateId(feature_id));
1393            }
1394            let queries = declaration_map(&feature, "queries")?;
1395            for query_id in queries.keys() {
1396                declaration_id(query_id)?;
1397            }
1398            let (pages, feature_actions, bundle_summaries) =
1399                feature_pages_and_actions(&feature_id, &feature)?;
1400            compiled_bundles.extend(bundle_summaries);
1401            for (page_id, page) in &pages {
1402                let (route, page_spec) =
1403                    compile_page(artifact_root, &feature_id, page_id, page, queries)?;
1404                let route_id = route
1405                    .get("route_id")
1406                    .and_then(Value::as_str)
1407                    .unwrap_or_default();
1408                if !route_ids.insert(route_id.to_owned()) {
1409                    return Err(FeatureCompileError::DuplicateId(route_id.to_owned()));
1410                }
1411                let page_id = page_spec
1412                    .get("page_id")
1413                    .and_then(Value::as_str)
1414                    .unwrap_or_default();
1415                write_json(
1416                    artifact_root,
1417                    &format!("templates/.pharos-generated/{page_id}.page.json"),
1418                    &page_spec,
1419                )?;
1420                route_rows.push(route);
1421            }
1422            for (action_id, action) in &feature_actions {
1423                let (route, contract) =
1424                    compile_action(artifact_root, &feature_id, action_id, action, queries)?;
1425                if !action_ids.insert(action_id.to_owned()) {
1426                    return Err(FeatureCompileError::DuplicateId(action_id.to_owned()));
1427                }
1428                let route_enabled = action.get("route").and_then(Value::as_bool).unwrap_or(true);
1429                if route_enabled {
1430                    let route_id = route
1431                        .get("route_id")
1432                        .and_then(Value::as_str)
1433                        .unwrap_or_default();
1434                    if !route_ids.insert(route_id.to_owned()) {
1435                        return Err(FeatureCompileError::DuplicateId(route_id.to_owned()));
1436                    }
1437                    let method = route
1438                        .get("method")
1439                        .and_then(Value::as_str)
1440                        .unwrap_or("POST")
1441                        .to_owned();
1442                    let path = route
1443                        .get("path")
1444                        .and_then(Value::as_str)
1445                        .unwrap_or_default()
1446                        .to_owned();
1447                    let target = contract
1448                        .get("response_contract")
1449                        .and_then(|value| value.get("target"))
1450                        .and_then(Value::as_str)
1451                        .unwrap_or_default()
1452                        .to_owned();
1453                    form_actions.insert(action_id.to_owned(), (method, path, target));
1454                    route_rows.push(route);
1455                }
1456                action_rows.push(contract);
1457            }
1458            for (asset_id, declaration) in declaration_map(&feature, "protected_assets")? {
1459                let asset_id = declaration_id(asset_id)?;
1460                let path = route_path(
1461                    declaration
1462                        .get("path")
1463                        .and_then(Value::as_str)
1464                        .ok_or_else(|| FeatureCompileError::InvalidDocument(asset_id.clone()))?,
1465                )?;
1466                for field in ["entity", "key_field", "owner_field", "asset_field"] {
1467                    declaration_id(
1468                        declaration
1469                            .get(field)
1470                            .and_then(Value::as_str)
1471                            .ok_or_else(|| {
1472                                FeatureCompileError::InvalidDocument(asset_id.clone())
1473                            })?,
1474                    )?;
1475                }
1476                let root = declaration
1477                    .get("root")
1478                    .and_then(Value::as_str)
1479                    .ok_or_else(|| FeatureCompileError::InvalidDocument(asset_id.clone()))?;
1480                let root_path = safe_artifact_path(artifact_root, root)?;
1481                if !root.starts_with("private/") || !root_path.is_dir() {
1482                    return Err(FeatureCompileError::UnsafePath(root.to_owned()));
1483                }
1484                if let Some(on_authorized) = declaration.get("on_authorized") {
1485                    transaction_effects(on_authorized)?;
1486                }
1487                protected_assets.push(serde_json::json!({
1488                    "asset_id": asset_id,
1489                    "feature_id": feature_id,
1490                    "path": path,
1491                    "entity": declaration.get("entity"),
1492                    "key_field": declaration.get("key_field"),
1493                    "owner_field": declaration.get("owner_field"),
1494                    "asset_field": declaration.get("asset_field"),
1495                    "root": root,
1496                    "on_authorized": declaration.get("on_authorized"),
1497                }));
1498            }
1499            compiled_features.push(feature);
1500        }
1501    }
1502
1503    if !action_rows.is_empty() {
1504        actions["effect_policy"] = serde_json::json!({"deny_undeclared": true});
1505    }
1506    write_json(artifact_root, "data/contracts/routes.json", &routes)?;
1507    write_json(artifact_root, "data/contracts/actions.json", &actions)?;
1508    write_json(
1509        artifact_root,
1510        "data/contracts/features.json",
1511        &serde_json::json!({
1512            "contract_version": CONTRACT_VERSION,
1513            "features": compiled_features,
1514        }),
1515    )?;
1516    if !compiled_bundles.is_empty() {
1517        write_json(
1518            artifact_root,
1519            "data/contracts/feature-bundles.json",
1520            &serde_json::json!({
1521                "contract_version": CONTRACT_VERSION,
1522                "bundles": compiled_bundles,
1523            }),
1524        )?;
1525    }
1526    if !protected_assets.is_empty() {
1527        write_json(
1528            artifact_root,
1529            "data/contracts/protected-assets.json",
1530            &serde_json::json!({
1531                "contract_version": CONTRACT_VERSION,
1532                "assets": protected_assets,
1533            }),
1534        )?;
1535    }
1536    compile_template_tree(artifact_root, &form_actions)?;
1537    Ok(compiled_features.len())
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542    use std::time::{SystemTime, UNIX_EPOCH};
1543
1544    use super::{compile_feature_sources, compile_form_attributes};
1545
1546    /// Creates one unique test root without introducing another test dependency.
1547    fn test_root(label: &str) -> std::path::PathBuf {
1548        let nonce = SystemTime::now()
1549            .duration_since(UNIX_EPOCH)
1550            .expect("clock")
1551            .as_nanos();
1552        std::env::temp_dir().join(format!(
1553            "pharos-feature-{label}-{}-{nonce}",
1554            std::process::id()
1555        ))
1556    }
1557
1558    /// Writes one fixture file and all required parent directories.
1559    fn write(root: &std::path::Path, relative: &str, content: &str) {
1560        let path = root.join(relative);
1561        std::fs::create_dir_all(path.parent().expect("parent")).expect("directories");
1562        std::fs::write(path, content).expect("fixture");
1563    }
1564
1565    #[test]
1566    /// Performs compiles one feature into routes actions pages and forms.
1567    fn compiles_one_feature_into_routes_actions_pages_and_forms() {
1568        let root = test_root("compile");
1569        write(
1570            &root,
1571            "data/contracts/routes.json",
1572            r#"{"contract_version":"1.0","security_defaults":{"csrf_mode":"mutation_required","trusted_origin_policy":"mutation_required","tenant_scope":"policy_bound","rate_limit_policy":"runtime_default","capability_policy":"policy_gate"},"routes":[{"route_id":"account_login_submit","path":"/accounts/login/","kind":"action","method":"POST","action_id":"login","auth_gate":"anonymous_or_session","policy_gate":"account_login"}]}"#,
1573        );
1574        write(
1575            &root,
1576            "data/contracts/actions.json",
1577            r#"{"contract_version":"1.0","actions":[{"action_id":"login","policy_gate":"account_login"}]}"#,
1578        );
1579        write(
1580            &root,
1581            "templates/storefront.body.html",
1582            r#"<a href="/cart" pharos-nav="app-main">Cart</a><form method="get" action="/" pharos-get="app-main"><button>Filter</button></form><form pharos-action="login"><button>Log in</button></form><form pharos-action="add_item" pharos-target="cart-panel"><button>Add</button></form>"#,
1583        );
1584        write(
1585            &root,
1586            "templates/fragments/cart_summary.html",
1587            r#"<aside id="cart-panel">{cart_total}</aside>"#,
1588        );
1589        write(
1590            &root,
1591            "data/features/cart.json",
1592            r#"{
1593              "contract_version":"1.0",
1594              "feature_id":"cart",
1595              "queries":{"current_cart":{"entity":"shopping_cart","result":"one"}},
1596              "bundles":{
1597                "cart_flow":{
1598                  "kind":"list_create",
1599                  "policy":"buy",
1600                  "target":"cart-panel",
1601                  "fallback_redirect":"/",
1602                  "list":{
1603                    "page_id":"storefront",
1604                    "path":"/",
1605                    "template":"templates/storefront.body.html",
1606                    "title":"Store",
1607                    "queries":["current_cart"],
1608                    "response":{"target":"app-main","history":"push","queries":["current_cart"]}
1609                  },
1610                  "create":{
1611                    "action_id":"add_item",
1612                    "path":"/cart/items",
1613                    "method":"POST",
1614                    "input":{"product_id":{"type":"integer","required":true}},
1615                    "transaction":{"upsert":{"entity":"shopping_cart_item","unique_by":["cart_id","product_id"],"values":{"product_id":"input.product_id"}}},
1616                    "render":"cart_summary",
1617                    "queries":["current_cart"]
1618                  }
1619                }
1620              }
1621            }"#,
1622        );
1623
1624        assert_eq!(compile_feature_sources(&root), Ok(1));
1625        let routes =
1626            std::fs::read_to_string(root.join("data/contracts/routes.json")).expect("routes");
1627        let actions =
1628            std::fs::read_to_string(root.join("data/contracts/actions.json")).expect("actions");
1629        let bundles = std::fs::read_to_string(root.join("data/contracts/feature-bundles.json"))
1630            .expect("bundle ledger");
1631        let template =
1632            std::fs::read_to_string(root.join("templates/storefront.body.html")).expect("template");
1633        assert!(routes.contains("cart_add_item"));
1634        assert!(actions.contains("transaction_contract"));
1635        assert!(bundles.contains("\"bundle_id\": \"cart_flow\""));
1636        assert!(bundles.contains("\"action_id\": \"add_item\""));
1637        assert!(bundles.contains("\"target\": \"cart-panel\""));
1638        assert!(template.contains("action=\"/accounts/login/\""));
1639        assert!(template.contains("data-pharos-action=\"/cart/items\""));
1640        assert!(template.contains("data-pharos-target=\"cart-panel\""));
1641        assert!(template.contains("data-pharos-nav=\"app-main\""));
1642        assert!(template.contains("data-pharos-get=\"app-main\""));
1643        let interaction_client =
1644            std::fs::read_to_string(root.join("static/pharos/interaction-v1.js"))
1645                .expect("interaction client");
1646        assert!(interaction_client.contains("response.headers.get(\"HX-Redirect\")"));
1647        assert!(interaction_client.contains("location.assign(hxRedirect)"));
1648        assert!(
1649            root.join("templates/.pharos-generated/storefront.page.json")
1650                .is_file()
1651        );
1652        std::fs::remove_dir_all(root).expect("cleanup");
1653    }
1654
1655    #[test]
1656    /// Performs rewrites safe form targets even when the route map lacks target metadata.
1657    fn rewrites_safe_form_targets_independently_from_action_targets() {
1658        let mut actions = std::collections::BTreeMap::new();
1659        actions.insert(
1660            "add_item".to_owned(),
1661            ("POST".to_owned(), "/items".to_owned(), String::new()),
1662        );
1663        let template = compile_form_attributes(
1664            r#"<form pharos-action="add_item" pharos-target="item-panel"><button>Add</button></form>"#,
1665            &actions,
1666            "",
1667        )
1668        .expect("compiled form");
1669
1670        assert!(template.contains(r#"data-pharos-action="/items""#));
1671        assert!(template.contains(r#"data-pharos-target="item-panel""#));
1672        assert!(!template.contains(" pharos-target="));
1673    }
1674
1675    #[test]
1676    /// Performs rejects unresolved fragment targets.
1677    fn rejects_unresolved_fragment_targets() {
1678        let root = test_root("missing-fragment");
1679        write(
1680            &root,
1681            "data/contracts/routes.json",
1682            r#"{"contract_version":"1.0","routes":[]}"#,
1683        );
1684        write(&root, "templates/storefront.body.html", "<main></main>");
1685        write(
1686            &root,
1687            "data/features/cart.json",
1688            r#"{"contract_version":"1.0","feature_id":"cart","queries":{},"actions":{"add_item":{"path":"/cart/items","method":"POST","transaction":{"insert":{"entity":"item","values":{}}},"response":{"render":"missing","target":"cart","queries":[],"fallback":{"redirect":"/"}}}}}"#,
1689        );
1690        assert!(compile_feature_sources(&root).is_err());
1691        std::fs::remove_dir_all(root).expect("cleanup");
1692    }
1693
1694    #[test]
1695    /// Accepts a declared redirect for a mutation that has no fragment response.
1696    fn compiles_redirect_action_response() {
1697        let compiled = super::compile_action_response(
1698            std::path::Path::new("."),
1699            "business",
1700            "create_service",
1701            &serde_json::json!({
1702                "response_kind": "redirect",
1703                "fallback": {"redirect": "/businesses/"}
1704            }),
1705            &serde_json::Map::new(),
1706        )
1707        .expect("compile app-owned redirect");
1708        assert_eq!(compiled["response_kind"], "redirect");
1709        assert_eq!(compiled["fallback"]["redirect"], "/businesses/");
1710    }
1711
1712    #[test]
1713    /// Performs expands crud bundle with deterministic ids and overrides.
1714    fn expands_crud_bundle_with_deterministic_ids_and_overrides() {
1715        let root = test_root("crud-bundle");
1716        write(
1717            &root,
1718            "data/contracts/routes.json",
1719            r#"{"contract_version":"1.0","routes":[]}"#,
1720        );
1721        write(&root, "templates/tasks.body.html", "<main></main>");
1722        write(&root, "templates/task.body.html", "<main></main>");
1723        write(&root, "templates/task-edit.body.html", "<main></main>");
1724        write(
1725            &root,
1726            "templates/fragments/task_list.html",
1727            r#"<section id="task-panel"></section>"#,
1728        );
1729        write(
1730            &root,
1731            "data/features/tasks.json",
1732            r#"{
1733              "contract_version":"1.0",
1734              "feature_id":"tasks",
1735              "queries":{"task_list":{"entity":"task","result":"many"}},
1736              "bundles":{
1737                "task_flow":{
1738                  "kind":"crud",
1739                  "layout":"default_shell",
1740                  "policy":"public",
1741                  "target":"task-panel",
1742                  "fallback_redirect":"/tasks",
1743                  "list":{"path":"/tasks","template":"templates/tasks.body.html","title":"Tasks","queries":["task_list"]},
1744                  "detail":{"path":"/tasks/:task_id","template":"templates/task.body.html","title":"Task detail"},
1745                  "edit":{"path":"/tasks/:task_id/edit","template":"templates/task-edit.body.html","title":"Edit task"},
1746                  "create":{
1747                    "path":"/tasks",
1748                    "input":{"title":{"type":"string","required":true}},
1749                    "transaction":{"insert":{"entity":"task","values":{"title":"input.title"}}},
1750                    "render":"task_list",
1751                    "queries":["task_list"]
1752                  },
1753                  "update":{
1754                    "action_id":"save_task",
1755                    "path":"/tasks/:task_id",
1756                    "method":"PATCH",
1757                    "input":{"title":{"type":"string","required":true}},
1758                    "transaction":{"update":{"entity":"task","filters":{"id":"path.task_id"},"values":{"title":"input.title"}}},
1759                    "response":{"render":"task_list","target":"task-panel","queries":["task_list"],"fallback":{"redirect":"/tasks"}}
1760                  }
1761                }
1762              }
1763            }"#,
1764        );
1765        assert_eq!(compile_feature_sources(&root), Ok(1));
1766        let routes =
1767            std::fs::read_to_string(root.join("data/contracts/routes.json")).expect("routes");
1768        let actions =
1769            std::fs::read_to_string(root.join("data/contracts/actions.json")).expect("actions");
1770        let bundles = std::fs::read_to_string(root.join("data/contracts/feature-bundles.json"))
1771            .expect("bundles");
1772        assert!(routes.contains("\"route_id\": \"tasks_task_flow_list\""));
1773        assert!(routes.contains("\"route_id\": \"tasks_task_flow_detail\""));
1774        assert!(routes.contains("\"route_id\": \"tasks_task_flow_edit\""));
1775        assert!(routes.contains("\"route_id\": \"tasks_save_task\""));
1776        assert!(actions.contains("\"action_id\": \"task_flow_create\""));
1777        assert!(actions.contains("\"action_id\": \"save_task\""));
1778        assert!(bundles.contains("\"bundle_kind\": \"crud\""));
1779        assert!(bundles.contains("\"page_id\": \"task_flow_list\""));
1780        assert!(bundles.contains("\"action_id\": \"save_task\""));
1781        std::fs::remove_dir_all(root).expect("cleanup");
1782    }
1783}