use serde_json::Value;
pub const NODE_TEXT_VERSION: u32 = 1;
pub const NODE_TEXT_VERSION_KEY: &str = "node_text_version";
const SKIP_KEYS: &[&str] = &[
"created_at",
"updated_at",
"last_engaged",
"linkedin_url",
"profile_url",
"source_url",
"url",
"id",
"uuid",
"avatar",
"image",
];
const MAX_LEN: usize = 1000;
pub fn node_text(node_type: &str, properties: &Value) -> String {
let name = properties
.get("name")
.or_else(|| properties.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let mut parts = Vec::new();
parts.push(if name.is_empty() {
node_type.to_string()
} else {
format!("{node_type}: {name}")
});
if let Some(map) = properties.as_object() {
for (key, value) in map {
if key == "name" || key == "title" || SKIP_KEYS.contains(&key.as_str()) {
continue;
}
let Some(rendered) = scalar_to_string(value) else {
continue;
};
let rendered = rendered.trim();
if rendered.is_empty()
|| rendered.starts_with("http://")
|| rendered.starts_with("https://")
{
continue;
}
parts.push(format!("{key}: {rendered}"));
}
}
truncate_on_char_boundary(&parts.join("; "), MAX_LEN)
}
fn scalar_to_string(value: &Value) -> Option<String> {
match value {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
}
}
fn truncate_on_char_boundary(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn leads_with_type_and_name() {
let text = node_text(
"person",
&json!({"name": "Ada Lovelace", "role": "Founder"}),
);
assert!(text.starts_with("person: Ada Lovelace"), "{text}");
assert!(text.contains("role: Founder"), "{text}");
}
#[test]
fn drops_identifiers_and_urls() {
let text = node_text(
"person",
&json!({
"name": "Ada",
"linkedin_url": "https://example.com/ada",
"created_at": "2026-01-01",
"homepage": "https://ada.dev",
"note": "writes compilers",
}),
);
assert!(!text.contains("example.com"), "{text}");
assert!(!text.contains("2026-01-01"), "{text}");
assert!(!text.contains("ada.dev"), "{text}");
assert!(text.contains("note: writes compilers"), "{text}");
}
#[test]
fn a_node_with_no_name_still_composes() {
let text = node_text("event", &json!({"summary": "four comments posted"}));
assert_eq!(text, "event; summary: four comments posted");
}
#[test]
fn nested_values_are_skipped() {
let text = node_text(
"project",
&json!({"name": "Prime", "tags": ["a", "b"], "meta": {"k": "v"}}),
);
assert_eq!(text, "project: Prime");
}
#[test]
fn the_same_node_composes_the_same_sentence_twice() {
let props = json!({"name": "Ada", "b": "two", "a": "one", "c": "three"});
assert_eq!(node_text("person", &props), node_text("person", &props));
}
#[test]
fn truncation_does_not_split_a_codepoint() {
let long = "é".repeat(MAX_LEN);
let text = node_text("note", &json!({"name": "x", "body": long}));
assert!(text.len() <= MAX_LEN);
assert!(text.chars().all(|c| c != '\u{fffd}'));
}
}