use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::meta::Project;
#[derive(Debug, Default, Serialize)]
pub struct AndroidPieces {
#[serde(rename = "javaSrcDirs")]
pub java_src_dirs: Vec<String>,
pub dependencies: Vec<String>,
pub repositories: Vec<String>,
pub permissions: Vec<String>,
}
#[derive(Deserialize)]
struct Metadata {
packages: Vec<Package>,
resolve: Option<Resolve>,
}
#[derive(Deserialize)]
struct Package {
id: String,
name: String,
manifest_path: String,
#[serde(default)]
metadata: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct Resolve {
root: Option<String>,
nodes: Vec<Node>,
}
#[derive(Deserialize)]
struct Node {
id: String,
#[serde(default)]
deps: Vec<Dep>,
}
#[derive(Deserialize)]
struct Dep {
pkg: String,
}
#[derive(Deserialize, Default)]
struct AndroidMeta {
#[serde(default)]
java: StringOrVec,
#[serde(default, rename = "gradle-dependencies")]
gradle_dependencies: Vec<String>,
#[serde(default, rename = "gradle-repositories")]
gradle_repositories: Vec<String>,
#[serde(default)]
permissions: Vec<String>,
}
#[derive(Default)]
struct StringOrVec(Vec<String>);
impl<'de> Deserialize<'de> for StringOrVec {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum V {
One(String),
Many(Vec<String>),
}
Ok(StringOrVec(match V::deserialize(d)? {
V::One(s) => vec![s],
V::Many(v) => v,
}))
}
}
#[derive(Deserialize, Default)]
struct PieceMeta {
#[serde(default)]
backends: Vec<String>,
}
pub fn feature_union(project: &Project, backend: &str) -> Vec<String> {
let meta = match cargo_metadata(project, &[backend]) {
Ok(m) => m,
Err(e) => {
eprintln!(
"day: piece feature discovery failed ({e}); \
building with the app's declared features only"
);
return Vec::new();
}
};
let in_closure = closure(&meta);
let mut feats = Vec::new();
for pkg in &meta.packages {
if !in_closure.contains(&pkg.id) {
continue;
}
let Some(piece) = piece_meta::<PieceMeta>(pkg, "piece") else {
continue;
};
if piece.backends.iter().any(|b| b == backend) {
feats.push(format!("{}/{backend}", pkg.name));
}
}
feats.sort();
feats.dedup();
feats
}
fn cargo_metadata(project: &Project, features: &[&str]) -> Result<Metadata, String> {
let manifest = project.root.join("Cargo.toml");
let mut cmd = Command::new("cargo");
cmd.args(["metadata", "--format-version", "1", "--no-default-features"])
.arg("--manifest-path")
.arg(&manifest);
if !features.is_empty() {
cmd.arg("--features").arg(features.join(","));
}
let out = cmd.output().map_err(|e| format!("cargo metadata: {e}"))?;
if !out.status.success() {
return Err(format!(
"cargo metadata failed: {}",
String::from_utf8_lossy(&out.stderr)
.lines()
.next_back()
.unwrap_or("")
));
}
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata parse: {e}"))
}
fn piece_meta<T: serde::de::DeserializeOwned>(pkg: &Package, toolkit: &str) -> Option<T> {
let table = pkg
.metadata
.as_ref()
.and_then(|m| m.get("day")) .and_then(|d| d.get(toolkit))?;
match serde_json::from_value(table.clone()) {
Ok(v) => Some(v),
Err(e) => {
eprintln!(
"day: {} has malformed [package.metadata.day.{toolkit}]: {e}",
pkg.manifest_path
);
None
}
}
}
pub fn resolve_android(project: &Project, features: &[&str]) -> Result<AndroidPieces, String> {
let meta = cargo_metadata(project, features)?;
let in_closure = closure(&meta);
let mut pieces = AndroidPieces::default();
let mut seen_java = HashSet::new();
for pkg in &meta.packages {
if !in_closure.contains(&pkg.id) {
continue;
}
let Some(android) = piece_meta::<AndroidMeta>(pkg, "android") else {
continue;
};
let crate_dir = Path::new(&pkg.manifest_path)
.parent()
.unwrap_or(Path::new("."));
for rel in &android.java.0 {
let dir = crate_dir.join(rel);
if !dir.is_dir() {
eprintln!("day: {} java dir {:?} not found — skipping", pkg.id, dir);
continue;
}
let abs = dir.to_string_lossy().into_owned();
if seen_java.insert(abs.clone()) {
pieces.java_src_dirs.push(abs);
}
}
for dep in android.gradle_dependencies {
if !pieces.dependencies.contains(&dep) {
pieces.dependencies.push(dep);
}
}
for repo in android.gradle_repositories {
if !pieces.repositories.contains(&repo) {
pieces.repositories.push(repo);
}
}
for perm in android.permissions {
if !pieces.permissions.contains(&perm) {
pieces.permissions.push(perm);
}
}
}
Ok(pieces)
}
fn closure(meta: &Metadata) -> HashSet<String> {
let Some(resolve) = &meta.resolve else {
return meta.packages.iter().map(|p| p.id.clone()).collect();
};
let by_id: std::collections::HashMap<&str, &Node> =
resolve.nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let Some(root) = &resolve.root else {
return resolve.nodes.iter().map(|n| n.id.clone()).collect();
};
let mut seen = HashSet::new();
let mut stack = vec![root.clone()];
while let Some(id) = stack.pop() {
if !seen.insert(id.clone()) {
continue;
}
if let Some(node) = by_id.get(id.as_str()) {
for d in &node.deps {
stack.push(d.pkg.clone());
}
}
}
seen
}
pub fn write_android_manifest(project: &Project) -> Result<(), String> {
let pieces = resolve_android(project, &["widget"]).unwrap_or_else(|e| {
eprintln!("day: piece discovery failed ({e}); building with framework pieces only");
AndroidPieces::default()
});
let dir = project.root.join("build/day/android");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let json = serde_json::to_string_pretty(&pieces).map_err(|e| e.to_string())?;
std::fs::write(dir.join("day-pieces.json"), json).map_err(|e| e.to_string())?;
let overlay = dir.join("day-pieces-manifest.xml");
if pieces.permissions.is_empty() {
let _ = std::fs::remove_file(&overlay);
} else {
std::fs::write(&overlay, permissions_manifest(&pieces.permissions))
.map_err(|e| e.to_string())?;
}
Ok(())
}
fn permissions_manifest(permissions: &[String]) -> String {
let mut s = String::from(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<!-- Generated by `day build` from standalone-piece [package.metadata.day.android] \
permissions. Do not edit. -->\n\
<manifest xmlns:android=\"http://schemas.android.com/apk/res/android\">\n",
);
for perm in permissions {
s.push_str(&format!(
" <uses-permission android:name=\"{perm}\" />\n"
));
}
s.push_str("</manifest>\n");
s
}
#[derive(Debug, Clone, Deserialize)]
struct SwiftPackage {
url: String,
#[serde(default)]
from: Option<String>,
#[serde(default)]
exact: Option<String>,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
revision: Option<String>,
#[serde(default)]
products: Vec<String>,
}
impl SwiftPackage {
fn identity(&self) -> String {
self.url
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or(&self.url)
.trim_end_matches(".git")
.to_string()
}
fn requirement(&self) -> String {
if let Some(v) = &self.exact {
format!("exact: \"{v}\"")
} else if let Some(b) = &self.branch {
format!("branch: \"{b}\"")
} else if let Some(r) = &self.revision {
format!("revision: \"{r}\"")
} else {
format!("from: \"{}\"", self.from.as_deref().unwrap_or("0.0.0"))
}
}
}
#[derive(Deserialize, Default)]
struct IosMeta {
#[serde(default)]
swift: StringOrVec,
#[serde(default, rename = "swift-packages")]
swift_packages: Vec<SwiftPackage>,
#[serde(default)]
frameworks: Vec<String>,
}
#[derive(Default)]
struct IosPieces {
swift_dirs: Vec<(String, String)>,
packages: Vec<SwiftPackage>,
frameworks: Vec<String>,
}
fn resolve_ios(project: &Project, features: &[&str]) -> Result<IosPieces, String> {
let meta = cargo_metadata(project, features)?;
let in_closure = closure(&meta);
let mut pieces = IosPieces::default();
let mut seen_dirs = HashSet::new();
let mut seen_pkgs = HashSet::new();
for pkg in &meta.packages {
if !in_closure.contains(&pkg.id) {
continue;
}
let Some(ios) = piece_meta::<IosMeta>(pkg, "ios") else {
continue;
};
let crate_dir = Path::new(&pkg.manifest_path)
.parent()
.unwrap_or(Path::new("."));
let namespace = crate_dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "piece".into());
for rel in &ios.swift.0 {
let dir = crate_dir.join(rel);
if !dir.is_dir() {
eprintln!("day: {} swift dir {:?} not found — skipping", pkg.id, dir);
continue;
}
let abs = dir.to_string_lossy().into_owned();
if seen_dirs.insert(abs.clone()) {
pieces.swift_dirs.push((namespace.clone(), abs));
}
}
for spkg in ios.swift_packages {
if seen_pkgs.insert(spkg.identity()) {
pieces.packages.push(spkg);
}
}
for fw in ios.frameworks {
if !pieces.frameworks.contains(&fw) {
pieces.frameworks.push(fw);
}
}
}
Ok(pieces)
}
pub fn write_ios_pieces(project: &Project) -> Result<(), String> {
let pieces = resolve_ios(project, &["uikit"]).unwrap_or_else(|e| {
eprintln!("day: iOS piece discovery failed ({e}); building with framework pieces only");
IosPieces::default()
});
let pkg_dir = project.root.join("build/day/ios/DayPieces");
let sources = pkg_dir.join("Sources/DayPieces");
let _ = std::fs::remove_dir_all(&sources);
std::fs::create_dir_all(&sources).map_err(|e| e.to_string())?;
std::fs::write(
sources.join("_DayPieces.swift"),
"// Generated by `day build`. The DayPieces local package aggregates every standalone piece's\n\
// iOS Swift shims and SwiftPM package dependencies (docs/extending.md). Do not edit.\n\
enum _DayPieces {}\n",
)
.map_err(|e| e.to_string())?;
for (namespace, dir) in &pieces.swift_dirs {
stage_swift_dir(Path::new(dir), &sources.join(namespace))?;
}
let images = crate::resources::ResourceSet::scan(project).images;
let has_resources = crate::resources::apple::write_media_xcassets(&sources, &images)?;
let fonts = crate::resources::scan_fonts(project)?;
if !fonts.is_empty() {
let fdir = sources.join("fonts");
std::fs::create_dir_all(&fdir).map_err(|e| e.to_string())?;
for f in &fonts {
let name = f.path.file_name().ok_or("font file name")?;
std::fs::copy(&f.path, fdir.join(name)).map_err(|e| e.to_string())?;
}
}
std::fs::write(
pkg_dir.join("Package.swift"),
package_swift(&pieces, has_resources, !fonts.is_empty()),
)
.map_err(|e| e.to_string())?;
Ok(())
}
fn stage_swift_dir(src: &Path, dest: &Path) -> Result<(), String> {
std::fs::create_dir_all(dest).map_err(|e| e.to_string())?;
let rd = std::fs::read_dir(src).map_err(|e| format!("{}: {e}", src.display()))?;
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stage_swift_dir(&path, &dest.join(entry.file_name()))?;
} else if path.extension().and_then(|e| e.to_str()) == Some("swift") {
std::fs::copy(&path, dest.join(entry.file_name())).map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn package_swift(pieces: &IosPieces, has_resources: bool, has_fonts: bool) -> String {
let deps: String = pieces
.packages
.iter()
.map(|p| {
format!(
" .package(url: \"{}\", {}),\n",
p.url,
p.requirement()
)
})
.collect();
let products: String = pieces
.packages
.iter()
.flat_map(|p| {
let id = p.identity();
p.products.iter().map(move |prod| {
format!(" .product(name: \"{prod}\", package: \"{id}\"),\n")
})
})
.collect();
let linker: String = if pieces.frameworks.is_empty() {
String::new()
} else {
let fws: String = pieces
.frameworks
.iter()
.map(|f| format!(".linkedFramework(\"{f}\"), "))
.collect();
format!(", linkerSettings: [{fws}]")
};
let mut entries: Vec<&str> = Vec::new();
if has_resources {
entries.push(".process(\"Media.xcassets\")");
}
if has_fonts {
entries.push(".copy(\"fonts\")");
}
let resources = if entries.is_empty() {
String::new()
} else {
format!(", resources: [{}]", entries.join(", "))
};
format!(
"// swift-tools-version:5.9\n\
// Generated by `day build` from standalone pieces' [package.metadata.day.ios]. Do not edit.\n\
import PackageDescription\n\n\
let package = Package(\n\
\x20 name: \"DayPieces\",\n\
\x20 platforms: [.iOS(.v15)],\n\
\x20 products: [.library(name: \"DayPieces\", targets: [\"DayPieces\"])],\n\
\x20 dependencies: [\n{deps} ],\n\
\x20 targets: [\n\
\x20 .target(name: \"DayPieces\", dependencies: [\n{products} ], path: \"Sources/DayPieces\"{resources}{linker}),\n\
\x20 ]\n\
)\n"
)
}