1use crate::formats::kdl::{
2 KdlFormat, KdlMetadata, KdlSpec, document_to_string, node_rows_to_kdl_document,
3 resolve_non_roundtrip, value_to_jik_document,
4};
5use nu_engine::command_prelude::*;
6
7#[derive(Clone)]
8pub struct ToKdl;
9
10impl Command for ToKdl {
11 fn name(&self) -> &str {
12 "to kdl"
13 }
14
15 fn signature(&self) -> Signature {
16 Signature::build("to kdl")
17 .input_output_types(vec![(Type::Any, Type::String)])
18 .param(
19 Flag::new("spec")
20 .arg(SyntaxShape::Int)
21 .desc("KDL language version (1 or 2 (default)).")
22 .completion(Completion::new_list(&["1", "2"])),
23 )
24 .param(
25 Flag::new("format")
26 .arg(SyntaxShape::String)
27 .desc(
28 "Data model: 'jik' (default) for JSON-in-KDL, or 'nodes' for KDL AST rows.",
29 )
30 .completion(Completion::new_list(&["nodes", "jik"])),
31 )
32 .switch(
33 "serialize",
34 "Serialize nushell types that cannot be deserialized (shorthand for --non-roundtrip lossy).",
35 Some('s'),
36 )
37 .param(
38 Flag::new("non-roundtrip")
39 .arg(SyntaxShape::String)
40 .desc("How to handle values that are non-roundtrippable ('error' (default), 'null', or 'lossy').")
41 .completion(Completion::new_list(&["error", "null", "lossy"])),
42 )
43 .category(Category::Formats)
44 }
45
46 fn description(&self) -> &str {
47 "Converts structured data into KDL text."
48 }
49
50 fn search_terms(&self) -> Vec<&str> {
51 vec!["convert", "export", "config"]
52 }
53
54 fn run(
55 &self,
56 engine_state: &EngineState,
57 stack: &mut Stack,
58 call: &Call,
59 mut input: PipelineData,
60 ) -> Result<PipelineData, ShellError> {
61 let call_span = input.span().unwrap_or(call.head);
62 let mut metadata = input.take_metadata().unwrap_or_default();
63
64 let from_meta = KdlMetadata::read_from(&metadata);
65 KdlMetadata::clear(&mut metadata);
66 let metadata = metadata.with_content_type(Some("application/x-kdl".to_owned()));
67
68 let format = match call.get_flag::<String>(engine_state, stack, "format")? {
69 Some(s) => {
70 let flag_span = call.get_flag_span(stack, "format").unwrap_or(call.head);
71 KdlFormat::parse(&s, flag_span)?
72 }
73 None => from_meta
74 .as_ref()
75 .map(|m| m.format)
76 .unwrap_or(KdlFormat::Jik),
77 };
78
79 let spec = match call.get_flag::<i64>(engine_state, stack, "spec")? {
80 Some(n) => {
81 let flag_span = call.get_flag_span(stack, "spec").unwrap_or(call.head);
82 KdlSpec::from_i64(n, flag_span)?
83 }
84 None => from_meta.as_ref().map(|m| m.spec).unwrap_or_default(),
85 };
86
87 let non_roundtrip_flag =
88 call.get_flag::<Spanned<String>>(engine_state, stack, "non-roundtrip")?;
89 let serialize = call.has_flag(engine_state, stack, "serialize")?;
90
91 if serialize
93 && let Some(nr) = non_roundtrip_flag.as_ref()
94 && nr.item != "lossy"
95 {
96 return Err(ShellError::IncompatibleParameters {
97 left_message: "this is a shorthand to".into(),
98 left_span: call.get_flag_span(stack, "serialize").unwrap_or(call.head),
99 right_message: "this with `lossy`".into(),
100 right_span: nr.span,
101 });
102 }
103
104 let non_roundtrip = resolve_non_roundtrip(serialize, non_roundtrip_flag, engine_state)?;
105
106 let value = input.into_value(call_span)?;
107
108 let document = match format {
109 KdlFormat::Jik => value_to_jik_document(&value, &non_roundtrip, call_span)?,
110 KdlFormat::Nodes => node_rows_to_kdl_document(&value, &non_roundtrip, call_span)?,
111 };
112
113 let output_string = document_to_string(document, spec);
114
115 Ok(output_string
116 .into_value(call_span)
117 .into_pipeline_data_with_metadata(metadata))
118 }
119
120 fn examples(&self) -> Vec<Example<'_>> {
121 vec![
122 Example {
123 description: "Convert a record to JSON-in-KDL (default KDL v2 keywords).",
124 example: "{a: 1, b: true} | to kdl",
125 result: Some(Value::test_string("- a=1 b=#true\n")),
126 },
127 Example {
128 description: "Emit KDL v2 explicitly with --spec 2.",
129 example: "{a: 1, b: true} | to kdl --spec 2",
130 result: Some(Value::test_string("- a=1 b=#true\n")),
131 },
132 Example {
133 description: "Emit KDL v1 keywords with --spec 1 (bare true/false/null).",
134 example: "{a: 1, b: true} | to kdl --spec 1",
135 result: Some(Value::test_string("- a=1 b=true\n")),
136 },
137 Example {
138 description: "Convert a list to JSON-in-KDL.",
139 example: "[1 2 3] | to kdl",
140 result: Some(Value::test_string("- 1 2 3\n")),
141 },
142 Example {
143 description: "Emit null/bool keywords as KDL v2.",
144 example: "[null false true] | to kdl --spec 2",
145 result: Some(Value::test_string("- #null #false #true\n")),
146 },
147 Example {
148 description: "Emit null/bool keywords as KDL v1.",
149 example: "[null false true] | to kdl --spec 1",
150 result: Some(Value::test_string("- null false true\n")),
151 },
152 Example {
153 description: "Round-trip KDL through node rows (default v2).",
154 example: "'node one; node two' | from kdl | to kdl",
155 result: Some(Value::test_string("node one\nnode two\n")),
156 },
157 Example {
158 description: "Round-trip a KDL v1 document; metadata keeps --spec 1 on to kdl.",
159 example: r#""item 1 enabled=true" | from kdl --spec 1 | to kdl"#,
160 result: Some(Value::test_string("item 1 enabled=true\n")),
161 },
162 Example {
163 description: "Round-trip a KDL v2 document with --spec 2 on both sides.",
164 example: r#""item 1 enabled=#true" | from kdl --spec 2 | to kdl --spec 2"#,
165 result: Some(Value::test_string("item 1 enabled=#true\n")),
166 },
167 Example {
168 description: "Override metadata: parse as v1 but emit as v2.",
169 example: r#""item 1 enabled=true" | from kdl --spec 1 | to kdl --spec 2"#,
170 result: Some(Value::test_string("item 1 enabled=#true\n")),
171 },
172 Example {
173 description: "Serialize a closure as a string.",
174 example: "{|| 1 + 1} | to kdl --serialize",
175 result: Some(Value::test_string("- \"{|| 1 + 1}\"\n")),
176 },
177 Example {
178 description: "Emit Nushell filesize and duration with type annotations.",
179 example: "{size: 1kib, wait: 5sec} | to kdl",
180 result: Some(Value::test_string(
181 "- size=(filesize)1024 wait=(duration)5000000000\n",
182 )),
183 },
184 Example {
185 description: "Emit a datetime as a (timestamp) annotation (RFC 3339 string).",
186 example: "{when: 2020-01-02T03:04:05+00:00} | to kdl",
187 result: Some(Value::test_string(
188 "- when=(timestamp)\"2020-01-02T03:04:05+00:00\"\n",
189 )),
190 },
191 Example {
192 description: "Emit a cell-path with a (cell-path) annotation.",
193 example: "$.1.abc | to kdl",
194 result: Some(Value::test_string("- (cell-path)$.1.abc\n")),
195 },
196 Example {
197 description: "Emit a range with a (range) annotation.",
198 example: "1..3 | to kdl",
199 result: Some(Value::test_string("- (range)\"1..3\"\n")),
200 },
201 Example {
202 description: "Emit a glob with a (glob) annotation.",
203 example: r#""*.rs" | into glob | to kdl"#,
204 result: Some(Value::test_string("- (glob)*.rs\n")),
205 },
206 Example {
207 description: "Emit binary data as base64 with a (binary) annotation.",
208 example: "0x[01 02 03] | to kdl",
209 result: Some(Value::test_string("- (binary)AQID\n")),
210 },
211 Example {
212 description: "Round-trip annotated Nushell types through KDL.",
213 example: "{size: 1kib, wait: 5sec} | to kdl | from kdl --format jik",
214 result: Some(Value::test_record(record! {
215 "size" => Value::test_filesize(1024),
216 "wait" => Value::test_duration(5_000_000_000),
217 })),
218 },
219 Example {
220 description: "Round-trip a list of records (table-shaped data) as JSON-in-KDL.",
221 example: "[{a: 1}, {a: 2}] | to kdl | from kdl --format jik",
222 result: Some(Value::test_list(vec![
223 Value::test_record(record! { "a" => Value::test_int(1) }),
224 Value::test_record(record! { "a" => Value::test_int(2) }),
225 ])),
226 },
227 ]
228 }
229}
230
231#[cfg(test)]
232mod test {
233 use super::*;
234 use crate::formats::kdl::{
235 KdlMetadata, NonRoundtrip, document_to_string, node_rows_to_kdl_document,
236 value_to_jik_document,
237 };
238 use crate::{Get, Metadata};
239 use nu_cmd_lang::eval_pipeline_without_terminal_expression;
240 use nu_protocol::PipelineMetadata;
241
242 fn eval_kdl(cmd: &str) -> Value {
243 let mut engine_state = Box::new(EngineState::new());
244 let delta = {
245 let mut working_set = StateWorkingSet::new(&engine_state);
246 working_set.add_decl(Box::new(crate::formats::FromKdl));
247 working_set.add_decl(Box::new(ToKdl));
248 working_set.add_decl(Box::new(Metadata {}));
249 working_set.add_decl(Box::new(Get {}));
250 working_set.render()
251 };
252 engine_state
253 .merge_delta(delta)
254 .expect("error merging delta");
255 eval_pipeline_without_terminal_expression(
256 cmd,
257 std::env::temp_dir().as_ref(),
258 &mut engine_state,
259 )
260 .expect("pipeline should succeed")
261 }
262
263 #[test]
264 fn test_examples() -> nu_test_support::Result {
265 nu_test_support::test().examples(ToKdl)
266 }
267
268 #[test]
269 fn jik_wraps_scalars_in_anon_node() {
270 let document =
271 value_to_jik_document(&Value::test_int(5), &NonRoundtrip::Error, Span::test_data())
272 .expect("scalar should serialize");
273
274 assert_eq!(document.to_string(), "- 5\n");
275 }
276
277 #[test]
278 fn jik_empty_list_and_record() {
279 let span = Span::test_data();
280 let list_doc = value_to_jik_document(&Value::test_list(vec![]), &NonRoundtrip::Error, span)
281 .expect("empty list");
282 assert_eq!(list_doc.to_string(), "(array)-\n");
283
284 let rec_doc =
285 value_to_jik_document(&Value::test_record(record! {}), &NonRoundtrip::Error, span)
286 .expect("empty record");
287 assert_eq!(rec_doc.to_string(), "(object)-\n");
288 }
289
290 #[test]
291 fn node_rows_round_trip_shape() {
292 let span = Span::test_data();
293 let rows = Value::test_list(vec![Value::test_record(record! {
294 "name" => Value::string("item", span),
295 "args" => Value::test_list(vec![Value::int(1, span)]),
296 "props" => Value::test_record(record! { "enabled" => Value::bool(true, span) }),
297 "children" => Value::test_list(vec![]),
298 })]);
299
300 let document =
301 node_rows_to_kdl_document(&rows, &NonRoundtrip::Error, span).expect("serialize");
302 assert_eq!(document.to_string(), "item 1 enabled=#true\n");
303 }
304
305 #[test]
306 fn nodes_format_rejects_plain_records() {
307 let span = Span::test_data();
308 let value = Value::test_record(record! {
309 "plain" => Value::int(7, span),
310 });
311
312 let err = node_rows_to_kdl_document(&value, &NonRoundtrip::Error, span)
313 .expect_err("should reject");
314 match err {
315 ShellError::UnsupportedInput { msg, .. } => {
316 assert!(
317 msg.contains("nodes format") || msg.contains("node row"),
318 "unexpected error message: {msg}"
319 );
320 }
321 other => panic!("expected UnsupportedInput, got {other:?}"),
322 }
323 }
324
325 #[test]
326 fn metadata_round_trip_defaults_format_and_spec() {
327 let span = Span::test_data();
328 let mut metadata = PipelineMetadata::default();
329 KdlMetadata {
330 format: KdlFormat::Nodes,
331 spec: KdlSpec::V1,
332 }
333 .write_to(&mut metadata, span);
334
335 let read = KdlMetadata::read_from(&metadata).expect("metadata");
336 assert_eq!(read.format, KdlFormat::Nodes);
337 assert_eq!(read.spec, KdlSpec::V1);
338 }
339
340 #[test]
341 fn from_kdl_marker_flows_to_to_kdl_command() {
342 let result = eval_kdl(
345 "'node one; node two' | from kdl | to kdl | metadata | get content_type | $in",
346 );
347 assert_eq!(result, Value::test_string("application/x-kdl"));
348 }
349
350 #[test]
351 fn pipeline_to_kdl_spec_1_emits_bare_keywords() {
352 let result = eval_kdl("{a: 1, b: true} | to kdl --spec 1 | $in");
353 assert_eq!(result, Value::test_string("- a=1 b=true\n"));
354 }
355
356 #[test]
357 fn pipeline_to_kdl_spec_2_emits_hash_keywords() {
358 let result = eval_kdl("{a: 1, b: true} | to kdl --spec 2 | $in");
359 assert_eq!(result, Value::test_string("- a=1 b=#true\n"));
360 }
361
362 #[test]
363 fn pipeline_default_spec_is_v2() {
364 let result = eval_kdl("{a: 1, b: true} | to kdl | $in");
365 assert_eq!(result, Value::test_string("- a=1 b=#true\n"));
366 }
367
368 #[test]
369 fn pipeline_from_spec_1_metadata_keeps_v1_on_to_kdl() {
370 let result = eval_kdl(r#""item 1 enabled=true" | from kdl --spec 1 | to kdl | $in"#);
371 assert_eq!(result, Value::test_string("item 1 enabled=true\n"));
372 }
373
374 #[test]
375 fn pipeline_from_spec_2_metadata_keeps_v2_on_to_kdl() {
376 let result = eval_kdl(r#""item 1 enabled=#true" | from kdl --spec 2 | to kdl | $in"#);
377 assert_eq!(result, Value::test_string("item 1 enabled=#true\n"));
378 }
379
380 #[test]
381 fn pipeline_explicit_spec_overrides_metadata() {
382 let to_v2 =
383 eval_kdl(r#""item 1 enabled=true" | from kdl --spec 1 | to kdl --spec 2 | $in"#);
384 assert_eq!(to_v2, Value::test_string("item 1 enabled=#true\n"));
385 let to_v1 =
386 eval_kdl(r#""item 1 enabled=#true" | from kdl --spec 2 | to kdl --spec 1 | $in"#);
387 assert_eq!(to_v1, Value::test_string("item 1 enabled=true\n"));
388 }
389
390 #[test]
391 fn pipeline_list_keywords_follow_spec() {
392 assert_eq!(
393 eval_kdl("[null false true] | to kdl --spec 1 | $in"),
394 Value::test_string("- null false true\n")
395 );
396 assert_eq!(
397 eval_kdl("[null false true] | to kdl --spec 2 | $in"),
398 Value::test_string("- #null #false #true\n")
399 );
400 }
401
402 #[test]
403 fn pipeline_from_spec_1_and_2_decode_same_data() {
404 let v1 = eval_kdl(r#""node true false null" | from kdl --spec 1 | get 0.args | $in"#);
405 let v2 = eval_kdl(r#""node #true #false #null" | from kdl --spec 2 | get 0.args | $in"#);
406 assert_eq!(v1, v2);
407 }
408
409 #[test]
410 fn pipeline_from_v1_jik_and_to_v1_jik_round_trip() {
411 let result = eval_kdl(
412 "'- a=1 b=true' | from kdl --format jik --spec 1 | to kdl --format jik --spec 1 | $in",
413 );
414 assert_eq!(result, Value::test_string("- a=1 b=true\n"));
415 }
416
417 #[test]
418 fn pipeline_from_v2_jik_and_to_v2_jik_round_trip() {
419 let result = eval_kdl(
420 "'- a=1 b=#true' | from kdl --format jik --spec 2 | to kdl --format jik --spec 2 | $in",
421 );
422 assert_eq!(result, Value::test_string("- a=1 b=#true\n"));
423 }
424
425 #[test]
426 fn pipeline_list_of_records_jik_round_trip() {
427 let result = eval_kdl("[{a: 1}, {a: 2}] | to kdl | from kdl --format jik | $in");
429 let span = Span::test_data();
430 assert_eq!(
431 result,
432 Value::test_list(vec![
433 Value::test_record(record! { "a" => Value::int(1, span) }),
434 Value::test_record(record! { "a" => Value::int(2, span) }),
435 ])
436 );
437 }
438
439 #[test]
440 fn pipeline_jik_multi_anon_children_is_list() {
441 let result = eval_kdl("'- { - a=1; - a=2 }' | from kdl --format jik | $in");
442 let span = Span::test_data();
443 assert_eq!(
444 result,
445 Value::test_list(vec![
446 Value::test_record(record! { "a" => Value::int(1, span) }),
447 Value::test_record(record! { "a" => Value::int(2, span) }),
448 ])
449 );
450 }
451
452 #[test]
453 fn pipeline_jik_sole_anon_child_is_list_not_object() {
454 let result = eval_kdl("'- { - 1 }' | from kdl --format jik | $in");
455 assert_eq!(result, Value::test_list(vec![Value::test_int(1)]));
456 }
457
458 #[test]
459 fn pipeline_jik_object_key_dash_needs_annotation() {
460 let result = eval_kdl("'(object)- { - 1 }' | from kdl --format jik | $in");
461 let span = Span::test_data();
462 assert_eq!(
463 result,
464 Value::test_record(record! { "-" => Value::int(1, span) })
465 );
466 }
467
468 #[test]
469 fn document_to_string_respects_spec_for_node_rows() {
470 let span = Span::test_data();
471 let rows = Value::test_list(vec![Value::test_record(record! {
472 "name" => Value::string("item", span),
473 "args" => Value::test_list(vec![Value::int(1, span)]),
474 "props" => Value::test_record(record! { "enabled" => Value::bool(true, span) }),
475 "children" => Value::test_list(vec![]),
476 })]);
477 let doc = node_rows_to_kdl_document(&rows, &NonRoundtrip::Error, span).unwrap();
478 assert_eq!(
479 document_to_string(doc.clone(), KdlSpec::V1),
480 "item 1 enabled=true\n"
481 );
482 assert_eq!(
483 document_to_string(doc, KdlSpec::V2),
484 "item 1 enabled=#true\n"
485 );
486 }
487}