use std::collections::HashMap;
use std::path::Path;
use anyhow::Context;
use serde::{Deserialize, Serialize};
pub(crate) const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct DistroManifest {
pub(crate) schema_version: u32,
pub(crate) distro: DistroMeta,
#[serde(default)]
pub(crate) variables: HashMap<String, VariableDef>,
#[serde(default, rename = "capsule")]
pub(crate) capsules: Vec<DistroCapsule>,
#[serde(default)]
pub(crate) invites: Option<InviteConfig>,
#[serde(default)]
pub(crate) branding: Option<BrandingConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct InviteConfig {
#[serde(default)]
pub(crate) issuers: Vec<String>,
#[serde(default)]
pub(crate) default_group: Option<String>,
#[serde(default)]
pub(crate) default_expires: Option<String>,
#[serde(default)]
pub(crate) max_principals: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub(crate) struct BrandingConfig {
#[serde(default)]
pub(crate) icon: Option<String>,
#[serde(default)]
pub(crate) primary_color: Option<String>,
#[serde(default)]
pub(crate) accent_color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct DistroMeta {
pub(crate) id: String,
pub(crate) name: String,
#[serde(default)]
pub(crate) pretty_name: Option<String>,
pub(crate) version: String,
#[serde(default)]
pub(crate) codename: Option<String>,
#[serde(default)]
pub(crate) release_date: Option<String>,
#[serde(default)]
pub(crate) description: Option<String>,
#[serde(default)]
pub(crate) authors: Vec<String>,
#[serde(default)]
pub(crate) maintainers: Vec<String>,
#[serde(default)]
pub(crate) homepage: Option<String>,
#[serde(default)]
pub(crate) support: Option<String>,
#[serde(default)]
pub(crate) bug_tracker: Option<String>,
#[serde(default)]
pub(crate) repository: Option<String>,
#[serde(default)]
pub(crate) license: Option<String>,
#[serde(default)]
pub(crate) astrid_version: Option<String>,
#[serde(default)]
pub(crate) requires: HashMap<String, HashMap<String, String>>,
#[serde(default)]
pub(crate) signing: Option<SigningConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct SigningConfig {
pub(crate) pubkey: String,
#[serde(default)]
pub(crate) endorses: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct VariableDef {
#[serde(default)]
pub(crate) secret: bool,
#[serde(default)]
pub(crate) description: Option<String>,
#[serde(default)]
pub(crate) default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct DistroCapsule {
pub(crate) name: String,
pub(crate) source: String,
pub(crate) version: String,
#[serde(default)]
pub(crate) tag: Option<String>,
#[serde(default)]
pub(crate) branch: Option<String>,
#[serde(default)]
pub(crate) rev: Option<String>,
#[serde(default)]
pub(crate) default: bool,
#[serde(default)]
pub(crate) group: Option<String>,
#[serde(default)]
pub(crate) role: Option<String>,
#[serde(default)]
pub(crate) env: HashMap<String, String>,
}
pub(crate) fn parse_manifest(content: &str) -> anyhow::Result<DistroManifest> {
let manifest: DistroManifest =
toml::from_str(content).context("failed to parse Distro.toml")?;
super::validate::validate_manifest(&manifest)?;
Ok(manifest)
}
pub(crate) fn load_manifest(path: &Path) -> anyhow::Result<DistroManifest> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
parse_manifest(&content)
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL: &str = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "astrid-capsule-cli"
source = "@example-org/capsule-cli"
version = "0.1.0"
role = "uplink"
"#;
#[test]
fn parse_minimal_manifest() {
let m = parse_manifest(MINIMAL).unwrap();
assert_eq!(m.schema_version, 1);
assert_eq!(m.distro.id, "test");
assert_eq!(m.distro.name, "Test");
assert_eq!(m.distro.version, "0.1.0");
assert_eq!(m.capsules.len(), 1);
assert_eq!(m.capsules[0].name, "astrid-capsule-cli");
assert_eq!(m.capsules[0].role.as_deref(), Some("uplink"));
}
#[test]
fn parse_full_manifest() {
let toml = r#"
schema-version = 1
[distro]
id = "example-distro"
name = "Example Distro"
pretty-name = "Example Distro 0.1.0 (Genesis)"
version = "0.1.0"
codename = "genesis"
release-date = "2026-03-21"
description = "An example Astrid distro"
authors = ["Example Maintainers"]
maintainers = ["Example Maintainer <maintainer@example.com>"]
homepage = "https://example.com/example-distro"
support = "https://example.com/support"
bug-tracker = "https://example.com/issues"
repository = "https://example.com/example-distro.git"
license = "MIT OR Apache-2.0"
astrid-version = ">=0.5.0"
[distro.requires.astrid]
llm = "^1.0"
session = "^1.0"
[variables]
api_key = { secret = true, description = "API key" }
base_url = { description = "Base URL", default = "https://api.openai.com" }
[[capsule]]
name = "astrid-capsule-cli"
source = "@example-org/capsule-cli"
version = "0.1.0"
role = "uplink"
[[capsule]]
name = "astrid-capsule-openai-compat"
source = "@example-org/capsule-openai-compat"
version = "0.1.0"
group = "llm"
[capsule.env]
api_key = "{{ api_key }}"
base_url = "{{ base_url }}"
"#;
let m = parse_manifest(toml).unwrap();
assert_eq!(m.distro.codename.as_deref(), Some("genesis"));
assert_eq!(m.distro.maintainers.len(), 1);
assert_eq!(m.variables.len(), 2);
assert!(m.variables["api_key"].secret);
assert_eq!(
m.variables["base_url"].default.as_deref(),
Some("https://api.openai.com")
);
assert_eq!(m.capsules.len(), 2);
assert_eq!(m.capsules[1].group.as_deref(), Some("llm"));
assert_eq!(m.capsules[1].env["api_key"], "{{ api_key }}");
let requires = &m.distro.requires;
assert_eq!(requires["astrid"]["llm"], "^1.0");
}
#[test]
fn parse_capsule_ref_and_default_fields() {
let toml = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[distro.signing]
pubkey = "ed25519:AAAA"
[[capsule]]
name = "astrid-capsule-cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
[[capsule]]
name = "astrid-capsule-a"
source = "@org/a"
version = "0.2.0"
tag = "v0.2.0-rc1"
group = "llm"
default = true
[[capsule]]
name = "astrid-capsule-b"
source = "@org/b"
version = "0.3.0"
group = "llm"
"#;
let m = parse_manifest(toml).unwrap();
assert_eq!(m.distro.signing.as_ref().unwrap().pubkey, "ed25519:AAAA");
let a = m
.capsules
.iter()
.find(|c| c.name == "astrid-capsule-a")
.unwrap();
assert_eq!(a.tag.as_deref(), Some("v0.2.0-rc1"));
assert!(a.default);
let b = m
.capsules
.iter()
.find(|c| c.name == "astrid-capsule-b")
.unwrap();
assert!(!b.default);
assert_eq!(b.group.as_deref(), Some("llm"));
}
#[test]
fn branch_and_rev_fields_still_deserialize_on_struct() {
let toml = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "astrid-capsule-b"
source = "@org/b"
version = "0.3.0"
branch = "main"
rev = "abc123"
role = "uplink"
"#;
let m: DistroManifest = toml::from_str(toml).unwrap();
let b = &m.capsules[0];
assert_eq!(b.branch.as_deref(), Some("main"));
assert_eq!(b.rev.as_deref(), Some("abc123"));
assert!(parse_manifest(toml).is_err());
}
#[test]
fn parse_rejects_wrong_schema_version() {
let toml = r#"
schema-version = 99
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
"#;
let err = parse_manifest(toml).unwrap_err();
assert!(err.to_string().contains("schema-version"), "got: {err}");
}
#[test]
fn parse_rejects_invalid_distro_id() {
let toml = r#"
schema-version = 1
[distro]
id = "INVALID"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
"#;
let err = parse_manifest(toml).unwrap_err();
assert!(err.to_string().contains("distro.id"), "got: {err}");
}
#[test]
fn parse_rejects_no_capsules() {
let toml = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
"#;
let err = parse_manifest(toml).unwrap_err();
assert!(
err.to_string().contains("at least one capsule"),
"got: {err}"
);
}
#[test]
fn parse_rejects_no_uplink() {
let toml = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "astrid-capsule-session"
source = "@org/session"
version = "0.1.0"
"#;
let err = parse_manifest(toml).unwrap_err();
assert!(err.to_string().contains("uplink"), "got: {err}");
}
#[test]
fn parse_rejects_duplicate_capsule_names() {
let toml = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "astrid-capsule-cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
[[capsule]]
name = "astrid-capsule-cli"
source = "@org/cli2"
version = "0.2.0"
role = "uplink"
"#;
let err = parse_manifest(toml).unwrap_err();
assert!(err.to_string().contains("duplicate"), "got: {err}");
}
#[test]
fn parse_rejects_undefined_variable_ref() {
let toml = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "0.1.0"
[[capsule]]
name = "astrid-capsule-cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
[[capsule]]
name = "astrid-capsule-llm"
source = "@org/llm"
version = "0.1.0"
[capsule.env]
key = "{{ undefined_var }}"
"#;
let err = parse_manifest(toml).unwrap_err();
assert!(err.to_string().contains("undefined_var"), "got: {err}");
}
#[test]
fn parse_rejects_invalid_distro_version() {
let toml = r#"
schema-version = 1
[distro]
id = "test"
name = "Test"
version = "not_semver"
[[capsule]]
name = "cli"
source = "@org/cli"
version = "0.1.0"
role = "uplink"
"#;
let err = parse_manifest(toml).unwrap_err();
assert!(err.to_string().contains("version"), "got: {err}");
}
}