day-cli 0.3.0

Declarative app development API using native UI toolkits
// Copyright © The Daybrite Project
// SPDX-License-Identifier: MPL-2.0

//! The `DayApp.xcconfig` split (§17.4): user-adjustable Xcode build settings live in a
//! committed `DayApp.xcconfig` beside each `DayApp.xcodeproj`, and the Day.toml-derived
//! identity (bundle id, version, build number) is written to a gitignored
//! `build/day/xcconfig/<platform>.xcconfig` that the committed file `#include?`s LAST — so
//! Day.toml stays authoritative once `day build` has run, while a fresh checkout still
//! builds in the Xcode IDE from the committed fallback lines. Precedence still ends at the
//! command line: the settings `day build`/`day pack` pass to xcodebuild override both files.
//!
//! [`ensure_split`] migrates a pre-split scaffold in place: it extracts the current values
//! from the pbxproj, writes `DayApp.xcconfig` from the embedded template with those values
//! substituted (a hand-raised deployment target survives the move), and rewires the pbxproj
//! — file reference, group entry, `baseConfigurationReference` on every configuration, the
//! moved settings stripped. The edits anchor on the scaffold template's deterministic object
//! ids; a hand-restructured project degrades to a warning and keeps its old behavior, never
//! a half-edit — the whole transform happens in memory and is written only when complete.

use crate::meta::Project;
use crate::ops::status;

/// The pbxproj object id of the `DayApp.xcconfig` file reference — from the same reserved
/// `DA…` space as every other scaffold-stamped id.
const REF_ID: &str = "DA0000000000000000000006";

/// The settings that move out of the pbxproj. Stripping them is what lets the xcconfig
/// (and the Xcode Build Settings editor, and the generated include) take effect — a value
/// left in a target's `buildSettings` would override all of those.
const MOVED: [&str; 7] = [
    "CODE_SIGNING_ALLOWED",
    "CODE_SIGN_IDENTITY",
    "CURRENT_PROJECT_VERSION",
    "MARKETING_VERSION",
    "PRODUCT_BUNDLE_IDENTIFIER",
    "TARGETED_DEVICE_FAMILY",
    "IPHONEOS_DEPLOYMENT_TARGET", // MACOSX_DEPLOYMENT_TARGET is appended per platform
];

fn target_for(platform: &str) -> &'static str {
    if platform == "macos" {
        "macos-appkit"
    } else {
        "ios-uikit"
    }
}

/// Write `build/day/xcconfig/<platform>.xcconfig` — the Day-managed values the committed
/// `DayApp.xcconfig` includes last. Rewritten (when changed) on every build so the bundle
/// id, version, and build number always track Day.toml.
pub fn write_generated(project: &Project, platform: &str) -> Result<(), String> {
    let resolved = project.manifest.resolve(target_for(platform));
    let out = format!(
        "// Generated by `day build` from Day.toml — do not edit (change Day.toml [app] or\n\
         // Cargo.toml [package] instead). platform/{platform}/DayApp.xcconfig includes this last.\n\
         PRODUCT_BUNDLE_IDENTIFIER = {}\n\
         MARKETING_VERSION = {}\n\
         CURRENT_PROJECT_VERSION = {}\n\
         DAY_URL_SCHEME = {}\n",
        resolved.id,
        resolved.version,
        resolved.build,
        resolved.scheme()
    );
    let dir = project.root.join("build/day/xcconfig");
    std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {e}", dir.display()))?;
    let path = dir.join(format!("{platform}.xcconfig"));
    if std::fs::read_to_string(&path).ok().as_deref() != Some(out.as_str()) {
        std::fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?;
    }
    Ok(())
}

/// The values a pre-split pbxproj carried for the moved settings, so migration preserves
/// anything the user changed by hand rather than resetting it to the template default.
#[derive(Default)]
struct Extracted {
    id: Option<String>,
    version: Option<String>,
    build: Option<String>,
    device_family: Option<String>,
    deployment: Option<String>,
}

