feat(manifest): app manifest + decision crate (strict TOML subset, checked newtypes, capability catalog); docs/apps.md spec

This commit is contained in:
2026-10-27 22:32:40 +01:00
parent ec3bc0d2a1
commit 9bc31a9bbe
11 changed files with 1016 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
//! An app's manifest: who it is and what it asks for.
use crate::Error;
use crate::capability::{Capabilities, Capability};
use crate::fields::{Accent, AppId, Command, FileName, Text, Version};
use crate::toml::{self, Entry, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Manifest {
pub id: AppId,
pub name: Text<64>,
pub version: Version,
pub binary: FileName,
/// What the shell runs it as.
pub command: Command,
pub author: Option<Text<64>>,
pub icon: Option<FileName>,
/// Denied = not installed.
pub required: Capabilities,
/// The owner decides each.
pub optional: Capabilities,
pub accent: Option<Accent>,
}
/// Fields as they are read, before the required ones are known present.
#[derive(Default)]
struct Draft {
id: Option<AppId>,
name: Option<Text<64>>,
version: Option<Version>,
binary: Option<FileName>,
command: Option<Command>,
author: Option<Text<64>>,
icon: Option<FileName>,
required: Option<Capabilities>,
optional: Option<Capabilities>,
accent: Option<Accent>,
}
/// Sets `slot` once; a second value for the same key is an error.
fn set<T>(slot: &mut Option<T>, value: Option<T>, line: usize) -> Result<(), Error> {
if slot.is_some() {
return Err(Error::Duplicate { line });
}
*slot = Some(value.ok_or(Error::Invalid { line })?);
Ok(())
}
fn text<'a>(value: Value<'a>) -> Option<&'a str> {
match value {
Value::Str(s) => Some(s),
Value::List(_) => None,
}
}
/// A list of capability names, each known and listed once.
pub(crate) fn capabilities(value: Value, line: usize) -> Result<Capabilities, Error> {
let Value::List(list) = value else {
return Err(Error::Invalid { line });
};
let mut set = Capabilities::NONE;
for name in list.iter() {
let c = Capability::from_name(name).ok_or(Error::Unknown { line })?;
if set.contains(c) {
return Err(Error::Duplicate { line });
}
set = set.with(c);
}
Ok(set)
}
impl Draft {
fn take(&mut self, e: Entry) -> Result<(), Error> {
let l = e.line;
let s = text(e.value);
match (e.table, e.key) {
("app", "id") => set(&mut self.id, s.and_then(AppId::new), l),
("app", "name") => set(&mut self.name, s.and_then(Text::new), l),
("app", "version") => set(&mut self.version, s.and_then(Version::parse), l),
("app", "binary") => set(&mut self.binary, s.and_then(FileName::new), l),
("app", "command") => set(&mut self.command, s.and_then(Command::new), l),
("app", "author") => set(&mut self.author, s.and_then(Text::new), l),
("app", "icon") => set(&mut self.icon, s.and_then(FileName::new), l),
("capabilities", "required") => {
set(&mut self.required, Some(capabilities(e.value, l)?), l)
}
("capabilities", "optional") => {
set(&mut self.optional, Some(capabilities(e.value, l)?), l)
}
("appearance", "accent") => set(&mut self.accent, s.and_then(Accent::parse), l),
_ => Err(Error::Unknown { line: l }),
}
}
}
impl Manifest {
pub fn parse(text: &str) -> Result<Self, Error> {
let mut d = Draft::default();
toml::parse(text, |e| d.take(e))?;
let binary = d.binary.ok_or(Error::Missing("binary"))?;
let command = match d.command {
Some(c) => c,
// A binary name is a command name when it fits the stricter rules.
None => Command::new(binary.as_str()).ok_or(Error::Missing("command"))?,
};
let required = d.required.unwrap_or_default();
let optional = d.optional.unwrap_or_default();
if !required.intersect(optional).is_empty() {
return Err(Error::Conflict);
}
Ok(Self {
id: d.id.ok_or(Error::Missing("id"))?,
name: d.name.ok_or(Error::Missing("name"))?,
version: d.version.ok_or(Error::Missing("version"))?,
binary,
command,
author: d.author,
icon: d.icon,
required,
optional,
accent: d.accent,
})
}
/// Everything it asks for.
pub fn requested(&self) -> Capabilities {
self.required.union(self.optional)
}
}