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>>,
}
#[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) 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 = "@unicity-astrid/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 = "astralis"
name = "Astralis"
pretty-name = "Astralis 0.1.0 (Genesis)"
version = "0.1.0"
codename = "genesis"
release-date = "2026-03-21"
description = "The complete Astrid AI assistant experience"
authors = ["Astrid Core Team"]
maintainers = ["Joshua J. Bouw <josh@unicity-labs.com>"]
homepage = "https://github.com/unicity-astrid/astralis"
support = "https://github.com/unicity-astrid/astrid/discussions"
bug-tracker = "https://github.com/unicity-astrid/astralis/issues"
repository = "https://github.com/unicity-astrid/astralis"
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 = "@unicity-astrid/capsule-cli"
version = "0.1.0"
role = "uplink"
[[capsule]]
name = "astrid-capsule-openai-compat"
source = "@unicity-astrid/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_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}");
}
}