//! The core data unit. An [`Item`] is a single entry in the //! menu, either a plain-text string or a JSON object with //! structured fields. use serde_json::Value; /// A menu entry wrapping a JSON value. Plain text is stored /// as `Value::String`, structured entries as /// `Value::Object`. The label is extracted and cached at /// construction time so display calls are just a pointer /// dereference. Sublabel, icon, and group are also cached /// for structured items. /// /// Serializes as the inner Value (transparent for output) /// but construction always requires label extraction. #[derive(Debug, Clone)] pub struct Item { pub value: Value, label_cache: String, sublabel_cache: Option, icon_cache: Option, group_cache: Option, } impl Item { /// Create an Item from a JSON value, extracting the display /// label from the given key. String values use the string /// itself. Object values look up the key. Also extracts /// sublabel, icon, and group from known keys on objects. pub fn new(value: Value, label_key: &str) -> Self { let label_cache = extract_label(&value, label_key).to_string(); let sublabel_cache = extract_optional(&value, "sublabel"); let icon_cache = extract_optional(&value, "icon"); let group_cache = extract_optional(&value, "group"); Self { value, label_cache, sublabel_cache, icon_cache, group_cache, } } /// Wrap a plain-text string as an Item. Stored internally /// as `Value::String` with the label cached. Optional /// fields are left as None. pub fn from_plain_text(line: &str) -> Self { Self { value: Value::String(line.to_string()), label_cache: line.to_string(), sublabel_cache: None, icon_cache: None, group_cache: None, } } /// Get the display label for this item. Cached at /// construction time, so this is just a borrow. pub fn label(&self) -> &str { &self.label_cache } /// Get the sublabel for this item, if present. pub fn sublabel(&self) -> Option<&str> { self.sublabel_cache.as_deref() } /// Get the icon for this item, if present. pub fn icon(&self) -> Option<&str> { self.icon_cache.as_deref() } /// Get the group for this item, if present. pub fn group(&self) -> Option<&str> { self.group_cache.as_deref() } /// Get the `meta` field from the underlying value, if it /// exists and the value is an object. pub fn meta(&self) -> Option<&Value> { match &self.value { Value::Object(map) => map.get("meta"), _ => None, } } /// Resolve a dotted field path against this item's value. /// For example, `field_value("meta.resolution")` on /// `{"meta": {"resolution": "1080p"}}` returns /// `Some(Value::String("1080p"))`. pub fn field_value(&self, path: &str) -> Option<&Value> { resolve_field_path(&self.value, path) } } /// Extract the display label from a JSON value. fn extract_label<'a>(value: &'a Value, key: &str) -> &'a str { match value { Value::String(s) => s.as_str(), Value::Object(map) => map.get(key).and_then(|v| v.as_str()).unwrap_or(""), _ => "", } } /// Extract an optional string field from a JSON object value. fn extract_optional(value: &Value, key: &str) -> Option { match value { Value::Object(map) => map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()), _ => None, } } /// Walk a dotted path (e.g. `"meta.tags.0"`) through nested /// JSON objects. Returns None if any intermediate is not an /// object or the key is missing. pub fn resolve_field_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { let mut current = value; for segment in path.split('.') { match current { Value::Object(map) => { current = map.get(segment)?; } _ => return None, } } Some(current) } impl serde::Serialize for Item { fn serialize(&self, serializer: S) -> Result { self.value.serialize(serializer) } } impl<'de> serde::Deserialize<'de> for Item { fn deserialize>(deserializer: D) -> Result { let value = Value::deserialize(deserializer)?; // Default to "label" key when deserializing. Callers that need // a different key should construct via Item::new() instead. Ok(Item::new(value, "label")) } } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn label_from_string() { let item = Item::new(json!("hello"), "label"); assert_eq!(item.label(), "hello"); } #[test] fn label_from_object_with_key() { let item = Item::new(json!({"label": "foo", "value": 42}), "label"); assert_eq!(item.label(), "foo"); } #[test] fn label_from_object_missing_key() { let item = Item::new(json!({"name": "bar"}), "label"); assert_eq!(item.label(), ""); } #[test] fn label_from_object_custom_key() { let item = Item::new(json!({"name": "bar", "label": "ignored"}), "name"); assert_eq!(item.label(), "bar"); } #[test] fn label_from_number() { let item = Item::new(json!(42), "label"); assert_eq!(item.label(), ""); } #[test] fn label_from_null() { let item = Item::new(json!(null), "label"); assert_eq!(item.label(), ""); } #[test] fn from_plain_text() { let item = Item::from_plain_text("hello world"); assert_eq!(item.label(), "hello world"); assert!(item.value.is_string()); assert!(item.sublabel().is_none()); assert!(item.icon().is_none()); assert!(item.group().is_none()); } #[test] fn serialize_string_item() { let item = Item::from_plain_text("test"); let json = serde_json::to_string(&item).unwrap_or_default(); assert_eq!(json, "\"test\""); } #[test] fn serialize_object_item() { let item = Item::new(json!({"label": "foo"}), "label"); let json = serde_json::to_string(&item).unwrap_or_default(); assert_eq!(json, r#"{"label":"foo"}"#); } // -- New accessor tests -- #[test] fn sublabel_from_object() { let item = Item::new( json!({"label": "Firefox", "sublabel": "Web Browser"}), "label", ); assert_eq!(item.sublabel(), Some("Web Browser")); } #[test] fn sublabel_missing() { let item = Item::new(json!({"label": "Firefox"}), "label"); assert!(item.sublabel().is_none()); } #[test] fn icon_from_object() { let item = Item::new( json!({"label": "Firefox", "icon": "firefox.png"}), "label", ); assert_eq!(item.icon(), Some("firefox.png")); } #[test] fn group_from_object() { let item = Item::new( json!({"label": "Firefox", "group": "browsers"}), "label", ); assert_eq!(item.group(), Some("browsers")); } #[test] fn meta_from_object() { let item = Item::new( json!({"label": "test", "meta": {"res": 1080}}), "label", ); let meta = item.meta(); assert!(meta.is_some()); assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080))); } #[test] fn meta_from_plain_text() { let item = Item::from_plain_text("hello"); assert!(item.meta().is_none()); } // -- Field path resolution tests -- #[test] fn resolve_simple_path() { let value = json!({"label": "test"}); assert_eq!(resolve_field_path(&value, "label"), Some(&json!("test"))); } #[test] fn resolve_nested_path() { let value = json!({"meta": {"resolution": {"width": 3840}}}); assert_eq!( resolve_field_path(&value, "meta.resolution.width"), Some(&json!(3840)) ); } #[test] fn resolve_missing_path() { let value = json!({"label": "test"}); assert!(resolve_field_path(&value, "nope").is_none()); } #[test] fn resolve_missing_nested() { let value = json!({"meta": {"a": 1}}); assert!(resolve_field_path(&value, "meta.b").is_none()); } #[test] fn resolve_non_object_intermediate() { let value = json!({"meta": "not an object"}); assert!(resolve_field_path(&value, "meta.foo").is_none()); } #[test] fn resolve_on_string_value() { let value = json!("hello"); assert!(resolve_field_path(&value, "anything").is_none()); } #[test] fn field_value_on_item() { let item = Item::new( json!({"label": "test", "meta": {"tags": ["a", "b"]}}), "label", ); assert_eq!(item.field_value("meta.tags"), Some(&json!(["a", "b"]))); assert!(item.field_value("meta.nope").is_none()); } }