use std::path::Path;
use serde_json::{Map as JMap, Value as JValue};
use toml_edit::{DocumentMut, Item, Table};
use crate::error::{Error, Issue, Result, code};
use crate::util;
pub const TOP_BARE_KEYS: &[&str] = &[
"str",
"spec",
"kind",
"id",
"name",
"type",
"title",
"summary",
"tags",
"revision",
"created_at",
"updated_at",
"schema",
];
pub const TABLE_ORDER: &[&str] = &["policies", "authors", "refs", "entries", "ext"];
pub const POLICIES_KEYS: &[&str] = &[
"id_version",
"max_depth",
"manifest",
"sha256",
"large_asset_bytes",
"deep_tree_warn",
];
pub const AUTHOR_KEYS: &[&str] = &["id", "name", "role", "at"];
pub const REF_KEYS: &[&str] = &["id", "target", "rel", "title", "order", "note"];
pub const ENTRY_KEYS: &[&str] = &[
"path",
"role",
"id",
"type",
"title",
"summary",
"order",
"media_type",
"size",
"sha256",
"count",
"schema",
"optional",
"note",
];
pub const ROOT_ALLOWED: &[&str] = &[
"str", "spec", "kind", "id", "name", "type", "title", "summary", "tags", "revision",
"created_at", "updated_at", "schema", "policies", "authors", "refs", "entries", "ext",
];
pub const BRANCH_ALLOWED: &[&str] = &[
"str", "spec", "kind", "id", "name", "type", "title", "summary", "tags", "revision",
"created_at", "updated_at", "schema", "authors", "refs", "entries", "ext",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Root,
Node,
Branch,
}
impl Kind {
pub fn as_str(self) -> &'static str {
match self {
Kind::Root => "root",
Kind::Node => "node",
Kind::Branch => "branch",
}
}
pub fn parse(s: &str) -> Option<Kind> {
match s {
"root" => Some(Kind::Root),
"node" => Some(Kind::Node),
"branch" => Some(Kind::Branch),
_ => None,
}
}
pub fn allowed_top_keys(self) -> &'static [&'static str] {
match self {
Kind::Root => ROOT_ALLOWED,
_ => BRANCH_ALLOWED,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManifestPolicy {
Strict,
Advisory,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShaPolicy {
Required,
Optional,
Off,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policies {
pub id_version: usize,
pub max_depth: usize,
pub manifest: ManifestPolicy,
pub sha256: ShaPolicy,
pub large_asset_bytes: u64,
pub deep_tree_warn: usize,
}
impl Default for Policies {
fn default() -> Self {
Self {
id_version: 7,
max_depth: 32,
manifest: ManifestPolicy::Strict,
sha256: ShaPolicy::Required,
large_asset_bytes: 10 * 1024 * 1024,
deep_tree_warn: 16,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Author {
pub id: String,
pub name: Option<String>,
pub role: String,
pub at: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RefItem {
pub id: String,
pub target: String,
pub rel: String,
pub title: Option<String>,
pub order: Option<i64>,
pub note: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Entry {
pub path: String,
pub role: String,
pub id: Option<String>,
pub r#type: Option<String>,
pub title: Option<String>,
pub summary: Option<String>,
pub order: Option<i64>,
pub media_type: Option<String>,
pub size: Option<i64>,
pub sha256: Option<String>,
pub count: Option<i64>,
pub schema: Option<String>,
pub optional: bool,
pub note: Option<String>,
}
impl Entry {
pub fn is_branch(&self) -> bool {
self.role == "node" || self.role == "branch"
}
pub fn is_file_like(&self) -> bool {
self.role == "payload" || self.role == "asset"
}
}
#[derive(Debug)]
pub struct Meta {
pub doc: DocumentMut,
pub str_version: Option<i64>,
pub spec: Option<String>,
pub kind_raw: Option<String>,
pub kind: Option<Kind>,
pub id: Option<String>,
pub name: Option<String>,
pub r#type: Option<String>,
pub title: Option<String>,
pub summary: Option<String>,
pub tags: Vec<String>,
pub revision: Option<i64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub schema: Option<String>,
pub policies: Policies,
pub authors: Vec<Author>,
pub refs: Vec<RefItem>,
pub entries: Vec<Entry>,
}
pub enum MetaLoad {
Ok(Box<Meta>, Vec<Issue>),
Failed(Vec<Issue>),
}
pub fn load(path: &Path, rel: &str) -> Result<MetaLoad> {
let bytes = std::fs::read(path).map_err(|e| Error::io(path, e))?;
let mut issues = Vec::new();
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
issues.push(Issue::error(
code::PARSE,
rel,
"文件含 UTF-8 BOM,规范要求 UTF-8 无 BOM",
));
return Ok(MetaLoad::Failed(issues));
}
let text = match std::str::from_utf8(&bytes) {
Ok(t) => t,
Err(e) => {
issues.push(Issue::error(
code::PARSE,
rel,
format!("编码非 UTF-8:{e}"),
));
return Ok(MetaLoad::Failed(issues));
}
};
let doc: DocumentMut = match text.parse() {
Ok(d) => d,
Err(e) => {
issues.push(Issue::error(code::PARSE, rel, format!("TOML 解析失败:{e}")));
return Ok(MetaLoad::Failed(issues));
}
};
let (meta, mut field_issues) = extract(doc, rel);
issues.append(&mut field_issues);
Ok(MetaLoad::Ok(Box::new(meta), issues))
}
pub fn extract(doc: DocumentMut, rel: &str) -> (Meta, Vec<Issue>) {
let mut cx = Ctx {
rel: rel.to_string(),
issues: Vec::new(),
};
let table = doc.as_table().clone();
let str_version = cx.opt_int(&table, "str");
let spec = cx.opt_str(&table, "spec");
let kind_raw = cx.opt_str(&table, "kind");
let kind = match kind_raw.as_deref() {
Some(s) => match Kind::parse(s) {
Some(k) => Some(k),
None => {
cx.err(
code::KIND_INVALID,
format!("`kind` = {s:?} 非法(应为 root / node / branch)"),
);
None
}
},
None => {
cx.err(code::SCHEMA_FIELD, "缺少必填字段 `kind`");
None
}
};
let allowed = kind.map(|k| k.allowed_top_keys()).unwrap_or(BRANCH_ALLOWED);
cx.check_unknown(&table, allowed);
for key in ["id", "revision", "created_at", "updated_at"] {
if table.get(key).is_none() {
cx.err(code::SCHEMA_FIELD, format!("缺少必填字段 `{key}`"));
}
}
let id = cx.opt_str(&table, "id");
let name = cx.opt_str(&table, "name");
let r#type = cx.opt_str(&table, "type");
let title = cx.opt_str(&table, "title");
let summary = cx.opt_str(&table, "summary");
let tags = cx.opt_str_array(&table, "tags").unwrap_or_default();
let revision = cx.opt_int(&table, "revision");
let created_at = cx.opt_dt(&table, "created_at");
let updated_at = cx.opt_dt(&table, "updated_at");
let schema = cx.opt_str(&table, "schema");
let mut policies = Policies::default();
if let Some(item) = table.get("policies") {
match item.as_table() {
Some(pt) => {
if kind != Some(Kind::Root) {
cx.err(
code::SCHEMA_FIELD,
"`[policies]` 只能出现在 root 的 `._meta` 中",
);
}
cx.check_unknown(pt, POLICIES_KEYS);
if let Some(v) = cx.opt_int(pt, "id_version") {
policies.id_version = v.max(0) as usize;
}
if let Some(v) = cx.opt_int(pt, "max_depth") {
policies.max_depth = v.max(1) as usize;
}
if let Some(v) = cx.opt_str(pt, "manifest") {
policies.manifest = match v.as_str() {
"strict" => ManifestPolicy::Strict,
"advisory" => ManifestPolicy::Advisory,
_ => {
cx.err(
code::SCHEMA_FIELD,
format!("`policies.manifest` = {v:?} 非法(strict / advisory)"),
);
policies.manifest
}
};
}
if let Some(v) = cx.opt_str(pt, "sha256") {
policies.sha256 = match v.as_str() {
"required" => ShaPolicy::Required,
"optional" => ShaPolicy::Optional,
"off" => ShaPolicy::Off,
_ => {
cx.err(
code::SCHEMA_FIELD,
format!("`policies.sha256` = {v:?} 非法(required / optional / off)"),
);
policies.sha256
}
};
}
if let Some(v) = cx.opt_int(pt, "large_asset_bytes") {
policies.large_asset_bytes = v.max(0) as u64;
}
if let Some(v) = cx.opt_int(pt, "deep_tree_warn") {
policies.deep_tree_warn = v.max(1) as usize;
}
}
None => cx.err(code::SCHEMA_FIELD, "`policies` 必须是表 `[policies]`"),
}
}
let mut authors = Vec::new();
if let Some(aot) = table.get("authors").and_then(|i| i.as_array_of_tables()) {
for (i, t) in aot.iter().enumerate() {
cx.set_rel(&format!("{rel} #authors[{i}]"));
cx.check_unknown(t, AUTHOR_KEYS);
let aid = cx.req_str(t, "id").unwrap_or_default();
let role = cx.req_str(t, "role").unwrap_or_default();
let aname = cx.opt_str(t, "name");
let at = cx.opt_dt(t, "at");
authors.push(Author {
id: aid,
name: aname,
role,
at,
});
}
cx.set_rel(rel);
} else if table.get("authors").is_some() {
cx.err(code::SCHEMA_FIELD, "`authors` 必须是数组表 `[[authors]]`");
}
let mut refs = Vec::new();
if let Some(aot) = table.get("refs").and_then(|i| i.as_array_of_tables()) {
for (i, t) in aot.iter().enumerate() {
cx.set_rel(&format!("{rel} #refs[{i}]"));
cx.check_unknown(t, REF_KEYS);
refs.push(RefItem {
id: cx.req_str(t, "id").unwrap_or_default(),
target: cx.req_str(t, "target").unwrap_or_default(),
rel: cx.req_str(t, "rel").unwrap_or_default(),
title: cx.opt_str(t, "title"),
order: cx.opt_int(t, "order"),
note: cx.opt_str(t, "note"),
});
}
cx.set_rel(rel);
} else if table.get("refs").is_some() {
cx.err(code::SCHEMA_FIELD, "`refs` 必须是数组表 `[[refs]]`");
}
let mut entries = Vec::new();
if let Some(aot) = table.get("entries").and_then(|i| i.as_array_of_tables()) {
for (i, t) in aot.iter().enumerate() {
cx.set_rel(&format!("{rel} #entries[{i}]"));
cx.check_unknown(t, ENTRY_KEYS);
let path = cx.req_str(t, "path").unwrap_or_default();
let role = cx.req_str(t, "role").unwrap_or_default();
if path.contains('/') {
cx.err(
code::SCHEMA_FIELD,
format!("`entries[].path` = {path:?} 必须是单段路径(不含 `/`)"),
);
}
let e = Entry {
path,
role,
id: cx.opt_str(t, "id"),
r#type: cx.opt_str(t, "type"),
title: cx.opt_str(t, "title"),
summary: cx.opt_str(t, "summary"),
order: cx.opt_int(t, "order"),
media_type: cx.opt_str(t, "media_type"),
size: cx.opt_int(t, "size"),
sha256: cx.opt_str(t, "sha256"),
count: cx.opt_int(t, "count"),
schema: cx.opt_str(t, "schema"),
optional: cx.opt_bool(t, "optional").unwrap_or(false),
note: cx.opt_str(t, "note"),
};
entries.push(e);
}
cx.set_rel(rel);
} else if table.get("entries").is_some() {
cx.err(code::SCHEMA_FIELD, "`entries` 必须是数组表 `[[entries]]`");
}
let meta = Meta {
doc,
str_version,
spec,
kind_raw,
kind,
id,
name,
r#type,
title,
summary,
tags,
revision,
created_at,
updated_at,
schema,
policies,
authors,
refs,
entries,
};
(meta, cx.issues)
}
pub fn canonicalize_doc(doc: &mut DocumentMut) {
let top_rank = |k: &str, it: &Item| -> usize {
match it {
Item::Table(_) | Item::ArrayOfTables(_) => {
TOP_BARE_KEYS.len()
+ 1
+ TABLE_ORDER
.iter()
.position(|x| *x == k)
.unwrap_or(TABLE_ORDER.len())
}
_ => TOP_BARE_KEYS
.iter()
.position(|x| *x == k)
.unwrap_or(TOP_BARE_KEYS.len()),
}
};
doc.as_table_mut()
.sort_values_by(|k1, i1, k2, i2| top_rank(k1.get(), i1).cmp(&top_rank(k2.get(), i2)));
for (key, order) in [
("policies", POLICIES_KEYS),
("authors", AUTHOR_KEYS),
("refs", REF_KEYS),
("entries", ENTRY_KEYS),
] {
let rank = |k: &str| order.iter().position(|x| *x == k).unwrap_or(usize::MAX);
match doc.as_table_mut().get_mut(key) {
Some(Item::Table(t)) => {
t.sort_values_by(|k1, _, k2, _| rank(k1.get()).cmp(&rank(k2.get())));
}
Some(Item::ArrayOfTables(a)) => {
for t in a.iter_mut() {
t.sort_values_by(|k1, _, k2, _| rank(k1.get()).cmp(&rank(k2.get())));
}
}
_ => {}
}
}
sort_collections_in(doc);
renumber_positions_in(doc);
}
fn collection_sort_key(t: &Table) -> (i64, String) {
let order = t
.get("order")
.and_then(Item::as_integer)
.unwrap_or(i64::MAX);
let id = t
.get("path")
.or_else(|| t.get("id"))
.and_then(Item::as_str)
.unwrap_or("")
.to_string();
(order, id)
}
fn sort_collections_in(doc: &mut DocumentMut) {
for key in ["entries", "refs"] {
let Some(aot) = doc
.as_table_mut()
.get_mut(key)
.and_then(Item::as_array_of_tables_mut)
else {
continue;
};
let mut items: Vec<Table> = aot.iter().cloned().collect();
items.sort_by_key(collection_sort_key);
for (slot, table) in aot.iter_mut().zip(items) {
*slot = table;
}
}
}
fn renumber_positions_in(doc: &mut DocumentMut) {
fn walk(t: &mut Table, pos: &mut isize) {
*pos += 1;
t.set_position(Some(*pos));
for (_, item) in t.iter_mut() {
match item {
Item::Table(child) => walk(child, pos),
Item::ArrayOfTables(a) => {
for child in a.iter_mut() {
walk(child, pos);
}
}
_ => {}
}
}
}
let mut pos = 0isize;
walk(doc.as_table_mut(), &mut pos);
}
impl Meta {
pub fn to_json(&self) -> JValue {
let table = self.doc.as_table();
let mut map = JMap::new();
for key in TOP_BARE_KEYS {
if let Some(item) = table.get(key) {
map.insert((*key).to_string(), item_to_json(item));
}
}
if let Some(item) = table.get("policies") {
match item.as_table() {
Some(t) => {
map.insert("policies".into(), table_to_json(t, POLICIES_KEYS));
}
None => {
map.insert("policies".into(), item_to_json(item));
}
}
}
if let Some(aot) = table.get("authors").and_then(|i| i.as_array_of_tables()) {
map.insert(
"authors".into(),
JValue::Array(
aot.iter()
.map(|t| table_to_json(t, AUTHOR_KEYS))
.collect(),
),
);
}
map.insert(
"refs".into(),
match table.get("refs").and_then(|i| i.as_array_of_tables()) {
Some(aot) => JValue::Array(
aot.iter()
.map(|t| table_to_json(t, REF_KEYS))
.collect(),
),
None => JValue::Array(Vec::new()),
},
);
map.insert(
"entries".into(),
match table.get("entries").and_then(|i| i.as_array_of_tables()) {
Some(aot) => JValue::Array(
aot.iter()
.map(|t| table_to_json(t, ENTRY_KEYS))
.collect(),
),
None => JValue::Array(Vec::new()),
},
);
if let Some(item) = table.get("ext") {
map.insert("ext".into(), item_to_json(item));
}
for (k, v) in table.iter() {
if !map.contains_key(k) {
map.insert(k.to_string(), item_to_json(v));
}
}
JValue::Object(map)
}
pub fn sort_collections(&mut self) {
sort_collections_in(&mut self.doc);
renumber_positions_in(&mut self.doc);
}
pub fn canonicalize(&mut self) {
canonicalize_doc(&mut self.doc);
}
pub fn revision_issues(&self, rel: &str) -> Vec<Issue> {
let mut out = Vec::new();
match self.revision {
None => {}
Some(r) if r < 1 => out.push(Issue::error(
code::REVISION_STALE,
rel,
format!("`revision` = {r},必须为 ≥ 1 的整数"),
)),
Some(_) => {}
}
if let (Some(c), Some(u)) = (self.created_at.as_deref(), self.updated_at.as_deref()) {
match (util::parse_rfc3339(c), util::parse_rfc3339(u)) {
(Some(cd), Some(ud)) if ud < cd => out.push(Issue::error(
code::REVISION_STALE,
rel,
format!("`updated_at`({u})早于 `created_at`({c})"),
)),
_ => {}
}
}
out
}
}
fn item_to_json(item: &Item) -> JValue {
match item {
Item::None => JValue::Null,
Item::Value(v) => value_to_json(v),
Item::Table(t) => {
let mut m = JMap::new();
for (k, v) in t.iter() {
m.insert(k.to_string(), item_to_json(v));
}
JValue::Object(m)
}
Item::ArrayOfTables(aot) => JValue::Array(
aot.iter()
.map(|t| {
let mut m = JMap::new();
for (k, v) in t.iter() {
m.insert(k.to_string(), item_to_json(v));
}
JValue::Object(m)
})
.collect(),
),
}
}
fn value_to_json(v: &toml_edit::Value) -> JValue {
if let Some(s) = v.as_str() {
return JValue::String(s.to_string());
}
if let Some(i) = v.as_integer() {
return JValue::from(i);
}
if let Some(f) = v.as_float() {
return serde_json::Number::from_f64(f)
.map(JValue::Number)
.unwrap_or(JValue::Null);
}
if let Some(b) = v.as_bool() {
return JValue::Bool(b);
}
if let Some(d) = v.as_datetime() {
return JValue::String(d.to_string());
}
if let Some(a) = v.as_array() {
return JValue::Array(a.iter().map(value_to_json).collect());
}
if let Some(t) = v.as_inline_table() {
let mut m = JMap::new();
for (k, val) in t.iter() {
m.insert(k.to_string(), value_to_json(val));
}
return JValue::Object(m);
}
JValue::Null
}
fn table_to_json(table: &Table, order: &[&str]) -> JValue {
let mut m = JMap::new();
for k in order {
if let Some(v) = table.get(k) {
m.insert((*k).to_string(), item_to_json(v));
}
}
for (k, v) in table.iter() {
if !m.contains_key(k) {
m.insert(k.to_string(), item_to_json(v));
}
}
JValue::Object(m)
}
struct Ctx {
rel: String,
issues: Vec<Issue>,
}
impl Ctx {
fn set_rel(&mut self, rel: &str) {
self.rel = rel.to_string();
}
fn err(&mut self, code: &'static str, message: impl Into<String>) {
self.issues
.push(Issue::error(code, self.rel.clone(), message));
}
fn check_unknown(&mut self, t: &Table, allowed: &[&str]) {
for (k, _) in t.iter() {
if !allowed.contains(&k) {
self.err(
code::SCHEMA_FIELD,
format!("未知字段 `{k}`(扩展请放入 `[ext]`)"),
);
}
}
}
fn req_str(&mut self, t: &Table, key: &str) -> Option<String> {
match t.get(key) {
None => {
self.err(code::SCHEMA_FIELD, format!("缺少必填字段 `{key}`"));
None
}
Some(item) => match item.as_str() {
Some(s) => Some(s.to_string()),
None => {
self.err(code::SCHEMA_FIELD, format!("`{key}` 必须是字符串"));
None
}
},
}
}
fn opt_str(&mut self, t: &Table, key: &str) -> Option<String> {
match t.get(key) {
None => None,
Some(item) => match item.as_str() {
Some(s) => Some(s.to_string()),
None => {
self.err(code::SCHEMA_FIELD, format!("`{key}` 必须是字符串"));
None
}
},
}
}
fn opt_int(&mut self, t: &Table, key: &str) -> Option<i64> {
match t.get(key) {
None => None,
Some(item) => match item.as_integer() {
Some(i) => Some(i),
None => {
self.err(code::SCHEMA_FIELD, format!("`{key}` 必须是整数"));
None
}
},
}
}
fn opt_bool(&mut self, t: &Table, key: &str) -> Option<bool> {
match t.get(key) {
None => None,
Some(item) => match item.as_bool() {
Some(b) => Some(b),
None => {
self.err(code::SCHEMA_FIELD, format!("`{key}` 必须是布尔值"));
None
}
},
}
}
fn opt_dt(&mut self, t: &Table, key: &str) -> Option<String> {
let Some(item) = t.get(key) else {
return None;
};
if item.as_str().is_some() {
self.err(
code::PARSE,
format!("`{key}` 必须使用 TOML 原生 offset date-time,不得写成字符串"),
);
return None;
}
match item.as_datetime() {
Some(d) => {
if d.date.is_none() || d.time.is_none() || d.offset.is_none() {
self.err(
code::PARSE,
format!("`{key}` 必须是带时区偏移的 offset date-time(如 2026-09-14T10:03:11+08:00)"),
);
return None;
}
Some(d.to_string())
}
None => {
self.err(
code::PARSE,
format!("`{key}` 必须是 offset date-time"),
);
None
}
}
}
fn opt_str_array(&mut self, t: &Table, key: &str) -> Option<Vec<String>> {
let Some(item) = t.get(key) else {
return None;
};
let Some(arr) = item.as_array() else {
self.err(code::SCHEMA_FIELD, format!("`{key}` 必须是字符串数组"));
return None;
};
let mut out = Vec::new();
for v in arr.iter() {
match v.as_str() {
Some(s) => out.push(s.to_string()),
None => {
self.err(code::SCHEMA_FIELD, format!("`{key}` 的元素必须是字符串"));
}
}
}
Some(out)
}
}