1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use clap::{Args, Subcommand};
6use greentic_types::pack::extensions::capabilities::{
7 CapabilityHookAppliesToV1, CapabilityOfferV1, CapabilityProviderRefV1, CapabilitySetupV1,
8};
9use greentic_types::provider::{ProviderDecl, ProviderRuntimeRef};
10use serde_json::{Value as JsonValue, json};
11use serde_yaml_bw::{self, Mapping, Sequence, Value as YamlValue};
12use walkdir::WalkDir;
13
14use crate::config::PackConfig;
15use crate::extension_refs::{
16 ExtensionDependency, ExtensionDependencySource, PackExtensionsFile,
17 default_extensions_file_path, infer_reference_kind, read_extensions_file,
18 write_extensions_file,
19};
20
21pub const PROVIDER_RUNTIME_WORLD: &str = "greentic:provider/schema-core@1.0.0";
22const PROVIDER_EXTENSION_KEY: &str = "greentic.provider-extension.v1";
23const PROVIDER_EXTENSION_PATH: [&str; 3] = ["greentic", "provider-extension", "v1"];
24const CAPABILITIES_EXTENSION_KEY: &str = "greentic.ext.capabilities.v1";
25const DEPLOYER_EXTENSION_KEY: &str = "greentic.deployer.v1";
26
27#[derive(Debug, Subcommand)]
28pub enum AddExtensionCommand {
29 Provider(ProviderArgs),
31 Capability(CapabilityArgs),
33 Deployer(DeployerArgs),
35 Dependency(DependencyArgs),
37}
38
39#[derive(Debug, Args)]
40pub struct ProviderArgs {
41 #[arg(long = "pack-dir", value_name = "DIR")]
43 pub pack_dir: PathBuf,
44
45 #[arg(long)]
47 pub dry_run: bool,
48
49 #[arg(long = "id", value_name = "PROVIDER_ID")]
51 pub provider_id: String,
52
53 #[arg(long = "kind", value_name = "KIND")]
55 pub kind: String,
56
57 #[arg(long, value_name = "TITLE")]
59 pub title: Option<String>,
60
61 #[arg(long, value_name = "DESCRIPTION")]
63 pub description: Option<String>,
64 #[arg(long = "validator-ref", value_name = "VALIDATOR_REF")]
66 pub validator_ref: Option<String>,
67 #[arg(long = "validator-digest", value_name = "DIGEST")]
69 pub validator_digest: Option<String>,
70
71 #[arg(long = "route", value_name = "ROUTE")]
73 pub route: Option<String>,
74
75 #[arg(long = "flow", value_name = "FLOW")]
77 pub flow: Option<String>,
78}
79
80#[derive(Debug, Args)]
81pub struct CapabilityArgs {
82 #[arg(long = "pack-dir", value_name = "DIR")]
84 pub pack_dir: PathBuf,
85
86 #[arg(long)]
88 pub dry_run: bool,
89
90 #[arg(long = "offer-id", value_name = "ID")]
92 pub offer_id: String,
93
94 #[arg(long = "cap-id", value_name = "CAP_ID")]
96 pub cap_id: String,
97
98 #[arg(long, default_value = "v1")]
100 pub version: String,
101
102 #[arg(long = "component-ref", value_name = "COMPONENT")]
104 pub component_ref: String,
105
106 #[arg(long = "op", value_name = "OP")]
108 pub op: String,
109
110 #[arg(long, default_value_t = 0)]
112 pub priority: i32,
113
114 #[arg(long = "requires-setup", default_value_t = false)]
116 pub requires_setup: bool,
117
118 #[arg(long = "qa-ref", value_name = "REF")]
120 pub qa_ref: Option<String>,
121
122 #[arg(long = "hook-op-name", value_name = "OP_NAME")]
124 pub hook_op_names: Vec<String>,
125}
126
127#[derive(Debug, Args)]
128pub struct DeployerArgs {
129 #[arg(long = "pack-dir", value_name = "DIR")]
131 pub pack_dir: PathBuf,
132
133 #[arg(long)]
135 pub dry_run: bool,
136
137 #[arg(long = "contract-id", value_name = "CONTRACT")]
139 pub contract_id: String,
140
141 #[arg(long = "op", value_name = "OP")]
143 pub ops: Vec<String>,
144
145 #[arg(long = "flow-ref", value_name = "OP=PATH")]
147 pub flow_refs: Vec<String>,
148}
149
150#[derive(Debug, Args)]
151pub struct DependencyArgs {
152 #[arg(long = "pack-dir", value_name = "DIR")]
154 pub pack_dir: PathBuf,
155
156 #[arg(long)]
158 pub dry_run: bool,
159
160 #[arg(long = "id", value_name = "ID")]
162 pub id: String,
163
164 #[arg(long = "role", value_name = "ROLE")]
166 pub role: String,
167
168 #[arg(long = "ref", value_name = "REF")]
170 pub reference: String,
171
172 #[arg(long = "allow-tags", default_value_t = false)]
174 pub allow_tags: bool,
175}
176
177#[derive(Debug, Clone)]
178pub(crate) struct CapabilityOfferSpec {
179 pub offer_id: String,
180 pub cap_id: String,
181 pub version: String,
182 pub component_ref: String,
183 pub op: String,
184 pub priority: i32,
185 pub requires_setup: bool,
186 pub qa_ref: Option<String>,
187 pub hook_op_names: Vec<String>,
188}
189
190pub fn handle(command: AddExtensionCommand) -> Result<()> {
191 match command {
192 AddExtensionCommand::Provider(args) => handle_provider(args),
193 AddExtensionCommand::Capability(args) => handle_capability(args),
194 AddExtensionCommand::Deployer(args) => handle_deployer(args),
195 AddExtensionCommand::Dependency(args) => handle_dependency(args),
196 }
197}
198
199fn handle_provider(args: ProviderArgs) -> Result<()> {
200 eprintln!(
201 "note: provider extension updates use the legacy schema-core path (`greentic:provider/schema-core@1.0.0`)"
202 );
203 edit_pack_dir(&args.pack_dir, &args)?;
204 Ok(())
205}
206
207fn handle_capability(args: CapabilityArgs) -> Result<()> {
208 let root = normalize_root(&args.pack_dir)?;
209 let pack_yaml = root.join("pack.yaml");
210 let (_, contents) = read_pack_yaml(&pack_yaml)?;
211 let updated_yaml = inject_capability_offer_spec(&contents, &args.to_spec()?)?;
212
213 if args.dry_run {
214 println!("--- dry-run: updated pack.yaml ---");
215 println!("{updated_yaml}");
216 return Ok(());
217 }
218
219 fs::write(&pack_yaml, updated_yaml)
220 .with_context(|| format!("write {}", pack_yaml.display()))?;
221 println!("capabilities extension updated in {}", pack_yaml.display());
222 Ok(())
223}
224
225fn handle_deployer(args: DeployerArgs) -> Result<()> {
226 let root = normalize_root(&args.pack_dir)?;
227 let pack_yaml = root.join("pack.yaml");
228 let (_, contents) = read_pack_yaml(&pack_yaml)?;
229 let payload = args.to_payload()?;
230 let updated_yaml = inject_deployer_extension_payload(&contents, &payload)?;
231
232 if args.dry_run {
233 println!("--- dry-run: updated pack.yaml ---");
234 println!("{updated_yaml}");
235 return Ok(());
236 }
237
238 fs::write(&pack_yaml, updated_yaml)
239 .with_context(|| format!("write {}", pack_yaml.display()))?;
240 write_deployer_extension_sidecar(&root, &payload)?;
241 println!("deployer extension updated in {}", pack_yaml.display());
242 Ok(())
243}
244
245fn handle_dependency(args: DependencyArgs) -> Result<()> {
246 let root = normalize_root(&args.pack_dir)?;
247 let file_path = default_extensions_file_path(&root);
248 let mut file = if file_path.exists() {
249 read_extensions_file(&file_path)?
250 } else {
251 PackExtensionsFile::new(Vec::new())
252 };
253 let dependency = args.to_dependency()?;
254
255 if let Some(existing) = file
256 .extensions
257 .iter_mut()
258 .find(|item| item.id == dependency.id)
259 {
260 *existing = dependency;
261 } else {
262 file.extensions.push(dependency);
263 file.extensions
264 .sort_by(|left, right| left.id.cmp(&right.id));
265 }
266
267 if args.dry_run {
268 println!("--- dry-run: updated {} ---", file_path.display());
269 println!(
270 "{}",
271 serde_json::to_string_pretty(&file).context("serialize pack.extensions.json")?
272 );
273 return Ok(());
274 }
275
276 write_extensions_file(&file_path, &file)?;
277 println!("extension dependency updated in {}", file_path.display());
278 Ok(())
279}
280
281impl CapabilityArgs {
282 fn to_spec(&self) -> Result<CapabilityOfferSpec> {
283 if self.requires_setup && self.qa_ref.is_none() {
284 anyhow::bail!("--qa-ref is required when --requires-setup is set");
285 }
286 if let Some(qa_ref) = self.qa_ref.as_ref()
287 && qa_ref.trim().is_empty()
288 {
289 anyhow::bail!("--qa-ref must not be empty");
290 }
291 Ok(CapabilityOfferSpec {
292 offer_id: self.offer_id.clone(),
293 cap_id: self.cap_id.clone(),
294 version: self.version.clone(),
295 component_ref: self.component_ref.clone(),
296 op: self.op.clone(),
297 priority: self.priority,
298 requires_setup: self.requires_setup,
299 qa_ref: self.qa_ref.clone(),
300 hook_op_names: self.hook_op_names.clone(),
301 })
302 }
303}
304
305impl DeployerArgs {
306 fn to_payload(&self) -> Result<JsonValue> {
307 let contract_id = self.contract_id.trim();
308 if contract_id.is_empty() {
309 anyhow::bail!("--contract-id must not be empty");
310 }
311
312 let ops = if self.ops.is_empty() {
313 vec![
314 "generate".to_string(),
315 "plan".to_string(),
316 "apply".to_string(),
317 "destroy".to_string(),
318 "status".to_string(),
319 "rollback".to_string(),
320 ]
321 } else {
322 self.ops
323 .iter()
324 .map(|op| op.trim())
325 .filter(|op| !op.is_empty())
326 .map(ToString::to_string)
327 .collect::<Vec<_>>()
328 };
329 if ops.is_empty() {
330 anyhow::bail!("at least one non-empty --op value is required");
331 }
332
333 let mut flow_refs = serde_json::Map::new();
334 if self.flow_refs.is_empty() {
335 for op in &ops {
336 flow_refs.insert(op.clone(), JsonValue::String(format!("flows/{op}.ygtc")));
337 }
338 } else {
339 for mapping in &self.flow_refs {
340 let (op, path) = mapping
341 .split_once('=')
342 .ok_or_else(|| anyhow::anyhow!("--flow-ref must be in OP=PATH form"))?;
343 let op = op.trim();
344 let path = path.trim();
345 if op.is_empty() || path.is_empty() {
346 anyhow::bail!("--flow-ref must not contain empty op or path");
347 }
348 flow_refs.insert(op.to_string(), JsonValue::String(path.to_string()));
349 }
350 }
351
352 Ok(json!({
353 "version": 1,
354 "provides": [{
355 "capability": DEPLOYER_EXTENSION_KEY,
356 "contract": contract_id,
357 "ops": ops,
358 }],
359 "flow_refs": flow_refs,
360 }))
361 }
362}
363
364impl DependencyArgs {
365 fn to_dependency(&self) -> Result<ExtensionDependency> {
366 let id = self.id.trim();
367 let role = self.role.trim();
368 let reference = self.reference.trim();
369 if id.is_empty() {
370 anyhow::bail!("--id must not be empty");
371 }
372 if role.is_empty() {
373 anyhow::bail!("--role must not be empty");
374 }
375 if reference.is_empty() {
376 anyhow::bail!("--ref must not be empty");
377 }
378 let kind = infer_reference_kind(reference)?;
379 Ok(ExtensionDependency {
380 id: id.to_string(),
381 role: role.to_string(),
382 source: ExtensionDependencySource {
383 kind,
384 reference: reference.to_string(),
385 allow_tags: self.allow_tags,
386 },
387 })
388 }
389}
390
391fn edit_pack_dir(pack_dir: &Path, args: &ProviderArgs) -> Result<()> {
392 let root = normalize_root(pack_dir)?;
393 let pack_yaml = root.join("pack.yaml");
394 let (pack_config, contents) = read_pack_yaml(&pack_yaml)?;
395 let metadata = ProviderMetadata::from_args(args);
396 let updated_yaml = inject_provider_entry(
397 &contents,
398 &build_provider_decl(args, &root)?,
399 metadata,
400 &pack_config.version,
401 )?;
402
403 if args.dry_run {
404 println!("--- dry-run: updated pack.yaml ---");
405 println!("{updated_yaml}");
406 return Ok(());
407 }
408
409 fs::write(&pack_yaml, updated_yaml)
410 .with_context(|| format!("write {}", pack_yaml.display()))?;
411 println!("provider extension updated in {}", pack_yaml.display());
412 Ok(())
413}
414
415fn normalize_root(path: &Path) -> Result<PathBuf> {
416 let canonical = if path.is_absolute() {
417 path.to_path_buf()
418 } else {
419 std::env::current_dir()?.join(path)
420 };
421 Ok(canonical)
422}
423
424fn read_pack_yaml(path: &Path) -> Result<(PackConfig, String)> {
425 let contents = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
426 let config: PackConfig = serde_yaml_bw::from_str(&contents)
427 .with_context(|| format!("{} is not a valid pack.yaml", path.display()))?;
428 Ok((config, contents))
429}
430
431#[derive(Default)]
432struct ProviderMetadata {
433 title: Option<String>,
434 description: Option<String>,
435 route: Option<String>,
436 flow: Option<String>,
437 validator_ref: Option<String>,
438 validator_digest: Option<String>,
439}
440
441impl ProviderMetadata {
442 fn from_args(args: &ProviderArgs) -> Self {
443 Self {
444 title: args.title.clone(),
445 description: args.description.clone(),
446 route: args.route.clone(),
447 flow: args.flow.clone(),
448 validator_ref: args.validator_ref.clone(),
449 validator_digest: args.validator_digest.clone(),
450 }
451 }
452}
453
454fn build_provider_decl(args: &ProviderArgs, root: &Path) -> Result<ProviderDecl> {
455 let config_ref = find_config_schema_ref(root, &args.kind, &args.provider_id);
456 let capabilities = vec![args.kind.clone()];
457 let ops = match args.kind.as_str() {
458 "messaging" => vec!["send".to_string(), "receive".to_string()],
459 "events" => vec!["emit".to_string(), "subscribe".to_string()],
460 _ => vec!["run".to_string()],
461 };
462
463 Ok(ProviderDecl {
464 provider_type: args.provider_id.clone(),
465 provider_id: None,
466 capabilities,
467 ops,
468 config_schema_ref: config_ref,
469 state_schema_ref: None,
470 runtime: ProviderRuntimeRef {
471 component_ref: args.provider_id.clone(),
472 export: "provider".to_string(),
473 world: PROVIDER_RUNTIME_WORLD.to_string(),
474 },
475 docs_ref: None,
476 })
477}
478
479pub(crate) fn inject_provider_entry_for_wizard(
480 contents: &str,
481 provider_id: &str,
482 kind: &str,
483 version: &str,
484) -> Result<String> {
485 let provider = ProviderDecl {
486 provider_type: provider_id.to_string(),
487 provider_id: None,
488 capabilities: vec![kind.to_string()],
489 ops: match kind {
490 "messaging" => vec!["send".to_string(), "receive".to_string()],
491 "events" => vec!["emit".to_string(), "subscribe".to_string()],
492 _ => vec!["run".to_string()],
493 },
494 config_schema_ref: format!("schemas/{kind}/{provider_id}/config.schema.json"),
495 state_schema_ref: None,
496 runtime: ProviderRuntimeRef {
497 component_ref: provider_id.to_string(),
498 export: "provider".to_string(),
499 world: PROVIDER_RUNTIME_WORLD.to_string(),
500 },
501 docs_ref: None,
502 };
503 inject_provider_entry(contents, &provider, ProviderMetadata::default(), version)
504}
505
506fn find_config_schema_ref(root: &Path, kind: &str, provider_id: &str) -> String {
507 let schemas = root.join("schemas");
508 if schemas.exists() {
509 let provider_kw = provider_id.to_ascii_lowercase();
510 for entry in WalkDir::new(&schemas)
511 .into_iter()
512 .filter_map(Result::ok)
513 .filter(|entry| entry.file_type().is_file())
514 {
515 let name = entry.file_name().to_string_lossy().to_ascii_lowercase();
516 if name.contains(&provider_kw)
517 && name.contains("config.schema")
518 && let Ok(rel) = entry.path().strip_prefix(root)
519 {
520 return rel
521 .components()
522 .map(|comp| comp.as_os_str().to_string_lossy())
523 .collect::<Vec<_>>()
524 .join("/");
525 }
526 }
527 }
528
529 format!("schemas/{}/{}/config.schema.json", kind, provider_id)
530}
531
532fn inject_provider_entry(
533 contents: &str,
534 provider: &ProviderDecl,
535 metadata: ProviderMetadata,
536 version: &str,
537) -> Result<String> {
538 let mut document: YamlValue =
539 serde_yaml_bw::from_str(contents).context("parse pack.yaml for extension merge")?;
540 let mapping = document
541 .as_mapping_mut()
542 .ok_or_else(|| anyhow::anyhow!("pack.yaml root must be a mapping"))?;
543 let extensions = mapping
544 .entry(yaml_key("extensions"))
545 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
546 let extensions_map = extensions
547 .as_mapping_mut()
548 .ok_or_else(|| anyhow::anyhow!("extensions must be a mapping"))?;
549
550 let location = detect_extension_location(extensions_map);
551 let extension_map = resolve_extension_map(extensions_map, &location)
552 .context("locate provider extension slot")?;
553 extension_map
554 .entry(yaml_key("kind"))
555 .or_insert_with(|| YamlValue::String(PROVIDER_EXTENSION_KEY.to_string(), None));
556 extension_map
557 .entry(yaml_key("version"))
558 .or_insert_with(|| YamlValue::String(version.to_string(), None));
559
560 let inline = extension_map
561 .entry(yaml_key("inline"))
562 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
563 let inline_map = match inline {
564 YamlValue::Mapping(map) => map,
565 _ => {
566 *inline = YamlValue::Mapping(Mapping::new());
567 inline.as_mapping_mut().unwrap()
568 }
569 };
570
571 let providers_key = yaml_key("providers");
572 let providers_entry = inline_map
573 .entry(providers_key.clone())
574 .or_insert_with(|| YamlValue::Sequence(Sequence::default()));
575 let providers = match providers_entry {
576 YamlValue::Sequence(seq) => seq,
577 _ => {
578 *providers_entry = YamlValue::Sequence(Sequence::default());
579 providers_entry.as_sequence_mut().unwrap()
580 }
581 };
582
583 let mut provider_value =
584 serde_yaml_bw::to_value(provider).context("serialize provider declaration")?;
585 if let Some(map) = provider_value.as_mapping_mut() {
586 if let Some(title) = metadata.title {
587 map.insert(yaml_key("title"), YamlValue::String(title, None));
588 }
589 if let Some(desc) = metadata.description {
590 map.insert(yaml_key("description"), YamlValue::String(desc, None));
591 }
592 if let Some(route) = metadata.route {
593 map.insert(yaml_key("route"), YamlValue::String(route, None));
594 }
595 if let Some(flow) = metadata.flow {
596 map.insert(yaml_key("flow"), YamlValue::String(flow, None));
597 }
598 if let Some(validator_ref) = metadata.validator_ref {
599 map.insert(
600 yaml_key("validator_ref"),
601 YamlValue::String(validator_ref, None),
602 );
603 }
604 if let Some(validator_digest) = metadata.validator_digest {
605 map.insert(
606 yaml_key("validator_digest"),
607 YamlValue::String(validator_digest, None),
608 );
609 }
610 }
611 upsert_provider(providers, provider_value, &provider.provider_type);
612
613 serde_yaml_bw::to_string(&document).context("serialize updated pack.yaml")
614}
615
616pub(crate) fn ensure_capabilities_extension(contents: &str) -> Result<String> {
617 let mut document: YamlValue =
618 serde_yaml_bw::from_str(contents).context("parse pack.yaml for extension merge")?;
619 let mapping = document
620 .as_mapping_mut()
621 .ok_or_else(|| anyhow::anyhow!("pack.yaml root must be a mapping"))?;
622 let extensions = mapping
623 .entry(yaml_key("extensions"))
624 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
625 let extensions_map = extensions
626 .as_mapping_mut()
627 .ok_or_else(|| anyhow::anyhow!("extensions must be a mapping"))?;
628 let extension_slot = extensions_map
629 .entry(yaml_key(CAPABILITIES_EXTENSION_KEY))
630 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
631 let extension_map = extension_slot
632 .as_mapping_mut()
633 .ok_or_else(|| anyhow::anyhow!("capabilities extension slot must be a mapping"))?;
634 extension_map
635 .entry(yaml_key("kind"))
636 .or_insert_with(|| YamlValue::String(CAPABILITIES_EXTENSION_KEY.to_string(), None));
637 extension_map
638 .entry(yaml_key("version"))
639 .or_insert_with(|| YamlValue::String("1.0.0".to_string(), None));
640
641 let inline = extension_map
642 .entry(yaml_key("inline"))
643 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
644 let inline_map = match inline {
645 YamlValue::Mapping(map) => map,
646 _ => {
647 *inline = YamlValue::Mapping(Mapping::new());
648 inline.as_mapping_mut().expect("inline map")
649 }
650 };
651 inline_map
652 .entry(yaml_key("schema_version"))
653 .or_insert_with(|| YamlValue::Number(1u64.into(), None));
654
655 let offers_entry = inline_map
656 .entry(yaml_key("offers"))
657 .or_insert_with(|| YamlValue::Sequence(Sequence::default()));
658 if !matches!(offers_entry, YamlValue::Sequence(_)) {
659 *offers_entry = YamlValue::Sequence(Sequence::default());
660 }
661
662 serde_yaml_bw::to_string(&document).context("serialize updated pack.yaml")
663}
664
665pub(crate) fn inject_capability_offer_spec(
666 contents: &str,
667 spec: &CapabilityOfferSpec,
668) -> Result<String> {
669 let mut document: YamlValue = serde_yaml_bw::from_str(
670 &ensure_capabilities_extension(contents).context("prepare capabilities extension")?,
671 )
672 .context("parse pack.yaml for capability offer merge")?;
673 let mapping = document
674 .as_mapping_mut()
675 .ok_or_else(|| anyhow::anyhow!("pack.yaml root must be a mapping"))?;
676 let extensions_map = mapping
677 .get_mut(yaml_key("extensions"))
678 .and_then(YamlValue::as_mapping_mut)
679 .ok_or_else(|| anyhow::anyhow!("extensions must be a mapping"))?;
680 let extension_map = extensions_map
681 .get_mut(yaml_key(CAPABILITIES_EXTENSION_KEY))
682 .and_then(YamlValue::as_mapping_mut)
683 .ok_or_else(|| anyhow::anyhow!("capabilities extension slot must be a mapping"))?;
684 let inline_map = extension_map
685 .get_mut(yaml_key("inline"))
686 .and_then(YamlValue::as_mapping_mut)
687 .ok_or_else(|| anyhow::anyhow!("capabilities extension inline must be a mapping"))?;
688 let offers_entry = inline_map
689 .entry(yaml_key("offers"))
690 .or_insert_with(|| YamlValue::Sequence(Sequence::default()));
691 let offers = match offers_entry {
692 YamlValue::Sequence(seq) => seq,
693 _ => {
694 *offers_entry = YamlValue::Sequence(Sequence::default());
695 offers_entry.as_sequence_mut().expect("offers seq")
696 }
697 };
698
699 let offer = CapabilityOfferV1 {
700 offer_id: spec.offer_id.clone(),
701 cap_id: spec.cap_id.clone(),
702 version: spec.version.clone(),
703 provider: CapabilityProviderRefV1 {
704 component_ref: spec.component_ref.clone(),
705 op: spec.op.clone(),
706 },
707 scope: None,
708 priority: spec.priority,
709 requires_setup: spec.requires_setup,
710 setup: spec.qa_ref.as_ref().map(|qa_ref| CapabilitySetupV1 {
711 qa_ref: qa_ref.clone(),
712 }),
713 applies_to: (!spec.hook_op_names.is_empty()).then(|| CapabilityHookAppliesToV1 {
714 op_names: spec.hook_op_names.clone(),
715 }),
716 };
717 let offer_value =
718 serde_yaml_bw::to_value(&offer).context("serialize capability offer payload")?;
719 upsert_capability_offer(offers, offer_value, &spec.offer_id);
720 sort_capability_offers(offers);
721
722 serde_yaml_bw::to_string(&document).context("serialize updated pack.yaml")
723}
724
725fn inject_deployer_extension_payload(contents: &str, payload: &JsonValue) -> Result<String> {
726 let mut document: YamlValue = serde_yaml_bw::from_str(contents)
727 .context("parse pack.yaml for deployer extension merge")?;
728 let mapping = document
729 .as_mapping_mut()
730 .ok_or_else(|| anyhow::anyhow!("pack.yaml root must be a mapping"))?;
731 let extensions = mapping
732 .entry(yaml_key("extensions"))
733 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
734 let extensions_map = extensions
735 .as_mapping_mut()
736 .ok_or_else(|| anyhow::anyhow!("extensions must be a mapping"))?;
737 let extension_slot = extensions_map
738 .entry(yaml_key(DEPLOYER_EXTENSION_KEY))
739 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
740 let extension_map = extension_slot
741 .as_mapping_mut()
742 .ok_or_else(|| anyhow::anyhow!("deployer extension slot must be a mapping"))?;
743 extension_map
744 .entry(yaml_key("kind"))
745 .or_insert_with(|| YamlValue::String(DEPLOYER_EXTENSION_KEY.to_string(), None));
746 extension_map
747 .entry(yaml_key("version"))
748 .or_insert_with(|| YamlValue::String("1.0.0".to_string(), None));
749 extension_map.insert(
750 yaml_key("inline"),
751 serde_yaml_bw::to_value(payload).context("serialize deployer extension payload")?,
752 );
753
754 serde_yaml_bw::to_string(&document).context("serialize updated pack.yaml")
755}
756
757fn write_deployer_extension_sidecar(root: &Path, payload: &JsonValue) -> Result<()> {
758 let extensions_dir = root.join("extensions");
759 fs::create_dir_all(&extensions_dir)
760 .with_context(|| format!("create {}", extensions_dir.display()))?;
761 let path = extensions_dir.join("deployer.json");
762 let bytes = serde_json::to_vec_pretty(&json!({
763 "extension_type": "deployer",
764 "canonical_extension_key": DEPLOYER_EXTENSION_KEY,
765 "source": "add-extension deployer",
766 "deployer_extension": payload,
767 }))
768 .context("serialize deployer extension sidecar")?;
769 fs::write(&path, bytes).with_context(|| format!("write {}", path.display()))?;
770 Ok(())
771}
772
773fn upsert_capability_offer(offers: &mut Vec<YamlValue>, offer: YamlValue, offer_id: &str) {
774 for entry in offers.iter_mut() {
775 if entry_matches_capability_offer(entry, offer_id) {
776 *entry = offer;
777 return;
778 }
779 }
780 offers.push(offer);
781}
782
783fn sort_capability_offers(offers: &mut [YamlValue]) {
784 offers.sort_by(|left, right| {
785 capability_offer_id(left)
786 .cmp(&capability_offer_id(right))
787 .then_with(|| {
788 let left_yaml = serde_yaml_bw::to_string(left).unwrap_or_default();
789 let right_yaml = serde_yaml_bw::to_string(right).unwrap_or_default();
790 left_yaml.cmp(&right_yaml)
791 })
792 });
793}
794
795fn capability_offer_id(entry: &YamlValue) -> String {
796 let key = yaml_key("offer_id");
797 if let YamlValue::Mapping(map) = entry
798 && let Some(YamlValue::String(value, _)) = map.get(&key)
799 {
800 return value.clone();
801 }
802 String::new()
803}
804
805fn entry_matches_capability_offer(entry: &YamlValue, offer_id: &str) -> bool {
806 let key = yaml_key("offer_id");
807 if let YamlValue::Mapping(map) = entry
808 && let Some(YamlValue::String(value, _)) = map.get(&key)
809 {
810 return value == offer_id;
811 }
812 false
813}
814
815fn upsert_provider(providers: &mut Vec<YamlValue>, provider: YamlValue, provider_id: &str) {
816 for entry in providers.iter_mut() {
817 if entry_matches_provider(entry, provider_id) {
818 *entry = provider;
819 return;
820 }
821 }
822 providers.push(provider);
823}
824
825fn entry_matches_provider(entry: &YamlValue, provider_id: &str) -> bool {
826 let provider_key = yaml_key("provider_type");
827 if let YamlValue::Mapping(map) = entry
828 && let Some(YamlValue::String(value, _)) = map.get(&provider_key)
829 {
830 return value == provider_id;
831 }
832 false
833}
834
835enum ExtensionLocation {
836 Flat,
837 Nested,
838}
839
840fn detect_extension_location(extensions: &Mapping) -> ExtensionLocation {
841 let provider_key = yaml_key(PROVIDER_EXTENSION_KEY);
842 if extensions.contains_key(&provider_key) {
843 return ExtensionLocation::Flat;
844 }
845 let mut current = extensions;
846 for segment in PROVIDER_EXTENSION_PATH
847 .iter()
848 .take(PROVIDER_EXTENSION_PATH.len() - 1)
849 {
850 let key = yaml_key(*segment);
851 if let Some(next) = current.get(&key).and_then(YamlValue::as_mapping) {
852 current = next;
853 } else {
854 return ExtensionLocation::Flat;
855 }
856 }
857 ExtensionLocation::Nested
858}
859
860fn resolve_extension_map<'a>(
861 extensions: &'a mut Mapping,
862 location: &ExtensionLocation,
863) -> Result<&'a mut Mapping> {
864 match location {
865 ExtensionLocation::Flat => {
866 let key = yaml_key(PROVIDER_EXTENSION_KEY);
867 let slot = extensions
868 .entry(key)
869 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
870 slot.as_mapping_mut()
871 .ok_or_else(|| anyhow::anyhow!("extension slot must be a mapping"))
872 }
873 ExtensionLocation::Nested => {
874 let mut current_map = extensions;
875 for segment in PROVIDER_EXTENSION_PATH.iter() {
876 let key = yaml_key(*segment);
877 let entry = current_map
878 .entry(key)
879 .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
880 current_map = entry
881 .as_mapping_mut()
882 .ok_or_else(|| anyhow::anyhow!("nested extension value must be a mapping"))?;
883 }
884 Ok(current_map)
885 }
886 }
887}
888
889fn yaml_key(value: impl Into<String>) -> YamlValue {
890 YamlValue::String(value.into(), None)
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use serde_yaml_bw;
897
898 fn sample_flat_yaml() -> String {
899 r#"pack_id: demo
900version: 0.1.0
901extensions:
902 greentic.provider-extension.v1:
903 kind: greentic.provider-extension.v1
904 version: 0.1.0
905 inline:
906 providers:
907 - provider_type: existing
908 capabilities: [messaging]
909 ops: [send]
910 config_schema_ref: schemas/messaging/existing/config.schema.json
911 runtime:
912 component_ref: existing
913 export: provider
914 world: greentic:provider/schema-core@1.0.0
915"#
916 .to_string()
917 }
918
919 fn sample_nested_yaml() -> String {
920 r#"pack_id: demo
921version: 0.1.0
922extensions:
923 greentic:
924 provider-extension:
925 v1:
926 inline:
927 providers: []
928"#
929 .to_string()
930 }
931
932 fn provider_decl() -> ProviderDecl {
933 ProviderDecl {
934 provider_type: "demo.provider".to_string(),
935 provider_id: None,
936 capabilities: vec!["messaging".to_string()],
937 ops: vec!["send".to_string()],
938 config_schema_ref: "schemas/messaging/demo/config.schema.json".to_string(),
939 state_schema_ref: None,
940 runtime: ProviderRuntimeRef {
941 component_ref: "demo.provider".to_string(),
942 export: "provider".to_string(),
943 world: PROVIDER_RUNTIME_WORLD.to_string(),
944 },
945 docs_ref: None,
946 }
947 }
948
949 #[test]
950 fn inject_flat_extension() {
951 let contents = sample_flat_yaml();
952 let updated = inject_provider_entry(
953 &contents,
954 &provider_decl(),
955 ProviderMetadata::default(),
956 "0.1.0",
957 )
958 .unwrap();
959 let doc: YamlValue = serde_yaml_bw::from_str(&updated).unwrap();
960
961 let providers = doc["extensions"]["greentic.provider-extension.v1"]["inline"]["providers"]
962 .as_sequence()
963 .expect("providers list");
964 assert!(
965 providers
966 .iter()
967 .any(|entry| entry_matches_provider(entry, "demo.provider"))
968 );
969 }
970
971 #[test]
972 fn inject_nested_extension() {
973 let contents = sample_nested_yaml();
974 let updated = inject_provider_entry(
975 &contents,
976 &provider_decl(),
977 ProviderMetadata::default(),
978 "0.1.0",
979 )
980 .unwrap();
981 let doc: YamlValue = serde_yaml_bw::from_str(&updated).unwrap();
982
983 assert!(
984 doc["extensions"]["greentic"]["provider-extension"]["v1"]["inline"]["providers"]
985 .as_sequence()
986 .unwrap()
987 .iter()
988 .any(|entry| entry_matches_provider(entry, "demo.provider"))
989 );
990 }
991}