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,109 @@
//! What an app can ask for: one name per app-facing server in the
//! catalog (see the vision's Servers note). Whether this device has the
//! server is a separate question, asked when an app is installed.
macro_rules! catalog {
($($variant:ident = $name:literal,)*) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Capability {
$($variant,)*
}
impl Capability {
pub const ALL: &[Capability] = &[$(Capability::$variant,)*];
pub fn from_name(name: &str) -> Option<Self> {
match name {
$($name => Some(Capability::$variant),)*
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
$(Capability::$variant => $name,)*
}
}
}
};
}
catalog! {
Display = "display",
Storage = "storage",
AudioPlayback = "audio_playback",
AudioCapture = "audio_capture",
Camera = "camera",
Network = "network",
Telephony = "telephony",
Bluetooth = "bluetooth",
Gps = "gps",
Usb = "usb",
Power = "power",
Notifications = "notifications",
Clipboard = "clipboard",
Contacts = "contacts",
Calendar = "calendar",
Alarms = "alarms",
MediaIndex = "media_index",
Keychain = "keychain",
Sync = "sync",
Accessibility = "accessibility",
Print = "print",
}
impl Capability {
fn bit(self) -> u32 {
1 << (self as u32)
}
}
/// A set of capabilities.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Capabilities(u32);
impl Capabilities {
pub const NONE: Self = Self(0);
pub fn with(self, c: Capability) -> Self {
Self(self.0 | c.bit())
}
pub fn without(self, c: Capability) -> Self {
Self(self.0 & !c.bit())
}
pub fn contains(self, c: Capability) -> bool {
self.0 & c.bit() != 0
}
pub fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
pub fn intersect(self, other: Self) -> Self {
Self(self.0 & other.0)
}
pub fn is_empty(self) -> bool {
self.0 == 0
}
/// Whether every capability in `self` is in `other`.
pub fn is_subset(self, other: Self) -> bool {
self.0 & !other.0 == 0
}
pub fn iter(self) -> impl Iterator<Item = Capability> {
Capability::ALL
.iter()
.copied()
.filter(move |c| self.contains(*c))
}
}
impl FromIterator<Capability> for Capabilities {
fn from_iter<I: IntoIterator<Item = Capability>>(iter: I) -> Self {
iter.into_iter().fold(Self::NONE, Self::with)
}
}

View File

@@ -0,0 +1,59 @@
//! The owner's decision about one app: what it was granted.
use crate::Error;
use crate::capability::{Capabilities, Capability};
use crate::toml;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Decision {
pub granted: Capabilities,
}
/// Longest decision file `write` produces: the header and every name.
pub const MAX_TEXT: usize = 512;
impl Decision {
pub fn parse(text: &str) -> Result<Self, Error> {
let mut granted = None;
toml::parse(text, |e| match (e.table, e.key) {
("decision", "granted") if granted.is_none() => {
granted = Some(crate::manifest::capabilities(e.value, e.line)?);
Ok(())
}
("decision", "granted") => Err(Error::Duplicate { line: e.line }),
_ => Err(Error::Unknown { line: e.line }),
})?;
Ok(Self {
granted: granted.ok_or(Error::Missing("granted"))?,
})
}
/// The file this decision is stored as, in `out`; its length.
pub fn write(&self, out: &mut [u8; MAX_TEXT]) -> usize {
let mut w = Writer { out, len: 0 };
w.put("[decision]\ngranted = [");
for (i, c) in self.granted.iter().enumerate() {
if i > 0 {
w.put(", ");
}
w.put("\"");
w.put(Capability::name(c));
w.put("\"");
}
w.put("]\n");
w.len
}
}
struct Writer<'a> {
out: &'a mut [u8; MAX_TEXT],
len: usize,
}
impl Writer<'_> {
fn put(&mut self, s: &str) {
let end = (self.len + s.len()).min(MAX_TEXT);
self.out[self.len..end].copy_from_slice(&s.as_bytes()[..end - self.len]);
self.len = end;
}
}

View File

