1use std::collections::BTreeSet;
54use std::path::Path;
55
56use crate::kdl::{KdlDocument, KdlEntry, KdlNode};
57use serde::Serialize;
58
59use crate::SpecFlag;
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::{NodeHelper, string_entry};
65use crate::spec::{Spec, is_false};
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 && !outputs.is_empty()
495 {
496 materialize(cmd, inherited_flags, name, &outputs)?;
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!(
706 error(
707 r#"
708name "ex"
709cmd "ls" { output "x" framing="protobuf" }
710"#
711 )
712 .contains("expected one of: text, json, jsonl")
713 );
714 }
715
716 #[test]
717 fn a_select_fills_the_flags_choices() {
718 let spec = parse(
719 r#"
720name "ex"
721cmd "ls" {
722 flag "--format <FMT>"
723 output "human" help="A table"
724 output "json" framing="json"
725 select "--format"
726}
727"#,
728 );
729 let choices = spec.cmd.subcommands["ls"].flags[0]
730 .arg
731 .as_ref()
732 .unwrap()
733 .choices
734 .as_ref()
735 .unwrap();
736 assert_eq!(choices.values(), vec!["human", "json"]);
737 assert_eq!(choices.details[0].help.as_deref(), Some("A table"));
739 }
740
741 #[test]
742 fn a_global_selector_is_narrowed_per_command() {
743 let spec = parse(
747 r#"
748name "ex"
749flag "--format <FMT>" global=#true
750cmd "ls" { output "human"; output "json" framing="json"; select "--format" }
751cmd "render" { output "svg"; output "png"; select "--format" }
752"#,
753 );
754 let choices_of = |cmd: &str| {
755 spec.cmd.subcommands[cmd]
756 .flags
757 .iter()
758 .find(|f| f.long.iter().any(|l| l == "format"))
759 .and_then(|f| f.arg.as_ref())
760 .and_then(|a| a.choices.as_ref())
761 .map(|c| c.values())
762 };
763 assert_eq!(choices_of("ls"), Some(vec!["human".into(), "json".into()]));
764 assert_eq!(choices_of("render"), Some(vec!["svg".into(), "png".into()]));
765 assert!(spec.cmd.flags[0].arg.as_ref().unwrap().choices.is_none());
767 }
768
769 #[test]
770 fn choices_written_by_hand_are_checked_rather_than_replaced() {
771 let spec = parse(
774 r#"
775name "ex"
776cmd "ls" {
777 flag "--format <FMT>" {
778 arg "<FMT>" { choices { choice "human" help="Kept"; choice "json" } }
779 }
780 output "human"
781 output "json" framing="json"
782 select "--format"
783}
784"#,
785 );
786 let choices = spec.cmd.subcommands["ls"].flags[0]
787 .arg
788 .as_ref()
789 .unwrap()
790 .choices
791 .as_ref()
792 .unwrap();
793 assert_eq!(choices.details[0].help.as_deref(), Some("Kept"));
794 }
795
796 #[test]
797 fn choices_that_disagree_with_the_outputs_are_an_error() {
798 let err = error(
799 r#"
800name "ex"
801cmd "ls" {
802 flag "--format <FMT>" { arg "<FMT>" { choices "human" "yaml" } }
803 output "human"
804 output "json" framing="json"
805 select "--format"
806}
807"#,
808 );
809 assert!(err.contains("`yaml` with no output"), "{err}");
810 assert!(err.contains("no choice offers `json`"), "{err}");
811 }
812
813 #[test]
814 fn a_select_naming_no_flag_says_what_is_declared() {
815 let err = error(
816 r#"
817name "ex"
818cmd "ls" {
819 flag "--verbose"
820 output "json" framing="json"
821 select "--format"
822}
823"#,
824 );
825 assert!(err.contains("names no flag here or above it"), "{err}");
826 assert!(err.contains("--verbose"), "{err}");
827 }
828
829 #[test]
830 fn a_select_naming_a_boolean_points_at_the_other_spelling() {
831 let err = error(
832 r#"
833name "ex"
834cmd "ls" {
835 flag "--format"
836 output "json" framing="json"
837 select "--format"
838}
839"#,
840 );
841 assert!(err.contains("takes no value"), "{err}");
842 assert!(err.contains("output … select=\"--format\""), "{err}");
843 }
844
845 #[test]
846 fn a_boolean_selector_picks_one_output() {
847 let spec = parse(
848 r#"
849name "ex"
850cmd "ls" {
851 flag "--json"
852 output "text" default=#true
853 output "json" framing="json" select="--json"
854}
855"#,
856 );
857 let ls = &spec.cmd.subcommands["ls"];
858 let json = ls.outputs.iter().find(|o| o.name == "json").unwrap();
859 assert_eq!(
860 json.select_argv(ls).map(|s| s.argv()),
861 Some(vec!["--json".to_string()])
862 );
863 let text = ls.outputs.iter().find(|o| o.name == "text").unwrap();
865 assert_eq!(text.select_argv(ls), None);
866 }
867
868 #[test]
869 fn an_inherited_boolean_selector_stops_with_its_non_global_flag() {
870 let spec = parse(
871 r#"
872name "ex"
873flag "--json"
874output "text" default=#true
875output "json" framing="json" select="--json"
876cmd "nested"
877"#,
878 );
879 let nested = spec.cmd.subcommands["nested"].clone();
880 let outputs = effective_outputs(&spec, &[nested]);
881 assert_eq!(
882 outputs
883 .iter()
884 .find(|output| output.name == "json")
885 .and_then(|output| output.select.as_deref()),
886 None
887 );
888 }
889
890 #[test]
891 fn an_inherited_boolean_selector_reaches_nested_commands_with_its_global_flag() {
892 let spec = parse(
893 r#"
894name "ex"
895flag "--json" global=#true
896output "text" default=#true
897output "json" framing="json" select="--json"
898cmd "outer" {
899 cmd "inner"
900}
901"#,
902 );
903 let outer = &spec.cmd.subcommands["outer"];
904 let inner = &outer.subcommands["inner"];
905 let outputs = effective_outputs_ref(&spec, [outer, inner]);
906 assert_eq!(
907 outputs
908 .iter()
909 .find(|output| output.name == "json")
910 .and_then(|output| output.select.as_deref()),
911 Some("--json")
912 );
913 }
914
915 #[test]
916 fn a_root_boolean_selector_still_has_to_name_a_flag() {
917 let err = error(
918 r#"
919name "ex"
920output "json" framing="json" select="--json"
921"#,
922 );
923 assert!(err.contains("names no flag here or above it"), "{err}");
924 }
925
926 #[test]
927 fn a_boolean_selector_naming_a_value_flag_points_back() {
928 let err = error(
929 r#"
930name "ex"
931cmd "ls" {
932 flag "--format <FMT>"
933 output "json" framing="json" select="--format"
934}
935"#,
936 );
937 assert!(err.contains("which takes a value"), "{err}");
938 assert!(err.contains("`select \"--format\"`"), "{err}");
939 }
940
941 #[test]
942 fn two_defaults_are_an_error() {
943 let err = error(
944 r#"
945name "ex"
946cmd "ls" { output "a" default=#true; output "b" default=#true }
947"#,
948 );
949 assert!(err.contains("more than one default output (a, b)"), "{err}");
950 }
951
952 #[test]
953 fn cli_wide_outputs_are_inherited_and_can_be_taken_away() {
954 let spec = parse(
955 r#"
956name "ex"
957output "human" default=#true
958output "json" framing="json"
959select "--format"
960flag "--format <FMT>" global=#true
961cmd "stream" {
962 output "human" hide=#true
963 output "jsonl" framing="jsonl"
964}
965"#,
966 );
967 let stream = spec.cmd.subcommands["stream"].clone();
968 let names: Vec<String> = effective_outputs(&spec, std::slice::from_ref(&stream))
969 .into_iter()
970 .map(|o| o.name)
971 .collect();
972 assert_eq!(names, vec!["json", "jsonl"]);
974 let choices = stream
975 .flags
976 .iter()
977 .find(|f| f.long.iter().any(|l| l == "format"))
978 .and_then(|f| f.arg.as_ref())
979 .and_then(|a| a.choices.as_ref())
980 .map(|c| c.values());
981 assert_eq!(choices, Some(vec!["json".into(), "jsonl".into()]));
982 }
983
984 #[test]
985 fn resolution_is_idempotent_so_a_spec_round_trips() {
986 let src = r#"
987name "ex"
988flag "--format <FMT>" global=#true
989cmd "ls" {
990 output "human" default=#true help="A table"
991 output "json" framing="json"
992 select "--format"
993}
994"#;
995 let once = parse(src).to_string();
996 let twice = parse(&once).to_string();
997 assert_eq!(once, twice);
1000 assert_snapshot!(once, @r#"
1001 name ex
1002 flag --format global=#true {
1003 arg <FMT>
1004 }
1005 cmd ls {
1006 flag --format global=#true {
1007 arg <FMT> {
1008 choices {
1009 choice human help="A table"
1010 choice json
1011 }
1012 }
1013 }
1014 output human help="A table" default=#true
1015 output json framing=json
1016 select "--format"
1017 }
1018 "#);
1019 }
1020
1021 #[test]
1022 fn a_schema_survives_a_round_trip_with_its_newlines() {
1023 let src = "name \"ex\"\ncmd \"ls\" {\n output \"json\" framing=\"json\" {\n schema \"{\\n \\\"type\\\": \\\"object\\\"\\n}\"\n }\n}\n";
1024 let spec = parse(src);
1025 let schema = spec.cmd.subcommands["ls"].outputs[0]
1026 .schema
1027 .clone()
1028 .unwrap();
1029 assert_eq!(schema, "{\n \"type\": \"object\"\n}");
1030 let again = parse(&spec.to_string());
1031 assert_eq!(
1032 again.cmd.subcommands["ls"].outputs[0].schema.as_deref(),
1033 Some(schema.as_str())
1034 );
1035 }
1036
1037 #[test]
1038 fn an_external_schema_is_relative_to_the_kdl_that_declares_it() {
1039 let dir = tempfile::tempdir().unwrap();
1040 let shared = dir.path().join("shared");
1041 std::fs::create_dir(&shared).unwrap();
1042 let root = dir.path().join("root.usage.kdl");
1043 let included = shared.join("outputs.usage.kdl");
1044 let schema_file = shared.join("report.schema.json");
1045 let schema = "{\n \"type\": \"object\"\n}\n";
1046 std::fs::write(&schema_file, schema).unwrap();
1047 std::fs::write(
1048 &included,
1049 "output \"json\" framing=\"json\" { schema file=\"report.schema.json\" }\n",
1050 )
1051 .unwrap();
1052 std::fs::write(
1053 &root,
1054 "name \"ex\"\ninclude file=\"shared/outputs.usage.kdl\"\n",
1055 )
1056 .unwrap();
1057
1058 let spec = Spec::parse_file(&root).unwrap();
1059 assert_eq!(spec.outputs[0].schema.as_deref(), Some(schema));
1060 assert!(spec.sources.contains(&schema_file));
1061
1062 let emitted = spec.to_string();
1065 assert!(!emitted.contains("report.schema.json"));
1066 assert_eq!(parse(&emitted).outputs[0].schema.as_deref(), Some(schema));
1067 }
1068
1069 #[test]
1070 fn an_included_selector_resolves_against_the_parent_document() {
1071 let dir = tempfile::tempdir().unwrap();
1072 let root = dir.path().join("root.usage.kdl");
1073 let included = dir.path().join("outputs.usage.kdl");
1074 std::fs::write(
1075 &included,
1076 "output \"text\" default=#true\noutput \"json\" framing=\"json\"\nselect \"--format\"\n",
1077 )
1078 .unwrap();
1079 std::fs::write(
1080 &root,
1081 "name \"ex\"\ninclude file=\"outputs.usage.kdl\"\nflag \"--format <FORMAT>\"\n",
1082 )
1083 .unwrap();
1084
1085 let spec = Spec::parse_file(&root).unwrap();
1086 let choices = spec.cmd.flags[0]
1087 .arg
1088 .as_ref()
1089 .and_then(|arg| arg.choices.as_ref())
1090 .map(|choices| choices.values());
1091 assert_eq!(choices, Some(vec!["text".into(), "json".into()]));
1092 }
1093
1094 #[test]
1095 fn a_relative_schema_needs_a_source_file() {
1096 let err = error("name \"ex\"\noutput \"json\" { schema file=\"report.schema.json\" }\n");
1097 assert_eq!(err, "relative schema files require a source file");
1098 }
1099
1100 #[test]
1101 fn an_unreadable_schema_names_its_resolved_path() {
1102 let dir = tempfile::tempdir().unwrap();
1103 let root = dir.path().join("root.usage.kdl");
1104 let missing = dir.path().join("missing.schema.json");
1105 std::fs::write(
1106 &root,
1107 "name \"ex\"\noutput \"json\" { schema file=\"missing.schema.json\" }\n",
1108 )
1109 .unwrap();
1110
1111 match Spec::parse_file(&root).unwrap_err() {
1112 UsageErr::FileError(_, file) => assert_eq!(file, missing),
1113 err => panic!("unexpected error: {err:?}"),
1114 }
1115 }
1116}