diff --git a/docs/configuration/build-section.md b/docs/configuration/build-section.md index 18790d7..040f101 100644 --- a/docs/configuration/build-section.md +++ b/docs/configuration/build-section.md @@ -13,7 +13,8 @@ kind = "bin" # optional: # inherit = true # Inherit from base (default true) # targets = ["linux", "macos"] # Multiple targets to build -# target = "./dist" +# out_dir = "./dist" +# target = "x86_64-unknown-linux-gnu" # platform = "x86_64" # cflags = ["-Wall"] # ldflags = ["-lm"] @@ -40,8 +41,8 @@ kind = "bin" - `extension` (string, optional): custom file extension without the leading dot (e.g. `BIN`, `efi`, `EFI`). Combined with `filename`. - `inherit` (bool, optional): inherit settings from base `[build]` (default `true`). If `false`, only use target/profile specific settings. - `targets` (string array, optional): list of targets to build simultaneously. Supports short names (`linux`, `macos`, `windows`). -- `target` (string, optional): custom output directory for final artifact. - When set, DCR automatically injects `--target=` into `cflags` (useful for clang cross-compilation). +- `target` (string, optional): platform target triple (e.g. `x86_64-unknown-linux-gnu`) or short name (`linux`, `macos`, `windows`). Same as `--target` CLI flag. +- `out_dir` (string, optional): custom output directory for final artifact (overrides default `target///`). - `platform` (string, optional): architecture hint for compiler (used for `-march` or `/arch`). - `cflags` (string array, optional): extra compile flags. Supports variable substitution — see [Variable substitution](#variable-substitution) below. diff --git a/docs/dependencies-and-build/target-directory.md b/docs/dependencies-and-build/target-directory.md index 18c9ff9..62428ef 100644 --- a/docs/dependencies-and-build/target-directory.md +++ b/docs/dependencies-and-build/target-directory.md @@ -2,22 +2,29 @@ By default, DCR writes final artifacts to `target///` (where `` is `native` or target triple). -You can override this with `build.target`: +You can override this with `build.out_dir`: ```toml [build] -target = "./dist" +out_dir = "./dist" ``` ## Behavior - Binary mode: - - Linux/macOS: `/` - - Windows: `/.exe` + - Linux/macOS: `/` + - Windows: `/.exe` - Static library mode: - - Linux/macOS: `/lib.a` - - Windows: `/.lib` + - Linux/macOS: `/lib.a` + - Windows: `/.lib` ## Important detail -`build.target` changes only the final artifact location. Object cache and dependency sync directories still use `target///...`. +`build.out_dir` changes only the final artifact location. Object cache and dependency sync directories still use `target///...`. + +## Distinction from platform targets + +- `build.out_dir` — custom **output directory** path. +- `build.target` / `--target ` CLI flag — **platform target** for cross-compilation (e.g. `x86_64-unknown-linux-gnu`, or short name `linux`). +- `build.targets` (array) — list of **platform targets** to build simultaneously. +- `[build.]` sections — **target-specific config overrides** (e.g. `[build.linux]`). diff --git a/scripts/sync-releases-to-gitlab.py b/scripts/sync-releases-to-gitlab.py new file mode 100644 index 0000000..d1e5bed --- /dev/null +++ b/scripts/sync-releases-to-gitlab.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Sync all GitHub Releases (with assets) to GitLab Releases.""" + +import json +import os +import sys +import time +import urllib.error +import urllib.request +import uuid + +GL_TOKEN = os.environ.get("GL_TOKEN") +if not GL_TOKEN: + print("FATAL: GL_TOKEN environment variable not set", file=sys.stderr) + sys.exit(1) + +GL_PROJECT = "dexoron-studio%2Fdcr" +GL_PROJECT_RAW = "dexoron-studio/dcr" +GL_API = "https://gitlab.com/api/v4" +GH_REPO = "dexoron/dcr" + + +def gl_api(method, path, data=None, filepath=None, retries=5): + url = f"{GL_API}/{path}" + headers = {"PRIVATE-TOKEN": GL_TOKEN} + + if data is not None and filepath is None: + headers["Content-Type"] = "application/json" + body = json.dumps(data).encode() + elif filepath: + boundary = uuid.uuid4().hex + with open(filepath, "rb") as f: + fdata = f.read() + fname = os.path.basename(filepath) + parts = [] + parts.append(f"--{boundary}".encode()) + parts.append(f'Content-Disposition: form-data; name="file"; filename="{fname}"'.encode()) + parts.append(b"Content-Type: application/octet-stream") + parts.append(b"") + parts.append(fdata) + parts.append(f"--{boundary}--".encode()) + parts.append(b"") + body = b"\r\n".join(parts) + headers["Content-Type"] = f"multipart/form-data; boundary={boundary}" + else: + body = None + + for attempt in range(retries): + try: + req = urllib.request.Request(url, data=body, headers=headers, method=method) + with urllib.request.urlopen(req) as resp: + text = resp.read().decode() + return json.loads(text) if text else None + except urllib.error.HTTPError as e: + body_text = e.read().decode() if e.fp else "" + if e.code in (409, 429) and attempt < retries - 1: + time.sleep(3) + continue + if attempt < retries - 1: + time.sleep(1) + continue + print(f" HTTP {e.code}: {body_text[:300]}", file=sys.stderr) + return None + except Exception as e: + if attempt < retries - 1: + time.sleep(1) + continue + print(f" Error: {e}", file=sys.stderr) + return None + return None + + +def download_file(url, dest): + with urllib.request.urlopen(url) as resp: + with open(dest, "wb") as f: + f.write(resp.read()) + + +# --- MAIN --- +print("Fetching GitHub releases...") +with urllib.request.urlopen(f"https://api.github.com/repos/{GH_REPO}/releases?per_page=50") as r: + gh_releases = json.loads(r.read().decode()) + +print(f"Found {len(gh_releases)} releases on GitHub") + +# Reverse to process oldest first (GitHub returns newest first) +gh_releases.reverse() + +print("Fetching existing GitLab releases and their assets...") +existing = gl_api("GET", f"projects/{GL_PROJECT}/releases?per_page=100") or [] +existing = existing if isinstance(existing, list) else [] + +# Build map: tag -> set of existing asset names +gl_assets = {} +for rel in existing: + tag = rel["tag_name"] + links = rel.get("assets", {}).get("links", []) or rel.get("assets", {}).get("sources", []) or [] + gl_assets[tag] = {l.get("name", "") for l in links if isinstance(l, dict)} + # Also get the release description to check if it needs updating + gl_assets[tag + "__desc"] = rel.get("description", "") + +print(f"Existing GitLab releases: {len(existing)}") + +download_dir = "/tmp/gh-assets" +os.makedirs(download_dir, exist_ok=True) + +# Reverse: process older first so newer releases with many assets get their description too +# Actually, process in original order +total = len(gh_releases) +for i, rel in enumerate(gh_releases): + tag = rel["tag_name"] + body = rel.get("body", "") or "" + assets = rel.get("assets", []) + if isinstance(assets, dict): + assets = assets.get("nodes", []) or [] + + print(f"\n{'='*60}") + print(f"[{i+1}/{total}] {tag} ({len(assets)} assets)") + + # Create or update release + existing_tag = tag in gl_assets + if not existing_tag: + print(f" Creating release...") + result = gl_api("POST", f"projects/{GL_PROJECT}/releases", data={ + "tag_name": tag, + "name": rel.get("name", tag), + "description": body, + "ref": "master", + }) + if result is None: + print(f" FAILED — skip") + continue + print(f" OK") + else: + # Update description if needed + existing_desc = gl_assets.get(tag + "__desc", "") + if body and body != existing_desc: + print(f" Updating description...") + gl_api("PUT", f"projects/{GL_PROJECT}/releases/{urllib.request.quote(tag, safe='')}", + data={"description": body}) + + existing_links = gl_assets.get(tag, set()) + + for j, asset in enumerate(assets): + aname = asset.get("name", "") + aurl = asset.get("browser_download_url", "") + if not aurl: + continue + + if aname in existing_links: + print(f" [{j+1}/{len(assets)}] {aname} — already exists") + continue + + fpath = os.path.join(download_dir, aname) + print(f" [{j+1}/{len(assets)}] {aname}...", end=" ", flush=True) + + # Download from GitHub + try: + download_file(aurl, fpath) + except Exception as e: + print(f"download failed: {e}") + continue + + # Upload to GitLab + up = gl_api("POST", f"projects/{GL_PROJECT}/uploads", filepath=fpath) + if not up or "url" not in up: + size_mb = os.path.getsize(fpath) / (1024 * 1024) + print(f"upload failed ({size_mb:.1f} MB)") + os.remove(fpath) + continue + + file_url = up["url"] + full_url = f"https://gitlab.com/{GL_PROJECT_RAW}{file_url}" + + # Link asset to release + encoded_tag = urllib.request.quote(tag, safe='') + link = gl_api("POST", + f"projects/{GL_PROJECT}/releases/{encoded_tag}/assets/links", + data={"name": aname, "url": full_url}, + retries=5) + if link: + print(f"linked") + existing_links.add(aname) + else: + print(f"link failed") + + os.remove(fpath) + + time.sleep(0.3) + +print(f"\n{'='*60}") +print("Done!") \ No newline at end of file diff --git a/src/cli/build.rs b/src/cli/build.rs index 7a751ff..53d7691 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -211,7 +211,7 @@ fn get_string_with_profile_and_target( } } -fn get_build_string_with_profile(config: &Config, field: &str, profile: &str) -> String { +pub fn get_build_string_with_profile(config: &Config, field: &str, profile: &str) -> String { get_string_with_profile_and_target(config, field, profile, None) } @@ -712,26 +712,31 @@ fn build_project_at( let resolved_linker = resolve_tool("DCR_LD", tc_ld.as_deref()); let resolved_archiver = resolve_tool("DCR_AR", tc_ar.as_deref()); - let target_dir_binding = match workspace_root { - Some(root) => { - let target_str = build_target - .map(normalize_target_os) - .filter(|t| !t.is_empty()); - let rel: PathBuf = match target_str { - Some(ref t) => Path::new("target").join(t).join(profile), - None => { - let default_dir = if cfg!(target_os = "linux") { - let arch = std::env::consts::ARCH; - format!("{arch}-unknown-linux-gnu/{profile}") - } else { - profile.to_string() - }; - Path::new("target").join(&default_dir) - } - }; - root.join(&rel).to_string_lossy().to_string() + let out_dir_config = get_build_string_with_profile(&config, "out_dir", profile); + let target_dir_binding = if !out_dir_config.is_empty() { + out_dir_config + } else { + match workspace_root { + Some(root) => { + let target_str = build_target + .map(normalize_target_os) + .filter(|t| !t.is_empty()); + let rel: PathBuf = match target_str { + Some(ref t) => Path::new("target").join(t).join(profile), + None => { + let default_dir = if cfg!(target_os = "linux") { + let arch = std::env::consts::ARCH; + format!("{arch}-unknown-linux-gnu/{profile}") + } else { + profile.to_string() + }; + Path::new("target").join(&default_dir) + } + }; + root.join(&rel).to_string_lossy().to_string() + } + None => normalize_target(build_target.unwrap_or(""), profile).unwrap_or_default(), } - None => normalize_target(build_target.unwrap_or(""), profile).unwrap_or_default(), }; let target_dir = if target_dir_binding.is_empty() { None diff --git a/src/cli/run.rs b/src/cli/run.rs index e1b09f4..cd6b858 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -83,7 +83,6 @@ pub fn run(args: &[String]) -> i32 { return 1; } }; - let flags = match parse_build_run_flags(args) { Ok(v) => v, Err(_) => return 1, @@ -188,14 +187,20 @@ fn run_project( .or_else(|| config.get("build.kind").and_then(|v| v.as_str())) .unwrap_or(""); - let normalized_target_dir = match workspace_root { - Some(wr) => target - .as_ref() - .and_then(|t| crate::cli::build::normalize_target(t, &flags.profile)) - .map(|rel| wr.join(&rel).to_string_lossy().to_string()), - None => target - .as_ref() - .and_then(|t| crate::cli::build::normalize_target(t, &flags.profile)), + let out_dir = + crate::cli::build::get_build_string_with_profile(&config, "out_dir", &flags.profile); + let normalized_target_dir = if !out_dir.is_empty() { + Some(out_dir) + } else { + match workspace_root { + Some(wr) => target + .as_ref() + .and_then(|t| crate::cli::build::normalize_target(t, &flags.profile)) + .map(|rel| wr.join(&rel).to_string_lossy().to_string()), + None => target + .as_ref() + .and_then(|t| crate::cli::build::normalize_target(t, &flags.profile)), + } }; let version = config diff --git a/src/core/config.rs b/src/core/config.rs index adcff55..f0fd405 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -119,6 +119,8 @@ pub struct BuildConfig { #[serde(default)] pub target: Option, #[serde(default)] + pub out_dir: Option, + #[serde(default)] pub platform: Option, #[serde(default)] pub cflags: Vec, @@ -396,6 +398,9 @@ impl Config { if let Some(target) = &build.target { validate_non_empty_string(target, "build.target")?; } + if let Some(out_dir) = &build.out_dir { + validate_non_empty_string(out_dir, "build.out_dir")?; + } for profile in ["release", "debug"] { if let Some(section) = self .data @@ -539,7 +544,9 @@ impl Config { if let Some(lang) = table.get("language") { self.validate_language(lang)?; } - for key in ["standard", "compiler", "kind", "target", "platform"] { + for key in [ + "standard", "compiler", "kind", "target", "out_dir", "platform", + ] { if let Some(value) = table.get(key) { let s = value.as_str().unwrap_or(""); if s.trim().is_empty() { @@ -784,6 +791,11 @@ fn format_toml(value: &Value) -> Result { { out.push_str(&format!("target = \"{target}\"\n")); } + if let Some(out_dir) = build.get("out_dir").and_then(|v| v.as_str()) + && !out_dir.trim().is_empty() + { + out.push_str(&format!("out_dir = \"{out_dir}\"\n")); + } if let Some(cflags) = build.get("cflags") { out.push_str(&format!("cflags = {}\n", format_string_array(cflags))); } diff --git a/tests/cli_basic.rs b/tests/cli_basic.rs index ff494bd..fd55ce9 100644 --- a/tests/cli_basic.rs +++ b/tests/cli_basic.rs @@ -383,6 +383,96 @@ fn registry_dependency_is_built_from_cache() { ); } +fn parse_project_name(toml: &str) -> String { + toml.lines() + .find(|l| l.trim().starts_with("name =")) + .and_then(|l| l.split('=').nth(1)) + .map(|s| s.trim().trim_matches('"').to_string()) + .expect("could not parse project name from dcr.toml") +} + +fn default_artifact_path(project_root: &Path, project_name: &str) -> PathBuf { + project_root + .join("target") + .join("x86_64-unknown-linux-gnu") + .join("debug") + .join(project_name) +} + +#[test] +fn build_with_target_config() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping target config test"); + return; + }; + + let dir = unique_sandbox_dir("target_cfg"); + let out = run_dcr(&["init"], &dir); + assert!(out.status.success(), "dcr init should succeed"); + + let toml_path = dir.join("dcr.toml"); + let toml = std::fs::read_to_string(&toml_path).expect("failed to read dcr.toml"); + let project_name = parse_project_name(&toml); + let updated = toml.replace("[build]", "[build]\ntarget = \"linux\""); + std::fs::write(&toml_path, updated).expect("failed to write dcr.toml"); + + let envs = [("DCR_COMPILER", compiler)]; + let out = run_dcr_env(&["build"], &dir, &envs); + assert!( + out.status.success(), + "dcr build with target = \"linux\" should succeed" + ); + + let artifact = default_artifact_path(&dir, &project_name); + assert!( + artifact.is_file(), + "artifact should be at default path target/x86_64-unknown-linux-gnu/debug/{}", + project_name + ); +} + +#[test] +fn build_with_out_dir() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping out_dir test"); + return; + }; + + let dir = unique_sandbox_dir("out_dir"); + let out = run_dcr(&["init"], &dir); + assert!(out.status.success(), "dcr init should succeed"); + + let toml_path = dir.join("dcr.toml"); + let toml = std::fs::read_to_string(&toml_path).expect("failed to read dcr.toml"); + let project_name = parse_project_name(&toml); + let updated = toml.replace("[build]", "[build]\nout_dir = \"./_BUILD\""); + std::fs::write(&toml_path, updated).expect("failed to write dcr.toml"); + + let envs = [("DCR_COMPILER", compiler)]; + let out = run_dcr_env(&["build"], &dir, &envs); + if !out.status.success() { + eprintln!("stdout: {}", String::from_utf8_lossy(&out.stdout)); + eprintln!("stderr: {}", String::from_utf8_lossy(&out.stderr)); + } + assert!( + out.status.success(), + "dcr build with out_dir should succeed" + ); + + let artifact = dir.join("_BUILD").join(&project_name); + assert!( + artifact.is_file(), + "artifact should be at _BUILD/{} (custom out_dir)", + project_name + ); + + let default_path = default_artifact_path(&dir, &project_name); + assert!( + !default_path.exists(), + "artifact should NOT be at default path when out_dir is set" + ); +} + #[test] fn dcr_test_runs_without_sandbox_dependency() { let Some(compiler) = available_compiler() else {