1use core_api::{ExportEdge, NodeInfo, RuleDef, Value};
26use std::collections::{BTreeMap, BTreeSet};
27use std::fmt::Write as _;
28use std::io::Write as _;
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use arrow_array::{ArrayRef, BooleanArray, RecordBatch, StringArray};
33use arrow_schema::{DataType, Field, Schema};
34use parquet::arrow::ArrowWriter;
35use parquet::file::properties::WriterProperties;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ExportFormat {
40 Jsonl,
41 Parquet,
42 Graphml,
43}
44
45impl ExportFormat {
46 pub fn parse(s: &str) -> Option<Self> {
47 match s {
48 "jsonl" => Some(Self::Jsonl),
49 "parquet" => Some(Self::Parquet),
50 "graphml" => Some(Self::Graphml),
51 _ => None,
52 }
53 }
54
55 pub fn name(&self) -> &'static str {
56 match self {
57 Self::Jsonl => "jsonl",
58 Self::Parquet => "parquet",
59 Self::Graphml => "graphml",
60 }
61 }
62}
63
64fn value_to_json(v: &Value) -> serde_json::Value {
69 match v {
70 Value::Int(i) => serde_json::Value::Number((*i).into()),
71 Value::Float(f) => serde_json::Number::from_f64(*f)
73 .map(serde_json::Value::Number)
74 .unwrap_or(serde_json::Value::Null),
75 Value::Str(s) => serde_json::Value::String(s.clone()),
76 Value::Bool(b) => serde_json::Value::Bool(*b),
77 Value::List(xs) => serde_json::Value::Array(xs.iter().map(value_to_json).collect()),
78 Value::Map(m) => serde_json::Value::Object(
79 m.iter()
80 .map(|(k, v)| (k.clone(), value_to_json(v)))
81 .collect(),
82 ),
83 }
84}
85
86pub fn write_jsonl(
91 nodes: &[NodeInfo],
92 edges: &[ExportEdge],
93 rules: &[RuleDef],
94 dest: &Path,
95) -> Result<(), crate::CliError> {
96 std::fs::create_dir_all(dest)?;
97
98 {
100 let mut f = std::fs::File::create(dest.join("nodes.jsonl"))?;
101 for node in nodes {
102 let mut obj = serde_json::Map::new();
103 obj.insert("key".into(), serde_json::Value::String(node.key.clone()));
104 obj.insert(
105 "label".into(),
106 serde_json::Value::String(node.label.clone()),
107 );
108 for (k, v) in &node.props {
109 obj.insert(k.clone(), value_to_json(v));
110 }
111 let line = serde_json::to_string(&serde_json::Value::Object(obj))
112 .map_err(|e| crate::CliError(format!("json encode error: {e}")))?;
113 writeln!(f, "{line}")?;
114 }
115 }
116
117 {
119 let mut f = std::fs::File::create(dest.join("edges.jsonl"))?;
120 for edge in edges {
121 let obj = serde_json::json!({
122 "edge_type": edge.edge_type,
123 "src": edge.src,
124 "dst": edge.dst,
125 "derived": edge.derived,
126 "rule": edge.rule,
127 });
128 let line = serde_json::to_string(&obj)
129 .map_err(|e| crate::CliError(format!("json encode error: {e}")))?;
130 writeln!(f, "{line}")?;
131 }
132 }
133
134 {
136 let mut f = std::fs::File::create(dest.join("rules.jsonl"))?;
137 for rule in rules {
138 let line = serde_json::to_string(rule)
139 .map_err(|e| crate::CliError(format!("json encode rule: {e}")))?;
140 writeln!(f, "{line}")?;
141 }
142 }
143
144 Ok(())
145}
146
147pub fn write_parquet(
161 nodes: &[NodeInfo],
162 edges: &[ExportEdge],
163 rules: &[RuleDef],
164 dest: &Path,
165) -> Result<(), crate::CliError> {
166 std::fs::create_dir_all(dest)?;
167 let props = WriterProperties::builder().build();
168
169 {
171 let schema = Arc::new(Schema::new(vec![
172 Field::new("key", DataType::Utf8, false),
173 Field::new("label", DataType::Utf8, false),
174 Field::new("props", DataType::Utf8, false),
175 ]));
176 let mut keys = Vec::new();
177 let mut labels = Vec::new();
178 let mut props_json = Vec::new();
179 for node in nodes {
180 keys.push(node.key.clone());
181 labels.push(node.label.clone());
182 let p: serde_json::Map<String, serde_json::Value> = node
183 .props
184 .iter()
185 .map(|(k, v)| (k.clone(), value_to_json(v)))
186 .collect();
187 props_json.push(
188 serde_json::to_string(&serde_json::Value::Object(p))
189 .map_err(|e| crate::CliError(format!("json encode: {e}")))?,
190 );
191 }
192 let batch = RecordBatch::try_new(
193 schema.clone(),
194 vec![
195 Arc::new(StringArray::from(keys)) as ArrayRef,
196 Arc::new(StringArray::from(labels)) as ArrayRef,
197 Arc::new(StringArray::from(props_json)) as ArrayRef,
198 ],
199 )
200 .map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
201
202 let file = std::fs::File::create(dest.join("nodes.parquet"))?;
203 let mut writer = ArrowWriter::try_new(file, schema, Some(props.clone()))
204 .map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
205 writer
206 .write(&batch)
207 .map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
208 writer
209 .close()
210 .map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
211 }
212
213 {
215 let schema = Arc::new(Schema::new(vec![
216 Field::new("edge_type", DataType::Utf8, false),
217 Field::new("src", DataType::Utf8, false),
218 Field::new("dst", DataType::Utf8, false),
219 Field::new("derived", DataType::Boolean, false),
220 Field::new("rule", DataType::Utf8, true),
221 ]));
222 let mut etypes = Vec::new();
223 let mut srcs = Vec::new();
224 let mut dsts = Vec::new();
225 let mut deriveds = Vec::new();
226 let mut rule_names: Vec<Option<String>> = Vec::new();
227 for edge in edges {
228 etypes.push(edge.edge_type.clone());
229 srcs.push(edge.src.clone());
230 dsts.push(edge.dst.clone());
231 deriveds.push(edge.derived);
232 rule_names.push(edge.rule.clone());
233 }
234 let batch = RecordBatch::try_new(
235 schema.clone(),
236 vec![
237 Arc::new(StringArray::from(etypes)) as ArrayRef,
238 Arc::new(StringArray::from(srcs)) as ArrayRef,
239 Arc::new(StringArray::from(dsts)) as ArrayRef,
240 Arc::new(BooleanArray::from(deriveds)) as ArrayRef,
241 Arc::new(StringArray::from(rule_names)) as ArrayRef,
242 ],
243 )
244 .map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
245
246 let file = std::fs::File::create(dest.join("edges.parquet"))?;
247 let mut writer = ArrowWriter::try_new(file, schema, Some(props.clone()))
248 .map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
249 writer
250 .write(&batch)
251 .map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
252 writer
253 .close()
254 .map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
255 }
256
257 {
259 let schema = Arc::new(Schema::new(vec![
260 Field::new("name", DataType::Utf8, false),
261 Field::new("definition", DataType::Utf8, false),
262 ]));
263 let mut names = Vec::new();
264 let mut definitions = Vec::new();
265 for rule in rules {
266 names.push(rule.name.clone());
267 definitions.push(
268 serde_json::to_string(rule)
269 .map_err(|e| crate::CliError(format!("json encode: {e}")))?,
270 );
271 }
272 let batch = RecordBatch::try_new(
273 schema.clone(),
274 vec![
275 Arc::new(StringArray::from(names)) as ArrayRef,
276 Arc::new(StringArray::from(definitions)) as ArrayRef,
277 ],
278 )
279 .map_err(|e| crate::CliError(format!("arrow error: {e}")))?;
280
281 let file = std::fs::File::create(dest.join("rules.parquet"))?;
282 let mut writer = ArrowWriter::try_new(file, schema, Some(props))
283 .map_err(|e| crate::CliError(format!("parquet writer: {e}")))?;
284 writer
285 .write(&batch)
286 .map_err(|e| crate::CliError(format!("parquet write: {e}")))?;
287 writer
288 .close()
289 .map_err(|e| crate::CliError(format!("parquet close: {e}")))?;
290 }
291
292 Ok(())
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
299enum GmlType {
300 Boolean,
301 Long,
306 Double,
307 String,
308}
309
310impl GmlType {
311 fn as_str(self) -> &'static str {
312 match self {
313 Self::Boolean => "boolean",
314 Self::Long => "long",
315 Self::Double => "double",
316 Self::String => "string",
317 }
318 }
319}
320
321fn value_gml_type(v: &Value) -> GmlType {
324 match v {
325 Value::Int(_) => GmlType::Long,
326 Value::Float(_) => GmlType::Double,
327 Value::Bool(_) => GmlType::Boolean,
328 Value::Str(_) | Value::List(_) | Value::Map(_) => GmlType::String,
329 }
330}
331
332fn value_gml_text(v: &Value) -> String {
340 match value_to_json(v) {
341 serde_json::Value::Null => String::new(),
342 serde_json::Value::Bool(b) => b.to_string(),
343 serde_json::Value::Number(n) => n.to_string(),
344 serde_json::Value::String(s) => s,
345 other => other.to_string(),
346 }
347}
348
349fn xml_escape(s: &str) -> String {
353 let mut out = String::with_capacity(s.len());
354 for c in s.chars() {
355 match c {
356 '&' => out.push_str("&"),
357 '<' => out.push_str("<"),
358 '>' => out.push_str(">"),
359 '"' => out.push_str("""),
360 '\'' => out.push_str("'"),
361 '\u{9}' | '\u{A}' | '\u{D}' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' => {
362 out.push(c)
363 }
364 _ => {} }
366 }
367 out
368}
369
370fn sanitize_id_component(s: &str) -> String {
374 let mut out = String::with_capacity(s.len());
375 for (i, c) in s.chars().enumerate() {
376 let ok = if i == 0 {
377 c.is_ascii_alphabetic() || c == '_'
378 } else {
379 c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-'
380 };
381 out.push(if ok { c } else { '_' });
382 }
383 if out.is_empty() {
384 out.push('_');
385 }
386 out
387}
388
389fn unique_id(base: String, used: &mut BTreeSet<String>) -> String {
393 if used.insert(base.clone()) {
394 return base;
395 }
396 let mut n = 2u32;
397 loop {
398 let candidate = format!("{base}_{n}");
399 if used.insert(candidate.clone()) {
400 return candidate;
401 }
402 n += 1;
403 }
404}
405
406fn resolve_graphml_dest(dest: &Path) -> Result<PathBuf, crate::CliError> {
412 if dest.is_dir() {
413 Ok(dest.join("graph.graphml"))
414 } else {
415 if let Some(parent) = dest.parent() {
416 if !parent.as_os_str().is_empty() {
417 std::fs::create_dir_all(parent)?;
418 }
419 }
420 Ok(dest.to_path_buf())
421 }
422}
423
424pub fn write_graphml(
454 nodes: &[NodeInfo],
455 edges: &[ExportEdge],
456 dest: &Path,
457) -> Result<PathBuf, crate::CliError> {
458 let file_path = resolve_graphml_dest(dest)?;
459
460 let mut node_key_types: BTreeMap<String, Option<GmlType>> = BTreeMap::new();
464 node_key_types.insert("label".to_string(), Some(GmlType::String));
465 for node in nodes {
466 for (k, v) in &node.props {
467 let t = value_gml_type(v);
468 node_key_types
469 .entry(k.clone())
470 .and_modify(|existing| {
471 if *existing != Some(t) {
472 *existing = None;
473 }
474 })
475 .or_insert(Some(t));
476 }
477 }
478 let node_key_types: BTreeMap<String, GmlType> = node_key_types
479 .into_iter()
480 .map(|(k, t)| (k, t.unwrap_or(GmlType::String)))
481 .collect();
482
483 let mut edge_key_types: Vec<(&str, GmlType)> = vec![
485 ("type", GmlType::String),
486 ("derived", GmlType::Boolean),
487 ("rule", GmlType::String),
488 ("weight", GmlType::Double),
489 ];
490 edge_key_types.sort_by(|a, b| a.0.cmp(b.0));
491
492 let mut used_ids: BTreeSet<String> = BTreeSet::new();
495 let mut node_key_ids: BTreeMap<String, String> = BTreeMap::new();
496 for name in node_key_types.keys() {
497 let base = format!("n_{}", sanitize_id_component(name));
498 node_key_ids.insert(name.clone(), unique_id(base, &mut used_ids));
499 }
500 let mut edge_key_ids: BTreeMap<&str, String> = BTreeMap::new();
501 for (name, _) in &edge_key_types {
502 let base = format!("e_{}", sanitize_id_component(name));
503 edge_key_ids.insert(name, unique_id(base, &mut used_ids));
504 }
505
506 let mut out = String::new();
507 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
508 out.push_str("<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">\n");
509
510 for (name, gtype) in &node_key_types {
511 let _ = writeln!(
512 out,
513 " <key id=\"{}\" for=\"node\" attr.name=\"{}\" attr.type=\"{}\"/>",
514 xml_escape(&node_key_ids[name]),
515 xml_escape(name),
516 gtype.as_str()
517 );
518 }
519 for (name, gtype) in &edge_key_types {
520 let _ = writeln!(
521 out,
522 " <key id=\"{}\" for=\"edge\" attr.name=\"{}\" attr.type=\"{}\"/>",
523 xml_escape(&edge_key_ids[name]),
524 xml_escape(name),
525 gtype.as_str()
526 );
527 }
528
529 out.push_str(" <graph id=\"G\" edgedefault=\"directed\">\n");
530
531 for node in nodes {
532 let _ = writeln!(out, " <node id=\"{}\">", xml_escape(&node.key));
533 let _ = writeln!(
534 out,
535 " <data key=\"{}\">{}</data>",
536 xml_escape(&node_key_ids["label"]),
537 xml_escape(&node.label)
538 );
539 for (k, v) in &node.props {
540 let _ = writeln!(
541 out,
542 " <data key=\"{}\">{}</data>",
543 xml_escape(&node_key_ids[k]),
544 xml_escape(&value_gml_text(v))
545 );
546 }
547 out.push_str(" </node>\n");
548 }
549
550 for (i, edge) in edges.iter().enumerate() {
551 let _ = writeln!(
552 out,
553 " <edge id=\"e{i}\" source=\"{}\" target=\"{}\">",
554 xml_escape(&edge.src),
555 xml_escape(&edge.dst)
556 );
557 let _ = writeln!(
558 out,
559 " <data key=\"{}\">{}</data>",
560 xml_escape(&edge_key_ids["type"]),
561 xml_escape(&edge.edge_type)
562 );
563 let _ = writeln!(
564 out,
565 " <data key=\"{}\">{}</data>",
566 xml_escape(&edge_key_ids["derived"]),
567 edge.derived
568 );
569 if let Some(rule) = &edge.rule {
570 let _ = writeln!(
571 out,
572 " <data key=\"{}\">{}</data>",
573 xml_escape(&edge_key_ids["rule"]),
574 xml_escape(rule)
575 );
576 }
577 if let Some(w) = edge.weight {
578 let _ = writeln!(
579 out,
580 " <data key=\"{}\">{}</data>",
581 xml_escape(&edge_key_ids["weight"]),
582 xml_escape(&value_gml_text(&Value::Float(w)))
583 );
584 }
585 out.push_str(" </edge>\n");
586 }
587
588 out.push_str(" </graph>\n");
589 out.push_str("</graphml>\n");
590
591 std::fs::write(&file_path, out)?;
592 Ok(file_path)
593}