Skip to main content

greentic_deployer/environment/
messaging.rs

1//! Messaging-endpoint projection producer (`Phase M1.2`).
2//!
3//! `environment.json` is the source of truth; per-endpoint files under
4//! `<env_dir>/messaging/` are derived projections rebuilt on every mutation.
5//! Mirrors the [`runtime_config`](super::runtime_config) pattern: a single
6//! pure projector + a [`Locked::refresh_messaging_projection`] helper that
7//! reconciles disk against the just-saved env inside the same transaction.
8//!
9//! Per-endpoint file: `<env_dir>/messaging/<endpoint_id>.json`. Each endpoint
10//! is written verbatim so external tooling can read one endpoint without
11//! parsing the full `environment.json`. An index file
12//! `<env_dir>/messaging/index.json` carries a stable list of `(endpoint_id,
13//! provider_type, provider_id, display_name)` tuples for quick enumeration.
14//!
15//! `refresh_messaging_projection` removes per-endpoint files for ids no
16//! longer in the env so the directory tracks the source-of-truth set exactly.
17
18use greentic_deploy_spec::{Environment, MessagingEndpoint, MessagingEndpointId};
19use serde::{Deserialize, Serialize};
20
21/// One row of the on-disk `messaging/index.json` projection.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct MessagingEndpointIndexEntry {
24    pub endpoint_id: MessagingEndpointId,
25    pub provider_type: String,
26    pub provider_id: String,
27    pub display_name: String,
28}
29
30impl MessagingEndpointIndexEntry {
31    pub fn from(endpoint: &MessagingEndpoint) -> Self {
32        Self {
33            endpoint_id: endpoint.endpoint_id,
34            provider_type: endpoint.provider_type.clone(),
35            provider_id: endpoint.provider_id.clone(),
36            display_name: endpoint.display_name.clone(),
37        }
38    }
39}
40
41/// Materialize the `messaging/index.json` projection. Pure and total: one
42/// row per endpoint in `Environment.messaging_endpoints` order. An env with
43/// no endpoints yields an empty list (callers delete the file instead of
44/// writing an empty array, mirroring the runtime-config "absence is the
45/// nothing-live signal" precedent).
46pub fn materialize_messaging_index(env: &Environment) -> Vec<MessagingEndpointIndexEntry> {
47    env.messaging_endpoints
48        .iter()
49        .map(MessagingEndpointIndexEntry::from)
50        .collect()
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use chrono::Utc;
57    use greentic_deploy_spec::{
58        EnvId, EnvironmentHostConfig, MessagingEndpoint, MessagingEndpointId, SchemaVersion,
59    };
60
61    fn endpoint(provider_type: &str, provider_id: &str) -> MessagingEndpoint {
62        MessagingEndpoint {
63            schema: SchemaVersion::new(SchemaVersion::MESSAGING_ENDPOINT_V1),
64            env_id: EnvId::try_from("local").unwrap(),
65            endpoint_id: MessagingEndpointId::new(),
66            provider_id: provider_id.into(),
67            provider_type: provider_type.into(),
68            display_name: format!("{provider_type}: {provider_id}"),
69            secret_refs: vec![],
70            webhook_secret_ref: None,
71            linked_bundles: vec![],
72            welcome_flow: None,
73            generation: 1,
74            created_at: Utc::now(),
75            updated_at: Utc::now(),
76            updated_by: "operator://test".into(),
77        }
78    }
79
80    fn env(endpoints: Vec<MessagingEndpoint>) -> Environment {
81        let env_id = EnvId::try_from("local").unwrap();
82        Environment {
83            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
84            environment_id: env_id.clone(),
85            name: "local".into(),
86            host_config: EnvironmentHostConfig {
87                env_id,
88                region: None,
89                tenant_org_id: None,
90                listen_addr: None,
91                public_base_url: None,
92                gui_enabled: None,
93            },
94            packs: vec![],
95            credentials_ref: None,
96            bundles: vec![],
97            revisions: vec![],
98            traffic_splits: vec![],
99            messaging_endpoints: endpoints,
100            extensions: Vec::new(),
101            revocation: Default::default(),
102            retention: Default::default(),
103            health: Default::default(),
104        }
105    }
106
107    #[test]
108    fn empty_env_yields_empty_index() {
109        assert!(materialize_messaging_index(&env(vec![])).is_empty());
110    }
111
112    #[test]
113    fn index_preserves_endpoint_order() {
114        let legal = endpoint("teams", "legal-bot");
115        let accounting = endpoint("teams", "accounting-bot");
116        let index = materialize_messaging_index(&env(vec![legal.clone(), accounting.clone()]));
117        assert_eq!(index.len(), 2);
118        assert_eq!(index[0].endpoint_id, legal.endpoint_id);
119        assert_eq!(index[1].endpoint_id, accounting.endpoint_id);
120    }
121
122    #[test]
123    fn index_carries_human_label() {
124        let ep = endpoint("teams", "legal-bot");
125        let index = materialize_messaging_index(&env(vec![ep.clone()]));
126        assert_eq!(index[0].display_name, ep.display_name);
127        assert_eq!(index[0].provider_type, "teams");
128        assert_eq!(index[0].provider_id, "legal-bot");
129    }
130}