1use std::collections::BTreeSet;
54use std::path::Path;
55
56use crate::kdl::{KdlDocument, KdlEntry, KdlNode};
57use serde::Serialize;
58
59use crate::error::{Result, UsageErr};
60use crate::spec::choices::{SpecChoice, SpecChoices};
61use crate::spec::cmd::SpecCommand;
62use crate::spec::context::ParsingContext;
63use crate::spec::helpers::{string_entry, NodeHelper};
64use crate::spec::{is_false, Spec};
65use crate::SpecFlag;
66
67fn invalid(msg: String) -> UsageErr {
70 UsageErr::InvalidOutput(msg)
71}
72
73#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum Framing {
82 #[default]
85 Text,
86 Json,
88 Jsonl,
91}
92
93impl_string_enum!(Framing {
94 Framing::Text => "text",
95 Framing::Json => "json",
96 Framing::Jsonl => "jsonl",
97});
98
99impl Framing {
100 pub fn as_str(&self) -> &'static str {
101 match self {
102 Framing::Text => "text",
103 Framing::Json => "json",
104 Framing::Jsonl => "jsonl",
105 }
106 }
107
108 pub fn is_streaming(&self) -> bool {
110 matches!(self, Framing::Jsonl)
111 }
112}
113
114pub(crate) const FRAMING_VALUES: &str = "text, json, jsonl";
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
119#[serde(tag = "kind", rename_all = "snake_case")]
120pub enum Selector {
121 Value { flag: String, value: String },
123 Present { flag: String },
125}
126
127impl Selector {
128 pub fn argv(&self) -> Vec<String> {
130 match self {
131 Selector::Value { flag, value } => vec![flag.clone(), value.clone()],
132 Selector::Present { flag } => vec![flag.clone()],
133 }
134 }
135
136 pub fn flag(&self) -> &str {
138 match self {
139 Selector::Value { flag, .. } | Selector::Present { flag } => flag,
140 }
141 }
142}
143
144#[derive(Debug, Default, Clone, Serialize)]
146#[non_exhaustive]
147pub struct SpecOutput {
148 pub name: String,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub media_type: Option<String>,
153 #[serde(skip_serializing_if = "is_text")]
155 pub framing: Framing,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pub help: Option<String>,
158 #[serde(skip_serializing_if = "Option::is_none")]
165 pub schema: Option<String>,
166 #[serde(skip_serializing_if = "is_false")]
168 pub default: bool,
169 #[serde(skip_serializing_if = "is_false")]
173 pub hide: bool,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub select: Option<String>,
177}
178
179fn is_text(framing: &Framing) -> bool {
180 *framing == Framing::Text
181}
182
183impl SpecOutput {
184 pub fn new(name: impl Into<String>) -> Self {
185 Self {
186 name: name.into(),
187 ..Default::default()
188 }
189 }
190
191 pub fn framing(mut self, framing: Framing) -> Self {
192 self.framing = framing;
193 self
194 }
195
196 pub fn media_type(mut self, media_type: impl Into<String>) -> Self {
197 self.media_type = Some(media_type.into());
198 self
199 }
200
201 pub fn help(mut self, help: impl Into<String>) -> Self {
202 self.help = Some(help.into());
203 self
204 }
205
206 pub fn schema(mut self, schema: impl Into<String>) -> Self {
207 self.schema = Some(schema.into());
208 self
209 }
210
211 pub fn default_output(mut self) -> Self {
212 self.default = true;
213 self
214 }
215
216 pub fn select_argv(&self, cmd: &SpecCommand) -> Option<Selector> {
221 self.select_argv_with(cmd.select.as_deref())
222 }
223
224 pub(crate) fn select_argv_with(&self, command_select: Option<&str>) -> Option<Selector> {
225 if let Some(flag) = &self.select {
226 return Some(Selector::Present { flag: flag.clone() });
227 }
228 command_select.map(|flag| Selector::Value {
229 flag: flag.to_string(),
230 value: self.name.clone(),
231 })
232 }
233
234 pub(crate) fn parse(ctx: &ParsingContext, node: &NodeHelper) -> Result<Self> {
235 node.ensure_arg_len(1..=1)?;
236 let mut output = SpecOutput::new(node.arg(0)?.ensure_string()?);
237 for (k, v) in node.props() {
238 match k {
239 "media_type" => output.media_type = Some(v.ensure_string()?),
240 "framing" => output.framing = parse_framing(ctx, v.ensure_string()?, v.entry)?,
241 "help" => output.help = Some(v.ensure_string()?),
242 "schema" => output.schema = Some(v.ensure_string()?),
243 "default" => output.default = v.ensure_bool()?,
244 "hide" => output.hide = v.ensure_bool()?,
245 "select" => output.select = Some(v.ensure_string()?),
246 k => bail_parse!(ctx, v.entry.span(), "unsupported output key {k}"),
247 }
248 }
249 for child in node.children() {
250 match child.name() {
251 "schema" => parse_schema(ctx, &child, &mut output)?,
255 "help" => output.help = Some(child.arg(0)?.ensure_string()?),
256 k => bail_parse!(
257 ctx,
258 child.node.name().span(),
259 "unsupported output value key {k}"
260 ),
261 }
262 }
263 if output.name.is_empty() {
264 bail_parse!(ctx, node.span(), "an output needs a name");
265 }
266 Ok(output)
267 }
268}
269
270fn parse_schema(ctx: &ParsingContext, node: &NodeHelper, output: &mut SpecOutput) -> Result<()> {
271 if let Some(file) = node.get("file") {
272 node.ensure_arg_len(0..=0)?;
273 for (key, value) in node.props() {
274 if key != "file" {
275 bail_parse!(ctx, value.entry.span(), "unsupported schema key {key}");
276 }
277 }
278 let declared = file.ensure_string()?;
279 let path = Path::new(&declared);
280 let path = if path.is_relative() {
281 ctx.file
282 .parent()
283 .filter(|_| !ctx.file.as_os_str().is_empty())
284 .ok_or_else(|| {
285 ctx.build_err(
286 "relative schema files require a source file".into(),
287 node.span(),
288 )
289 })?
290 .join(path)
291 } else {
292 path.to_path_buf()
293 };
294 output.schema = Some(
295 std::fs::read_to_string(&path).map_err(|err| UsageErr::FileError(err, path.clone()))?,
296 );
297 ctx.record_source(path);
298 } else {
299 node.ensure_arg_len(1..=1)?;
300 if let Some((key, value)) = node.props().into_iter().next() {
301 bail_parse!(ctx, value.entry.span(), "unsupported schema key {key}");
302 }
303 output.schema = Some(node.arg(0)?.ensure_string()?);
304 }
305 Ok(())
306}
307
308fn parse_framing(ctx: &ParsingContext, raw: String, entry: &KdlEntry) -> Result<Framing> {
309 raw.parse().map_err(|_| {
310 ctx.build_err(
311 format!("unsupported framing {raw}, expected one of: {FRAMING_VALUES}"),
312 entry.span(),
313 )
314 })
315}
316
317impl From<&SpecOutput> for KdlNode {
318 fn from(output: &SpecOutput) -> KdlNode {
319 let mut node = KdlNode::new("output");
320 node.push(string_entry(None, &output.name));
321 if let Some(media_type) = &output.media_type {
322 node.push(string_entry(Some("media_type"), media_type));
323 }
324 if output.framing != Framing::Text {
325 node.push(string_entry(Some("framing"), output.framing.as_str()));
326 }
327 if let Some(help) = &output.help {
328 node.push(string_entry(Some("help"), help));
329 }
330 if output.default {
331 node.push(KdlEntry::new_prop("default", true));
332 }
333 if output.hide {
334 node.push(KdlEntry::new_prop("hide", true));
335 }
336 if let Some(select) = &output.select {
337 node.push(string_entry(Some("select"), select));
338 }
339 if let Some(schema) = &output.schema {
340 let mut schema_node = KdlNode::new("schema");
341 schema_node.push(string_entry(None, schema));
342 let mut children = KdlDocument::new();
343 children.nodes_mut().push(schema_node);
344 node.set_children(children);
345 }
346 node
347 }
348}
349
350pub fn effective_outputs(spec: &Spec, path: &[SpecCommand]) -> Vec<SpecOutput> {
361 effective_outputs_ref(spec, path.iter())
362}
363
364pub fn effective_outputs_ref<'a>(
366 spec: &Spec,
367 path: impl IntoIterator<Item = &'a SpecCommand>,
368) -> Vec<SpecOutput> {
369 let mut out: Vec<SpecOutput> = spec.outputs.clone();
370 let mut selected = &spec.cmd;
371 let mut inherited_globals = Vec::new();
372 for cmd in path {
373 inherited_globals.extend(selected.flags.iter().filter(|flag| flag.global));
374 selected = cmd;
375 for output in &cmd.outputs {
376 match out.iter_mut().find(|o| o.name == output.name) {
377 Some(existing) => *existing = output.clone(),
378 None => out.push(output.clone()),
379 }
380 }
381 }
382 out.retain(|o| !o.hide);
383 for output in &mut out {
384 if output.select.as_deref().is_some_and(|name| {
385 !selected.flags.iter().any(|flag| flag_named(flag, name))
386 && !inherited_globals.iter().any(|flag| flag_named(flag, name))
387 }) {
388 output.select = None;
389 }
390 }
391 out
392}
393
394pub fn effective_select(spec: &Spec, path: &[SpecCommand]) -> Option<String> {
396 let refs = path.iter().collect::<Vec<_>>();
397 effective_select_ref(spec, &refs)
398}
399
400pub fn effective_select_ref(spec: &Spec, path: &[&SpecCommand]) -> Option<String> {
402 let mut select = spec.select.clone();
403 for cmd in path {
404 if let Some(own) = &cmd.select {
405 select = Some(own.clone());
406 }
407 }
408 let selected = path.last().copied().unwrap_or(&spec.cmd);
409 select.filter(|name| selected.flags.iter().any(|flag| flag_named(flag, name)))
410}
411
412fn find_selector<'a>(
418 cmd: &'a SpecCommand,
419 inherited: &'a [SpecFlag],
420 name: &str,
421) -> Option<(&'a SpecFlag, bool)> {
422 if let Some(flag) = cmd.flags.iter().find(|f| flag_named(f, name)) {
423 return Some((flag, true));
424 }
425 inherited
426 .iter()
427 .find(|f| flag_named(f, name))
428 .map(|f| (f, false))
429}
430
431fn flag_named(flag: &SpecFlag, name: &str) -> bool {
432 let bare = name.trim_start_matches('-');
433 flag.long.iter().any(|l| l == bare)
434 || flag.short.iter().any(|s| s.to_string() == bare)
435 || flag.name == bare
436}
437
438pub(crate) fn resolve_selectors(spec: &mut Spec) -> Result<()> {
444 let root_outputs = spec.outputs.clone();
445 let root_select = spec.select.clone();
446 resolve_cmd(
447 &mut spec.cmd,
448 &[],
449 &root_outputs,
450 root_select.as_deref(),
451 true,
452 )
453}
454
455fn resolve_cmd(
456 cmd: &mut SpecCommand,
457 inherited_flags: &[SpecFlag],
458 inherited_outputs: &[SpecOutput],
459 inherited_select: Option<&str>,
460 is_root: bool,
461) -> Result<()> {
462 let mut outputs: Vec<SpecOutput> = inherited_outputs.to_vec();
465 for output in &cmd.outputs {
466 match outputs.iter_mut().find(|o| o.name == output.name) {
467 Some(existing) => *existing = output.clone(),
468 None => outputs.push(output.clone()),
469 }
470 }
471 outputs.retain(|o| !o.hide);
472
473 let mut available: Vec<SpecFlag> = inherited_flags.to_vec();
479 available.extend(cmd.flags.iter().filter(|f| f.global).cloned());
480
481 let select = match cmd.select.as_deref() {
486 Some(own) => Some(own.to_owned()),
487 None => inherited_select
488 .filter(|name| find_selector(cmd, inherited_flags, name).is_some())
489 .map(str::to_owned),
490 };
491
492 check_declarations(cmd, &outputs)?;
493 if let Some(name) = &select {
494 if !outputs.is_empty() {
495 materialize(cmd, inherited_flags, name, &outputs)?;
496 }
497 }
498 let local_outputs = if is_root {
499 inherited_outputs
500 } else {
501 &cmd.outputs
502 };
503 check_boolean_selectors(cmd, inherited_flags, &outputs, local_outputs)?;
504
505 for sub in cmd.subcommands.values_mut() {
506 resolve_cmd(sub, &available, &outputs, select.as_deref(), false)?;
507 }
508 Ok(())
509}
510
511fn check_declarations(cmd: &SpecCommand, outputs: &[SpecOutput]) -> Result<()> {
513 let defaults: Vec<&str> = outputs
514 .iter()
515 .filter(|o| o.default)
516 .map(|o| o.name.as_str())
517 .collect();
518 if defaults.len() > 1 {
519 return Err(invalid(format!(
520 "`{}` has more than one default output ({}); only one can be what runs when \
521 nothing selects otherwise",
522 cmd.name,
523 defaults.join(", ")
524 )));
525 }
526 Ok(())
527}
528
529fn materialize(
531 cmd: &mut SpecCommand,
532 inherited: &[SpecFlag],
533 name: &str,
534 outputs: &[SpecOutput],
535) -> Result<()> {
536 let Some((found, local)) = find_selector(cmd, inherited, name) else {
537 let declared: Vec<String> = cmd
538 .flags
539 .iter()
540 .chain(inherited)
541 .map(|f| f.usage())
542 .collect();
543 return Err(invalid(format!(
544 "select `{name}` on `{}` names no flag here or above it (declared: {})",
545 cmd.name,
546 if declared.is_empty() {
547 "none".to_string()
548 } else {
549 declared.join(", ")
550 }
551 )));
552 };
553
554 if found.arg.is_none() {
555 return Err(invalid(format!(
556 "select `{name}` on `{}` names a flag that takes no value, so it cannot carry an \
557 output name; a boolean picks one output with `output … select=\"{name}\"`",
558 cmd.name
559 )));
560 }
561
562 let mut flag = found.clone();
566 let arg = flag.arg.as_mut().expect("checked just above");
567 if let Some(existing) = &arg.choices {
568 let declared: BTreeSet<String> = existing.values().into_iter().collect();
572 let expected: BTreeSet<String> = outputs.iter().map(|o| o.name.clone()).collect();
573 if declared != expected {
574 let mut detail = Vec::new();
575 let only_flag = difference(&declared, &expected);
576 let only_output = difference(&expected, &declared);
577 if !only_flag.is_empty() {
578 detail.push(format!("the flag accepts {only_flag} with no output"));
579 }
580 if !only_output.is_empty() {
581 detail.push(format!("no choice offers {only_output}"));
582 }
583 return Err(invalid(format!(
584 "`{}` selects outputs with `{name}`, but its choices disagree: {}",
585 cmd.name,
586 detail.join(", ")
587 )));
588 }
589 return Ok(());
590 }
591
592 arg.choices = Some(SpecChoices {
593 choices: outputs.iter().map(|o| o.name.clone()).collect(),
594 details: outputs
599 .iter()
600 .filter(|o| o.help.is_some())
601 .map(|o| SpecChoice {
602 value: o.name.clone(),
603 help: o.help.clone(),
604 ..Default::default()
605 })
606 .collect(),
607 ..Default::default()
608 });
609
610 if local {
611 if let Some(slot) = cmd.flags.iter_mut().find(|f| flag_named(f, name)) {
612 *slot = flag;
613 }
614 } else {
615 cmd.flags.push(flag);
616 }
617 Ok(())
618}
619
620fn difference(a: &BTreeSet<String>, b: &BTreeSet<String>) -> String {
621 a.difference(b)
622 .map(|v| format!("`{v}`"))
623 .collect::<Vec<_>>()
624 .join(", ")
625}
626
627fn check_boolean_selectors(
629 cmd: &SpecCommand,
630 inherited: &[SpecFlag],
631 outputs: &[SpecOutput],
632 local_outputs: &[SpecOutput],
633) -> Result<()> {
634 for output in outputs.iter().filter(|o| o.select.is_some()) {
635 let name = output.select.as_deref().expect("filtered");
636 let Some((flag, _)) = find_selector(cmd, inherited, name) else {
637 if local_outputs.iter().any(|local| local.name == output.name) {
638 return Err(invalid(format!(
639 "output `{}` on `{}` is selected by `{name}`, which names no flag here or \
640 above it",
641 output.name, cmd.name
642 )));
643 }
644 continue;
645 };
646 if flag.arg.is_some() {
647 return Err(invalid(format!(
648 "output `{}` on `{}` is selected by `{name}`, which takes a value; a flag that \
649 carries an output name belongs on the command as `select \"{name}\"`",
650 output.name, cmd.name
651 )));
652 }
653 }
654 Ok(())
655}
656
657pub fn has_outputs(spec: &Spec, path: &[SpecCommand]) -> bool {
659 !spec.outputs.is_empty() || path.iter().any(|c| !c.outputs.is_empty())
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use insta::assert_snapshot;
666
667 fn parse(src: &str) -> Spec {
668 src.parse().expect("the fixture should parse")
669 }
670
671 fn error(src: &str) -> String {
674 match src.parse::<Spec>().expect_err("should be rejected") {
675 UsageErr::InvalidInput(msg, ..) => msg,
676 other => other.to_string(),
677 }
678 }
679
680 #[test]
681 fn framing_defaults_to_text_and_names_stay_the_users() {
682 let spec = parse(
683 r#"
684name "ex"
685cmd "ls" {
686 output "human" default=#true
687 output "ndjson" framing="jsonl"
688 output "checkstyle" media_type="application/xml"
689}
690"#,
691 );
692 let ls = &spec.cmd.subcommands["ls"];
693 assert_eq!(ls.outputs[0].framing, Framing::Text);
694 assert_eq!(ls.outputs[1].framing, Framing::Jsonl);
695 assert_eq!(ls.outputs[1].name, "ndjson");
698 assert!(ls.outputs[1].framing.is_streaming());
699 assert_eq!(ls.outputs[2].framing, Framing::Text);
700 assert_eq!(ls.outputs[2].media_type.as_deref(), Some("application/xml"));
701 }
702
703 #[test]
704 fn an_unknown_framing_names_the_ones_that_exist() {
705 assert!(error(
706 r#"
707name "ex"
708cmd "ls" { output "x" framing="protobuf" }
709"#
710 )
711 .contains("expected one of: text, json, jsonl"));
712 }
713
714 #[test]
715 fn a_select_fills_the_flags_choices() {
716 let spec = parse(
717 r#"
718name "ex"
719cmd "ls" {
720 flag "--format <FMT>"
721 output "human" help="A table"
722 output "json" framing="json"
723 select "--format"
724}
725"#,
726 );
727 let choices = spec.cmd.subcommands["ls"].flags[0]
728 .arg
729 .as_ref()
730 .unwrap()
731 .choices
732 .as_ref()
733 .unwrap();
734 assert_eq!(choices.values(), vec!["human", "json"]);
735 assert_eq!(choices.details[0].help.as_deref(), Some("A table"));
737 }
738
739 #[test]
740 fn a_global_selector_is_narrowed_per_command() {
741 let spec = parse(
745 r#"
746name "ex"
747flag "--format <FMT>" global=#true
748cmd "ls" { output "human"; output "json" framing="json"; select "--format" }
749cmd "render" { output "svg"; output "png"; select "--format" }
750"#,
751 );
752 let choices_of = |cmd: &str| {
753 spec.cmd.subcommands[cmd]
754 .flags
755 .iter()
756 .find(|f| f.long.iter().any(|l| l == "format"))
757 .and_then(|f| f.arg.as_ref())
758 .and_then(|a| a.choices.as_ref())
759 .map(|c| c.values())
760 };
761 assert_eq!(choices_of("ls"), Some(vec!["human".into(), "json".into()]));
762 assert_eq!(choices_of("render"), Some(vec!["svg".into(), "png".into()]));
763 assert!(spec.cmd.flags[0].arg.as_ref().unwrap().choices.is_none());
765 }
766
767 #[test]
768 fn choices_written_by_hand_are_checked_rather_than_replaced() {
769 let spec = parse(
772 r#"
773name "ex"
774cmd "ls" {
775 flag "--format <FMT>" {
776 arg "<FMT>" { choices { choice "human" help="Kept"; choice "json" } }
777 }
778 output "human"
779 output "json" framing="json"
780 select "--format"
781}
782"#,
783 );
784 let choices = spec.cmd.subcommands["ls"].flags[0]
785 .arg
786 .as_ref()
787 .unwrap()
788 .choices
789 .as_ref()
790 .unwrap();
791 assert_eq!(choices.details[0].help.as_deref(), Some("Kept"));
792 }
793
794 #[test]
795 fn choices_that_disagree_with_the_outputs_are_an_error() {
796 let err = error(
797 r#"
798name "ex"
799cmd "ls" {
800 flag "--format <FMT>" { arg "<FMT>" { choices "human" "yaml" } }
801 output "human"
802 output "json" framing="json"
803 select "--format"
804}
805"#,
806 );
807 assert!(err.contains("`yaml` with no output"), "{err}");
808 assert!(err.contains("no choice offers `json`"), "{err}");
809 }
810
811 #[test]
812 fn a_select_naming_no_flag_says_what_is_declared() {
813 let err = error(
814 r#"
815name "ex"
816cmd "ls" {
817 flag "--verbose"
818 output "json" framing="json"
819 select "--format"
820}
821"#,
822 );
823 assert!(err.contains("names no flag here or above it"), "{err}");
824 assert!(err.contains("--verbose"), "{err}");
825 }
826
827 #[test]
828 fn a_select_naming_a_boolean_points_at_the_other_spelling() {
829 let err = error(
830 r#"
831name "ex"
832cmd "ls" {
833 flag "--format"
834 output "json" framing="json"
835 select "--format"
836}
837"#,
838 );
839 assert!(err.contains("takes no value"), "{err}");
840 assert!(err.contains("output … select=\"--format\""), "{err}");
841 }
842
843 #[test]
844 fn a_boolean_selector_picks_one_output() {
845 let spec = parse(
846 r#"
847name "ex"
848cmd "ls" {
849 flag "--json"
850 output "text" default=#true
851 output "json" framing="json" select="--json"
852}
853"#,
854 );
855 let ls = &spec.cmd.subcommands["ls"];
856 let json = ls.outputs.iter().find(|o| o.name == "json").unwrap();
857 assert_eq!(
858 json.select_argv(ls).map(|s| s.argv()),
859 Some(vec!["--json".to_string()])
860 );
861 let text = ls.outputs.iter().find(|o| o.name == "text").unwrap();
863 assert_eq!(text.select_argv(ls), None);
864 }
865
866 #[test]
867 fn an_inherited_boolean_selector_stops_with_its_non_global_flag() {
868 let spec = parse(
869 r#"
870name "ex"
871flag "--json"
872output "text" default=#true
873output "json" framing="json" select="--json"
874cmd "nested"
875"#,
876 );
877 let nested = spec.cmd.subcommands["nested"].clone();
878 let outputs = effective_outputs(&spec, &[nested]);
879 assert_eq!(
880 outputs
881 .iter()
882 .find(|output| output.name == "json")
883 .and_then(|output| output.select.as_deref()),
884 None
885 );
886 }
887
888 #[test]
889 fn an_inherited_boolean_selector_reaches_nested_commands_with_its_global_flag() {
890 let spec = parse(
891 r#"
892name "ex"
893flag "--json" global=#true
894output "text" default=#true
895output "json" framing="json" select="--json"
896cmd "outer" {
897 cmd "inner"
898}
899"#,
900 );
901 let outer = &spec.cmd.subcommands["outer"];
902 let inner = &outer.subcommands["inner"];
903 let outputs = effective_outputs_ref(&spec, [outer, inner]);
904 assert_eq!(
905 outputs
906 .iter()
907 .find(|output| output.name == "json")
908 .and_then(|output| output.select.as_deref()),
909 Some("--json")
910 );
911 }
912
913 #[test]
914 fn a_root_boolean_selector_still_has_to_name_a_flag() {
915 let err = error(
916 r#"
917name "ex"
918output "json" framing="json" select="--json"
919"#,
920 );
921 assert!(err.contains("names no flag here or above it"), "{err}");
922 }
923
924 #[test]
925 fn a_boolean_selector_naming_a_value_flag_points_back() {
926 let err = error(
927 r#"
928name "ex"
929cmd "ls" {
930 flag "--format <FMT>"
931 output "json" framing="json" select="--format"
932}
933"#,
934 );
935 assert!(err.contains("which takes a value"), "{err}");
936 assert!(err.contains("`select \"--format\"`"), "{err}");
937 }
938
939 #[test]
940 fn two_defaults_are_an_error() {
941 let err = error(
942 r#"
943name "ex"
944cmd "ls" { output "a" default=#true; output "b" default=#true }
945"#,
946 );
947 assert!(err.contains("more than one default output (a, b)"), "{err}");
948 }
949
950 #[test]
951 fn cli_wide_outputs_are_inherited_and_can_be_taken_away() {
952 let spec = parse(
953 r#"
954name "ex"
955output "human" default=#true
956output "json" framing="json"
957select "--format"
958flag "--format <FMT>" global=#true
959cmd "stream" {
960 output "human" hide=#true
961 output "jsonl" framing="jsonl"
962}
963"#,
964 );
965 let stream = spec.cmd.subcommands["stream"].clone();
966 let names: Vec<String> = effective_outputs(&spec, std::slice::from_ref(&stream))
967 .into_iter()
968 .map(|o| o.name)
969 .collect();
970 assert_eq!(names, vec!["json", "jsonl"]);
972 let choices = stream
973 .flags
974 .iter()
975 .find(|f| f.long.iter().any(|l| l == "format"))
976 .and_then(|f| f.arg.as_ref())
977 .and_then(|a| a.choices.as_ref())
978 .map(|c| c.values());
979 assert_eq!(choices, Some(vec!["json".into(), "jsonl".into()]));
980 }
981
982 #[test]
983 fn resolution_is_idempotent_so_a_spec_round_trips() {
984 let src = r#"
985name "ex"
986flag "--format <FMT>" global=#true
987cmd "ls" {
988 output "human" default=#true help="A table"
989 output "json" framing="json"
990 select "--format"
991}
992"#;
993 let once = parse(src).to_string();
994 let twice = parse(&once).to_string();
995 assert_eq!(once, twice);
998 assert_snapshot!(once, @r#"
999 name ex
1000 flag --format global=#true {
1001 arg <FMT>
1002 }
1003 cmd ls {
1004 flag --format global=#true {
1005 arg <FMT> {
1006 choices {
1007 choice human help="A table"
1008 choice json
1009 }
1010 }
1011 }
1012 output human help="A table" default=#true
1013 output json framing=json
1014 select "--format"
1015 }
1016 "#);
1017 }
1018
1019 #[test]
1020 fn a_schema_survives_a_round_trip_with_its_newlines() {
1021 let src = "name \"ex\"\ncmd \"ls\" {\n output \"json\" framing=\"json\" {\n schema \"{\\n \\\"type\\\": \\\"object\\\"\\n}\"\n }\n}\n";
1022 let spec = parse(src);
1023 let schema = spec.cmd.subcommands["ls"].outputs[0]
1024 .schema
1025 .clone()
1026 .unwrap();
1027 assert_eq!(schema, "{\n \"type\": \"object\"\n}");
1028 let again = parse(&spec.to_string());
1029 assert_eq!(
1030 again.cmd.subcommands["ls"].outputs[0].schema.as_deref(),
1031 Some(schema.as_str())
1032 );
1033 }
1034
1035 #[test]
1036 fn an_external_schema_is_relative_to_the_kdl_that_declares_it() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let shared = dir.path().join("shared");
1039 std::fs::create_dir(&shared).unwrap();
1040 let root = dir.path().join("root.usage.kdl");
1041 let included = shared.join("outputs.usage.kdl");
1042 let schema_file = shared.join("report.schema.json");
1043 let schema = "{\n \"type\": \"object\"\n}\n";
1044 std::fs::write(&schema_file, schema).unwrap();
1045 std::fs::write(
1046 &included,
1047 "output \"json\" framing=\"json\" { schema file=\"report.schema.json\" }\n",
1048 )
1049 .unwrap();
1050 std::fs::write(
1051 &root,
1052 "name \"ex\"\ninclude file=\"shared/outputs.usage.kdl\"\n",
1053 )
1054 .unwrap();
1055
1056 let spec = Spec::parse_file(&root).unwrap();
1057 assert_eq!(spec.outputs[0].schema.as_deref(), Some(schema));
1058 assert!(spec.sources.contains(&schema_file));
1059
1060 let emitted = spec.to_string();
1063 assert!(!emitted.contains("report.schema.json"));
1064 assert_eq!(parse(&emitted).outputs[0].schema.as_deref(), Some(schema));
1065 }
1066
1067 #[test]
1068 fn an_included_selector_resolves_against_the_parent_document() {
1069 let dir = tempfile::tempdir().unwrap();
1070 let root = dir.path().join("root.usage.kdl");
1071 let included = dir.path().join("outputs.usage.kdl");
1072 std::fs::write(
1073 &included,
1074 "output \"text\" default=#true\noutput \"json\" framing=\"json\"\nselect \"--format\"\n",
1075 )
1076 .unwrap();
1077 std::fs::write(
1078 &root,
1079 "name \"ex\"\ninclude file=\"outputs.usage.kdl\"\nflag \"--format <FORMAT>\"\n",
1080 )
1081 .unwrap();
1082
1083 let spec = Spec::parse_file(&root).unwrap();
1084 let choices = spec.cmd.flags[0]
1085 .arg
1086 .as_ref()
1087 .and_then(|arg| arg.choices.as_ref())
1088 .map(|choices| choices.values());
1089 assert_eq!(choices, Some(vec!["text".into(), "json".into()]));
1090 }
1091
1092 #[test]
1093 fn a_relative_schema_needs_a_source_file() {
1094 let err = error("name \"ex\"\noutput \"json\" { schema file=\"report.schema.json\" }\n");
1095 assert_eq!(err, "relative schema files require a source file");
1096 }
1097
1098 #[test]
1099 fn an_unreadable_schema_names_its_resolved_path() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let root = dir.path().join("root.usage.kdl");
1102 let missing = dir.path().join("missing.schema.json");
1103 std::fs::write(
1104 &root,
1105 "name \"ex\"\noutput \"json\" { schema file=\"missing.schema.json\" }\n",
1106 )
1107 .unwrap();
1108
1109 match Spec::parse_file(&root).unwrap_err() {
1110 UsageErr::FileError(_, file) => assert_eq!(file, missing),
1111 err => panic!("unexpected error: {err:?}"),
1112 }
1113 }
1114}