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