use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ManifestError {
pub reason: String,
pub is_fatal: bool,
}
impl ManifestError {
pub fn fatal(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
is_fatal: true,
}
}
pub fn recoverable(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
is_fatal: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionManifest {
pub extension: ExtensionMetadata,
pub runtime: RuntimeConfig,
#[serde(default)]
pub startup: StartupConfig,
#[serde(default)]
pub widgets: HashMap<String, WidgetConfig>,
#[serde(default)]
pub mind: MindConfig,
#[serde(default)]
pub config: HashMap<String, ConfigField>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigField {
#[serde(rename = "type")]
pub field_type: ConfigFieldType,
pub label: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub default: Option<String>,
#[serde(default)]
pub pattern: Option<String>,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigFieldType {
String,
Number,
Boolean,
Enum,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionMetadata {
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub sdk_version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum RuntimeConfig {
Native { binary: String },
Oci { image: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartupConfig {
#[serde(default = "default_ping_method")]
pub ping_method: String,
#[serde(default = "default_timeout_ms")]
pub timeout_ms: u64,
}
fn default_ping_method() -> String {
"get_tools".to_string()
}
fn default_timeout_ms() -> u64 {
5000
}
impl Default for StartupConfig {
fn default() -> Self {
Self {
ping_method: default_ping_method(),
timeout_ms: default_timeout_ms(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetConfig {
pub label: String,
pub kind: String, pub renderer: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MindConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
pub max_facts: Option<usize>,
#[serde(default)]
pub retention_days: Option<u32>,
}
impl ExtensionManifest {
pub fn from_file(path: &Path) -> Result<Self, ManifestError> {
let content = std::fs::read_to_string(path)
.map_err(|e| ManifestError::fatal(format!("failed to read manifest: {}", e)))?;
let manifest: ExtensionManifest = toml::from_str(&content)
.map_err(|e| ManifestError::fatal(format!("failed to parse manifest: {}", e)))?;
manifest.validate()?;
Ok(manifest)
}
fn validate(&self) -> Result<(), ManifestError> {
if self.extension.name.is_empty() {
return Err(ManifestError::fatal("extension.name must not be empty"));
}
if !self
.extension
.name
.chars()
.all(|c| (c.is_ascii_lowercase() || c.is_ascii_digit()) || c == '-')
{
return Err(ManifestError::fatal(
"extension.name must contain only lowercase alphanumeric and hyphens",
));
}
if self.extension.version.is_empty() {
return Err(ManifestError::fatal("extension.version must not be empty"));
}
match &self.runtime {
RuntimeConfig::Native { binary } => {
if binary.is_empty() {
return Err(ManifestError::fatal("runtime.binary must not be empty"));
}
}
RuntimeConfig::Oci { image } => {
if image.is_empty() {
return Err(ManifestError::fatal("runtime.image must not be empty"));
}
}
}
for (id, widget) in &self.widgets {
if id.is_empty() {
return Err(ManifestError::fatal("widget id must not be empty"));
}
if widget.label.is_empty() {
return Err(ManifestError::fatal("widget.label must not be empty"));
}
if widget.renderer.is_empty() {
return Err(ManifestError::fatal("widget.renderer must not be empty"));
}
match widget.kind.as_str() {
"stateful" | "ephemeral" => {}
_ => {
return Err(ManifestError::fatal(format!(
"widget.kind must be 'stateful' or 'ephemeral', got '{}'",
widget.kind
)));
}
}
}
if self.startup.timeout_ms == 0 {
return Err(ManifestError::fatal("startup.timeout_ms must be > 0"));
}
if self.startup.timeout_ms > 60000 {
return Err(ManifestError::recoverable(
"startup.timeout_ms > 60s is unusual; extensions should start faster",
));
}
for (name, field) in &self.config {
if name.is_empty() {
return Err(ManifestError::fatal("config field name must not be empty"));
}
if field.label.is_empty() {
return Err(ManifestError::fatal(format!(
"config.{name}.label must not be empty"
)));
}
if field.field_type == ConfigFieldType::Enum && field.values.is_empty() {
return Err(ManifestError::fatal(format!(
"config.{name} is type 'enum' but declares no values"
)));
}
if let Some(ref pattern) = field.pattern {
if pattern.is_empty() {
return Err(ManifestError::fatal(format!(
"config.{name}.pattern must not be empty if specified"
)));
}
}
}
Ok(())
}
pub fn check_sdk_version(&self, omegon_sdk_version: &str) -> Result<(), ManifestError> {
if self.extension.sdk_version.is_empty() {
return Err(ManifestError::recoverable(
"extension.sdk_version not specified; recommend adding for safety",
));
}
if !omegon_sdk_version.starts_with(&self.extension.sdk_version) {
return Err(ManifestError::fatal(format!(
"SDK version mismatch: extension requires {}, but omegon has {}",
self.extension.sdk_version, omegon_sdk_version
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_native_manifest() {
let manifest = ExtensionManifest {
extension: ExtensionMetadata {
name: "my-ext".to_string(),
version: "0.1.0".to_string(),
description: "Test".to_string(),
sdk_version: "0.15".to_string(),
},
runtime: RuntimeConfig::Native {
binary: "target/release/my-ext".to_string(),
},
startup: StartupConfig::default(),
widgets: HashMap::new(),
mind: MindConfig::default(),
config: HashMap::new(),
};
assert!(manifest.validate().is_ok());
}
#[test]
fn test_validate_invalid_name() {
let manifest = ExtensionManifest {
extension: ExtensionMetadata {
name: "MY_EXT".to_string(),
version: "0.1.0".to_string(),
description: "".to_string(),
sdk_version: "".to_string(),
},
runtime: RuntimeConfig::Native {
binary: "binary".to_string(),
},
startup: StartupConfig::default(),
widgets: HashMap::new(),
mind: MindConfig::default(),
config: HashMap::new(),
};
assert!(manifest.validate().is_err());
}
#[test]
fn test_sdk_version_check() {
let manifest = ExtensionManifest {
extension: ExtensionMetadata {
name: "test".to_string(),
version: "0.1.0".to_string(),
description: "".to_string(),
sdk_version: "0.15".to_string(),
},
runtime: RuntimeConfig::Native {
binary: "binary".to_string(),
},
startup: StartupConfig::default(),
widgets: HashMap::new(),
mind: MindConfig::default(),
config: HashMap::new(),
};
assert!(manifest.check_sdk_version("0.15.6").is_ok());
assert!(manifest.check_sdk_version("0.15.6-rc.1").is_ok());
assert!(manifest.check_sdk_version("0.16.0").is_err());
}
#[test]
fn test_config_fields_parse_from_toml() {
let toml_str = r#"
[extension]
name = "vox"
version = "0.1.0"
[runtime]
type = "native"
binary = "target/release/vox"
[config.signal_phone]
type = "string"
label = "Signal phone number"
description = "E.164 format"
required = true
pattern = '^\+[1-9]\d{1,14}$'
placeholder = "+14155551234"
[config.webhook_enabled]
type = "boolean"
label = "Enable webhook"
default = "false"
[config.imap_provider]
type = "enum"
label = "IMAP provider"
values = ["gmail", "fastmail", "custom"]
default = "gmail"
"#;
let manifest: ExtensionManifest = toml::from_str(toml_str).unwrap();
assert_eq!(manifest.config.len(), 3);
let phone = &manifest.config["signal_phone"];
assert_eq!(phone.field_type, ConfigFieldType::String);
assert!(phone.required);
assert_eq!(phone.pattern.as_deref(), Some(r"^\+[1-9]\d{1,14}$"));
let webhook = &manifest.config["webhook_enabled"];
assert_eq!(webhook.field_type, ConfigFieldType::Boolean);
assert!(!webhook.required);
assert_eq!(webhook.default.as_deref(), Some("false"));
let imap = &manifest.config["imap_provider"];
assert_eq!(imap.field_type, ConfigFieldType::Enum);
assert_eq!(imap.values, vec!["gmail", "fastmail", "custom"]);
assert!(manifest.validate().is_ok());
}
#[test]
fn test_config_enum_requires_values() {
let manifest = ExtensionManifest {
extension: ExtensionMetadata {
name: "test".into(),
version: "0.1.0".into(),
description: "".into(),
sdk_version: "".into(),
},
runtime: RuntimeConfig::Native { binary: "bin".into() },
startup: StartupConfig::default(),
widgets: HashMap::new(),
mind: MindConfig::default(),
config: HashMap::from([(
"my_enum".into(),
ConfigField {
field_type: ConfigFieldType::Enum,
label: "Pick one".into(),
description: "".into(),
required: false,
default: None,
pattern: None,
placeholder: None,
values: vec![],
},
)]),
};
let err = manifest.validate().unwrap_err();
assert!(err.reason.contains("no values"));
}
#[test]
fn test_config_backwards_compat_no_config_section() {
let toml_str = r#"
[extension]
name = "old-ext"
version = "0.1.0"
[runtime]
type = "native"
binary = "target/release/old"
"#;
let manifest: ExtensionManifest = toml::from_str(toml_str).unwrap();
assert!(manifest.config.is_empty());
assert!(manifest.validate().is_ok());
}
}