/// Migrate `platform/<platform>/` to the xcconfig split if it hasn't been already.
/// Returns `Ok` (with a warning) when the project can't be migrated automatically — the
/// pre-split layout keeps working, so an unrecognized pbxproj must not fail the build.
pub fn ensure_split(project: &Project, platform: &str) -> Result<(), String> {
    let dir = project.root.join("platform").join(platform);
    let pbx_path = dir.join("DayApp.xcodeproj/project.pbxproj");
    if !pbx_path.exists() {
        return Ok(());
    }
    let xcc_path = dir.join("DayApp.xcconfig");
    let pbx =
        std::fs::read_to_string(&pbx_path).map_err(|e| format!("{}: {e}", pbx_path.display()))?;
    let wired = pbx.contains("DayApp.xcconfig");
    if wired && xcc_path.exists() {
        return Ok(());
    }
    // A project that wired its OWN xcconfig gets left alone — a second base configuration
    // would silently displace theirs.
    if !wired && pbx.contains("baseConfigurationReference") {
        status(
            "Warning",
            &format!(
                "platform/{platform}: the pbxproj already uses a custom xcconfig — skipping \
                 the DayApp.xcconfig split"
            ),
        );
        return Ok(());
    }

    let deployment_key = if platform == "macos" {
        "MACOSX_DEPLOYMENT_TARGET"
    } else {
        "IPHONEOS_DEPLOYMENT_TARGET"
    };

    // Transform first, in memory: nothing is written unless every edit found its anchor.
    let (new_pbx, extracted) = if wired {
        (None, Extracted::default())
    } else {
        match split_pbxproj(&pbx, deployment_key) {
            Ok((text, ex)) => (Some(text), ex),
            Err(why) => {
                status(
                    "Warning",
                    &format!(
                        "platform/{platform}: {why} — build settings stay in the pbxproj. \
                         To adopt the split by hand, see the scaffold's DayApp.xcconfig \
                         (`day new`) and its project.pbxproj wiring."
                    ),
                );
                return Ok(());
            }
        }
    };

    // The committed user file: the scaffold template with this project's values substituted.
    // Never overwrite one that exists (it is the user's file).
    if !xcc_path.exists() {
        let content = render_user_xcconfig(project, platform, &extracted)?;
        std::fs::write(&xcc_path, content).map_err(|e| format!("{}: {e}", xcc_path.display()))?;
    }
    if let Some(text) = new_pbx {
        std::fs::write(&pbx_path, text).map_err(|e| format!("{}: {e}", pbx_path.display()))?;
    }
    status(
        "Splitting",
        &format!("platform/{platform} build settings → DayApp.xcconfig (edit settings there)"),
    );
    Ok(())
}

/// The pure pbxproj transform: insert the file reference, the main-group entry, and a
/// `baseConfigurationReference` on every build configuration; strip the moved settings,
/// returning their previous values.
fn split_pbxproj(text: &str, deployment_key: &str) -> Result<(String, Extracted), String> {
    if text.contains(REF_ID) {
        return Err(format!("pbxproj already uses object id {REF_ID}"));
    }
    let file_ref_end = "/* End PBXFileReference section */";
    let products_child = "\t\t\t\tDA0000000000000000000012 /* Products */,";
    let cfg_head = "isa = XCBuildConfiguration;\n\t\t\tbuildSettings = {";
    for (anchor, what) in [
        (file_ref_end, "PBXFileReference section"),
        (products_child, "main-group Products entry"),
        (cfg_head, "XCBuildConfiguration blocks"),
    ] {
        if !text.contains(anchor) {
            return Err(format!("no {what} at the scaffold's expected shape"));
        }
    }

    let mut moved: Vec<&str> = MOVED.to_vec();
    if !moved.contains(&deployment_key) {
        moved.push(deployment_key);
    }
    let mut extracted = Extracted::default();
    let mut kept = String::with_capacity(text.len());
    for line in text.lines() {
        let trimmed = line.trim();
        let key = trimmed.split(" = ").next().unwrap_or("");
        if moved.contains(&key) {
            let value = trimmed
                .split_once(" = ")
                .map(|(_, v)| v.trim_end_matches(';').trim_matches('"').to_string())
                .unwrap_or_default();
            match key {
                "PRODUCT_BUNDLE_IDENTIFIER" => extracted.id = Some(value),
                "MARKETING_VERSION" => extracted.version = Some(value),
                "CURRENT_PROJECT_VERSION" => extracted.build = Some(value),
                "TARGETED_DEVICE_FAMILY" => extracted.device_family = Some(value),
                k if k == deployment_key => extracted.deployment = Some(value),
                _ => {}
            }
            continue;
        }
        kept.push_str(line);
        kept.push('\n');
    }

    let with_base = kept.replace(
        cfg_head,
        &format!(
            "isa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = {REF_ID} \
             /* DayApp.xcconfig */;\n\t\t\tbuildSettings = {{"
        ),
    );
    let with_ref = with_base.replacen(
        file_ref_end,
        &format!(
            "\t\t{REF_ID} /* DayApp.xcconfig */ = {{isa = PBXFileReference; lastKnownFileType = \
             text.xcconfig; path = DayApp.xcconfig; sourceTree = \"<group>\"; }};\n{file_ref_end}"
        ),
        1,
    );
    let with_group = with_ref.replacen(
        products_child,
        &format!("\t\t\t\t{REF_ID} /* DayApp.xcconfig */,\n{products_child}"),
        1,
    );
    Ok((with_group, extracted))
}

