use crate::config::{CliConfig, all_export_targets, config_path_from_args};
use crate::output;
use oci_core::{
ColorExport, ColorInput, EncodeResult, EncodedSrgb, ExportSet, FloatRgb, Hsl, InspectResult,
OciId, Oklab, Oklch, Registry, RegistryStep, Rgb8, SupportStatus, build_support_matrix,
decode_oci_id, encode, encode_from_hex, export_all, inspect,
};
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::path::PathBuf;
use std::process::Command;
const UPDATE_RELEASES_URL: &str =
"https://api.github.com/repos/T-1234567890/open-chroma-index/releases?per_page=20";
const INSTALL_SCRIPT_URL: &str =
"https://raw.githubusercontent.com/T-1234567890/open-chroma-index/main/install.sh";
const CLI_MANIFEST: &str = include_str!("../Cargo.toml");
const D65_WHITE_X: f64 = 0.950_47;
const D65_WHITE_Y: f64 = 1.0;
const D65_WHITE_Z: f64 = 1.088_83;
const CIELAB_EPSILON: f64 = 216.0 / 24_389.0;
const CIELAB_KAPPA: f64 = 24_389.0 / 27.0;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliError {
pub code: String,
pub message: String,
}
impl CliError {
pub(crate) fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.to_string(),
message: message.into(),
}
}
}
pub fn run_cli(args: &[String]) -> Result<String, CliError> {
let Some(command) = args.first().map(String::as_str) else {
return Err(CliError::new("parse_error", "missing command"));
};
if matches!(command, "--help" | "-h" | "help") {
return Ok(help_text());
}
if matches!(command, "--version" | "-V" | "version") {
if args.iter().any(|arg| arg == "--core") {
return Ok(format!("oci-core {}", linked_core_version()));
}
return Ok(format!("oci {}", env!("CARGO_PKG_VERSION")));
}
if command == "config" {
return cmd_config(args, ConfigMode::NonInteractive);
}
if command == "update" {
return cmd_update(&args[1..]);
}
let config_path = config_path_from_args(args);
let mut config = CliConfig::load_from_args(args).map_err(config_error)?;
let out = run_cli_with_config(args, &config)?;
maybe_add_update_notice(args, &mut config, &config_path, out)
}
pub(crate) fn run_cli_with_config(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let Some(command) = args.first().map(String::as_str) else {
return Err(CliError::new("parse_error", "missing command"));
};
match command {
"encode" => cmd_encode(&args[1..], config),
"inspect" => cmd_inspect(&args[1..], config),
"export" => cmd_export(&args[1..], config),
"convert" => cmd_convert(&args[1..], config),
"serve" => cmd_serve(&args[1..], config),
"swatch" => cmd_swatch(&args[1..], config),
"registry" => cmd_registry(&args[1..], config),
"test" => cmd_test(&args[1..], config),
"validate" => cmd_validate(&args[1..], config),
_ => Err(CliError::new(
"parse_error",
format!("unknown command: {command}"),
)),
}
}
pub fn run_config_command(args: &[String]) -> Result<String, CliError> {
cmd_config(args, ConfigMode::Auto)
}
fn help_text() -> String {
[
"Open Chroma Index CLI",
"",
"Usage:",
" oci encode <INPUT> --space <SPACE> [--format json|pretty|plain] [--precision <N>] [--verify]",
" oci inspect <OCI_ID> [--format json|pretty|plain] [--exports all|none|summary|<LIST>] [--verify]",
" oci export <OCI_ID> --to <TARGETS> [--format json|plain|pretty] [--verify]",
" oci convert <INPUT> --from <SPACE> --to <TARGETS> [--format json|plain|pretty] [--verify]",
" oci serve [--host <HOST>] [--port <PORT>] [--config <PATH>] [--json]",
" oci swatch gen (--id <OCI_ID>|--family <INDEX_OR_CODE>|--range <START>..<END>) --out <DIR> [--template <SVG_PATH>] [--filename short|full] [--overwrite]",
" oci swatch data --id <OCI_ID>",
" oci update [--version <TAG>] [--dir <PATH>] [--system] [--no-checksum] [--force]",
" oci registry <SUBCOMMAND>",
" oci test <SUBCOMMAND>",
" oci validate <TARGET> [--type id|registry|color]",
" oci config [--path <TOML_PATH>]",
"",
"Common commands:",
" oci encode \"#E85A9A\" --space hex",
" oci inspect OCI-1-48RS-327",
" oci --version --core",
" oci serve",
" oci swatch gen --id OCI-1-22TL-326 --out out/",
" oci update",
" oci registry validate",
]
.join("\n")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ConfigMode {
Auto,
NonInteractive,
}
fn cmd_encode(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let input = positional(args, 0, "encode input")?;
let space = flag_value(args, "--space").unwrap_or(&config.color.default_input_space);
let format = configured_format(args, config)?;
let precision = configured_precision(args, config)?;
let verify = configured_verify(args, config);
let include_exports = !has_flag(args, "--no-exports") && config.output.show_exports;
let registry = load_registry(config)?;
let color_input = parse_color_input(input, space, ®istry)?;
let result = if space == "hex" {
encode_from_hex(input, ®istry).map_err(pipeline_error)?
} else {
encode(color_input, ®istry).map_err(pipeline_error)?
};
match format {
"json" => Ok(output::encode_json(input, space, &result, include_exports)),
"pretty" => Ok(encode_pretty(
input, space, &result, config, precision, verify,
)),
"plain" => Ok(preferred_oci_code(&result, config)),
other => Err(CliError::new(
"parse_error",
format!("unsupported output format: {other}"),
)),
}
}
fn cmd_inspect(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let input = positional(args, 0, "OCI ID")?;
let format = configured_format(args, config)?;
let precision = configured_precision(args, config)?;
let verify = configured_verify(args, config);
let exports = flag_value(args, "--exports").unwrap_or(&config.inspect.exports);
let registry = load_registry(config)?;
let id = OciId::parse_with_registry(input, ®istry).map_err(id_error)?;
let result = inspect(&id, ®istry).map_err(pipeline_error)?;
let include_exports = exports != "none";
match format {
"json" => Ok(output::inspect_json(input, &result, include_exports)),
"pretty" => Ok(inspect_pretty(
input, &result, config, exports, precision, verify,
)),
"plain" => Ok(result.canonical_id.to_short_string()),
other => Err(CliError::new(
"parse_error",
format!("unsupported output format: {other}"),
)),
}
}
fn cmd_export(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let input = positional(args, 0, "OCI ID")?;
let targets = flag_value(args, "--to")
.map(parse_targets)
.unwrap_or_else(|| config.output.default_exports.clone());
let format = configured_format(args, config)?;
let verify = configured_verify(args, config);
let registry = load_registry(config)?;
let id = OciId::parse_with_registry(input, ®istry).map_err(id_error)?;
let color = decode_oci_id(&id, ®istry).map_err(pipeline_error)?;
let exports = export_all(color);
match format {
"json" => Ok(output::export_json(input, &exports, &targets)),
"plain" => Ok(targets
.iter()
.map(|target| {
format!(
"{target}: {}",
output::selected_exports_json(&exports, std::slice::from_ref(target))
)
})
.collect::<Vec<_>>()
.join("\n")),
"pretty" => Ok(exports_pretty(&exports, &targets, verify)),
other => Err(CliError::new(
"parse_error",
format!("unsupported output format: {other}"),
)),
}
}
fn cmd_convert(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let input = positional(args, 0, "convert input")?;
let from = flag_value(args, "--from").unwrap_or(&config.color.default_input_space);
let targets = flag_value(args, "--to")
.map(parse_targets)
.unwrap_or_else(|| config.color.default_targets.clone());
let format = configured_format(args, config)?;
let precision = configured_precision(args, config)?;
let verify = configured_verify(args, config);
let registry = load_registry(config)?;
let result =
encode(parse_color_input(input, from, ®istry)?, ®istry).map_err(pipeline_error)?;
match format {
"json" => Ok(output::convert_json(input, from, &result, &targets)),
"plain" => Ok(output::selected_exports_json(&result.exports, &targets)),
"pretty" => Ok(convert_pretty(
input, from, &result, &targets, precision, verify,
)),
other => Err(CliError::new(
"parse_error",
format!("unsupported output format: {other}"),
)),
}
}
fn cmd_serve(args: &[String], config: &CliConfig) -> Result<String, CliError> {
if has_flag(args, "--help") || has_flag(args, "-h") {
return Ok(serve_help_text());
}
let options = crate::server::ServerOptions::from_args(args, config)?;
crate::server::serve(options, config.clone())?;
Ok(String::new())
}
fn cmd_update(args: &[String]) -> Result<String, CliError> {
if has_flag(args, "--help") || has_flag(args, "-h") {
return Ok(update_help_text());
}
let script_path = std::env::temp_dir().join(format!("oci-install-{}.sh", std::process::id()));
let download = Command::new("curl")
.args(["-fsSL", INSTALL_SCRIPT_URL, "-o"])
.arg(&script_path)
.status()
.map_err(|error| CliError::new("update_error", format!("failed to run curl: {error}")))?;
if !download.success() {
return Err(CliError::new(
"update_error",
format!("failed to download installer from {INSTALL_SCRIPT_URL}"),
));
}
let mut install_args = Vec::new();
for flag in ["--version", "--dir"] {
if let Some(value) = flag_value(args, flag) {
install_args.push(flag.to_string());
install_args.push(value.to_string());
}
}
for flag in ["--system", "--no-checksum", "--force"] {
if has_flag(args, flag) {
install_args.push(flag.to_string());
}
}
let status = Command::new("bash")
.arg(&script_path)
.args(&install_args)
.status()
.map_err(|error| {
CliError::new("update_error", format!("failed to run installer: {error}"))
})?;
let _ = fs::remove_file(&script_path);
if !status.success() {
return Err(CliError::new(
"update_error",
format!("installer exited with status {status}"),
));
}
Ok("OCI CLI update completed.".to_string())
}
fn maybe_add_update_notice(
args: &[String],
config: &mut CliConfig,
config_path: &std::path::Path,
output: String,
) -> Result<String, CliError> {
if !should_check_for_update(args, config, &output) {
return Ok(output);
}
let Some(latest) = latest_cli_release_tag() else {
return Ok(output);
};
let current = env!("CARGO_PKG_VERSION");
if !is_newer_cli_tag(&latest, current) {
return Ok(output);
}
if config.update.last_seen_version != latest {
config.update.last_seen_version = latest.clone();
config.update.notices_shown = 0;
}
if config.update.notices_shown >= 3 {
return Ok(output);
}
config.update.notices_shown += 1;
let _ = config.write_to_path(config_path);
let notice = format!(
"Update available: oci {current} -> {}. Run `oci update` to install it. ({}/3)",
latest.trim_start_matches("cli-v"),
config.update.notices_shown
);
Ok(format!("{notice}\n\n{output}"))
}
fn should_check_for_update(args: &[String], config: &CliConfig, output: &str) -> bool {
if cfg!(test) {
return false;
}
should_check_for_update_inner(args, config, output)
}
fn should_check_for_update_inner(args: &[String], config: &CliConfig, output: &str) -> bool {
if !config.update.check || config.update.notices_shown >= 3 {
return false;
}
let Some(command) = args.first().map(String::as_str) else {
return false;
};
if matches!(
command,
"--help" | "-h" | "help" | "--version" | "-V" | "version" | "config" | "update" | "serve"
) {
return false;
}
if output.trim_start().starts_with('{') {
return false;
}
!matches!(flag_value(args, "--format"), Some("json"))
}
fn latest_cli_release_tag() -> Option<String> {
let output = Command::new("curl")
.args(["-fsSL", "--max-time", "2", UPDATE_RELEASES_URL])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let body = String::from_utf8(output.stdout).ok()?;
latest_cli_tag_from_releases_json(&body)
}
fn latest_cli_tag_from_releases_json(body: &str) -> Option<String> {
let marker = "\"tag_name\"";
let mut rest = body;
while let Some(start) = rest.find(marker) {
let value_start = start + marker.len();
let value_rest = &rest[value_start..];
let colon = value_rest.find(':')?;
let after_colon = value_rest[colon + 1..].trim_start();
let tag_rest = after_colon.strip_prefix('"')?;
let end = tag_rest.find('"')?;
let tag = &tag_rest[..end];
if tag.starts_with("cli-v") {
return Some(tag.to_string());
}
rest = &tag_rest[end..];
}
None
}
fn is_newer_cli_tag(tag: &str, current: &str) -> bool {
let Some(version) = tag.strip_prefix("cli-v") else {
return false;
};
compare_versions(version, current).is_gt()
}
fn compare_versions(left: &str, right: &str) -> std::cmp::Ordering {
let mut left_parts = left.split('.').map(|part| part.parse::<u64>().unwrap_or(0));
let mut right_parts = right
.split('.')
.map(|part| part.parse::<u64>().unwrap_or(0));
for _ in 0..3 {
let left = left_parts.next().unwrap_or(0);
let right = right_parts.next().unwrap_or(0);
match left.cmp(&right) {
std::cmp::Ordering::Equal => {}
ordering => return ordering,
}
}
std::cmp::Ordering::Equal
}
fn cmd_swatch(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let Some(subcommand) = args.first().map(String::as_str) else {
return Err(CliError::new("parse_error", "missing swatch subcommand"));
};
let registry = load_registry(config)?;
match subcommand {
"gen" => cmd_swatch_gen(&args[1..], ®istry),
"data" => cmd_swatch_data(&args[1..], ®istry),
other => Err(CliError::new(
"parse_error",
format!("unknown swatch subcommand: {other}"),
)),
}
}
fn cmd_swatch_gen(args: &[String], registry: &Registry) -> Result<String, CliError> {
let out_dir = flag_value(args, "--out")
.ok_or_else(|| CliError::new("parse_error", "missing --out <DIR>"))?;
let filename_mode = flag_value(args, "--filename").unwrap_or("short");
if !matches!(filename_mode, "short" | "full") {
return Err(CliError::new(
"parse_error",
format!("unsupported filename mode: {filename_mode}"),
));
}
let overwrite = has_flag(args, "--overwrite");
let template_path = resolve_swatch_template(flag_value(args, "--template"))?;
let template = fs::read_to_string(&template_path).map_err(|error| {
CliError::new(
"template_error",
format!(
"failed to read template {}: {error}",
template_path.display()
),
)
})?;
let out_dir = PathBuf::from(out_dir);
let selection = swatch_selection(args, registry)?;
let records = swatch_records_for_selection(&selection, registry)?;
let mut generated_paths = Vec::with_capacity(records.len());
for record in &records {
let target_dir = match &selection {
SwatchSelection::Family(_) => out_dir.join(record.family_id.clone()),
_ => out_dir.clone(),
};
fs::create_dir_all(&target_dir).map_err(|error| {
CliError::new(
"output_error",
format!(
"failed to create output directory {}: {error}",
target_dir.display()
),
)
})?;
let file_id = if filename_mode == "full" {
record.base_full.clone()
} else {
record.base_short.clone()
};
let target = target_dir.join(format!("{file_id}.svg"));
if target.exists() && !overwrite {
return Err(CliError::new(
"output_exists",
format!(
"output file already exists: {} (pass --overwrite to replace it)",
target.display()
),
));
}
let rendered = render_swatch_template(&template, &record.placeholders);
fs::write(&target, rendered).map_err(|error| {
CliError::new(
"output_error",
format!("failed to write {}: {error}", target.display()),
)
})?;
generated_paths.push(target);
}
let first_file = generated_paths
.first()
.map(|path| path.display().to_string())
.unwrap_or_default();
let last_file = generated_paths
.last()
.map(|path| path.display().to_string())
.unwrap_or_default();
Ok(format!(
"{{\"generated\":{},\"out\":{},\"template\":{},\"firstFile\":{},\"lastFile\":{}}}",
records.len(),
json_string(&out_dir.display().to_string()),
json_string(&template_path.display().to_string()),
json_string(&first_file),
json_string(&last_file)
))
}
fn cmd_swatch_data(args: &[String], registry: &Registry) -> Result<String, CliError> {
let id = flag_value(args, "--id")
.ok_or_else(|| CliError::new("parse_error", "missing --id <OCI_ID>"))?;
let record = swatch_record_for_id(id, registry)?;
Ok(swatch_placeholder_json(&record.placeholders))
}
#[derive(Debug, Clone)]
enum SwatchSelection {
Id(String),
Family(String),
Range { start: OciId, end: OciId },
}
#[derive(Debug, Clone)]
struct SwatchRecord {
family_id: String,
base_short: String,
base_full: String,
placeholders: BTreeMap<String, String>,
}
fn swatch_selection(args: &[String], registry: &Registry) -> Result<SwatchSelection, CliError> {
let mut selectors = Vec::new();
if let Some(id) = flag_value(args, "--id") {
selectors.push(("id", id));
}
if let Some(family) = flag_value(args, "--family") {
selectors.push(("family", family));
}
if let Some(range) = flag_value(args, "--range") {
selectors.push(("range", range));
}
match selectors.as_slice() {
[] => Err(CliError::new(
"parse_error",
"one selector is required: --id, --family, or --range",
)),
[("id", id)] => Ok(SwatchSelection::Id((*id).to_string())),
[("family", family)] => {
let family = find_family(registry, family)?;
Ok(SwatchSelection::Family(family.id.to_string()))
}
[("range", range)] => {
let (start, end) = parse_swatch_range(range, registry)?;
Ok(SwatchSelection::Range { start, end })
}
_ => Err(CliError::new(
"parse_error",
"only one selector may be provided: --id, --family, or --range",
)),
}
}
fn swatch_records_for_selection(
selection: &SwatchSelection,
registry: &Registry,
) -> Result<Vec<SwatchRecord>, CliError> {
match selection {
SwatchSelection::Id(id) => Ok(vec![swatch_record_for_id(id, registry)?]),
SwatchSelection::Family(family_id) => {
let family = find_family(registry, family_id)?;
let mut steps = registry
.steps()
.iter()
.filter(|step| step.family_id == family.id)
.collect::<Vec<_>>();
steps.sort_by_key(|step| step.step_number);
steps
.into_iter()
.map(|step| swatch_record_for_step(step, registry))
.collect()
}
SwatchSelection::Range { start, end } => {
if start.family != end.family {
return Err(CliError::new(
"invalid_range",
"range must stay within one family; cross-family ranges are not supported",
));
}
let start_number = start.step.step_number();
let end_number = end.step.step_number();
if start_number > end_number {
return Err(CliError::new(
"invalid_range",
format!("range start {start_number:03} is greater than end {end_number:03}"),
));
}
(start_number..=end_number)
.map(|step_number| {
let step = oci_core::StepId::from_step_number(step_number).map_err(id_error)?;
let id = OciId::new(start.family, step, None);
swatch_record_for_oci_id(&id, registry)
})
.collect()
}
}
}
fn swatch_record_for_step(
step: &RegistryStep,
registry: &Registry,
) -> Result<SwatchRecord, CliError> {
let id = OciId::new(step.family_id, step.step, None);
swatch_record_for_oci_id(&id, registry)
}
fn swatch_record_for_id(id: &str, registry: &Registry) -> Result<SwatchRecord, CliError> {
let id = OciId::parse_with_registry(id, registry).map_err(id_error)?;
swatch_record_for_oci_id(&id, registry)
}
fn swatch_record_for_oci_id(id: &OciId, registry: &Registry) -> Result<SwatchRecord, CliError> {
let result = inspect(id, registry).map_err(pipeline_error)?;
let family = registry
.find_family(id.family)
.ok_or_else(|| CliError::new("invalid_family", format!("unknown family: {}", id.family)))?;
let mut base_id = id.clone();
base_id.offset = None;
let base_short = base_id.to_short_string();
let base_full = base_id.to_full_string();
let mut placeholders = BTreeMap::new();
let exports = &result.exports;
let clipped_srgb = clipped_srgb(exports.oklch);
let color_hex = oci_core::export::srgb_to_hex(clipped_srgb);
placeholders.insert("OCI_SHORT".to_string(), result.short_id.clone());
placeholders.insert("OCI_FULL".to_string(), result.full_id.clone());
placeholders.insert("FAMILY_INDEX".to_string(), id.family.index.to_string());
placeholders.insert("FAMILY_CODE".to_string(), id.family.to_string());
placeholders.insert("FAMILY_NAME".to_string(), family.name.clone());
placeholders.insert("STEP_NUMBER".to_string(), id.step.step_number().to_string());
placeholders.insert("ANCHOR".to_string(), id.step.anchor.to_string());
placeholders.insert(
"LIGHTNESS_LEVEL".to_string(),
format!("{:02}", id.step.lightness),
);
placeholders.insert("CHROMA_LEVEL".to_string(), format!("{:02}", id.step.chroma));
placeholders.insert("HEX".to_string(), string_export_display(&exports.hex));
placeholders.insert("RGB".to_string(), rgb8_export_display(&exports.rgb));
placeholders.insert("HSL".to_string(), hsl_export_display(&exports.hsl));
placeholders.insert(
"SRGB".to_string(),
float_rgb_export_display(&exports.srgb_float),
);
placeholders.insert(
"DISPLAY_P3".to_string(),
float_rgb_export_display(&exports.display_p3_float),
);
placeholders.insert(
"ADOBE_RGB".to_string(),
float_rgb_export_display(&exports.adobe_rgb_1998_float),
);
placeholders.insert(
"REC709".to_string(),
float_rgb_export_display(&exports.rec709_float),
);
placeholders.insert("OKLCH".to_string(), oklch_css_components(exports.oklch));
placeholders.insert("OKLAB".to_string(), oklab_swatch_value(exports.oklab));
placeholders.insert("COLOR_HEX".to_string(), color_hex);
placeholders.insert("COLOR_CSS".to_string(), exports.css.oklch.clone());
placeholders.insert("VERSION".to_string(), id.version.to_string());
Ok(SwatchRecord {
family_id: id.family.to_string(),
base_short,
base_full,
placeholders,
})
}
fn render_swatch_template(template: &str, placeholders: &BTreeMap<String, String>) -> String {
let mut rendered = template.to_string();
for (key, value) in placeholders {
rendered = rendered.replace(&format!("{{{{{key}}}}}"), &escape_xml(value));
}
rendered
}
fn swatch_placeholder_json(placeholders: &BTreeMap<String, String>) -> String {
let fields = placeholders
.iter()
.map(|(key, value)| format!("{}:{}", json_string(key), json_string(value)))
.collect::<Vec<_>>()
.join(",");
format!("{{{fields}}}")
}
fn resolve_swatch_template(explicit: Option<&str>) -> Result<PathBuf, CliError> {
if let Some(path) = explicit {
let path = PathBuf::from(path);
if path.exists() {
return Ok(path);
}
return Err(CliError::new(
"template_error",
format!("template file not found: {}", path.display()),
));
}
for candidate in [
PathBuf::from("Color_Cards_OCI_v1.svg"),
PathBuf::from("templates").join("Color_Cards_OCI_v1.svg"),
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Color_Cards_OCI_v1.svg"),
] {
if candidate.exists() {
return Ok(candidate);
}
}
Err(CliError::new(
"template_error",
"missing SVG template. Pass --template <SVG_PATH> or place Color_Cards_OCI_v1.svg in the current directory or templates/",
))
}
fn parse_swatch_range(value: &str, registry: &Registry) -> Result<(OciId, OciId), CliError> {
let Some((start, end)) = value.split_once("..") else {
return Err(CliError::new(
"invalid_range",
"range must use START..END syntax",
));
};
if start.is_empty() || end.is_empty() {
return Err(CliError::new(
"invalid_range",
"range start and end must not be empty",
));
}
let start = parse_swatch_range_endpoint(start, registry)?;
let end = parse_swatch_range_endpoint(end, registry)?;
if start.family != end.family {
return Err(CliError::new(
"invalid_range",
"range start and end must be in the same family",
));
}
if start.step.step_number() > end.step.step_number() {
return Err(CliError::new(
"invalid_range",
"range start must be less than or equal to range end",
));
}
Ok((start, end))
}
fn parse_swatch_range_endpoint(value: &str, registry: &Registry) -> Result<OciId, CliError> {
if value.starts_with("OCI-") {
return OciId::parse_with_registry(value, registry).map_err(id_error);
}
let Some((family, step)) = value.split_once('-') else {
return Err(CliError::new(
"invalid_range",
format!("invalid compact range endpoint: {value}"),
));
};
OciId::parse_with_registry(&format!("OCI-1-{family}-{step}"), registry).map_err(id_error)
}
fn find_family<'a>(registry: &'a Registry, key: &str) -> Result<&'a oci_core::Family, CliError> {
registry
.families()
.iter()
.find(|family| {
family.id.to_string() == key
|| family.id.code.to_string() == key
|| family.id.index.to_string() == key
})
.ok_or_else(|| CliError::new("invalid_family", format!("unknown family: {key}")))
}
fn string_export_display(export: &ColorExport<String>) -> String {
export
.value
.clone()
.unwrap_or_else(|| "Unavailable".to_string())
}
fn float_rgb_export_display(export: &ColorExport<FloatRgb>) -> String {
export.value.map_or_else(
|| "Unavailable".to_string(),
|rgb| format!("r={:.6} g={:.6} b={:.6}", rgb.r, rgb.g, rgb.b),
)
}
fn rgb8_export_display(export: &ColorExport<Rgb8>) -> String {
export.value.map_or_else(
|| "Unavailable".to_string(),
|rgb| format!("r={} g={} b={}", rgb.r, rgb.g, rgb.b),
)
}
fn hsl_export_display(export: &ColorExport<Hsl>) -> String {
export.value.map_or_else(
|| "Unavailable".to_string(),
|hsl| format!("h={:.6} s={:.6} l={:.6}", hsl.h, hsl.s, hsl.l),
)
}
fn clipped_srgb(color: Oklch) -> EncodedSrgb {
let srgb = color.to_encoded_srgb();
EncodedSrgb::new(
srgb.r.clamp(0.0, 1.0),
srgb.g.clamp(0.0, 1.0),
srgb.b.clamp(0.0, 1.0),
)
}
fn oklch_css_components(color: Oklch) -> String {
format!("{:.6}% {:.6} {:.6}deg", color.l * 100.0, color.c, color.h)
}
fn oklab_swatch_value(color: Oklab) -> String {
format!("L={:.6} a={:.6} b={:.6}", color.l, color.a, color.b)
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn cmd_registry(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let Some(subcommand) = args.first().map(String::as_str) else {
return Err(CliError::new("parse_error", "missing registry subcommand"));
};
let registry = load_registry(config)?;
match subcommand {
"info" => Ok(output::registry_info_json(
registry.families().len(),
registry.steps().len(),
)),
"families" => Ok(format!(
"{{\"families\":[{}]}}",
registry
.families()
.iter()
.map(|family| format!(
"{{\"id\":\"{}\",\"index\":{},\"code\":\"{}\",\"name\":\"{}\",\"group\":\"{}\",\"hueStart\":{:.6},\"hueEnd\":{:.6}}}",
family.id,
family.id.index,
family.id.code,
output::escape_json(&family.name),
output::escape_json(&family.group),
family.hue_start,
family.hue_end
))
.collect::<Vec<_>>()
.join(",")
)),
"family" => {
let key = positional(args, 1, "family index or code")?;
registry_family_json(®istry, key)
}
"step" => {
let key = positional(args, 1, "OCI ID or step")?;
registry_step_json(®istry, key)
}
"validate" => {
registry.validate().map_err(registry_error)?;
Ok(output::validation_json(true, "registry"))
}
"checksum" => Ok(output::checksum_json(&checksum_entries())),
other => Err(CliError::new(
"parse_error",
format!("unknown registry subcommand: {other}"),
)),
}
}
fn cmd_test(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let Some(subcommand) = args.first().map(String::as_str) else {
return Err(CliError::new("parse_error", "missing test subcommand"));
};
match subcommand {
"vectors" => test_vectors(config),
"roundtrip" => test_roundtrip(config),
"registry" => {
let registry = load_registry(config)?;
registry.validate().map_err(registry_error)?;
Ok("{\"test\":\"registry\",\"passed\":true}".to_string())
}
other => Err(CliError::new(
"parse_error",
format!("unknown test subcommand: {other}"),
)),
}
}
fn cmd_validate(args: &[String], config: &CliConfig) -> Result<String, CliError> {
let target = positional(args, 0, "validation target")?;
let target_type = flag_value(args, "--type").unwrap_or("id");
let registry = load_registry(config)?;
match target_type {
"id" => {
OciId::parse_with_registry(target, ®istry).map_err(id_error)?;
Ok(output::validation_json(true, target))
}
"registry" => {
registry.validate().map_err(registry_error)?;
Ok(output::validation_json(true, "registry"))
}
"color" => {
let space = flag_value(args, "--space").unwrap_or("hex");
parse_color_input(target, space, ®istry)?;
Ok(output::validation_json(true, target))
}
other => Err(CliError::new(
"parse_error",
format!("unknown validation type: {other}"),
)),
}
}
fn cmd_config(args: &[String], mode: ConfigMode) -> Result<String, CliError> {
let path = config_path_from_args(args);
let mut config = CliConfig::load_from_path(path.clone()).map_err(config_error)?;
if mode == ConfigMode::Auto && io::stdin().is_terminal() {
config = run_config_wizard(config, &path)?;
}
config.write_to_path(&path).map_err(config_error)?;
Ok(format!(
"OCI config written to {}\n{}",
path.display(),
config_summary(&config)
))
}
fn run_config_wizard(mut config: CliConfig, path: &std::path::Path) -> Result<CliConfig, CliError> {
println!("OCI configuration");
println!("Path: {}", path.display());
println!("Press Enter to keep the current value shown in brackets.");
config.output.format =
prompt_string("output format (pretty|json|plain)", &config.output.format)?;
config.output.precision = prompt_usize("precision", config.output.precision)?;
config.output.default_exports =
prompt_list("default export targets", &config.output.default_exports)?;
config.output.show_support = prompt_bool("show support matrix", config.output.show_support)?;
config.output.show_warnings = prompt_bool("show warnings", config.output.show_warnings)?;
config.output.show_exports = prompt_bool("show exports", config.output.show_exports)?;
config.output.verify = prompt_bool("show verification details", config.output.verify)?;
config.encode.include_offset = prompt_bool(
"include offset in encode output",
config.encode.include_offset,
)?;
config.encode.prefer_short_code =
prompt_bool("prefer short code", config.encode.prefer_short_code)?;
config.encode.include_full_code =
prompt_bool("include full code", config.encode.include_full_code)?;
config.inspect.exports = prompt_string(
"default inspect exports (all|none|summary|list)",
&config.inspect.exports,
)?;
config.inspect.default_export_list = prompt_list(
"inspect default export list",
&config.inspect.default_export_list,
)?;
config.color.default_input_space = prompt_string(
"default input color space",
&config.color.default_input_space,
)?;
config.color.default_targets =
prompt_list("default convert targets", &config.color.default_targets)?;
config.registry.source =
prompt_string("registry source (bundled|path)", &config.registry.source)?;
config.registry.path = prompt_string("registry path", &config.registry.path)?;
config.registry.validate_on_start = prompt_bool(
"validate registry on start",
config.registry.validate_on_start,
)?;
config.server.host = prompt_string("server host", &config.server.host)?;
config.server.port = prompt_usize("server port", config.server.port as usize)? as u16;
config.server.warn_non_localhost = prompt_bool(
"warn when server is not localhost",
config.server.warn_non_localhost,
)?;
config.update.check = prompt_bool("check for CLI updates", config.update.check)?;
Ok(config)
}
fn encode_pretty(
input: &str,
space: &str,
result: &EncodeResult,
config: &CliConfig,
precision: usize,
verify: bool,
) -> String {
let mut lines = vec![
"OCI Encode".to_string(),
format!("input: {input} ({space})"),
String::new(),
format!(
"OCI standard color code: {}",
standard_color_code(result, config)
),
format!(
"OCI precision color code: {}",
precision_color_code(result, config)
),
format!(
"oklch: L={} C={} H={}",
fixed(result.decoded_oklch.l, precision),
fixed(result.decoded_oklch.c, precision),
fixed(result.decoded_oklch.h, precision)
),
];
if config.encode.include_full_code {
lines.push(format!("full: {}", result.full_id));
}
if config.output.show_exports {
lines.push(String::new());
lines.push("exports:".to_string());
let targets = all_export_targets();
lines.push(indent(&selected_exports_pretty(&result.exports, &targets)));
lines.push(String::new());
lines.push(verification_pretty(&result.exports, &targets, verify));
}
if config.output.show_support {
lines.push(String::new());
lines.push(format!(
"support: {} targets evaluated",
result.support_matrix.entries.len()
));
}
if config.output.show_warnings {
lines.push("warnings: none".to_string());
}
lines.join("\n")
}
fn inspect_pretty(
input: &str,
result: &InspectResult,
config: &CliConfig,
export_mode: &str,
precision: usize,
verify: bool,
) -> String {
let mut lines = vec![
"OCI Inspect".to_string(),
format!("input: {input}"),
String::new(),
format!(
"OCI standard color code: {}",
inspect_standard_color_code(result, config)
),
format!("short: {}", result.short_id),
format!("full: {}", result.full_id),
format!(
"oklch: L={} C={} H={}",
fixed(result.canonical_oklch.l, precision),
fixed(result.canonical_oklch.c, precision),
fixed(result.canonical_oklch.h, precision)
),
];
let targets = inspect_targets(config, export_mode);
if !targets.is_empty() {
lines.push(String::new());
lines.push("exports:".to_string());
lines.push(indent(&selected_exports_pretty(&result.exports, &targets)));
lines.push(String::new());
lines.push(verification_pretty(&result.exports, &targets, verify));
}
if config.output.show_support {
lines.push(String::new());
lines.push(format!(
"support: {} targets evaluated",
result.support_matrix.entries.len()
));
}
if config.output.show_warnings {
lines.push("warnings: none".to_string());
}
lines.join("\n")
}
fn convert_pretty(
input: &str,
from: &str,
result: &EncodeResult,
targets: &[String],
precision: usize,
verify: bool,
) -> String {
format!(
"OCI Convert\ninput: {input} ({from})\n\noklch: L={} C={} H={}\n\nexports:\n{}\n\n{}",
fixed(result.decoded_oklch.l, precision),
fixed(result.decoded_oklch.c, precision),
fixed(result.decoded_oklch.h, precision),
indent(&selected_exports_pretty(&result.exports, targets)),
verification_pretty(&result.exports, targets, verify)
)
}
fn exports_pretty(exports: &ExportSet, targets: &[String], verify: bool) -> String {
format!(
"exports:\n{}\n\n{}",
indent(&selected_exports_pretty(exports, targets)),
verification_pretty(exports, targets, verify)
)
}
fn selected_exports_pretty(exports: &ExportSet, targets: &[String]) -> String {
targets
.iter()
.map(|target| export_target_pretty(exports, target))
.collect::<Vec<_>>()
.join("\n")
}
fn export_target_pretty(exports: &ExportSet, target: &str) -> String {
match target {
"hex" => format_string_export("HEX", &exports.hex),
"rgb" => format_rgb8_export("RGB", &exports.rgb),
"hsl" => format_hsl_export("HSL", &exports.hsl),
"srgb" => format_float_rgb_export("sRGB", &exports.srgb_float),
"display-p3" => format_float_rgb_export("Display P3", &exports.display_p3_float),
"adobe-rgb" => format_float_rgb_export("Adobe RGB", &exports.adobe_rgb_1998_float),
"rec709" => format_float_rgb_export("Rec.709", &exports.rec709_float),
"oklch" => format!(
"OKLCH: L={:.6} C={:.6} H={:.6}",
exports.oklch.l, exports.oklch.c, exports.oklch.h
),
"oklab" => format!(
"OKLab: L={:.6} a={:.6} b={:.6}",
exports.oklab.l, exports.oklab.a, exports.oklab.b
),
"css" => {
let mut lines = vec![format!("CSS OKLCH: {}", exports.css.oklch)];
if let Some(srgb) = exports.css.srgb.as_deref() {
lines.push(format!("CSS sRGB: {srgb}"));
}
if let Some(display_p3) = exports.css.display_p3.as_deref() {
lines.push(format!("CSS Display P3: {display_p3}"));
}
lines.join("\n")
}
"json-token" => {
let mut lines = vec!["JSON token:".to_string()];
for value in &exports.json {
let components = value
.components
.iter()
.map(|component| format!("{}={:.6}", component.name, component.value))
.collect::<Vec<_>>()
.join(" ");
lines.push(format!(" {}: {components}", value.model));
}
lines.join("\n")
}
"swift" => format!(
"Swift: Color(.displayP3, red: {:.6}, green: {:.6}, blue: {:.6})",
exports
.display_p3_float
.value
.map(|rgb| rgb.r)
.unwrap_or(0.0),
exports
.display_p3_float
.value
.map(|rgb| rgb.g)
.unwrap_or(0.0),
exports
.display_p3_float
.value
.map(|rgb| rgb.b)
.unwrap_or(0.0)
),
"tailwind" => format!("Tailwind: oci: {}", exports.css.oklch),
"cmyk" => format_string_export("CMYK", &exports.cmyk),
_ => format!("{target}: unsupported"),
}
}
fn format_string_export(label: &str, export: &ColorExport<String>) -> String {
match export.value.as_deref() {
Some(value) => format!("{label}: {value}"),
None => format!("{label}: unavailable"),
}
}
fn format_float_rgb_export(label: &str, export: &ColorExport<FloatRgb>) -> String {
match export.value {
Some(rgb) => format!("{label}: r={:.6} g={:.6} b={:.6}", rgb.r, rgb.g, rgb.b),
None => format!("{label}: unavailable"),
}
}
fn format_rgb8_export(label: &str, export: &ColorExport<Rgb8>) -> String {
match export.value {
Some(rgb) => format!("{label}: r={} g={} b={}", rgb.r, rgb.g, rgb.b),
None => format!("{label}: unavailable"),
}
}
fn format_hsl_export(label: &str, export: &ColorExport<Hsl>) -> String {
match export.value {
Some(hsl) => format!("{label}: h={:.6} s={:.6} l={:.6}", hsl.h, hsl.s, hsl.l),
None => format!("{label}: unavailable"),
}
}
fn status_label(status: SupportStatus) -> &'static str {
match status {
SupportStatus::Supported => "supported",
SupportStatus::Lossy => "lossy",
SupportStatus::GamutMapped => "gamut_mapped",
SupportStatus::Approximation => "approximation",
SupportStatus::Unsupported => "unsupported",
SupportStatus::ProfileRequired => "profile_required",
SupportStatus::ProofRequired => "proof_required",
SupportStatus::UserSuppliedReference => "user_supplied_reference",
}
}
fn compact_status_label(status: SupportStatus) -> &'static str {
match status {
SupportStatus::Supported => "supported",
SupportStatus::Lossy => "lossy",
SupportStatus::GamutMapped => "gamut mapped",
SupportStatus::Approximation => "approximation",
SupportStatus::Unsupported => "unsupported",
SupportStatus::ProfileRequired => "profile required",
SupportStatus::ProofRequired => "proof required",
SupportStatus::UserSuppliedReference => "user supplied reference",
}
}
#[derive(Debug, Clone)]
struct PrettyVerification {
label: &'static str,
status: SupportStatus,
round_trip_error: Option<f64>,
delta_e_ciede2000: Option<f64>,
}
fn verification_pretty(exports: &ExportSet, targets: &[String], detailed: bool) -> String {
let entries = verification_entries(exports, targets);
let mut lines = vec!["verification:".to_string()];
for status in [
SupportStatus::Lossy,
SupportStatus::Supported,
SupportStatus::GamutMapped,
SupportStatus::Approximation,
SupportStatus::Unsupported,
SupportStatus::ProfileRequired,
SupportStatus::ProofRequired,
SupportStatus::UserSuppliedReference,
] {
let labels = entries
.iter()
.filter(|entry| entry.status == status)
.map(|entry| entry.label)
.collect::<Vec<_>>();
if !labels.is_empty() {
lines.push(format!(
" {}: {}",
compact_status_label(status),
labels.join(", ")
));
}
}
let max_error = entries
.iter()
.filter_map(|entry| entry.round_trip_error)
.fold(None, |max: Option<f64>, value| {
Some(max.map_or(value, |current| current.max(value)))
});
lines.push(format!(
" max round-trip error: {}",
max_error.map_or_else(|| "none".to_string(), |value| format!("{value:.12}"))
));
let max_delta_e = entries
.iter()
.filter_map(|entry| entry.delta_e_ciede2000)
.fold(None, |max: Option<f64>, value| {
Some(max.map_or(value, |current| current.max(value)))
});
lines.push(format!(
" ΔE CIEDE2000: {}",
max_delta_e.map_or_else(|| "none".to_string(), |value| format!("{value:.12}"))
));
if detailed {
lines.push(String::new());
lines.push("verification details:".to_string());
for entry in entries {
let detail = entry.round_trip_error.map_or_else(
|| status_label(entry.status).to_string(),
|error| {
format!(
"{}, round-trip error {error:.12}",
status_label(entry.status)
)
},
);
lines.push(format!(" {}: {detail}", entry.label));
}
}
lines.join("\n")
}
fn verification_entries(exports: &ExportSet, targets: &[String]) -> Vec<PrettyVerification> {
let source = exports.oklch;
targets
.iter()
.filter_map(|target| match target.as_str() {
"hex" => Some(export_verification(
"HEX",
&exports.hex,
exports
.hex
.value
.as_deref()
.and_then(|hex| EncodedSrgb::from_hex(hex).ok())
.map(EncodedSrgb::to_oklch)
.and_then(|round_trip| delta_e_ciede2000(source, round_trip)),
)),
"rgb" => Some(export_verification(
"RGB",
&exports.rgb,
exports
.rgb
.value
.and_then(|rgb| EncodedSrgb::from_rgb_u8(rgb.r, rgb.g, rgb.b).ok())
.map(EncodedSrgb::to_oklch)
.and_then(|round_trip| delta_e_ciede2000(source, round_trip)),
)),
"hsl" => Some(export_verification(
"HSL",
&exports.hsl,
exports
.hsl
.value
.and_then(|hsl| EncodedSrgb::from_hsl(hsl.h, hsl.s, hsl.l).ok())
.map(EncodedSrgb::to_oklch)
.and_then(|round_trip| delta_e_ciede2000(source, round_trip)),
)),
"srgb" => Some(export_verification(
"sRGB",
&exports.srgb_float,
exports
.srgb_float
.value
.map(|rgb| EncodedSrgb::new(rgb.r, rgb.g, rgb.b).to_oklch())
.and_then(|round_trip| delta_e_ciede2000(source, round_trip)),
)),
"display-p3" => Some(export_verification(
"Display P3",
&exports.display_p3_float,
exports
.display_p3_float
.value
.map(|rgb| oci_core::EncodedDisplayP3::new(rgb.r, rgb.g, rgb.b).to_oklch())
.and_then(|round_trip| delta_e_ciede2000(source, round_trip)),
)),
"adobe-rgb" => Some(export_verification(
"Adobe RGB",
&exports.adobe_rgb_1998_float,
exports
.adobe_rgb_1998_float
.value
.map(|rgb| oci_core::EncodedAdobeRgb1998::new(rgb.r, rgb.g, rgb.b).to_oklch())
.and_then(|round_trip| delta_e_ciede2000(source, round_trip)),
)),
"rec709" => Some(export_verification(
"Rec.709",
&exports.rec709_float,
exports
.rec709_float
.value
.map(|rgb| oci_core::EncodedRec709::new(rgb.r, rgb.g, rgb.b).to_oklch())
.and_then(|round_trip| delta_e_ciede2000(source, round_trip)),
)),
"oklch" => Some(PrettyVerification {
label: "OKLCH",
status: SupportStatus::Supported,
round_trip_error: Some(0.0),
delta_e_ciede2000: Some(0.0),
}),
"oklab" => Some(PrettyVerification {
label: "OKLab",
status: SupportStatus::Supported,
round_trip_error: Some(0.0),
delta_e_ciede2000: Some(0.0),
}),
"css" => Some(PrettyVerification {
label: "CSS",
status: SupportStatus::Supported,
round_trip_error: None,
delta_e_ciede2000: None,
}),
"json-token" => Some(PrettyVerification {
label: "JSON token",
status: SupportStatus::Supported,
round_trip_error: None,
delta_e_ciede2000: None,
}),
"swift" => Some(PrettyVerification {
label: "Swift",
status: SupportStatus::Supported,
round_trip_error: None,
delta_e_ciede2000: None,
}),
"tailwind" => Some(PrettyVerification {
label: "Tailwind",
status: SupportStatus::Supported,
round_trip_error: None,
delta_e_ciede2000: None,
}),
"cmyk" => Some(export_verification("CMYK", &exports.cmyk, None)),
_ => None,
})
.collect()
}
fn export_verification<T>(
label: &'static str,
export: &ColorExport<T>,
delta_e_ciede2000: Option<f64>,
) -> PrettyVerification {
PrettyVerification {
label,
status: export.status,
round_trip_error: export.round_trip_error,
delta_e_ciede2000,
}
}
fn delta_e_ciede2000(source: Oklch, round_trip: Oklch) -> Option<f64> {
let value = ciede2000_distance(
CielabD65::from_oklch(source),
CielabD65::from_oklch(round_trip),
);
value.is_finite().then_some(value)
}
#[derive(Debug, Clone, Copy)]
struct CielabD65 {
l: f64,
a: f64,
b: f64,
}
impl CielabD65 {
fn from_oklch(color: Oklch) -> Self {
let xyz = color.to_xyz_d65();
let fx = cielab_f(xyz.x / D65_WHITE_X);
let fy = cielab_f(xyz.y / D65_WHITE_Y);
let fz = cielab_f(xyz.z / D65_WHITE_Z);
Self {
l: 116.0 * fy - 16.0,
a: 500.0 * (fx - fy),
b: 200.0 * (fy - fz),
}
}
}
fn cielab_f(value: f64) -> f64 {
if value > CIELAB_EPSILON {
value.cbrt()
} else {
(CIELAB_KAPPA * value + 16.0) / 116.0
}
}
fn ciede2000_distance(a: CielabD65, b: CielabD65) -> f64 {
let c1 = a.a.hypot(a.b);
let c2 = b.a.hypot(b.b);
let c_bar = (c1 + c2) / 2.0;
let c_bar7 = c_bar.powi(7);
let g = 0.5 * (1.0 - (c_bar7 / (c_bar7 + 25_f64.powi(7))).sqrt());
let a1_prime = (1.0 + g) * a.a;
let a2_prime = (1.0 + g) * b.a;
let c1_prime = a1_prime.hypot(a.b);
let c2_prime = a2_prime.hypot(b.b);
let h1_prime = hue_degrees(a.b, a1_prime);
let h2_prime = hue_degrees(b.b, a2_prime);
let delta_l_prime = b.l - a.l;
let delta_c_prime = c2_prime - c1_prime;
let delta_h_prime = if c1_prime * c2_prime == 0.0 {
0.0
} else {
let raw = h2_prime - h1_prime;
if raw.abs() <= 180.0 {
raw
} else if h2_prime <= h1_prime {
raw + 360.0
} else {
raw - 360.0
}
};
let delta_h_big_prime =
2.0 * (c1_prime * c2_prime).sqrt() * (delta_h_prime.to_radians() / 2.0).sin();
let l_bar_prime = (a.l + b.l) / 2.0;
let c_bar_prime = (c1_prime + c2_prime) / 2.0;
let h_bar_prime = if c1_prime * c2_prime == 0.0 {
h1_prime + h2_prime
} else {
let sum = h1_prime + h2_prime;
if (h1_prime - h2_prime).abs() <= 180.0 {
sum / 2.0
} else if sum < 360.0 {
(sum + 360.0) / 2.0
} else {
(sum - 360.0) / 2.0
}
};
let t = 1.0 - 0.17 * (h_bar_prime - 30.0).to_radians().cos()
+ 0.24 * (2.0 * h_bar_prime).to_radians().cos()
+ 0.32 * (3.0 * h_bar_prime + 6.0).to_radians().cos()
- 0.20 * (4.0 * h_bar_prime - 63.0).to_radians().cos();
let delta_theta = 30.0 * (-((h_bar_prime - 275.0) / 25.0).powi(2)).exp();
let c_bar_prime7 = c_bar_prime.powi(7);
let r_c = 2.0 * (c_bar_prime7 / (c_bar_prime7 + 25_f64.powi(7))).sqrt();
let s_l =
1.0 + (0.015 * (l_bar_prime - 50.0).powi(2)) / (20.0 + (l_bar_prime - 50.0).powi(2)).sqrt();
let s_c = 1.0 + 0.045 * c_bar_prime;
let s_h = 1.0 + 0.015 * c_bar_prime * t;
let r_t = -r_c * (2.0 * delta_theta).to_radians().sin();
let l_term = delta_l_prime / s_l;
let c_term = delta_c_prime / s_c;
let h_term = delta_h_big_prime / s_h;
(l_term.powi(2) + c_term.powi(2) + h_term.powi(2) + r_t * c_term * h_term).sqrt()
}
fn hue_degrees(b: f64, a: f64) -> f64 {
if a == 0.0 && b == 0.0 {
0.0
} else {
b.atan2(a).to_degrees().rem_euclid(360.0)
}
}
fn preferred_oci_code(result: &EncodeResult, config: &CliConfig) -> String {
let include_offset = config.encode.include_offset && result.oci_id.offset.is_some();
match (config.encode.prefer_short_code, include_offset) {
(true, true) => result.short_id.clone(),
(true, false) => base_short_string(&result.oci_id),
(false, true) => result.full_id.clone(),
(false, false) => base_full_string(&result.oci_id),
}
}
fn standard_color_code(result: &EncodeResult, config: &CliConfig) -> String {
if config.encode.prefer_short_code {
base_short_string(&result.oci_id)
} else {
base_full_string(&result.oci_id)
}
}
fn precision_color_code(result: &EncodeResult, config: &CliConfig) -> String {
if config.encode.include_offset && result.oci_id.offset.is_some() {
if config.encode.prefer_short_code {
result.short_id.clone()
} else {
result.full_id.clone()
}
} else {
standard_color_code(result, config)
}
}
fn inspect_standard_color_code(result: &InspectResult, config: &CliConfig) -> String {
if config.encode.prefer_short_code {
base_short_string(&result.oci_id)
} else {
base_full_string(&result.oci_id)
}
}
fn base_short_string(id: &OciId) -> String {
let mut id = id.clone();
id.offset = None;
id.to_short_string()
}
fn base_full_string(id: &OciId) -> String {
let mut id = id.clone();
id.offset = None;
id.to_full_string()
}
fn inspect_targets(_config: &CliConfig, export_mode: &str) -> Vec<String> {
match export_mode {
"none" => Vec::new(),
"all" | "summary" | "list" => all_export_targets(),
value => parse_targets(value),
}
}
fn configured_format<'a>(args: &'a [String], config: &'a CliConfig) -> Result<&'a str, CliError> {
let format = flag_value(args, "--format").unwrap_or(&config.output.format);
match format {
"pretty" | "json" | "plain" => Ok(format),
other => Err(CliError::new(
"parse_error",
format!("unsupported output format: {other}"),
)),
}
}
fn configured_precision(args: &[String], config: &CliConfig) -> Result<usize, CliError> {
flag_value(args, "--precision").map_or(Ok(config.output.precision), |value| {
value
.parse::<usize>()
.map_err(|_| CliError::new("parse_error", format!("invalid precision value: {value}")))
})
}
fn configured_verify(args: &[String], config: &CliConfig) -> bool {
has_flag(args, "--verify") || config.output.verify
}
fn config_summary(config: &CliConfig) -> String {
format!(
"output.format={}\noutput.precision={}\nregistry.source={}",
config.output.format, config.output.precision, config.registry.source
)
}
fn prompt_string(label: &str, current: &str) -> Result<String, CliError> {
let input = prompt(label, current)?;
if input.is_empty() {
Ok(current.to_string())
} else {
Ok(input)
}
}
fn prompt_bool(label: &str, current: bool) -> Result<bool, CliError> {
let input = prompt(label, if current { "true" } else { "false" })?;
match input.as_str() {
"" => Ok(current),
"true" | "yes" | "y" => Ok(true),
"false" | "no" | "n" => Ok(false),
_ => Err(CliError::new(
"parse_error",
format!("invalid boolean for {label}: {input}"),
)),
}
}
fn prompt_usize(label: &str, current: usize) -> Result<usize, CliError> {
let input = prompt(label, ¤t.to_string())?;
if input.is_empty() {
Ok(current)
} else {
input.parse::<usize>().map_err(|_| {
CliError::new(
"parse_error",
format!("invalid integer for {label}: {input}"),
)
})
}
}
fn prompt_list(label: &str, current: &[String]) -> Result<Vec<String>, CliError> {
let joined = current.join(",");
let input = prompt(label, &joined)?;
if input.is_empty() {
Ok(current.to_vec())
} else {
Ok(parse_targets(&input))
}
}
fn prompt(label: &str, current: &str) -> Result<String, CliError> {
print!("{label} [{current}]: ");
io::stdout()
.flush()
.map_err(|error| CliError::new("config_error", error.to_string()))?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|error| CliError::new("config_error", error.to_string()))?;
Ok(input.trim().to_string())
}
fn fixed(value: f64, precision: usize) -> String {
format!("{value:.precision$}")
}
fn indent(value: &str) -> String {
value
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn parse_color_input(
input: &str,
space: &str,
registry: &Registry,
) -> Result<ColorInput, CliError> {
match space {
"hex" => Ok(ColorInput::Hex(input.to_string())),
"rgb" => {
let values = parse_u8_components(input, 3)?;
Ok(ColorInput::SrgbRgb {
r: values[0],
g: values[1],
b: values[2],
})
}
"srgb" => {
let values = parse_f64_components(input, 3)?;
Ok(ColorInput::Srgb(EncodedSrgb::new(
values[0], values[1], values[2],
)))
}
"hsl" => {
let values = parse_f64_components(input, 3)?;
Ok(ColorInput::HslSrgb {
h: values[0],
s: values[1],
l: values[2],
})
}
"display-p3" => {
let values = parse_f64_components(input, 3)?;
Ok(ColorInput::DisplayP3Float {
r: values[0],
g: values[1],
b: values[2],
})
}
"adobe-rgb" => {
let values = parse_f64_components(input, 3)?;
Ok(ColorInput::AdobeRgb1998Float {
r: values[0],
g: values[1],
b: values[2],
})
}
"rec709" => {
let values = parse_f64_components(input, 3)?;
Ok(ColorInput::Rec709Float {
r: values[0],
g: values[1],
b: values[2],
})
}
"oklch" => {
let values = parse_f64_components(input, 3)?;
Ok(ColorInput::Oklch(Oklch::new(
values[0], values[1], values[2],
)))
}
"oklab" => {
let values = parse_f64_components(input, 3)?;
Ok(ColorInput::Oklab(Oklab::new(
values[0], values[1], values[2],
)))
}
"oci" => Ok(ColorInput::OciId(
OciId::parse_with_registry(input, registry).map_err(id_error)?,
)),
other => Err(CliError::new(
"unsupported_space",
format!("unsupported source color space: {other}"),
)),
}
}
fn registry_family_json(registry: &Registry, key: &str) -> Result<String, CliError> {
let family = registry
.families()
.iter()
.find(|family| {
family.id.to_string() == key
|| family.id.code.to_string() == key
|| family.id.index.to_string() == key
})
.ok_or_else(|| CliError::new("invalid_family", format!("unknown family: {key}")))?;
let count = registry
.steps()
.iter()
.filter(|step| step.family_id == family.id)
.count();
Ok(format!(
"{{\"family\":{{\"id\":\"{}\",\"index\":{},\"code\":\"{}\",\"name\":\"{}\",\"group\":\"{}\",\"stepCount\":{}}}}}",
family.id,
family.id.index,
family.id.code,
output::escape_json(&family.name),
output::escape_json(&family.group),
count
))
}
fn registry_step_json(registry: &Registry, key: &str) -> Result<String, CliError> {
let step = if key.starts_with("OCI-") {
let id = OciId::parse_with_registry(key, registry).map_err(id_error)?;
registry.find_step(id.family, id.step)
} else {
registry
.steps()
.iter()
.find(|step| step.id == key || step.short_id == key)
}
.ok_or_else(|| CliError::new("invalid_step", format!("unknown step: {key}")))?;
Ok(format!(
"{{\"step\":{{\"id\":\"{}\",\"shortId\":\"{}\",\"familyId\":\"{}\",\"stepNumber\":{},\"anchor\":{},\"lightnessLevel\":{},\"chromaLevel\":{},\"oklch\":{{\"l\":{:.6},\"c\":{:.6},\"h\":{:.6}}}}}}}",
step.id,
step.short_id,
step.family_id,
step.step_number,
step.step.anchor,
step.step.lightness,
step.step.chroma,
step.lightness,
step.chroma,
step.hue
))
}
fn test_vectors(config: &CliConfig) -> Result<String, CliError> {
let registry = load_registry(config)?;
let mut total = 0usize;
let mut passed = 0usize;
for line in Registry::frozen_test_vectors_json().lines() {
let object = line.trim().trim_end_matches(',');
if !object.starts_with('{') {
continue;
}
total += 1;
if run_vector_object(object, ®istry)? {
passed += 1;
}
}
Ok(format!(
"{{\"test\":\"vectors\",\"total\":{total},\"passed\":{passed}}}"
))
}
fn run_vector_object(object: &str, registry: &Registry) -> Result<bool, CliError> {
let kind = json_string_field(object, "kind").unwrap_or_default();
let input = json_string_field(object, "input").unwrap_or_default();
let source_space = json_string_field(object, "sourceSpace").unwrap_or_default();
match kind.as_str() {
"encode" => {
let result = encode(
parse_color_input(&input, &source_space, registry)?,
registry,
)
.map_err(pipeline_error)?;
Ok(result.short_id.starts_with("OCI-1-"))
}
"inspect" => {
let id = OciId::parse_with_registry(&input, registry).map_err(id_error)?;
inspect(&id, registry).map_err(pipeline_error)?;
Ok(true)
}
"invalid" => Ok(OciId::parse_with_registry(&input, registry).is_err()),
"support" => {
let color = parse_color_input(&input, &source_space, registry)?
.to_oklch(registry)
.map_err(pipeline_error)?;
let matrix = build_support_matrix(color);
Ok(!matrix.entries.is_empty())
}
_ => Ok(true),
}
}
fn test_roundtrip(config: &CliConfig) -> Result<String, CliError> {
let registry = load_registry(config)?;
let result = encode_from_hex("#E85A9A", ®istry).map_err(pipeline_error)?;
let decoded = decode_oci_id(&result.oci_id, ®istry).map_err(pipeline_error)?;
let encoded = encode(ColorInput::Oklch(decoded), ®istry).map_err(pipeline_error)?;
Ok(format!(
"{{\"test\":\"roundtrip\",\"passed\":{},\"short\":{}}}",
if encoded.short_id.starts_with("OCI-1-") {
"true"
} else {
"false"
},
json_string(&encoded.short_id)
))
}
fn checksum_entries() -> Vec<(String, String, bool)> {
let files = [
(
"registry/v1/families.json",
Registry::frozen_families_json(),
),
("registry/v1/steps.json", Registry::frozen_steps_json()),
(
"registry/v1/test-vectors.json",
Registry::frozen_test_vectors_json(),
),
("registry/v1/schema.json", Registry::frozen_schema_json()),
(
"registry/v1/metadata.json",
Registry::frozen_metadata_json(),
),
];
files
.iter()
.map(|(path, content)| {
let actual = oci_core::registry::sha256_normalized_text_hex(content);
let expected = checksum_expected(path).unwrap_or_else(|| actual.clone());
((*path).to_string(), actual.clone(), actual == expected)
})
.collect()
}
fn checksum_expected(path: &str) -> Option<String> {
for line in Registry::frozen_checksums_json().lines() {
if line.contains(path) {
return json_string_field(line.trim().trim_end_matches(','), "sha256");
}
}
None
}
fn parse_targets(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
}
fn parse_f64_components(input: &str, expected: usize) -> Result<Vec<f64>, CliError> {
let parts = split_components(input);
if parts.len() != expected {
return Err(CliError::new(
"parse_error",
format!("expected {expected} components, found {}", parts.len()),
));
}
parts
.iter()
.map(|part| {
part.parse::<f64>()
.map_err(|_| CliError::new("parse_error", format!("invalid number: {part}")))
})
.collect()
}
fn parse_u8_components(input: &str, expected: usize) -> Result<Vec<u8>, CliError> {
let parts = split_components(input);
if parts.len() != expected {
return Err(CliError::new(
"parse_error",
format!("expected {expected} components, found {}", parts.len()),
));
}
parts
.iter()
.map(|part| {
part.parse::<u8>()
.map_err(|_| CliError::new("parse_error", format!("invalid u8 component: {part}")))
})
.collect()
}
fn split_components(input: &str) -> Vec<&str> {
input
.split([',', '/', ' '])
.map(str::trim)
.filter(|part| !part.is_empty())
.collect()
}
fn load_registry(config: &CliConfig) -> Result<Registry, CliError> {
if config.registry.source != "bundled" {
return Err(CliError::new(
"registry_error",
"only bundled registry source is supported in v1-beta CLI",
));
}
let registry = Registry::load_frozen().map_err(registry_error)?;
if config.registry.validate_on_start {
registry.validate().map_err(registry_error)?;
}
Ok(registry)
}
fn positional<'a>(args: &'a [String], position: usize, label: &str) -> Result<&'a str, CliError> {
positional_args(args)
.into_iter()
.nth(position)
.map(String::as_str)
.ok_or_else(|| CliError::new("parse_error", format!("missing {label}")))
}
fn positional_args(args: &[String]) -> Vec<&String> {
let mut values = Vec::new();
let mut index = 0usize;
while index < args.len() {
let arg = &args[index];
if arg.starts_with("--") {
if flag_takes_value(arg) && index + 1 < args.len() {
index += 2;
} else {
index += 1;
}
} else {
values.push(arg);
index += 1;
}
}
values
}
fn flag_takes_value(flag: &str) -> bool {
matches!(
flag,
"--space"
| "--format"
| "--precision"
| "--exports"
| "--to"
| "--from"
| "--type"
| "--path"
| "--config"
| "--host"
| "--port"
| "--id"
| "--family"
| "--range"
| "--out"
| "--template"
| "--filename"
| "--version"
| "--dir"
)
}
fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.windows(2)
.find(|pair| pair[0] == flag)
.map(|pair| pair[1].as_str())
}
fn has_flag(args: &[String], flag: &str) -> bool {
args.iter().any(|arg| arg == flag)
}
fn serve_help_text() -> String {
[
"OCI Local Kernel API Server",
"",
"Usage:",
" oci serve [--host <HOST>] [--port <PORT>] [--config <PATH>] [--json]",
"",
"Defaults:",
" host: 127.0.0.1",
" port: 8765",
"",
"Endpoints:",
" GET /v1/health",
" POST /v1/encode",
" POST /v1/inspect",
" POST /v1/export",
" POST /v1/convert",
" GET /v1/registry/info",
" GET /v1/registry/families",
" GET /v1/registry/family/{indexOrCode}",
" GET /v1/registry/step/{idOrStep}",
]
.join("\n")
}
fn update_help_text() -> String {
[
"OCI CLI Update",
"",
"Usage:",
" oci update [--version <TAG>] [--dir <PATH>] [--system] [--no-checksum] [--force]",
"",
"Examples:",
" oci update",
" oci update --version cli-v0.3.0",
" oci update --dir ~/.local/bin",
" oci update --system",
]
.join("\n")
}
fn linked_core_version() -> &'static str {
CLI_MANIFEST
.lines()
.find(|line| line.contains("open-chroma-index") && line.contains("version"))
.and_then(extract_toml_version)
.unwrap_or("unknown")
}
fn extract_toml_version(line: &'static str) -> Option<&'static str> {
let marker = "version = \"";
let start = line.find(marker)? + marker.len();
let rest = &line[start..];
let end = rest.find('"')?;
Some(&rest[..end])
}
fn pipeline_error(error: oci_core::OciPipelineError) -> CliError {
CliError::new("parse_error", error.to_string())
}
fn id_error(error: oci_core::OciIdError) -> CliError {
let code = match error {
oci_core::OciIdError::InvalidFamilyCode { .. }
| oci_core::OciIdError::UnknownFamily { .. }
| oci_core::OciIdError::FamilyIndexCodeMismatch { .. } => "invalid_family",
oci_core::OciIdError::InvalidStepNumber { .. }
| oci_core::OciIdError::InvalidStepComponent { .. } => "invalid_step",
oci_core::OciIdError::InvalidOffset { .. } => "invalid_offset",
_ => "invalid_id",
};
CliError::new(code, error.to_string())
}
fn registry_error(error: oci_core::RegistryError) -> CliError {
CliError::new("registry_error", error.to_string())
}
fn config_error(error: crate::config::ConfigError) -> CliError {
CliError::new("config_error", error.to_string())
}
fn json_string(value: &str) -> String {
format!("\"{}\"", output::escape_json(value))
}
fn json_string_field(object: &str, key: &str) -> Option<String> {
let marker = format!("\"{key}\":\"");
let start = object.find(&marker)? + marker.len();
let rest = &object[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
fn temp_config_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("oci-{name}-{}.toml", std::process::id()))
}
fn temp_swatch_dir(name: &str) -> PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let path =
std::env::temp_dir().join(format!("oci-swatch-{name}-{}-{unique}", std::process::id()));
let _ = fs::remove_dir_all(&path);
fs::create_dir_all(&path).unwrap();
path
}
fn write_test_template(dir: &std::path::Path) -> PathBuf {
let path = dir.join("template.svg");
fs::write(
&path,
r#"<svg><rect id="COLOR_BLOCK" fill="oklch({{OKLCH}})"/><text>{{OCI_SHORT}}</text><text>{{COLOR_HEX}}</text><text>{{FAMILY_NAME}}	{{FAMILY_CODE}}</text></svg>"#,
)
.unwrap();
path
}
fn assert_all_supported_exports_are_visible(out: &str) {
for expected in [
"HEX:",
"RGB:",
"HSL:",
"sRGB:",
"Display P3:",
"Adobe RGB:",
"Rec.709:",
"OKLCH:",
"OKLab:",
"CSS OKLCH:",
"CSS sRGB:",
"CSS Display P3:",
"JSON token:",
"Swift:",
"Tailwind:",
"CMYK:",
] {
assert!(out.contains(expected), "missing export line: {expected}");
}
}
fn assert_no_inline_verification_metadata(out: &str) {
assert!(!out.contains("(lossy"));
assert!(!out.contains("(supported"));
assert!(!out.contains("(profile_required"));
assert!(!out.contains("round-trip error") || out.contains("verification:"));
}
fn assert_compact_verification_is_visible(out: &str) {
assert!(out.contains("\n\nverification:\n"));
assert!(out.contains("lossy: HEX, RGB"));
assert!(out.contains("supported:"));
assert!(out.contains("profile required: CMYK"));
assert!(out.contains("max round-trip error:"));
assert!(out.contains("ΔE CIEDE2000:"));
}
#[test]
fn parses_encode_command_and_emits_pretty_by_default() {
let out = run_cli(&args(&["encode", "#E85A9A", "--space", "hex"])).unwrap();
assert!(!out.starts_with('{'));
assert!(!out.contains("{\""));
assert!(out.contains("OCI Encode"));
assert!(out.contains("OCI standard color code: OCI-1-"));
assert!(out.contains("OCI precision color code: OCI-1-"));
assert!(out.contains("@L"));
assert!(out.contains("\n\nOCI standard color code:"));
assert!(out.contains("\n\nexports:"));
assert!(out.contains("CSS OKLCH:"));
assert!(!out.contains("CSS:\n"));
assert_all_supported_exports_are_visible(&out);
assert_no_inline_verification_metadata(&out);
assert_compact_verification_is_visible(&out);
assert!(!out.contains("verification details:"));
}
#[test]
fn help_and_version_commands_work() {
let help = run_cli(&args(&["--help"])).unwrap();
assert!(help.contains("Open Chroma Index CLI"));
assert!(help.contains("oci encode <INPUT>"));
assert!(help.contains("oci serve"));
let version = run_cli(&args(&["--version"])).unwrap();
assert!(version.starts_with("oci "));
let core_version = run_cli(&args(&["--version", "--core"])).unwrap();
assert!(core_version.starts_with("oci-core "));
}
#[test]
fn serve_help_exists() {
let help = run_cli(&args(&["serve", "--help"])).unwrap();
assert!(help.contains("OCI Local Kernel API Server"));
assert!(help.contains("GET /v1/health"));
}
#[test]
fn update_help_exists() {
let help = run_cli(&args(&["update", "--help"])).unwrap();
assert!(help.contains("OCI CLI Update"));
assert!(help.contains("oci update"));
assert!(help.contains("--version <TAG>"));
}
#[test]
fn update_release_helpers_find_and_compare_cli_tags() {
let body = r#"[{"tag_name": "core-v0.4.0"},{"tag_name": "cli-v0.3.2"}]"#;
assert_eq!(
latest_cli_tag_from_releases_json(body).as_deref(),
Some("cli-v0.3.2")
);
assert!(is_newer_cli_tag("cli-v0.3.2", "0.3.1"));
assert!(!is_newer_cli_tag("cli-v0.3.0", "0.3.1"));
}
#[test]
fn update_notice_decision_skips_json_and_stops_after_three_notices() {
let mut config = CliConfig::default();
assert!(should_check_for_update_inner(
&args(&["encode", "#E85A9A", "--space", "hex"]),
&config,
"OCI Encode"
));
assert!(!should_check_for_update_inner(
&args(&["encode", "#E85A9A", "--space", "hex", "--format", "json"]),
&config,
"{\"ok\":true}"
));
config.update.notices_shown = 3;
assert!(!should_check_for_update_inner(
&args(&["encode", "#E85A9A", "--space", "hex"]),
&config,
"OCI Encode"
));
}
#[test]
fn ciede2000_matches_reference_pair() {
let a = CielabD65 {
l: 50.0,
a: 2.6772,
b: -79.7751,
};
let b = CielabD65 {
l: 50.0,
a: 0.0,
b: -82.7485,
};
assert!((ciede2000_distance(a, b) - 2.0425).abs() < 0.0001);
}
#[test]
fn encode_json_output_still_works_with_format_flag() {
let out = run_cli(&args(&[
"encode", "#E85A9A", "--space", "hex", "--format", "json",
]))
.unwrap();
assert!(out.starts_with('{'));
assert!(out.contains("\"sourceSpace\":\"hex\""));
assert!(out.contains("\"oci\""));
assert!(out.contains("\"swift\""));
assert!(out.contains("\"tailwind\""));
assert!(out.contains("\"cmyk\""));
assert!(out.contains("\"roundTripError\""));
assert!(out.contains("\"status\":\"lossy\""));
}
#[test]
fn inspect_command_has_expected_structure() {
let out = run_cli(&args(&["inspect", "OCI-1-48RS-327"])).unwrap();
assert!(out.contains("OCI Inspect"));
assert!(out.contains("OCI standard color code: OCI-1-48RS-327\n"));
assert!(out.contains("exports:"));
assert!(out.contains("OKLCH:"));
assert_all_supported_exports_are_visible(&out);
assert_no_inline_verification_metadata(&out);
assert_compact_verification_is_visible(&out);
}
#[test]
fn export_selects_targets() {
let out = run_cli(&args(&[
"export",
"OCI-1-46PK-236",
"--to",
"hex,oklch,cmyk",
]))
.unwrap();
assert!(!out.contains("{\""));
assert!(out.contains("HEX:"));
assert!(out.contains("OKLCH:"));
assert!(out.contains("CMYK:"));
assert!(out.contains("profile required: CMYK"));
assert_no_inline_verification_metadata(&out);
}
#[test]
fn convert_command_has_expected_structure() {
let out = run_cli(&args(&[
"convert",
"#E85A9A",
"--from",
"hex",
"--to",
"srgb,oklch",
]))
.unwrap();
assert!(out.contains("OCI Convert"));
assert!(out.contains("exports:"));
assert!(out.contains("verification:"));
assert_no_inline_verification_metadata(&out);
}
#[test]
fn verify_flag_shows_detailed_verification() {
let out = run_cli(&args(&["encode", "#E85A9A", "--space", "hex", "--verify"])).unwrap();
assert!(out.contains("verification details:"));
assert!(out.contains("HEX: lossy, round-trip error"));
assert!(out.contains("sRGB: supported, round-trip error"));
assert!(out.contains("CMYK: profile_required"));
}
#[test]
fn config_verify_true_enables_detailed_verification() {
let path = temp_config_path("verify-enabled");
fs::write(&path, "[output]\nverify = true\n").unwrap();
let out = run_cli(&args(&[
"inspect",
"OCI-1-48RS-327",
"--path",
path.to_str().unwrap(),
]))
.unwrap();
assert!(out.contains("verification details:"));
let _ = fs::remove_file(path);
}
#[test]
fn verify_flag_overrides_false_config() {
let path = temp_config_path("verify-override");
fs::write(&path, "[output]\nverify = false\n").unwrap();
let out = run_cli(&args(&[
"export",
"OCI-1-48RS-327",
"--to",
"hex,rgb,cmyk",
"--verify",
"--path",
path.to_str().unwrap(),
]))
.unwrap();
assert!(out.contains("verification details:"));
assert!(out.contains("HEX: lossy, round-trip error"));
let _ = fs::remove_file(path);
}
#[test]
fn plain_output_remains_minimal() {
let out = run_cli(&args(&[
"encode", "#E85A9A", "--space", "hex", "--format", "plain",
]))
.unwrap();
assert!(out.starts_with("OCI-1-"));
assert!(!out.contains("exports:"));
assert!(!out.contains("verification:"));
}
#[test]
fn built_in_default_config_loads() {
let config = CliConfig::default();
assert_eq!(config.output.format, "pretty");
assert_eq!(config.output.precision, 6);
assert_eq!(config.registry.source, "bundled");
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.port, 8765);
assert!(config.update.check);
assert_eq!(config.update.notices_shown, 0);
}
#[test]
fn default_config_path_uses_installed_binary_directory() {
let path = crate::config::default_config_path();
assert_eq!(path.file_name().unwrap(), "config.toml");
assert!(!path.starts_with("cli"));
}
#[test]
fn missing_default_config_uses_built_in_defaults() {
let path = temp_config_path("missing-defaults");
let _ = fs::remove_file(&path);
let config = CliConfig::load_from_path(path).unwrap();
assert_eq!(config.output.format, "pretty");
assert!(config.output.default_exports.contains(&"hex".to_string()));
}
#[test]
fn custom_path_config_loads() {
let path = temp_config_path("custom-loads");
fs::write(
&path,
"[output]\nformat = \"json\"\ndefault_exports = [\"hex\"]\n",
)
.unwrap();
let out = run_cli(&args(&[
"encode",
"#E85A9A",
"--space",
"hex",
"--path",
path.to_str().unwrap(),
]))
.unwrap();
assert!(out.starts_with('{'));
let _ = fs::remove_file(path);
}
#[test]
fn cli_flags_override_config() {
let path = temp_config_path("flags-override");
fs::write(&path, "[output]\nformat = \"pretty\"\n").unwrap();
let out = run_cli(&args(&[
"encode",
"#E85A9A",
"--space",
"hex",
"--format",
"json",
"--path",
path.to_str().unwrap(),
]))
.unwrap();
assert!(out.starts_with('{'));
let _ = fs::remove_file(path);
}
#[test]
fn invalid_toml_returns_structured_error() {
let path = temp_config_path("invalid");
fs::write(&path, "[output]\nformat = [\n").unwrap();
let error = run_cli(&args(&[
"encode",
"#E85A9A",
"--space",
"hex",
"--path",
path.to_str().unwrap(),
]))
.unwrap_err();
assert_eq!(error.code, "config_error");
let _ = fs::remove_file(path);
}
#[test]
fn missing_config_can_be_created_through_config_command() {
let path = temp_config_path("create");
let _ = fs::remove_file(&path);
let out = run_cli(&args(&["config", "--path", path.to_str().unwrap()])).unwrap();
assert!(out.contains("OCI config written"));
let written = fs::read_to_string(&path).unwrap();
assert!(written.contains("[output]"));
assert!(written.contains("format = \"pretty\""));
assert!(written.contains("[update]"));
let _ = fs::remove_file(path);
}
#[test]
fn registry_info_and_validate_work() {
let info = run_cli(&args(&["registry", "info"])).unwrap();
assert!(info.contains("\"familyCount\":64"));
assert!(info.contains("\"stepCount\":23040"));
let validate = run_cli(&args(&["registry", "validate"])).unwrap();
assert!(validate.contains("\"valid\":true"));
}
#[test]
fn checksum_command_reports_sha256() {
let out = run_cli(&args(&["registry", "checksum"])).unwrap();
assert!(out.contains("\"algorithm\":\"sha256\""));
assert!(out.contains("\"valid\":true"));
}
#[test]
fn swatch_gen_id_creates_svg_and_replaces_placeholders() {
let dir = temp_swatch_dir("single");
let template = write_test_template(&dir);
let out_dir = dir.join("out");
let out = run_cli(&args(&[
"swatch",
"gen",
"--id",
"OCI-1-22TL-326",
"--template",
template.to_str().unwrap(),
"--out",
out_dir.to_str().unwrap(),
]))
.unwrap();
assert!(out.contains("\"generated\":1"));
assert!(out.contains("\"firstFile\""));
let svg_path = out_dir.join("OCI-1-22TL-326.svg");
let svg = fs::read_to_string(svg_path).unwrap();
assert!(svg.contains("OCI-1-22TL-326"));
assert!(!svg.contains("{{OCI_SHORT}}"));
assert!(!svg.contains("{{OKLCH}}"));
assert!(!svg.contains("{{COLOR_HEX}}"));
assert!(svg.contains("fill=\"oklch("));
assert!(svg.contains("deg)\""));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn swatch_gen_family_creates_360_svgs_in_family_directory() {
let dir = temp_swatch_dir("family");
let template = write_test_template(&dir);
let out_dir = dir.join("out");
run_cli(&args(&[
"swatch",
"gen",
"--family",
"22TL",
"--template",
template.to_str().unwrap(),
"--out",
out_dir.to_str().unwrap(),
]))
.unwrap();
let family_dir = out_dir.join("22TL");
let count = fs::read_dir(&family_dir).unwrap().count();
assert_eq!(count, 360);
assert!(family_dir.join("OCI-1-22TL-001.svg").exists());
assert!(family_dir.join("OCI-1-22TL-360.svg").exists());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn swatch_gen_range_creates_inclusive_svg_set() {
let dir = temp_swatch_dir("range");
let template = write_test_template(&dir);
let out_dir = dir.join("out");
run_cli(&args(&[
"swatch",
"gen",
"--range",
"OCI-1-22TL-001..OCI-1-22TL-003",
"--template",
template.to_str().unwrap(),
"--out",
out_dir.to_str().unwrap(),
]))
.unwrap();
assert_eq!(fs::read_dir(&out_dir).unwrap().count(), 3);
assert!(out_dir.join("OCI-1-22TL-001.svg").exists());
assert!(out_dir.join("OCI-1-22TL-003.svg").exists());
let _ = fs::remove_dir_all(dir);
}
#[test]
fn swatch_gen_compact_range_syntax_works() {
let dir = temp_swatch_dir("compact-range");
let template = write_test_template(&dir);
let out_dir = dir.join("out");
run_cli(&args(&[
"swatch",
"gen",
"--range",
"22TL-001..22TL-003",
"--template",
template.to_str().unwrap(),
"--out",
out_dir.to_str().unwrap(),
]))
.unwrap();
assert_eq!(fs::read_dir(&out_dir).unwrap().count(), 3);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn swatch_gen_missing_template_returns_clear_error() {
let dir = temp_swatch_dir("missing-template");
let error = run_cli(&args(&[
"swatch",
"gen",
"--id",
"OCI-1-22TL-326",
"--template",
dir.join("missing.svg").to_str().unwrap(),
"--out",
dir.join("out").to_str().unwrap(),
]))
.unwrap_err();
assert_eq!(error.code, "template_error");
assert!(error.message.contains("template file not found"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn swatch_gen_existing_output_requires_overwrite() {
let dir = temp_swatch_dir("overwrite");
let template = write_test_template(&dir);
let out_dir = dir.join("out");
let base_args = [
"swatch",
"gen",
"--id",
"OCI-1-22TL-326",
"--template",
template.to_str().unwrap(),
"--out",
out_dir.to_str().unwrap(),
];
run_cli(&args(&base_args)).unwrap();
let error = run_cli(&args(&base_args)).unwrap_err();
assert_eq!(error.code, "output_exists");
let mut overwrite_args = base_args.to_vec();
overwrite_args.push("--overwrite");
run_cli(&args(&overwrite_args)).unwrap();
let _ = fs::remove_dir_all(dir);
}
#[test]
fn swatch_gen_rejects_invalid_selector_combinations() {
let dir = temp_swatch_dir("selector-combo");
let template = write_test_template(&dir);
let error = run_cli(&args(&[
"swatch",
"gen",
"--id",
"OCI-1-22TL-326",
"--family",
"22TL",
"--template",
template.to_str().unwrap(),
"--out",
dir.join("out").to_str().unwrap(),
]))
.unwrap_err();
assert_eq!(error.code, "parse_error");
assert!(error.message.contains("only one selector"));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn swatch_data_id_returns_placeholder_json() {
let out = run_cli(&args(&["swatch", "data", "--id", "OCI-1-22TL-326"])).unwrap();
assert!(out.starts_with('{'));
assert!(out.contains("\"OCI_SHORT\":\"OCI-1-22TL-326\""));
assert!(out.contains("\"OKLCH\""));
assert!(out.contains("\"COLOR_HEX\":\"#"));
assert!(out.contains("\"FAMILY_CODE\":\"22TL\""));
assert!(out.contains("\"HEX\":\"Unavailable\""));
}
#[test]
fn test_vectors_command_runs() {
let out = run_cli(&args(&["test", "vectors"])).unwrap();
assert!(out.contains("\"test\":\"vectors\""));
assert!(out.contains("\"passed\""));
}
#[test]
fn invalid_cli_input_returns_error() {
let error = run_cli(&args(&["encode", "oops", "--space", "unknown"])).unwrap_err();
assert_eq!(error.code, "unsupported_space");
}
#[test]
fn invalid_oci_id_returns_error() {
let error = run_cli(&args(&["inspect", "OCI-1-46PK-999"])).unwrap_err();
assert_eq!(error.code, "invalid_step");
}
#[test]
fn cli_binary_is_named_oci() {
let manifest = include_str!("../Cargo.toml");
assert!(manifest.contains("name = \"oci\""));
}
}