use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema: u32,
pub app: App,
#[serde(default)]
pub window: Window,
#[serde(default)]
pub signing: Option<Signing>,
#[serde(default)]
pub permissions: Permissions,
#[serde(default)]
pub sbom: SbomConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum SbomMode {
#[default]
Sidecar,
Embed,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SbomFormat {
#[serde(alias = "cdx")]
Cyclonedx,
Spdx,
}
impl SbomFormat {
pub fn file_name(self) -> &'static str {
match self {
SbomFormat::Cyclonedx => "day-sbom.cdx.json",
SbomFormat::Spdx => "day-sbom.spdx.json",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SbomConfig {
pub mode: SbomMode,
pub formats: Vec<SbomFormat>,
}
impl Default for SbomConfig {
fn default() -> Self {
Self {
mode: SbomMode::Sidecar,
formats: vec![SbomFormat::Cyclonedx, SbomFormat::Spdx],
}
}
}
impl SbomConfig {
pub fn is_off(&self) -> bool {
self.mode == SbomMode::None || self.formats.is_empty()
}
}
impl<'de> serde::Deserialize<'de> for SbomConfig {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Table {
#[serde(default)]
mode: SbomMode,
#[serde(default)]
formats: Option<Vec<SbomFormat>>,
}
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Either {
Shorthand(String),
Table(Table),
}
match Either::deserialize(d)? {
Either::Table(t) => Ok(SbomConfig {
mode: t.mode,
formats: t.formats.unwrap_or_else(|| Self::default().formats),
}),
Either::Shorthand(text) => {
let mut words = text.split_whitespace();
let mode = match words.next() {
Some("sidecar") => SbomMode::Sidecar,
Some("embed") => SbomMode::Embed,
Some("none") => SbomMode::None,
other => {
return Err(serde::de::Error::custom(format!(
"sbom: expected `sidecar`, `embed`, or `none`, got {other:?}"
)));
}
};
let mut formats = Vec::new();
for w in words {
match w {
"cyclonedx" | "cdx" => formats.push(SbomFormat::Cyclonedx),
"spdx" => formats.push(SbomFormat::Spdx),
other => {
return Err(serde::de::Error::custom(format!(
"sbom: unknown format {other:?} (expected `spdx` or `cyclonedx`)"
)));
}
}
}
if formats.is_empty() {
formats = Self::default().formats;
}
Ok(SbomConfig { mode, formats })
}
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct Permissions {
#[serde(default)]
pub raw: RawPermissions,
#[serde(flatten)]
pub declared: BTreeMap<String, Declaration>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Declaration {
Reason(String),
Enabled(bool),
Detailed(Box<DeclarationTable>),
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct DeclarationTable {
pub reason: Option<String>,
pub ios_reason: Option<String>,
pub macos_reason: Option<String>,
pub ohos_reason: Option<String>,
pub platforms: Option<Vec<String>>,
}
impl Declaration {
pub fn reason_for(&self, platform: &str) -> Option<&str> {
match self {
Declaration::Reason(r) => Some(r.as_str()),
Declaration::Enabled(_) => None,
Declaration::Detailed(t) => {
let specific = match platform {
"ios" => t.ios_reason.as_deref(),
"macos" => t.macos_reason.as_deref(),
"ohos" => t.ohos_reason.as_deref(),
_ => None,
};
specific.or(t.reason.as_deref())
}
}
}
pub fn enabled(&self) -> bool {
!matches!(self, Declaration::Enabled(false))
}
pub fn covers(&self, platform: &str) -> bool {
match self {
Declaration::Detailed(t) => match &t.platforms {
Some(list) => list.iter().any(|p| p == platform),
None => true,
},
_ => true,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawPermissions {
#[serde(default)]
pub android: Vec<String>,
#[serde(default)]
pub ios: BTreeMap<String, String>,
#[serde(default)]
pub macos: BTreeMap<String, String>,
#[serde(default)]
pub ohos: Vec<RawOhosPermission>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawOhosPermission {
pub name: String,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub when: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Signing {
#[serde(default)]
pub macos: Option<MacosSigning>,
#[serde(default)]
pub ios: Option<IosSigning>,
#[serde(default)]
pub android: Option<AndroidSigning>,
#[serde(default)]
pub windows: Option<WindowsSigning>,
#[serde(default)]
pub ohos: Option<OhosSigning>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct MacosSigning {
#[serde(default)]
pub identity: Option<String>,
#[serde(default)]
pub entitlements: Option<String>,
#[serde(default)]
pub notarize: Option<Notarize>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct Notarize {
pub key_id: String,
pub issuer: String,
pub key_path: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct IosSigning {
pub team: String,
#[serde(default)]
pub export_method: Option<String>,
#[serde(default)]
pub key_id: Option<String>,
#[serde(default)]
pub issuer: Option<String>,
#[serde(default)]
pub key_path: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct AndroidSigning {
pub keystore: String,
pub key_alias: String,
pub store_pass: String,
pub key_pass: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct WindowsSigning {
pub provider: String,
#[serde(default)]
pub publisher: Option<String>,
#[serde(default)]
pub thumbprint: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub account: Option<String>,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub dlib: Option<String>,
#[serde(default)]
pub timestamp_url: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct OhosSigning {
pub keystore: String,
pub key_alias: String,
pub store_pass: String,
pub key_pass: String,
pub cert: String,
pub profile: String,
}
#[derive(Debug, Deserialize)]
pub struct App {
#[serde(skip)]
pub name: String,
#[serde(skip)]
pub version: String,
pub id: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default = "default_build")]
pub build: u64,
#[serde(default)]
pub targets: Vec<String>,
#[serde(flatten)]
pub overrides: BTreeMap<String, AppOverride>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AppOverride {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub build: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ResolvedApp {
pub name: String,
pub version: String,
pub id: String,
pub title: String,
pub build: u64,
}
impl Manifest {
pub fn resolve(&self, target: &str) -> ResolvedApp {
let mut out = ResolvedApp {
name: self.app.name.clone(),
version: self.app.version.clone(),
id: self.app.id.clone(),
title: self
.app
.title
.clone()
.unwrap_or_else(|| self.app.name.clone()),
build: self.app.build,
};
let platform = crate::targets::find(target)
.map(|t| t.os)
.unwrap_or_else(|| target.split_once('-').map(|(os, _)| os).unwrap_or_default());
let toolkit = target.split_once('-').map(|(_, t)| t).unwrap_or_default();
for key in [toolkit, platform, target] {
if let Some(o) = self.app.overrides.get(key) {
if let Some(id) = &o.id {
out.id = id.clone();
}
if let Some(title) = &o.title {
out.title = title.clone();
}
if let Some(build) = o.build {
out.build = build;
}
}
}
out
}
}
fn default_build() -> u64 {
1
}
#[derive(Debug, Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct Window {
#[serde(default = "default_w")]
pub width: f64,
#[serde(default = "default_h")]
pub height: f64,
}
impl Default for Window {
fn default() -> Self {
Window {
width: default_w(),
height: default_h(),
}
}
}
fn default_w() -> f64 {
480.0
}
fn default_h() -> f64 {
640.0
}
pub struct Project {
pub root: PathBuf,
pub manifest: Manifest,
}
fn strip_verbatim(p: PathBuf) -> PathBuf {
#[cfg(windows)]
if let Some(s) = p.to_str() {
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
return PathBuf::from(rest);
}
}
p
}
fn validate_permissions(day_toml: &str) -> Result<(), String> {
const LONG_FORM: &[&str] = &[
"reason",
"ios-reason",
"macos-reason",
"ohos-reason",
"platforms",
];
let raw: toml::Value = toml::from_str(day_toml).map_err(|e| format!("Day.toml: {e}"))?;
let Some(perms) = raw.get("permissions").and_then(|v| v.as_table()) else {
return Ok(());
};
for (key, value) in perms {
if key == "raw" {
continue; }
if day_build::permissions::find(key).is_none() {
return Err(format!(
"Day.toml: [permissions] {key:?} is not a known permission (valid: {})",
day_build::permissions::names().join(", ")
));
}
if let Some(table) = value.as_table() {
for k in table.keys() {
if !LONG_FORM.contains(&k.as_str()) {
return Err(format!(
"Day.toml: [permissions.{key}] has unknown key {k:?} (valid: {})",
LONG_FORM.join(", ")
));
}
}
}
}
Ok(())
}
pub fn parse_manifest(
day_toml: &str,
cargo_toml: &str,
workspace_version: Option<&str>,
) -> Result<Manifest, String> {
validate_permissions(day_toml)?;
let mut manifest: Manifest = toml::from_str(day_toml).map_err(|e| format!("Day.toml: {e}"))?;
if manifest.schema != 1 {
return Err(format!(
"Day.toml: unsupported schema version {}",
manifest.schema
));
}
let cargo: toml::Value = toml::from_str(cargo_toml).map_err(|e| format!("Cargo.toml: {e}"))?;
let package = cargo
.get("package")
.ok_or("Cargo.toml: no [package] table")?;
manifest.app.name = package
.get("name")
.and_then(|v| v.as_str())
.ok_or("Cargo.toml: no package.name")?
.to_string();
manifest.app.version = match package.get("version") {
Some(v) if v.as_str().is_some() => v.as_str().unwrap_or_default().to_string(),
Some(v) if inherits_from_workspace(v) => workspace_version
.ok_or(
"Cargo.toml: version.workspace = true, but no [workspace.package] version \
was found in an ancestor Cargo.toml",
)?
.to_string(),
_ => "0.1.0".to_string(),
};
Ok(manifest)
}
fn inherits_from_workspace(value: &toml::Value) -> bool {
value
.get("workspace")
.and_then(|w| w.as_bool())
.unwrap_or(false)
}
pub fn workspace_package_version(start: &Path) -> Option<String> {
for dir in start.ancestors() {
let candidate = dir.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&candidate) else {
continue;
};
let Ok(value) = toml::from_str::<toml::Value>(&text) else {
continue;
};
let Some(workspace) = value.get("workspace") else {
continue;
};
return workspace
.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.map(str::to_string);
}
None
}
pub fn find_project(start: Option<&Path>) -> Result<Project, String> {
let mut dir = match start {
Some(p) => p.to_path_buf(),
None => std::env::current_dir().map_err(|e| e.to_string())?,
};
loop {
let candidate = dir.join("Day.toml");
if candidate.exists() {
let day_toml = std::fs::read_to_string(&candidate).map_err(|e| e.to_string())?;
let cargo_path = dir.join("Cargo.toml");
let cargo_toml = std::fs::read_to_string(&cargo_path).map_err(|e| {
format!(
"{}: {e} (Day.toml marks a Day project, which is also a cargo package)",
cargo_path.display()
)
})?;
let inherited = workspace_package_version(&dir);
let manifest = parse_manifest(&day_toml, &cargo_toml, inherited.as_deref())?;
let root = std::fs::canonicalize(&dir).unwrap_or_else(|_| {
std::env::current_dir()
.map(|cwd| cwd.join(&dir))
.unwrap_or_else(|_| dir.clone())
});
return Ok(Project {
root: strip_verbatim(root),
manifest,
});
}
if !dir.pop() {
return Err("no Day.toml found in this directory or any ancestor".into());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const CARGO: &str = "[package]\nname = \"demo-app\"\nversion = \"1.2.3\"\n";
#[test]
fn identity_derives_from_cargo_toml() {
let m = parse_manifest(
"schema = 1\n[app]\nid = \"dev.example.demo\"\n",
CARGO,
None,
)
.unwrap();
assert_eq!(m.app.name, "demo-app");
assert_eq!(m.app.version, "1.2.3");
let r = m.resolve("macos-appkit");
assert_eq!(r.title, "demo-app"); assert_eq!(r.build, 1);
}
#[test]
fn overrides_resolve_most_specific_wins() {
let m = parse_manifest(
r#"
schema = 1
[app]
id = "dev.example.demo"
title = "Demo"
targets = ["ios-uikit", "macos-appkit", "macos-qt"]
# toolkit-wide override
[app.qt]
title = "Demo (Qt)"
# platform override beats toolkit
[app.macos]
id = "dev.example.demo.mac"
# exact target beats both
[app.macos-qt]
title = "Demo for macOS Qt"
build = 7
"#,
CARGO,
None,
)
.unwrap();
assert_eq!(m.resolve("ios-uikit").title, "Demo");
assert_eq!(m.resolve("macos-appkit").id, "dev.example.demo.mac");
assert_eq!(m.resolve("macos-appkit").title, "Demo");
let mq = m.resolve("macos-qt");
assert_eq!(mq.id, "dev.example.demo.mac"); assert_eq!(mq.title, "Demo for macOS Qt"); assert_eq!(mq.build, 7);
assert_eq!(m.resolve("linux-qt").title, "Demo (Qt)"); }
#[test]
fn schema_and_shape_are_validated() {
assert!(parse_manifest("schema = 2\n[app]\nid = \"x\"\n", CARGO, None).is_err());
assert!(parse_manifest("schema = 1\n", CARGO, None).is_err()); assert!(
parse_manifest(
"schema = 1\n[app]\nid = \"x\"\ntitel = \"y\"\n",
CARGO,
None
)
.is_err()
);
}
#[cfg(windows)]
#[test]
fn strip_verbatim_deverbatims_windows_paths() {
assert_eq!(
strip_verbatim(PathBuf::from(r"\\?\D:\a\day\day\apps\showcase")),
PathBuf::from(r"D:\a\day\day\apps\showcase")
);
assert_eq!(
strip_verbatim(PathBuf::from(r"\\?\UNC\server\share\proj")),
PathBuf::from(r"\\server\share\proj")
);
assert_eq!(
strip_verbatim(PathBuf::from(r"D:\a\proj")),
PathBuf::from(r"D:\a\proj")
);
let canon = std::fs::canonicalize(".").unwrap();
assert!(!strip_verbatim(canon).to_string_lossy().starts_with(r"\\?\"));
}
#[test]
fn manifest_without_permissions_still_parses() {
let m =
parse_manifest("schema = 1\n[app]\nid = \"dev.x.demo\"\n", CARGO, None).expect("parse");
assert!(m.permissions.declared.is_empty());
assert!(m.permissions.raw.android.is_empty());
}
#[test]
fn permission_declaration_forms() {
let toml = r#"
schema = 1
[app]
id = "dev.x.demo"
[permissions]
camera = "Scan a document."
notifications = true
[permissions.photos]
reason = "Attach a picture."
ios-reason = "Day attaches pictures from your library."
platforms = ["ios", "android"]
[permissions.raw]
android = ["android.permission.READ_CONTACTS"]
ios = { NSContactsUsageDescription = "Find friends." }
ohos = [{ name = "ohos.permission.READ_CONTACTS", reason = "Find friends.", when = "inuse" }]
"#;
let m = parse_manifest(toml, CARGO, None).expect("parse");
assert_eq!(m.permissions.declared.len(), 3);
let camera = &m.permissions.declared["camera"];
assert_eq!(camera.reason_for("ios"), Some("Scan a document."));
assert!(camera.covers("ohos"));
assert_eq!(
m.permissions.declared["notifications"].reason_for("ios"),
None
);
let photos = &m.permissions.declared["photos"];
assert_eq!(
photos.reason_for("ios"),
Some("Day attaches pictures from your library.")
);
assert_eq!(photos.reason_for("ohos"), Some("Attach a picture."));
assert!(photos.covers("android"));
assert!(
!photos.covers("ohos"),
"platforms = [...] must exclude ohos"
);
assert_eq!(
m.permissions.raw.android,
["android.permission.READ_CONTACTS"]
);
assert_eq!(
m.permissions
.raw
.ios
.get("NSContactsUsageDescription")
.map(String::as_str),
Some("Find friends.")
);
assert_eq!(
m.permissions.raw.ohos[0].name,
"ohos.permission.READ_CONTACTS"
);
}
#[test]
fn unknown_permission_name_is_rejected() {
let err = parse_manifest(
"schema = 1\n[app]\nid = \"dev.x.demo\"\n[permissions]\ncammera = \"typo\"\n",
CARGO,
None,
)
.expect_err("should reject");
assert!(err.contains("cammera"), "{err}");
assert!(
err.contains("camera"),
"the error must list the valid names: {err}"
);
}
#[test]
fn unknown_key_inside_a_declaration_is_rejected() {
let err = parse_manifest(
"schema = 1\n[app]\nid = \"dev.x.demo\"\n[permissions.camera]\nresaon = \"typo\"\n",
CARGO,
None,
)
.expect_err("should reject");
assert!(
err.contains("resaon") || err.contains("unknown field"),
"{err}"
);
}
}
#[cfg(test)]
mod workspace_inheritance {
use super::*;
#[test]
fn an_inherited_version_resolves_from_the_workspace() {
const INHERITED: &str = "[package]\nname = \"demo\"\nversion.workspace = true\n";
let m = parse_manifest(
"schema = 1\n[app]\nid = \"dev.example.demo\"\n",
INHERITED,
Some("9.9.9"),
)
.expect("parse");
assert_eq!(m.app.version, "9.9.9");
}
#[test]
fn an_inherited_version_without_a_workspace_is_an_error() {
const INHERITED: &str = "[package]\nname = \"demo\"\nversion.workspace = true\n";
let err = parse_manifest("schema = 1\n[app]\nid = \"dev.x.demo\"\n", INHERITED, None)
.expect_err("must not silently default");
assert!(err.contains("workspace"), "{err}");
}
#[test]
fn this_repo_resolves_its_own_workspace_version() {
let here = Path::new(env!("CARGO_MANIFEST_DIR")); let found = workspace_package_version(here);
assert_eq!(found.as_deref(), Some(env!("CARGO_PKG_VERSION")));
}
}