/// The committed `DayApp.xcconfig` content for a migrated project: the scaffold template
/// with the project's own values substituted for the template defaults.
fn render_user_xcconfig(
    project: &Project,
    platform: &str,
    extracted: &Extracted,
) -> Result<String, String> {
    let path = format!("platform/{platform}/DayApp.xcconfig");
    let template = crate::template::builtin_app()
        .into_iter()
        .find(|f| f.path == path)
        .and_then(|f| String::from_utf8(f.bytes).ok())
        .ok_or_else(|| format!("embedded template {path} missing"))?;
    let id = extracted
        .id
        .clone()
        .unwrap_or_else(|| project.manifest.resolve(target_for(platform)).id);
    let mut out = template.replace("{{id}}", &id);
    let defaults = [
        (
            "MARKETING_VERSION = 0.1.0",
            "MARKETING_VERSION",
            &extracted.version,
        ),
        (
            "CURRENT_PROJECT_VERSION = 1",
            "CURRENT_PROJECT_VERSION",
            &extracted.build,
        ),
        (
            "IPHONEOS_DEPLOYMENT_TARGET = 15.0",
            "IPHONEOS_DEPLOYMENT_TARGET",
            &extracted.deployment,
        ),
        (
            "MACOSX_DEPLOYMENT_TARGET = 13.0",
            "MACOSX_DEPLOYMENT_TARGET",
            &extracted.deployment,
        ),
        (
            "TARGETED_DEVICE_FAMILY = 1,2",
            "TARGETED_DEVICE_FAMILY",
            &extracted.device_family,
        ),
    ];
    for (default_line, key, value) in defaults {
        if let Some(v) = value {
            out = out.replacen(default_line, &format!("{key} = {v}"), 1);
        }
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A minimal pre-split pbxproj with the scaffold's anchor shapes.
    const PRE_SPLIT: &str = "// !$*UTF8*$!\n\
        /* Begin PBXFileReference section */\n\
        \t\tDA0000000000000000000005 /* Assets.xcassets */ = {isa = PBXFileReference; };\n\
        /* End PBXFileReference section */\n\
        \t\t\t\tDA0000000000000000000012 /* Products */,\n\
        \t\tDA0000000000000000000060 /* Debug */ = {\n\
        \t\t\tisa = XCBuildConfiguration;\n\
        \t\t\tbuildSettings = {\n\
        \t\t\t\tIPHONEOS_DEPLOYMENT_TARGET = 16.0;\n\
        \t\t\t\tSDKROOT = iphoneos;\n\
        \t\t\t};\n\
        \t\t};\n\
        \t\tDA0000000000000000000062 /* Debug */ = {\n\
        \t\t\tisa = XCBuildConfiguration;\n\
        \t\t\tbuildSettings = {\n\
        \t\t\t\tCODE_SIGNING_ALLOWED = NO;\n\
        \t\t\t\tMARKETING_VERSION = 2.3.4;\n\
        \t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.app;\n\
        \t\t\t\tPRODUCT_NAME = Example;\n\
        \t\t\t\tTARGETED_DEVICE_FAMILY = \"1,2\";\n\
        \t\t\t};\n\
        \t\t};\n";

    #[test]
    fn splits_and_extracts() {
        let (out, ex) = split_pbxproj(PRE_SPLIT, "IPHONEOS_DEPLOYMENT_TARGET").expect("split");
        // Both configurations gained the base reference; the file reference and group entry
        // appear once each.
        assert_eq!(out.matches("baseConfigurationReference").count(), 2);
        assert_eq!(
            out.matches("/* DayApp.xcconfig */ = {isa = PBXFileReference")
                .count(),
            1
        );
        assert_eq!(
            out.matches("\t\t\t\tDA0000000000000000000006 /* DayApp.xcconfig */,")
                .count(),
            1
        );
        // Moved settings are gone; untouched ones survive.
        assert!(!out.contains("MARKETING_VERSION"));
        assert!(!out.contains("IPHONEOS_DEPLOYMENT_TARGET"));
        assert!(out.contains("PRODUCT_NAME = Example;"));
        assert!(out.contains("SDKROOT = iphoneos;"));
        // Extraction preserved the project's own values, unquoted.
        assert_eq!(ex.id.as_deref(), Some("com.example.app"));
        assert_eq!(ex.version.as_deref(), Some("2.3.4"));
        assert_eq!(ex.deployment.as_deref(), Some("16.0"));
        assert_eq!(ex.device_family.as_deref(), Some("1,2"));
    }

    #[test]
    fn refuses_when_id_taken_or_shape_unknown() {
        let taken = PRE_SPLIT.replace("DA0000000000000000000005", "DA0000000000000000000006");
        assert!(split_pbxproj(&taken, "IPHONEOS_DEPLOYMENT_TARGET").is_err());
        assert!(split_pbxproj("not a pbxproj", "IPHONEOS_DEPLOYMENT_TARGET").is_err());
    }
}