Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.0] - 2026-02-16

### Changed

- **Breaking:** `Citation.reference` (String) renamed to `Citation.refs` (Vec\<String\>) to support multi-citation clusters (e.g., `[smith2023; jones2024]`)
- **Breaking:** `ExtensionMark::citation()` and `citation_with_page()` now emit `"refs"` (array) instead of `"ref"` (string)
- Deserialization accepts both old `"ref"` (string) and new `"refs"` (array) for backward compatibility

### Added

- `Citation::multi()` constructor for multi-reference citations
- `Citation::first_ref()` and `Citation::refs()` accessors
- `ExtensionMark::multi_citation()` convenience constructor
- `ExtensionMark::get_string_array_attribute()` for array-typed attributes
- `ExtensionMark::get_citation_refs()` helper supporting both `"refs"` and legacy `"ref"` keys
- `ExtensionMark::normalize_citation_attrs()` to migrate `"ref"` → `"refs"` in-place
- `ExtensionBlock::get_string_array_attribute()` for parity with `ExtensionMark`
- Backward-compatibility conformance tests for singular `"ref"` deserialization
- Multi-reference citation roundtrip tests

## [0.5.0] - 2026-02-16

### Changed
Expand Down Expand Up @@ -276,7 +296,8 @@ Initial release implementing Codex Document Format Specification v0.1.
- `sign_document` - Sign a document with ES256
- `extract_content` - Extract text content from blocks

[Unreleased]: https://github.com/Entrolution/cdx-core/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/Entrolution/cdx-core/compare/v0.6.0...HEAD
[0.6.0]: https://github.com/Entrolution/cdx-core/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/Entrolution/cdx-core/compare/v0.4.0...v0.5.0
[0.4.0]: https://github.com/Entrolution/cdx-core/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/Entrolution/cdx-core/compare/v0.2.0...v0.3.0
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ thiserror = "2.0"
chrono = { version = "0.4", features = ["serde", "now"], default-features = false }

# Internal crates
cdx-core = { path = "cdx-core", version = "0.5.0" }
cdx-core = { path = "cdx-core", version = "0.6.0" }
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

| Version | Supported |
|---------|-----------|
| 0.5.x | Yes |
| < 0.5 | No |
| 0.6.x | Yes |
| < 0.6 | No |

Only the latest minor release receives security updates. Earlier versions are not supported.

Expand Down
2 changes: 1 addition & 1 deletion cdx-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cdx-cli"
version = "0.5.0"
version = "0.6.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion cdx-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cdx-core"
version = "0.5.0"
version = "0.6.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
Expand Down
119 changes: 111 additions & 8 deletions cdx-core/src/content/text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,26 +255,78 @@ impl ExtensionMark {
self.attributes.get(key).and_then(serde_json::Value::as_str)
}

/// Get an array-of-strings attribute.
#[must_use]
pub fn get_string_array_attribute(&self, key: &str) -> Option<Vec<&str>> {
self.attributes.get(key).and_then(|v| {
v.as_array()
.map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect())
})
}

/// Get citation refs, supporting both `"refs"` (array) and legacy `"ref"` (string).
///
/// Returns `None` if neither key is present.
#[must_use]
pub fn get_citation_refs(&self) -> Option<Vec<&str>> {
// Try new format first
if let Some(refs) = self.get_string_array_attribute("refs") {
return Some(refs);
}
// Fall back to legacy singular "ref"
self.get_string_attribute("ref").map(|r| vec![r])
}

/// Rewrite legacy `"ref"` (string) → `"refs"` (array) in the attributes map.
///
/// No-op if `"refs"` already exists or `"ref"` is absent.
pub fn normalize_citation_attrs(&mut self) {
if let Some(obj) = self.attributes.as_object_mut() {
if obj.contains_key("refs") {
return;
}
if let Some(single) = obj.remove("ref") {
if let Some(s) = single.as_str() {
obj.insert(
"refs".to_string(),
serde_json::Value::Array(vec![serde_json::Value::String(s.to_string())]),
);
} else {
// Put it back if it wasn't a string
obj.insert("ref".to_string(), single);
}
}
}
}

// ===== Convenience constructors for common extension marks =====

/// Create a citation mark (semantic extension).
#[must_use]
pub fn citation(reference: impl Into<String>) -> Self {
Self::new("semantic", "citation").with_attributes(serde_json::json!({
"ref": reference.into()
"refs": [reference.into()]
}))
}

/// Create a citation mark with page locator.
#[must_use]
pub fn citation_with_page(reference: impl Into<String>, page: impl Into<String>) -> Self {
Self::new("semantic", "citation").with_attributes(serde_json::json!({
"ref": reference.into(),
"refs": [reference.into()],
"locator": page.into(),
"locatorType": "page"
}))
}

