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>,
}
#[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 = target.split('-').next().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
}
pub fn parse_manifest(day_toml: &str, cargo_toml: &str) -> Result<Manifest, String> {
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 = package
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("0.1.0")
.to_string();
Ok(manifest)
}
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 manifest = parse_manifest(&day_toml, &cargo_toml)?;
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).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,
)
.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).is_err());
assert!(parse_manifest("schema = 1\n", CARGO).is_err()); assert!(parse_manifest("schema = 1\n[app]\nid = \"x\"\ntitel = \"y\"\n", CARGO).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"\\?\"));
}
}