@@ -0,0 +1,145 @@
//! The values a manifest holds, each checked when it is made: an app id
//! that is not a reverse-DNS name, say, cannot exist.
/// Up to `N` bytes of printable ASCII, held inline (no allocation).
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Text<const N: usize> {
bytes: [u8; N],
len: u8,
}
impl<const N: usize> Text<N> {
/// `None` if `s` is empty or longer than `N`.
pub fn new(s: &str) -> Option<Self> {
if s.is_empty() || s.len() > N || N > u8::MAX as usize {
return None;
}
let mut bytes = [0; N];
bytes[..s.len()].copy_from_slice(s.as_bytes());
Some(Self {
bytes,
len: s.len() as u8,
})
}
pub fn as_str(&self) -> &str {
// Made only from a &str, so always UTF-8.
core::str::from_utf8(&self.bytes[..self.len as usize]).unwrap_or("")
}
}
impl<const N: usize> core::fmt::Debug for Text<N> {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
write!(f, "{:?}", self.as_str())
}
}
impl<const N: usize> core::fmt::Display for Text<N> {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
/// What identifies an app everywhere: lowercase reverse-DNS, two or more
/// dot-separated parts of `a-z`, `0-9` and `-` (`kos.snake`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AppId(Text<64>);
impl AppId {
pub fn new(s: &str) -> Option<Self> {
let part = |p: &str| {
!p.is_empty()
&& p.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
};
let parts = s.split('.');
(s.split('.').count() >= 2 && parts.clone().all(part)).then(|| Text::new(s).map(Self))?
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl core::fmt::Display for AppId {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
f.write_str(self.as_str())
}
}
/// A file directly in the app's folder: no `/`, not `.` or `..`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FileName(Text<32>);
impl FileName {
pub fn new(s: &str) -> Option<Self> {
let ok = s
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
&& s != "."
&& s != "..";
ok.then(|| Text::new(s).map(Self))?
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
/// What the shell runs an app as: `a-z`, `0-9`, `-` and `_`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Command(Text<16>);
impl Command {
pub fn new(s: &str) -> Option<Self> {
let ok = s
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_');
ok.then(|| Text::new(s).map(Self))?
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
/// `major.minor.patch`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u16,
pub minor: u16,
pub patch: u16,
}
impl Version {
pub fn parse(s: &str) -> Option<Self> {
let mut parts = s.split('.');
let mut next = || -> Option<u16> {
let p = parts.next()?;
// No signs, no leading zeros (other than 0 itself).
let digits = !p.is_empty()
&& p.bytes().all(|b| b.is_ascii_digit())
&& (p == "0" || !p.starts_with('0'));
digits.then(|| p.parse().ok())?
};
let v = Self {
major: next()?,
minor: next()?,
patch: next()?,
};
parts.next().is_none().then_some(v)
}
}
/// An app's brand colour, `#rrggbb`. Contrast is K-UI's to judge.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Accent(pub u32);
impl Accent {
pub fn parse(s: &str) -> Option<Self> {
let hex = s
.strip_prefix('#')
.filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
u32::from_str_radix(hex, 16).ok().map(Self)
}
}

View File

@@ -0,0 +1,37 @@
//! App manifests and the owner's decisions about them (see docs/apps.md):
//! parsed strictly from a TOML subset, into checked values. Pure: no
//! hardware, no kernel, no allocation.
#![no_std]
#![forbid(unsafe_code)]
mod capability;
mod decision;
mod fields;
mod manifest;
mod toml;
#[cfg(test)]
mod tests;
pub use capability::{Capabilities, Capability};
pub use decision::{Decision, MAX_TEXT as MAX_DECISION_TEXT};
pub use fields::{Accent, AppId, Command, FileName, Text, Version};
pub use manifest::Manifest;
/// Why a manifest or decision was refused; `line` is 1-based.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Error {
/// Not the TOML subset.
Syntax { line: usize },
/// A table, key or capability given twice.
Duplicate { line: usize },
/// A table, key or capability name that does not exist.
Unknown { line: usize },
/// A value of the right kind but not a valid one (a bad id, say).
Invalid { line: usize },
/// A required key is absent.
Missing(&'static str),
/// A capability both required and optional.
Conflict,
}

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)
}
}

View File

@@ -0,0 +1,241 @@
use crate::*;
const SNAKE: &str = r##"
# the game
[app]
id = "kos.snake"
name = "Snake"
version = "1.0.0"
binary = "snake"
author = "K-OS"
[capabilities]
required = ["display"]
optional = ["storage",] # high scores
[appearance]
accent = "#e91e63"
"##;
fn with(line: &str, replacing: &str) -> std::string::String {
SNAKE.replace(replacing, line)
}
extern crate std;
#[test]
fn a_full_manifest_reads_into_checked_values() {
let m = Manifest::parse(SNAKE).unwrap();
assert_eq!(m.id.as_str(), "kos.snake");
assert_eq!(m.name.as_str(), "Snake");
assert_eq!(
m.version,
Version {
major: 1,
minor: 0,
patch: 0
}
);
assert_eq!(m.binary.as_str(), "snake");
assert_eq!(m.command.as_str(), "snake", "the binary's name by default");
assert_eq!(m.author.unwrap().as_str(), "K-OS");
assert_eq!(m.required, Capabilities::NONE.with(Capability::Display));
assert_eq!(m.optional, Capabilities::NONE.with(Capability::Storage));
assert_eq!(m.accent, Some(Accent(0xe91e63)));
}
#[test]
fn only_app_identity_is_required() {
let m = Manifest::parse(
"[app]\nid = \"kos.cat\"\nname = \"cat\"\nversion = \"0.1.0\"\nbinary = \"cat\"\n",
)
.unwrap();
assert!(m.requested().is_empty());
for key in ["id", "name", "version", "binary"] {
let text = std::format!(
"[app]\n{}",
[
"id = \"a.b\"",
"name = \"n\"",
"version = \"1.0.0\"",
"binary = \"b\""
]
.iter()
.filter(|l| !l.starts_with(key))
.copied()
.collect::<std::vec::Vec<_>>()
.join("\n")
);
assert_eq!(Manifest::parse(&text), Err(Error::Missing(key)), "{key}");
}
}
#[test]
fn anything_unknown_is_refused_not_ignored() {
assert_eq!(
Manifest::parse(&with(
"homepage = \"x\"\nauthor = \"K-OS\"",
"author = \"K-OS\""
)),
Err(Error::Unknown { line: 8 })
);
assert_eq!(
Manifest::parse(&with(
"required = [\"teleport\"]",
"required = [\"display\"]"
)),
Err(Error::Unknown { line: 11 })
);
assert!(matches!(
Manifest::parse(&with("[extras]\nx = \"y\"\n[appearance]", "[appearance]")),
Err(Error::Unknown { .. })
));
}
#[test]
fn duplicates_are_refused() {
assert!(matches!(
Manifest::parse(&with(
"name = \"Snake\"\nname = \"Other\"",
"name = \"Snake\""
)),
Err(Error::Duplicate { .. })
));
assert!(matches!(
Manifest::parse(&with(
"required = [\"display\", \"display\"]",
"required = [\"display\"]"
)),
Err(Error::Duplicate { .. })
));
assert!(matches!(
Manifest::parse(&std::format!("{SNAKE}\n[app]\n")),
Err(Error::Duplicate { .. })
));
assert_eq!(
Manifest::parse(&with(
"required = [\"storage\"]",
"required = [\"display\"]"
)),
Err(Error::Conflict)
);
}
#[test]
fn values_are_checked() {
for (bad, good) in [
("id = \"Snake\"", "id = \"kos.snake\""),
("id = \"snake\"", "id = \"kos.snake\""),
("id = \"kos..snake\"", "id = \"kos.snake\""),
("version = \"1.0\"", "version = \"1.0.0\""),
("version = \"01.0.0\"", "version = \"1.0.0\""),
("version = \"1.0.0.0\"", "version = \"1.0.0\""),
("binary = \"../shell\"", "binary = \"snake\""),
("binary = \"bin/snake\"", "binary = \"snake\""),
("binary = \"..\"", "binary = \"snake\""),
("icon = \".\"", "author = \"K-OS\""),
("accent = \"e91e63\"", "accent = \"#e91e63\""),
("name = [\"Snake\"]", "name = \"Snake\""),
] {
assert!(
matches!(
Manifest::parse(&with(bad, good)),
Err(Error::Invalid { .. })
),
"{bad}"
);
}
// A binary that is no valid command needs one spelled out.
let odd = with("binary = \"Snake.bin\"", "binary = \"snake\"");
assert_eq!(Manifest::parse(&odd), Err(Error::Missing("command")));
let named = with(
"binary = \"Snake.bin\"\ncommand = \"snake\"",
"binary = \"snake\"",
);
assert_eq!(
Manifest::parse(&named).unwrap().binary.as_str(),
"Snake.bin"
);
}
#[test]
fn only_the_subset_parses() {
for bad in [
"id = 'kos.snake'", // single quotes
"id = \"kos.\\snake\"", // escapes
"id = \"kos.snake", // unclosed
"id = kos.snake", // bare value
"id \"kos.snake\"", // no =
"[app", // unclosed table
] {
assert!(
matches!(
Manifest::parse(&with(bad, "id = \"kos.snake\"")),
Err(Error::Syntax { .. })
),
"{bad}"
);
}
assert!(
matches!(
Manifest::parse("id = \"a.b\"\n"),
Err(Error::Syntax { line: 1 })
),
"a key outside any table"
);
// A comma inside a list item cannot be split wrongly: refused.
assert!(matches!(
Manifest::parse(&with(
"optional = [\"a, b\"]",
"optional = [\"storage\",] # high scores"
)),
Err(Error::Syntax { .. })
));
// `#` inside a string is not a comment.
let hash = with("name = \"Snake #1\"", "name = \"Snake\"");
assert_eq!(Manifest::parse(&hash).unwrap().name.as_str(), "Snake #1");
}
#[test]
fn decisions_round_trip() {
let d = Decision {
granted: Capabilities::NONE
.with(Capability::Display)
.with(Capability::Storage),
};
let mut buf = [0; MAX_DECISION_TEXT];
let n = d.write(&mut buf);
let text = core::str::from_utf8(&buf[..n]).unwrap();
assert_eq!(text, "[decision]\ngranted = [\"display\", \"storage\"]\n");
assert_eq!(Decision::parse(text), Ok(d));
assert_eq!(
Decision::parse("[decision]\ngranted = []\n"),
Ok(Decision::default())
);
assert_eq!(
Decision::parse("[decision]\n"),
Err(Error::Missing("granted"))
);
assert!(matches!(
Decision::parse("[decision]\ngranted = [\"x\"]\n"),
Err(Error::Unknown { .. })
));
}
#[test]
fn every_capability_name_round_trips_and_fits_the_set() {
assert!(Capability::ALL.len() <= 32);
for c in Capability::ALL {
assert_eq!(Capability::from_name(c.name()), Some(*c));
}
let all: Capabilities = Capability::ALL.iter().copied().collect();
let mut buf = [0; MAX_DECISION_TEXT];
let n = Decision { granted: all }.write(&mut buf);
assert!(n < MAX_DECISION_TEXT, "every name fits a decision file");
assert_eq!(
Decision::parse(core::str::from_utf8(&buf[..n]).unwrap())
.unwrap()
.granted,
all
);
}

135
crates/manifest/src/toml.rs Normal file
View File

@@ -0,0 +1,135 @@
//! The strict TOML subset manifests and decisions are written in:
//! `[table]` headers, `key = "string"`, `key = ["a", "b"]` on one line,
//! `#` comments. Strings are printable ASCII without `"` or `\`.
use crate::Error;
/// One `key = value` line, with the table it is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Entry<'a> {
pub line: usize,
pub table: &'a str,
pub key: &'a str,
pub value: Value<'a>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Value<'a> {
Str(&'a str),
/// A one-line array of strings, already checked.
List(List<'a>),
}
/// The inside of `[...]`: iterate it for its strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct List<'a>(&'a str);
impl<'a> List<'a> {
pub fn iter(&self) -> impl Iterator<Item = &'a str> {
self.0
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| &s[1..s.len() - 1])
}
}
/// A table may be opened only once.
const MAX_TABLES: usize = 8;
/// Calls `entry` with every `key = value` in `text`, in order; stops at
/// the first malformed line or the first error `entry` returns.
pub fn parse<'a>(
text: &'a str,
mut entry: impl FnMut(Entry<'a>) -> Result<(), Error>,
) -> Result<(), Error> {
let mut table = "";
let mut seen = [""; MAX_TABLES];
let mut tables = 0;
for (i, raw) in text.lines().enumerate() {
let line = i + 1;
let content = strip_comment(raw, line)?.trim();
if content.is_empty() {
continue;
}
if let Some(name) = content.strip_prefix('[') {
let name = name
.strip_suffix(']')
.filter(|n| is_key(n))
.ok_or(Error::Syntax { line })?;
if seen[..tables].contains(&name) {
return Err(Error::Duplicate { line });
}
*seen.get_mut(tables).ok_or(Error::Syntax { line })? = name;
tables += 1;
table = name;
continue;
}
let (key, value) = content.split_once('=').ok_or(Error::Syntax { line })?;
let (key, value) = (key.trim(), value.trim());
if table.is_empty() || !is_key(key) {
return Err(Error::Syntax { line });
}
entry(Entry {
line,
table,
key,
value: value_of(value, line)?,
})?;
}
Ok(())
}
/// The line up to a `#` outside a string.
fn strip_comment(raw: &str, line: usize) -> Result<&str, Error> {
let mut in_string = false;
for (i, c) in raw.char_indices() {
match c {
'"' => in_string = !in_string,
'#' if !in_string => return Ok(&raw[..i]),
_ => {}
}
}
if in_string {
Err(Error::Syntax { line })
} else {
Ok(raw)
}
}
fn is_key(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
fn string(s: &str, line: usize) -> Result<&str, Error> {
let inner = s
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.ok_or(Error::Syntax { line })?;
let printable = inner
.bytes()
.all(|b| (b' '..=b'~').contains(&b) && b != b'"' && b != b'\\');
if printable {
Ok(inner)
} else {
Err(Error::Syntax { line })
}
}
fn value_of(s: &str, line: usize) -> Result<Value<'_>, Error> {
let Some(inner) = s.strip_prefix('[') else {
return string(s, line).map(Value::Str);
};
let inner = inner.strip_suffix(']').ok_or(Error::Syntax { line })?;
let items: &str = inner.trim();
// A trailing comma is allowed; an empty item is not.
let body = items.strip_suffix(',').unwrap_or(items);
if !body.trim().is_empty() {
for item in body.split(',') {
string(item.trim(), line)?;
}
}
Ok(Value::List(List(body)))
}