feat: add package.dcr-version pin with load-time compatibility warnings

This commit is contained in:
2026-08-08 14:33:46 +08:00
parent a20c61cc88
commit 60d7420929
8 changed files with 198 additions and 5 deletions

View File

@@ -1,5 +1,31 @@
# Changelog # 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" ## [0.8.4] - 2026-08-04 "Инфраструктура .dcr/ и интеграция с IDE / Internal .dcr/ Layout & IDE Integration"
### RU ### RU

2
Cargo.lock generated
View File

@@ -154,7 +154,7 @@ dependencies = [
[[package]] [[package]]
name = "dcr" name = "dcr"
version = "0.8.4" version = "0.8.5"
dependencies = [ dependencies = [
"ctrlc", "ctrlc",
"fatfs", "fatfs",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "dcr" name = "dcr"
version = "0.8.4" version = "0.8.5"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"
description = "DCR is a utility for managing C/C++ projects in a Cargo-like style." description = "DCR is a utility for managing C/C++ projects in a Cargo-like style."

View File

@@ -4,6 +4,16 @@ sidebar_label: Changelog
# 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) ## 0.8.4 (2026-08-04)
**Added:** **Added:**

View File

@@ -43,7 +43,8 @@ Main project configuration file. Located at the project root.
| Field | Required | Description | | Field | Required | Description |
|-------|----------|-------------| |-------|----------|-------------|
| `name` | yes | Project name | | `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"`) | | `type` | no | `app`, `lib`, `none` (defaults to `"none"`) |
| `license` | no | SPDX license identifier | | `license` | no | SPDX license identifier |
| `author` | no | Author | | `author` | no | Author |
@@ -52,11 +53,26 @@ Main project configuration file. Located at the project root.
[package] [package]
name = "my-app" name = "my-app"
version = "0.1.0" version = "0.1.0"
dcr-version = "0.8.4"
type = "app" type = "app"
license = "MIT" license = "MIT"
author = "John Doe" 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] ## [build]
... ...

View File

@@ -154,6 +154,11 @@ Project name (required).
.B package.version .B package.version
Project version (default: \fB0.1.0\fR). Project version (default: \fB0.1.0\fR).
.TP .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 .B build.language
Programming language: \fBc\fR or \fBc++\fR (default: \fBc\fR). Programming language: \fBc\fR or \fBc++\fR (default: \fBc\fR).
.TP .TP

View File

@@ -132,11 +132,17 @@ pub struct ArchiveLayout {
pub to: String, pub to: String,
} }
/// Package metadata section (name, version, type). /// Package metadata section (name, version, type, dcr-version).
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct PackageConfig { pub struct PackageConfig {
pub name: String, pub name: String,
pub version: 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<String>,
#[serde(default, rename = "type")] #[serde(default, rename = "type")]
pub pkg_type: Option<String>, pub pkg_type: Option<String>,
} }
@@ -258,6 +264,7 @@ impl Config {
doc, doc,
}; };
cfg.validate()?; cfg.validate()?;
cfg.warn_dcr_version_mismatch();
Ok(cfg) Ok(cfg)
} }
@@ -276,6 +283,7 @@ impl Config {
doc, doc,
}; };
cfg.validate()?; cfg.validate()?;
cfg.warn_dcr_version_mismatch();
Ok(cfg) Ok(cfg)
} }
@@ -291,6 +299,68 @@ impl Config {
self.typed.package.as_ref() 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)] #[allow(dead_code)]
/// Returns the build configuration if present. /// Returns the build configuration if present.
pub fn build_config(&self) -> Option<&BuildConfig> { pub fn build_config(&self) -> Option<&BuildConfig> {
@@ -765,11 +835,13 @@ fn default_toml_text() -> String {
.ok() .ok()
.and_then(|p| p.file_name().map(|v| v.to_string_lossy().to_string())) .and_then(|p| p.file_name().map(|v| v.to_string_lossy().to_string()))
.unwrap_or_else(|| "project".to_string()); .unwrap_or_else(|| "project".to_string());
let dcr_version = env!("CARGO_PKG_VERSION");
format!( format!(
"[package]\n\ "[package]\n\
name = \"{name}\"\n\ name = \"{name}\"\n\
version = \"{DEFAULT_VERSION}\"\n\ version = \"{DEFAULT_VERSION}\"\n\
dcr-version = \"{dcr_version}\"\n\
type = \"none\"\n\ type = \"none\"\n\
description = \"\"\n\ description = \"\"\n\
author = \"\"\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<Mutex<HashSet<String>>> = 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> { fn set_doc_path(doc: &mut DocumentMut, path: &[&str], value: &Value) -> Result<(), ConfigError> {
let mut current = doc.as_table_mut(); let mut current = doc.as_table_mut();
for &key in &path[..path.len().saturating_sub(1)] { for &key in &path[..path.len().saturating_sub(1)] {
@@ -937,11 +1020,21 @@ mod tests {
let dir = temp_dir("typed_config"); let dir = temp_dir("typed_config");
let path = write_toml_file( let path = write_toml_file(
&dir, &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(); let config = Config::open(&path.to_string_lossy()).unwrap();
assert_eq!(config.package().unwrap().name, "typed"); 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().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().compiler, "clang");
assert_eq!(config.build_config().unwrap().cflags, ["-Wall"]); assert_eq!(config.build_config().unwrap().cflags, ["-Wall"]);
assert!(config.typed().dependencies.contains_key("foo")); assert!(config.typed().dependencies.contains_key("foo"));

View File

@@ -31,6 +31,39 @@ pub struct VersionInfo {
pub suffix_dash: String, 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<std::cmp::Ordering> {
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::<u64>().ok()?;
let minor = parts.next().unwrap_or("0").parse::<u64>().unwrap_or(0);
let patch = parts
.next()
.unwrap_or("0")
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u64>()
.unwrap_or(0);
Some((major, minor, patch))
}
/// Parses a version string into VersionInfo struct. /// Parses a version string into VersionInfo struct.
pub fn parse_version_info(version: &str) -> VersionInfo { pub fn parse_version_info(version: &str) -> VersionInfo {
let mut full = version.trim().to_string(); let mut full = version.trim().to_string();
@@ -761,6 +794,16 @@ mod tests {
assert_eq!(info.suffix_dash, "-beta"); 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 for normalize_target_with_profile function.
#[test] #[test]
fn normalize_target_with_profile() { fn normalize_target_with_profile() {