Skip to main content

packc/cli/
add_extension.rs

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    /// Add or update the provider extension entry.
30    Provider(ProviderArgs),
31    /// Add or update a capability offer entry.
32    Capability(CapabilityArgs),
33    /// Add or update a generic deployer extension entry.
34    Deployer(DeployerArgs),
35    /// Add or update an external extension dependency ref in pack.extensions.json.
36    Dependency(DependencyArgs),
37}
38
39#[derive(Debug, Args)]
40pub struct ProviderArgs {
41    /// Path to a pack source directory containing pack.yaml.
42    #[arg(long = "pack-dir", value_name = "DIR")]
43    pub pack_dir: PathBuf,
44
45    /// Print what would change without writing files.
46    #[arg(long)]
47    pub dry_run: bool,
48
49    /// Provider identifier to add or update.
50    #[arg(long = "id", value_name = "PROVIDER_ID")]
51    pub provider_id: String,
52
53    /// Provider kind (e.g. messaging, events).
54    #[arg(long = "kind", value_name = "KIND")]
55    pub kind: String,
56
57    /// Optional provider title to store in metadata.
58    #[arg(long, value_name = "TITLE")]
59    pub title: Option<String>,
60
61    /// Optional description to store in metadata.
62    #[arg(long, value_name = "DESCRIPTION")]
63    pub description: Option<String>,
64    /// Optional validator reference for generated provider metadata.
65    #[arg(long = "validator-ref", value_name = "VALIDATOR_REF")]
66    pub validator_ref: Option<String>,
67    /// Optional validator digest for strict pinning.
68    #[arg(long = "validator-digest", value_name = "DIGEST")]
69    pub validator_digest: Option<String>,
70
71    /// Convenience route hint (if schema supports route<->flow binding).
72    #[arg(long = "route", value_name = "ROUTE")]
73    pub route: Option<String>,
74
75    /// Convenience flow hint (if schema supports route<->flow binding).
76    #[arg(long = "flow", value_name = "FLOW")]
77    pub flow: Option<String>,
78}
79
80#[derive(Debug, Args)]
81pub struct CapabilityArgs {
82    /// Path to a pack source directory containing pack.yaml.
83    #[arg(long = "pack-dir", value_name = "DIR")]
84    pub pack_dir: PathBuf,
85
86    /// Print what would change without writing files.
87    #[arg(long)]
88    pub dry_run: bool,
89
90    /// Stable offer id.
91    #[arg(long = "offer-id", value_name = "ID")]
92    pub offer_id: String,
93
94    /// Capability identifier.
95    #[arg(long = "cap-id", value_name = "CAP_ID")]
96    pub cap_id: String,
97
98    /// Capability contract version.
99    #[arg(long, default_value = "v1")]
100    pub version: String,
101
102    /// Provider component reference.
103    #[arg(long = "component-ref", value_name = "COMPONENT")]
104    pub component_ref: String,
105
106    /// Provider operation.
107    #[arg(long = "op", value_name = "OP")]
108    pub op: String,
109
110    /// Selection priority (ascending).
111    #[arg(long, default_value_t = 0)]
112    pub priority: i32,
113
114    /// Mark offer as requiring setup.
115    #[arg(long = "requires-setup", default_value_t = false)]
116    pub requires_setup: bool,
117
118    /// Pack-relative QA spec ref (required when --requires-setup).
119    #[arg(long = "qa-ref", value_name = "REF")]
120    pub qa_ref: Option<String>,
121
122    /// Exact operation names for hook applicability (repeatable).
123    #[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    /// Path to a pack source directory containing pack.yaml.
130    #[arg(long = "pack-dir", value_name = "DIR")]
131    pub pack_dir: PathBuf,
132
133    /// Print what would change without writing files.
134    #[arg(long)]
135    pub dry_run: bool,
136
137    /// Deployer contract identifier.
138    #[arg(long = "contract-id", value_name = "CONTRACT")]
139    pub contract_id: String,
140
141    /// Supported deployer operation (repeatable).
142    #[arg(long = "op", value_name = "OP")]
143    pub ops: Vec<String>,
144
145    /// Optional explicit flow ref mapping (`op=flows/path.ygtc`), repeatable.
146    #[arg(long = "flow-ref", value_name = "OP=PATH")]
147    pub flow_refs: Vec<String>,
148}
149
150#[derive(Debug, Args)]
151pub struct DependencyArgs {
152    /// Path to a pack source directory containing pack.yaml.
153    #[arg(long = "pack-dir", value_name = "DIR")]
154    pub pack_dir: PathBuf,
155
156    /// Print what would change without writing files.
157    #[arg(long)]
158    pub dry_run: bool,
159
160    /// Logical dependency id.
161    #[arg(long = "id", value_name = "ID")]
162    pub id: String,
163
164    /// Logical dependency role (for example `deployer`).
165    #[arg(long = "role", value_name = "ROLE")]
166    pub role: String,
167
168    /// Source reference, for example `oci://...` or `file://...`.
169    #[arg(long = "ref", value_name = "REF")]
170    pub reference: String,
171
172    /// Allow tag refs in editable source metadata.
173    #[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}