diff --git a/CHANGELOG.md b/CHANGELOG.md index afe4f42..09c18b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## [v0.8.5] - 2026-08-08 "Фиксация версии dcr-version и предупреждения совместимости / Config Version Pinning & Compatibility Warnings" + +### RU + +**Добавлено:** + +- **`package.dcr-version`** — опциональный pin версии CLI в `dcr.toml`. + `dcr new` / `dcr init` пишут текущую версию. При загрузке конфига: + - нет / пустой pin → warn «добавь dcr-version»; + - dcr старше pin → warn «обнови dcr»; + - dcr новее pin → warn «проверь changelog, подними pin»; + - равенство — тишина. + Команды не падают, только предупреждение (раз на path за процесс). + +### EN + +**Added:** + +- **`package.dcr-version`** — optional CLI version pin in `dcr.toml`. + `dcr new` / `dcr init` write the current version. On config load: + - missing / empty pin → warn "add dcr-version"; + - dcr older than pin → warn "update dcr"; + - dcr newer than pin → warn "check changelog, update pin"; + - equal → silent. + Commands do not fail, warning only (once per path per process). + ## [0.8.4] - 2026-08-04 "Инфраструктура .dcr/ и интеграция с IDE / Internal .dcr/ Layout & IDE Integration" ### RU diff --git a/Cargo.lock b/Cargo.lock index 4894ba3..6059987 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,7 +154,7 @@ dependencies = [ [[package]] name = "dcr" -version = "0.8.4" +version = "0.8.5" dependencies = [ "ctrlc", "fatfs", diff --git a/Cargo.toml b/Cargo.toml index 1741a27..dd6bb1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dcr" -version = "0.8.4" +version = "0.8.5" edition = "2024" build = "build.rs" description = "DCR is a utility for managing C/C++ projects in a Cargo-like style." diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 2dcbea2..f80dfd9 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,16 @@ sidebar_label: Changelog # Changelog +## 0.8.5 (2026-08-08) + +- **`package.dcr-version`** — optional CLI version pin in `dcr.toml`. + `dcr new` / `dcr init` write the current version. On config load: + - missing / empty pin → warn "add dcr-version"; + - dcr older than pin → warn "update dcr"; + - dcr newer than pin → warn "check changelog, update pin"; + - equal → silent. + Commands do not fail, warning only (once per path per process). + ## 0.8.4 (2026-08-04) **Added:** diff --git a/docs/reference/dcr-toml.mdx b/docs/reference/dcr-toml.mdx index 45273f1..ac0a45d 100644 --- a/docs/reference/dcr-toml.mdx +++ b/docs/reference/dcr-toml.mdx @@ -43,7 +43,8 @@ Main project configuration file. Located at the project root. | Field | Required | Description | |-------|----------|-------------| | `name` | yes | Project name | -| `version` | yes | Semantic version | +| `version` | yes | Semantic version of the **project** | +| `dcr-version` | no | Semver of the **dcr tool** this project targets. Written by `dcr new` / `dcr init` to the current tool version. | | `type` | no | `app`, `lib`, `none` (defaults to `"none"`) | | `license` | no | SPDX license identifier | | `author` | no | Author | @@ -52,11 +53,26 @@ Main project configuration file. Located at the project root. [package] name = "my-app" version = "0.1.0" +dcr-version = "0.8.4" type = "app" license = "MIT" author = "John Doe" ``` +### `package.dcr-version` + +Optional pin of the DCR CLI the project was written for. On every `dcr.toml` load: + +| Situation | Behaviour | +|-----------|-----------| +| Field missing / empty | Warning: add `dcr-version` (set by `dcr new` / `dcr init`) | +| Running dcr **older** than pin | Warning: upgrade dcr (`dcr --update`) | +| Running dcr **newer** than pin | Warning: review changelog, bump the pin when ready | +| Equal | Silent | +| Invalid semver | Warning once, field ignored | + +Never fails the command — warnings only. Pre-release suffixes (`1.0.0-dev`) are ignored for comparison (`1.0.0-dev` ≡ `1.0.0`). + ## [build] ... diff --git a/man/man1/dcr.1 b/man/man1/dcr.1 index 0cdaa2e..a5bba7b 100644 --- a/man/man1/dcr.1 +++ b/man/man1/dcr.1 @@ -154,6 +154,11 @@ Project name (required). .B package.version Project version (default: \fB0.1.0\fR). .TP +.B package.dcr\-version +Semver of the DCR tool this project targets. Set automatically by +\fBdcr new\fR / \fBdcr init\fR. Missing pin, or an installed \fBdcr\fR older/newer +than the pin, prints a warning (commands still run). +.TP .B build.language Programming language: \fBc\fR or \fBc++\fR (default: \fBc\fR). .TP diff --git a/src/core/build_config.rs b/src/core/build_config.rs index 5b8ac0a..8144a24 100644 --- a/src/core/build_config.rs +++ b/src/core/build_config.rs @@ -132,11 +132,17 @@ pub struct ArchiveLayout { pub to: String, } -/// Package metadata section (name, version, type). +/// Package metadata section (name, version, type, dcr-version). #[derive(Debug, Clone, Deserialize)] pub struct PackageConfig { pub name: String, pub version: String, + /// Minimum/target DCR tool version this project was authored for (`package.dcr-version`). + /// + /// Compared against the running `dcr` binary (`CARGO_PKG_VERSION`) on config load. + /// Missing or empty means "no pin" — no warning is emitted. + #[serde(default, rename = "dcr-version")] + pub dcr_version: Option, #[serde(default, rename = "type")] pub pkg_type: Option, } @@ -258,6 +264,7 @@ impl Config { doc, }; cfg.validate()?; + cfg.warn_dcr_version_mismatch(); Ok(cfg) } @@ -276,6 +283,7 @@ impl Config { doc, }; cfg.validate()?; + cfg.warn_dcr_version_mismatch(); Ok(cfg) } @@ -291,6 +299,68 @@ impl Config { self.typed.package.as_ref() } + /// Warn once per config path about `package.dcr-version` vs this `dcr` binary. + /// + /// - Missing/`""` pin → recommend adding `dcr-version`. + /// - Tool older than pin → project may use features this binary does not support. + /// - Tool newer than pin → project may rely on outdated defaults; consider bumping the pin. + /// + /// Never fails the load; warnings only. Equal pin is silent. + fn warn_dcr_version_mismatch(&self) { + let Some(pkg) = self.typed.package.as_ref() else { + return; + }; + + let path_key = self + .path + .canonicalize() + .unwrap_or_else(|_| self.path.clone()) + .to_string_lossy() + .into_owned(); + if !dcr_version_warn_once(&path_key) { + return; + } + + let tool = env!("CARGO_PKG_VERSION"); + let required = pkg + .dcr_version + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + let Some(required) = required else { + crate::utils::log::warn(&format!( + "package.dcr-version is missing in {}. Add e.g. dcr-version = \"{tool}\" \ + so collaborators know which dcr this project targets \ + (written automatically by `dcr new` / `dcr init`).", + self.path.display() + )); + return; + }; + + match crate::utils::build::compare_semver(tool, required) { + Some(std::cmp::Ordering::Less) => { + crate::utils::log::warn(&format!( + "this project requires dcr {required} (package.dcr-version), \ + but you are running dcr {tool}. Some features may be missing or misbehave. \ + Upgrade dcr (e.g. `dcr --update`) or lower package.dcr-version if intentional." + )); + } + Some(std::cmp::Ordering::Greater) => { + crate::utils::log::warn(&format!( + "this project pins dcr {required} (package.dcr-version), \ + but you are running newer dcr {tool}. Config/defaults may be outdated. \ + Review the changelog and bump package.dcr-version when the project is verified." + )); + } + Some(std::cmp::Ordering::Equal) => {} + None => { + crate::utils::log::warn(&format!( + "package.dcr-version = \"{required}\" is not a valid semver (expected X.Y.Z); ignoring" + )); + } + } + } + #[allow(dead_code)] /// Returns the build configuration if present. pub fn build_config(&self) -> Option<&BuildConfig> { @@ -765,11 +835,13 @@ fn default_toml_text() -> String { .ok() .and_then(|p| p.file_name().map(|v| v.to_string_lossy().to_string())) .unwrap_or_else(|| "project".to_string()); + let dcr_version = env!("CARGO_PKG_VERSION"); format!( "[package]\n\ name = \"{name}\"\n\ version = \"{DEFAULT_VERSION}\"\n\ + dcr-version = \"{dcr_version}\"\n\ type = \"none\"\n\ description = \"\"\n\ author = \"\"\n\ @@ -785,6 +857,17 @@ fn default_toml_text() -> String { ) } +fn dcr_version_warn_once(path_key: &str) -> bool { + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + static WARNED: OnceLock>> = OnceLock::new(); + let set = WARNED.get_or_init(|| Mutex::new(HashSet::new())); + let Ok(mut guard) = set.lock() else { + return true; + }; + guard.insert(path_key.to_string()) +} + fn set_doc_path(doc: &mut DocumentMut, path: &[&str], value: &Value) -> Result<(), ConfigError> { let mut current = doc.as_table_mut(); for &key in &path[..path.len().saturating_sub(1)] { @@ -937,11 +1020,21 @@ mod tests { let dir = temp_dir("typed_config"); let path = write_toml_file( &dir, - "[package]\nname = \"typed\"\nversion = \"1.2.3\"\ntype = \"lib\"\n\n[build]\nlanguage = [\"c\", \"c++\"]\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"staticlib\"\ncflags = [\"-Wall\"]\n\n[dependencies]\nfoo = \"1.0.0\"\n", + "[package]\nname = \"typed\"\nversion = \"1.2.3\"\ndcr-version = \"0.8.4\"\ntype = \"lib\"\n\n[build]\nlanguage = [\"c\", \"c++\"]\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"staticlib\"\ncflags = [\"-Wall\"]\n\n[dependencies]\nfoo = \"1.0.0\"\n", ); let config = Config::open(&path.to_string_lossy()).unwrap(); assert_eq!(config.package().unwrap().name, "typed"); assert_eq!(config.typed().package.as_ref().unwrap().version, "1.2.3"); + assert_eq!( + config + .typed() + .package + .as_ref() + .unwrap() + .dcr_version + .as_deref(), + Some("0.8.4") + ); assert_eq!(config.build_config().unwrap().compiler, "clang"); assert_eq!(config.build_config().unwrap().cflags, ["-Wall"]); assert!(config.typed().dependencies.contains_key("foo")); diff --git a/src/utils/build.rs b/src/utils/build.rs index 9a4d0e8..8b716ec 100644 --- a/src/utils/build.rs +++ b/src/utils/build.rs @@ -31,6 +31,39 @@ pub struct VersionInfo { pub suffix_dash: String, } +/// Compares two semver-like strings (`X.Y.Z` with optional `-suffix`, suffix ignored). +/// +/// Returns `None` if either side has no parseable numeric major component. +/// Ordering is tool-centric: `compare_semver(tool, required)` → `Less` means tool is older. +pub fn compare_semver(left: &str, right: &str) -> Option { + let l = semver_numeric_parts(left)?; + let r = semver_numeric_parts(right)?; + Some(l.cmp(&r)) +} + +fn semver_numeric_parts(version: &str) -> Option<(u64, u64, u64)> { + let base = version + .trim() + .split_once('-') + .map(|(h, _)| h) + .unwrap_or(version.trim()); + if base.is_empty() { + return None; + } + let mut parts = base.split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next().unwrap_or("0").parse::().unwrap_or(0); + let patch = parts + .next() + .unwrap_or("0") + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse::() + .unwrap_or(0); + Some((major, minor, patch)) +} + /// Parses a version string into VersionInfo struct. pub fn parse_version_info(version: &str) -> VersionInfo { let mut full = version.trim().to_string(); @@ -761,6 +794,16 @@ mod tests { assert_eq!(info.suffix_dash, "-beta"); } + #[test] + fn compare_semver_orders_and_ignores_prerelease() { + use std::cmp::Ordering; + assert_eq!(compare_semver("0.8.4", "0.8.4"), Some(Ordering::Equal)); + assert_eq!(compare_semver("0.8.3", "0.8.4"), Some(Ordering::Less)); + assert_eq!(compare_semver("0.9.0", "0.8.4"), Some(Ordering::Greater)); + assert_eq!(compare_semver("1.0.0-dev", "1.0.0"), Some(Ordering::Equal)); + assert_eq!(compare_semver("not-a-version", "0.1.0"), None); + } + /// Test for normalize_target_with_profile function. #[test] fn normalize_target_with_profile() {