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
12 changes: 12 additions & 0 deletions .changeset/write-token-operation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"design-data-core": minor
"design-data-cli": minor
---

Add `write_token` operation to sdk/core and CLI (closes #976).

- **sdk/core/src/write.rs**: new `write_token` — validates against `$schema`,
merges into legacy JSON, records rationale in `product-context.json`.
- **sdk/core/Cargo.toml**: enable `preserve_order` on `serde_json`.
- **sdk/cli**: add `write-token` subcommand; auto-discovers schemas dir.
- Prerequisite for TUI RFC #973 M4 (wizard write path).
133 changes: 132 additions & 1 deletion sdk/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ mod format;

use std::collections::HashSet;

use clap::{Parser, Subcommand, ValueEnum};
use clap::{ArgGroup, Parser, Subcommand, ValueEnum};
use design_data_core::cascade::{resolve, ResolutionContext};
use design_data_core::compat::{
load_snapshot, snapshot_matches, write_snapshot, ValidationSnapshot,
Expand All @@ -32,6 +32,7 @@ use design_data_core::naming::NamingExceptionsFile;
use design_data_core::query;
use design_data_core::schema::SchemaRegistry;
use design_data_core::validate;
use design_data_core::write::{WriteTokenInput, write_token};
use miette::{IntoDiagnostic, WrapErr};

const SPEC_VERSION: &str = "1.0.0-draft";
Expand Down Expand Up @@ -173,6 +174,34 @@ enum Commands {
#[arg(short, long, value_name = "TEXT")]
rationale: Option<String>,
},
/// Validate and write a product-layer token to a target file
#[command(group(ArgGroup::new("token_source").required(true).args(["token_json", "token_file"])))]
WriteToken {
/// Token key (its name in the target file, e.g. "checkout-background-color")
#[arg(value_name = "KEY")]
key: String,
/// Token object as a JSON string (must include $schema and value)
#[arg(long, value_name = "JSON", group = "token_source")]
token_json: Option<String>,
/// Path to a JSON file containing the token object
#[arg(long, value_name = "FILE", group = "token_source")]
token_file: Option<PathBuf>,
/// Target legacy JSON file to write to (created if absent, merged if present)
#[arg(long, value_name = "FILE")]
target: PathBuf,
/// Path to product-context.json for rationale capture (created if absent)
#[arg(long, value_name = "FILE")]
product_context: Option<PathBuf>,
/// Why this token was created or changed
#[arg(long, value_name = "TEXT")]
rationale: Option<String>,
/// Token overrides an existing foundation/platform token (records in overrides[])
#[arg(long)]
is_override: bool,
/// Path to schemas directory (default: packages/tokens/schemas relative to target)
#[arg(long, value_name = "DIR")]
schema_path: Option<PathBuf>,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -1129,6 +1158,87 @@ fn run_write(output: &Path, rationale: Option<&str>) -> miette::Result<ExitCode>
Ok(ExitCode::SUCCESS)
}

struct WriteTokenOpts<'a> {
token_json: Option<&'a str>,
token_file: Option<&'a Path>,
product_context: Option<&'a Path>,
rationale: Option<&'a str>,
is_override: bool,
schema_path: Option<&'a Path>,
}

fn run_write_token(
key: &str,
target: &Path,
opts: WriteTokenOpts<'_>,
) -> miette::Result<ExitCode> {
let WriteTokenOpts { token_json, token_file, product_context, rationale, is_override, schema_path } = opts;
let token: serde_json::Value = match (token_json, token_file) {
(Some(raw), _) => serde_json::from_str(raw)
.into_diagnostic()
.wrap_err("failed to parse --token-json")?,
(None, Some(path)) => {
let text = std::fs::read_to_string(path)
.into_diagnostic()
.wrap_err_with(|| format!("failed to read {}", path.display()))?;
serde_json::from_str(&text)
.into_diagnostic()
.wrap_err_with(|| format!("failed to parse {}", path.display()))?
}
(None, None) => {
return Err(miette::miette!(
"one of --token-json or --token-file is required"
))
}
};

// Resolve schema directory: explicit flag → sibling of target → default relative path.
let schemas_dir = schema_path
.map(PathBuf::from)
.or_else(|| {
// Try target's parent up to repo root looking for packages/tokens/schemas.
target.ancestors().find_map(|p| {
let candidate = p.join("packages/tokens/schemas");
candidate.is_dir().then_some(candidate)
})
})
.ok_or_else(|| {
miette::miette!(
"cannot locate schemas directory; pass --schema-path explicitly"
)
})?;

let registry = SchemaRegistry::load_legacy_token_schemas(&schemas_dir)
.into_diagnostic()
.wrap_err("failed to load schema registry")?;

let result = write_token(
WriteTokenInput {
key: key.to_string(),
token,
target: target.to_path_buf(),
product_context: product_context.map(PathBuf::from),
rationale: rationale.map(str::to_string),
created_at: Some(Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()),
is_override,
},
&registry,
)
.into_diagnostic()
.wrap_err("write_token failed")?;

println!("Wrote token '{}' to {}", key, result.written_to.display());
if result.product_context_updated {
println!(
"Updated {}",
product_context
.map(|p| p.display().to_string())
.unwrap_or_default()
);
}
Ok(ExitCode::SUCCESS)
}

fn main() -> ExitCode {
let cli = Cli::parse();

Expand Down Expand Up @@ -1239,6 +1349,27 @@ fn main() -> ExitCode {
Commands::Write { output, rationale } => {
run_write(&output, rationale.as_deref())
}
Commands::WriteToken {
key,
token_json,
token_file,
target,
product_context,
rationale,
is_override,
schema_path,
} => run_write_token(
&key,
&target,
WriteTokenOpts {
token_json: token_json.as_deref(),
token_file: token_file.as_deref(),
product_context: product_context.as_deref(),
rationale: rationale.as_deref(),
is_override,
schema_path: schema_path.as_deref(),
},
),
};

match result {
Expand Down
4 changes: 3 additions & 1 deletion sdk/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ path = "src/lib.rs"
[dependencies]
jsonschema = "0.29"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# preserve_order switches serde_json's Map from BTreeMap to IndexMap (crate-wide).
# Required so write_token merges tokens without resorting keys on round-trip.
serde_json = { version = "1.0", features = ["preserve_order"] }
tempfile = "3.14"
thiserror = "2.0"
uuid = { version = "1.11", features = ["v4"] }
Expand Down
1 change: 1 addition & 0 deletions sdk/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub mod registry;
pub mod report;
pub mod schema;
pub mod validate;
pub mod write;

use std::path::PathBuf;

Expand Down
5 changes: 1 addition & 4 deletions sdk/core/src/validate/rules/spec003.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,7 @@ impl ValidationRule for Rule {
}
let mut path: Vec<String> = vec![start.name.clone()];
let mut current = start;
loop {
let Some(next_name) = current.alias_target.as_ref() else {
break;
};
while let Some(next_name) = current.alias_target.as_ref() {
if path.iter().any(|p| p == next_name) {
out.push(Diagnostic {
file: start.file.clone(),
Expand Down
Loading
Loading