/// Create a multi-reference citation mark (e.g., `[smith2023; jones2024]`).
#[must_use]
pub fn multi_citation(refs: &[String]) -> Self {
Self::new("semantic", "citation").with_attributes(serde_json::json!({
"refs": refs
}))
}

/// Create an entity link mark (semantic extension).
#[must_use]
pub fn entity(uri: impl Into<String>, entity_type: impl Into<String>) -> Self {
Expand Down Expand Up @@ -936,11 +988,14 @@ mod tests {
#[test]
fn test_extension_mark_with_attributes() {
let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
"ref": "smith2023",
"refs": ["smith2023"],
"page": "42"
}));

assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
assert_eq!(ext.get_string_attribute("page"), Some("42"));
}

Expand Down Expand Up @@ -969,14 +1024,14 @@ mod tests {
#[test]
fn test_extension_mark_serialization() {
let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
"ref": "smith2023"
"refs": ["smith2023"]
}));
let mark = Mark::Extension(ext);

let json = serde_json::to_string(&mark).unwrap();
// New format: type is "namespace:markType", attributes flattened
assert!(json.contains("\"type\":\"semantic:citation\""));
assert!(json.contains("\"ref\":\"smith2023\""));
assert!(json.contains("\"refs\":[\"smith2023\"]"));
// Should NOT contain old wrapper fields
assert!(!json.contains("\"namespace\""));
assert!(!json.contains("\"markType\""));
Expand Down Expand Up @@ -1046,18 +1101,66 @@ mod tests {
fn test_citation_convenience() {
let ext = ExtensionMark::citation("smith2023");
assert!(ext.is_type("semantic", "citation"));
assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
}

#[test]
fn test_citation_with_page_convenience() {
let ext = ExtensionMark::citation_with_page("smith2023", "42-45");
assert!(ext.is_type("semantic", "citation"));
assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
assert_eq!(ext.get_string_attribute("locator"), Some("42-45"));
assert_eq!(ext.get_string_attribute("locatorType"), Some("page"));
}

#[test]
fn test_multi_citation_convenience() {
let refs = vec!["smith2023".into(), "jones2024".into()];
let ext = ExtensionMark::multi_citation(&refs);
assert!(ext.is_type("semantic", "citation"));
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023", "jones2024"])
);
}

#[test]
fn test_get_citation_refs_legacy() {
// Legacy "ref" key should still be readable via get_citation_refs
let ext = ExtensionMark::new("semantic", "citation")
.with_attributes(serde_json::json!({"ref": "smith2023"}));
assert_eq!(ext.get_citation_refs(), Some(vec!["smith2023"]));
}

#[test]
fn test_normalize_citation_attrs() {
let mut ext = ExtensionMark::new("semantic", "citation")
.with_attributes(serde_json::json!({"ref": "smith2023"}));
ext.normalize_citation_attrs();
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
assert!(ext.get_string_attribute("ref").is_none());
}

#[test]
fn test_normalize_citation_attrs_noop_when_refs_exists() {
let mut ext = ExtensionMark::citation("smith2023");
ext.normalize_citation_attrs();
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
}

#[test]
fn test_entity_convenience() {
let ext = ExtensionMark::entity("https://www.wikidata.org/wiki/Q937", "person");
Expand Down
2 changes: 1 addition & 1 deletion cdx-core/src/document/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ mod tests {

let ext_block = Block::Extension(
ExtensionBlock::new("semantic", "citation")
.with_attributes(serde_json::json!({"ref": "smith2023"})),
.with_attributes(serde_json::json!({"refs": ["smith2023"]})),
);

let content = Content::new(vec![
Expand Down
16 changes: 14 additions & 2 deletions cdx-core/src/extensions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,15 @@ impl ExtensionBlock {
self.attributes.get(key).and_then(Value::as_str)
}

/// Get an array-of-strings attribute.
#[must_use]
pub fn get_string_array_attribute(&self, key: &str) -> Option<Vec<&str>> {
self.attributes.get(key).and_then(|v| {
v.as_array()
.map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect())
})
}

/// Get a boolean attribute.
#[must_use]
pub fn get_bool_attribute(&self, key: &str) -> Option<bool> {
Expand Down Expand Up @@ -303,7 +312,7 @@ mod tests {
"blockType": "citation",
"id": "cite-1",
"attributes": {
"ref": "smith2023",
"refs": ["smith2023"],
"page": 42
}
}"#;
Expand All @@ -312,7 +321,10 @@ mod tests {
assert_eq!(ext.namespace, "semantic");
assert_eq!(ext.block_type, "citation");
assert_eq!(ext.id, Some("cite-1".to_string()));
assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
assert_eq!(
ext.get_string_array_attribute("refs"),
Some(vec!["smith2023"])
);
assert_eq!(ext.get_i64_attribute("page"), Some(42));
}
}
Loading