1use thiserror::Error;
14
15use crate::EXIT_INVALID_INPUT;
16
17pub const APCLI_SUBCOMMAND_NAMES: &[&str] = &[
28 "list",
29 "describe",
30 "exec",
31 "validate",
32 "init",
33 "health",
34 "usage",
35 "enable",
36 "disable",
37 "reload",
38 "config",
39 "completion",
40 "describe-pipeline",
41];
42
43pub const RESERVED_GROUP_NAMES: &[&str] = &["apcli"];
45
46const VALID_USER_MODES: &[&str] = &["all", "none", "include", "exclude"];
49
50#[derive(Clone, Debug, Default, PartialEq, Eq)]
60pub enum ApcliMode {
61 #[default]
63 Auto,
64 All,
66 None,
68 Include(Vec<String>),
70 Exclude(Vec<String>),
72}
73
74#[derive(Clone, Debug, Default)]
79pub struct ApcliConfig {
80 pub mode: ApcliMode,
82 pub disable_env: bool,
84}
85
86#[derive(Debug, Error, PartialEq, Eq)]
92pub enum ApcliGroupError {
93 #[error("Error: apcli config must be a boolean or object; got {0}.")]
95 InvalidShape(String),
96
97 #[error(
99 "Error: apcli.mode must be a string; got {0}. \
100 Expected one of all|none|include|exclude."
101 )]
102 ModeNotString(String),
103
104 #[error(
106 "Error: apcli.mode '{0}' is invalid. \
107 Expected one of all|none|include|exclude."
108 )]
109 ModeInvalid(String),
110}
111
112#[derive(Debug)]
122pub struct ApcliGroup {
123 mode: InternalMode,
124 include: Vec<String>,
125 exclude: Vec<String>,
126 disable_env: bool,
127 registry_injected: bool,
128 from_cli_config: bool,
129}
130
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134enum InternalMode {
135 Auto,
136 All,
137 None,
138 Include,
139 Exclude,
140}
141
142impl InternalMode {
143 fn as_str(self) -> &'static str {
144 match self {
145 InternalMode::Auto => "auto",
146 InternalMode::All => "all",
147 InternalMode::None => "none",
148 InternalMode::Include => "include",
149 InternalMode::Exclude => "exclude",
150 }
151 }
152}
153
154impl ApcliGroup {
155 pub fn from_cli_config(config: Option<ApcliConfig>, registry_injected: bool) -> Self {
165 let (mode, include, exclude, disable_env) = match config {
166 None => (InternalMode::Auto, Vec::new(), Vec::new(), false),
167 Some(cfg) => {
168 let disable_env = cfg.disable_env;
169 match cfg.mode {
170 ApcliMode::Auto => (InternalMode::Auto, Vec::new(), Vec::new(), disable_env),
171 ApcliMode::All => (InternalMode::All, Vec::new(), Vec::new(), disable_env),
172 ApcliMode::None => (InternalMode::None, Vec::new(), Vec::new(), disable_env),
173 ApcliMode::Include(list) => {
174 Self::warn_unknown_entries(&list, "include");
175 (InternalMode::Include, list, Vec::new(), disable_env)
176 }
177 ApcliMode::Exclude(list) => {
178 Self::warn_unknown_entries(&list, "exclude");
179 (InternalMode::Exclude, Vec::new(), list, disable_env)
180 }
181 }
182 }
183 };
184
185 Self {
186 mode,
187 include,
188 exclude,
189 disable_env,
190 registry_injected,
191 from_cli_config: true,
192 }
193 }
194
195 pub fn from_yaml(yaml_value: Option<serde_yaml::Value>, registry_injected: bool) -> Self {
202 match Self::try_from_yaml(yaml_value, registry_injected) {
203 Ok(group) => group,
204 Err(e) => {
205 eprintln!("{e}");
206 std::process::exit(EXIT_INVALID_INPUT);
207 }
208 }
209 }
210
211 pub fn try_from_yaml(
214 yaml_value: Option<serde_yaml::Value>,
215 registry_injected: bool,
216 ) -> Result<Self, ApcliGroupError> {
217 use serde_yaml::Value;
218
219 let value = match yaml_value {
221 None => return Ok(Self::auto(registry_injected, false)),
222 Some(v) => v,
223 };
224
225 match value {
226 Value::Null => Ok(Self::auto(registry_injected, false)),
227 Value::Bool(true) => Ok(Self {
228 mode: InternalMode::All,
229 include: Vec::new(),
230 exclude: Vec::new(),
231 disable_env: false,
232 registry_injected,
233 from_cli_config: false,
234 }),
235 Value::Bool(false) => Ok(Self {
236 mode: InternalMode::None,
237 include: Vec::new(),
238 exclude: Vec::new(),
239 disable_env: false,
240 registry_injected,
241 from_cli_config: false,
242 }),
243 Value::Mapping(map) => Self::build_from_mapping(map, registry_injected),
244 Value::Sequence(_) => Err(ApcliGroupError::InvalidShape("array".to_string())),
245 Value::String(_) => Err(ApcliGroupError::InvalidShape("string".to_string())),
246 Value::Number(_) => Err(ApcliGroupError::InvalidShape("number".to_string())),
247 Value::Tagged(_) => Err(ApcliGroupError::InvalidShape("tagged".to_string())),
248 }
249 }
250
251 fn auto(registry_injected: bool, from_cli_config: bool) -> Self {
252 Self {
253 mode: InternalMode::Auto,
254 include: Vec::new(),
255 exclude: Vec::new(),
256 disable_env: false,
257 registry_injected,
258 from_cli_config,
259 }
260 }
261
262 fn build_from_mapping(
263 map: serde_yaml::Mapping,
264 registry_injected: bool,
265 ) -> Result<Self, ApcliGroupError> {
266 use serde_yaml::Value;
267
268 let get = |name: &str| -> Option<Value> {
271 for (k, v) in &map {
272 match k {
273 Value::String(s) if s == name => return Some(v.clone()),
274 _ => continue,
275 }
276 }
277 None
278 };
279
280 for (k, _) in &map {
282 if !matches!(k, Value::String(_)) {
283 tracing::warn!("apcli config has a non-string key; ignoring.");
284 break;
285 }
286 }
287
288 let mode = match get("mode") {
290 None | Some(Value::Null) => InternalMode::Auto,
291 Some(Value::String(s)) => {
292 if !VALID_USER_MODES.contains(&s.as_str()) {
293 return Err(ApcliGroupError::ModeInvalid(s));
294 }
295 match s.as_str() {
296 "all" => InternalMode::All,
297 "none" => InternalMode::None,
298 "include" => InternalMode::Include,
299 "exclude" => InternalMode::Exclude,
300 _ => unreachable!("VALID_USER_MODES check above"),
301 }
302 }
303 Some(other) => {
304 return Err(ApcliGroupError::ModeNotString(
305 yaml_type_name(&other).into(),
306 ));
307 }
308 };
309
310 let include = Self::normalize_list(get("include"), "include");
311 let exclude = Self::normalize_list(get("exclude"), "exclude");
312
313 let raw_disable_env = get("disable_env").or_else(|| get("disableEnv"));
315 let disable_env = match raw_disable_env {
316 None | Some(Value::Null) => false,
317 Some(Value::Bool(b)) => b,
318 Some(other) => {
319 tracing::warn!(
320 "apcli.disable_env must be boolean; got {}. Treating as false.",
321 yaml_type_name(&other)
322 );
323 false
324 }
325 };
326
327 Ok(Self {
328 mode,
329 include,
330 exclude,
331 disable_env,
332 registry_injected,
333 from_cli_config: false,
334 })
335 }
336
337 fn normalize_list(raw: Option<serde_yaml::Value>, label: &str) -> Vec<String> {
341 use serde_yaml::Value;
342 let raw = match raw {
343 None | Some(Value::Null) => return Vec::new(),
344 Some(v) => v,
345 };
346 let seq = match raw {
347 Value::Sequence(s) => s,
348 other => {
349 tracing::warn!(
350 "apcli.{} must be a list; got {}. Ignoring.",
351 label,
352 yaml_type_name(&other)
353 );
354 return Vec::new();
355 }
356 };
357 let mut out = Vec::with_capacity(seq.len());
358 for entry in seq {
359 match entry {
360 Value::String(s) if !s.is_empty() => {
361 if !APCLI_SUBCOMMAND_NAMES.contains(&s.as_str()) {
362 tracing::warn!(
363 "Unknown apcli subcommand '{}' in {} list -- ignoring.",
364 s,
365 label
366 );
367 }
368 out.push(s);
369 }
370 _ => {
371 tracing::warn!("apcli.{} contains non-string entry; skipping.", label);
372 }
373 }
374 }
375 out
376 }
377
378 fn warn_unknown_entries(list: &[String], label: &str) {
382 for entry in list {
383 if !APCLI_SUBCOMMAND_NAMES.contains(&entry.as_str()) {
384 tracing::warn!(
385 "Unknown apcli subcommand '{}' in {} list -- ignoring.",
386 entry,
387 label
388 );
389 }
390 }
391 }
392
393 pub fn resolve_visibility(&self) -> &'static str {
407 if self.from_cli_config && self.mode != InternalMode::Auto {
409 return self.mode.as_str();
410 }
411
412 if !self.disable_env {
414 if let Some(env_mode) = Self::parse_env(std::env::var("APCORE_CLI_APCLI").ok()) {
415 return env_mode;
416 }
417 }
418
419 if self.mode != InternalMode::Auto {
421 return self.mode.as_str();
422 }
423
424 if self.registry_injected {
426 "none"
427 } else {
428 "all"
429 }
430 }
431
432 pub fn is_subcommand_included(&self, subcommand: &str) -> bool {
438 match self.resolve_visibility() {
439 "include" => self.include.iter().any(|s| s == subcommand),
440 "exclude" => !self.exclude.iter().any(|s| s == subcommand),
441 other => unreachable!(
442 "is_subcommand_included called under mode '{other}'; caller should bypass."
443 ),
444 }
445 }
446
447 pub fn is_group_visible(&self) -> bool {
449 self.resolve_visibility() != "none"
450 }
451
452 pub fn include(&self) -> &[String] {
455 &self.include
456 }
457
458 pub fn exclude(&self) -> &[String] {
461 &self.exclude
462 }
463
464 pub fn disable_env(&self) -> bool {
466 self.disable_env
467 }
468
469 fn parse_env(raw: Option<String>) -> Option<&'static str> {
480 let raw = raw?;
481 if raw.is_empty() {
482 return None;
483 }
484 let normalized = raw.to_lowercase();
485 match normalized.as_str() {
486 "show" | "1" | "true" => Some("all"),
487 "hide" | "0" | "false" => Some("none"),
488 _ => {
489 tracing::warn!(
490 "Unknown APCORE_CLI_APCLI value '{}', ignoring. \
491 Expected: show, hide, 1, 0, true, false.",
492 raw
493 );
494 None
495 }
496 }
497 }
498}
499
500fn yaml_type_name(v: &serde_yaml::Value) -> &'static str {
505 use serde_yaml::Value;
506 match v {
507 Value::Null => "null",
508 Value::Bool(_) => "boolean",
509 Value::Number(_) => "number",
510 Value::String(_) => "string",
511 Value::Sequence(_) => "array",
512 Value::Mapping(_) => "object",
513 Value::Tagged(_) => "tagged",
514 }
515}
516
517#[cfg(test)]
522mod tests {
523 use super::*;
524 use serde_yaml::Value;
525 use std::sync::Mutex;
526
527 static ENV_MUTEX: Mutex<()> = Mutex::new(());
530
531 fn clear_env() {
532 unsafe {
534 std::env::remove_var("APCORE_CLI_APCLI");
535 }
536 }
537
538 fn set_env(val: &str) {
539 unsafe {
541 std::env::set_var("APCORE_CLI_APCLI", val);
542 }
543 }
544
545 #[test]
548 fn apcli_subcommand_names_has_13_entries() {
549 assert_eq!(APCLI_SUBCOMMAND_NAMES.len(), 13);
550 }
551
552 #[test]
553 fn apcli_subcommand_names_contents() {
554 for expected in &[
555 "list",
556 "describe",
557 "exec",
558 "validate",
559 "init",
560 "health",
561 "usage",
562 "enable",
563 "disable",
564 "reload",
565 "config",
566 "completion",
567 "describe-pipeline",
568 ] {
569 assert!(
570 APCLI_SUBCOMMAND_NAMES.contains(expected),
571 "missing: {expected}"
572 );
573 }
574 }
575
576 #[test]
577 fn reserved_group_names_contents() {
578 assert_eq!(RESERVED_GROUP_NAMES, &["apcli"]);
579 }
580
581 #[test]
584 fn from_cli_config_all_wins_in_embedded() {
585 let _g = ENV_MUTEX.lock().unwrap();
586 clear_env();
587 let group = ApcliGroup::from_cli_config(
588 Some(ApcliConfig {
589 mode: ApcliMode::All,
590 disable_env: false,
591 }),
592 true,
593 );
594 assert_eq!(group.resolve_visibility(), "all");
595 }
596
597 #[test]
598 fn from_cli_config_none_default_standalone_autodetect_all() {
599 let _g = ENV_MUTEX.lock().unwrap();
600 clear_env();
601 let group = ApcliGroup::from_cli_config(None, false);
602 assert_eq!(group.resolve_visibility(), "all");
603 }
604
605 #[test]
606 fn from_cli_config_none_default_embedded_autodetect_none() {
607 let _g = ENV_MUTEX.lock().unwrap();
608 clear_env();
609 let group = ApcliGroup::from_cli_config(None, true);
610 assert_eq!(group.resolve_visibility(), "none");
611 }
612
613 #[test]
614 fn from_cli_config_none_mode_beats_env_show() {
615 let _g = ENV_MUTEX.lock().unwrap();
618 set_env("show");
619 let group = ApcliGroup::from_cli_config(
620 Some(ApcliConfig {
621 mode: ApcliMode::None,
622 disable_env: false,
623 }),
624 false,
625 );
626 assert_eq!(group.resolve_visibility(), "none");
627 clear_env();
628 }
629
630 #[test]
633 fn from_yaml_bool_true_embedded_all() {
634 let _g = ENV_MUTEX.lock().unwrap();
635 clear_env();
636 let group = ApcliGroup::from_yaml(Some(Value::Bool(true)), true);
637 assert_eq!(group.resolve_visibility(), "all");
638 }
639
640 #[test]
641 fn from_yaml_bool_false_standalone_none() {
642 let _g = ENV_MUTEX.lock().unwrap();
643 clear_env();
644 let group =
645 ApcliGroup::from_yaml(Some(Value::Bool(false)), false);
646 assert_eq!(group.resolve_visibility(), "none");
647 }
648
649 #[test]
650 fn from_yaml_null_value_auto() {
651 let _g = ENV_MUTEX.lock().unwrap();
652 clear_env();
653 let group = ApcliGroup::from_yaml(Some(Value::Null), false);
654 assert_eq!(group.resolve_visibility(), "all");
655 }
656
657 #[test]
658 fn from_yaml_none_auto() {
659 let _g = ENV_MUTEX.lock().unwrap();
660 clear_env();
661 let group = ApcliGroup::from_yaml(None, true);
662 assert_eq!(group.resolve_visibility(), "none");
663 }
664
665 #[test]
668 fn from_yaml_null_env_show_all() {
669 let _g = ENV_MUTEX.lock().unwrap();
670 set_env("show");
671 let group = ApcliGroup::from_yaml(None, true);
672 assert_eq!(group.resolve_visibility(), "all");
673 clear_env();
674 }
675
676 #[test]
677 fn from_yaml_null_env_hide_none() {
678 let _g = ENV_MUTEX.lock().unwrap();
679 set_env("hide");
680 let group = ApcliGroup::from_yaml(None, false);
681 assert_eq!(group.resolve_visibility(), "none");
682 clear_env();
683 }
684
685 #[test]
686 fn from_yaml_mode_none_env_show_env_wins() {
687 let _g = ENV_MUTEX.lock().unwrap();
689 set_env("show");
690 let yaml: Value = serde_yaml::from_str("mode: none").unwrap();
691 let group = ApcliGroup::from_yaml(Some(yaml), true);
692 assert_eq!(group.resolve_visibility(), "all");
693 clear_env();
694 }
695
696 #[test]
697 fn from_yaml_mode_none_disable_env_env_show_yaml_wins() {
698 let _g = ENV_MUTEX.lock().unwrap();
700 set_env("show");
701 let yaml: Value = serde_yaml::from_str("mode: none\ndisable_env: true").unwrap();
702 let group = ApcliGroup::from_yaml(Some(yaml), true);
703 assert_eq!(group.resolve_visibility(), "none");
704 clear_env();
705 }
706
707 #[test]
708 fn from_yaml_disable_env_camel_case_also_accepted() {
709 let _g = ENV_MUTEX.lock().unwrap();
710 set_env("show");
711 let yaml: Value = serde_yaml::from_str("mode: none\ndisableEnv: true").unwrap();
712 let group = ApcliGroup::from_yaml(Some(yaml), true);
713 assert_eq!(group.resolve_visibility(), "none");
714 clear_env();
715 }
716
717 #[test]
718 fn env_case_insensitive_show() {
719 let _g = ENV_MUTEX.lock().unwrap();
720 for raw in &["SHOW", "Show", "sHoW"] {
721 set_env(raw);
722 let group = ApcliGroup::from_yaml(None, true);
723 assert_eq!(group.resolve_visibility(), "all", "raw={raw}");
724 }
725 clear_env();
726 }
727
728 #[test]
729 fn env_case_insensitive_true_hide_false_numeric() {
730 let _g = ENV_MUTEX.lock().unwrap();
731 for (raw, expected) in &[
732 ("True", "all"),
733 ("TRUE", "all"),
734 ("HIDE", "none"),
735 ("False", "none"),
736 ("1", "all"),
737 ("0", "none"),
738 ] {
739 set_env(raw);
740 let group = ApcliGroup::from_yaml(None, true);
741 assert_eq!(group.resolve_visibility(), *expected, "raw={raw}");
742 }
743 clear_env();
744 }
745
746 #[test]
747 fn env_unknown_value_falls_through() {
748 let _g = ENV_MUTEX.lock().unwrap();
751 set_env("bogus");
752 let group = ApcliGroup::from_yaml(None, true);
753 assert_eq!(group.resolve_visibility(), "none");
754 clear_env();
755 }
756
757 #[test]
758 fn env_empty_string_treated_as_unset() {
759 let _g = ENV_MUTEX.lock().unwrap();
760 set_env("");
761 let group = ApcliGroup::from_yaml(None, false);
762 assert_eq!(group.resolve_visibility(), "all");
763 clear_env();
764 }
765
766 #[test]
769 fn include_mode_filters_correctly() {
770 let _g = ENV_MUTEX.lock().unwrap();
771 clear_env();
772 let yaml: Value =
773 serde_yaml::from_str("mode: include\ninclude:\n - list\n - describe").unwrap();
774 let group = ApcliGroup::from_yaml(Some(yaml), true);
775 assert_eq!(group.resolve_visibility(), "include");
776 assert!(group.is_subcommand_included("list"));
777 assert!(group.is_subcommand_included("describe"));
778 assert!(!group.is_subcommand_included("init"));
779 assert!(!group.is_subcommand_included("exec"));
780 assert_eq!(group.include(), &["list", "describe"]);
781 assert!(group.exclude().is_empty());
782 }
783
784 #[test]
785 fn exclude_mode_filters_correctly() {
786 let _g = ENV_MUTEX.lock().unwrap();
787 clear_env();
788 let yaml: Value = serde_yaml::from_str("mode: exclude\nexclude:\n - init").unwrap();
789 let group = ApcliGroup::from_yaml(Some(yaml), true);
790 assert_eq!(group.resolve_visibility(), "exclude");
791 assert!(!group.is_subcommand_included("init"));
792 assert!(group.is_subcommand_included("list"));
793 assert!(group.is_subcommand_included("describe"));
794 assert!(group.include().is_empty());
795 assert_eq!(group.exclude(), &["init"]);
796 }
797
798 #[test]
799 fn from_cli_config_include_variant_filters_correctly() {
800 let _g = ENV_MUTEX.lock().unwrap();
801 clear_env();
802 let group = ApcliGroup::from_cli_config(
803 Some(ApcliConfig {
804 mode: ApcliMode::Include(vec!["list".into(), "describe".into()]),
805 disable_env: false,
806 }),
807 true,
808 );
809 assert_eq!(group.resolve_visibility(), "include");
810 assert!(group.is_subcommand_included("list"));
811 assert!(!group.is_subcommand_included("init"));
812 }
813
814 #[test]
817 fn is_group_visible_false_only_for_none_mode() {
818 let _g = ENV_MUTEX.lock().unwrap();
819 clear_env();
820 let hidden = ApcliGroup::from_cli_config(
821 Some(ApcliConfig {
822 mode: ApcliMode::None,
823 disable_env: false,
824 }),
825 true,
826 );
827 assert!(!hidden.is_group_visible());
828
829 let shown = ApcliGroup::from_cli_config(
830 Some(ApcliConfig {
831 mode: ApcliMode::All,
832 disable_env: false,
833 }),
834 true,
835 );
836 assert!(shown.is_group_visible());
837
838 let include = ApcliGroup::from_cli_config(
839 Some(ApcliConfig {
840 mode: ApcliMode::Include(vec!["list".into()]),
841 disable_env: false,
842 }),
843 true,
844 );
845 assert!(include.is_group_visible());
846 }
847
848 #[test]
851 fn try_from_yaml_rejects_mode_auto() {
852 let yaml: Value = serde_yaml::from_str("mode: auto").unwrap();
855 let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
856 assert!(matches!(err, ApcliGroupError::ModeInvalid(ref s) if s == "auto"));
857 }
858
859 #[test]
860 fn try_from_yaml_rejects_unknown_mode() {
861 let yaml: Value = serde_yaml::from_str("mode: whitelist").unwrap();
862 let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
863 assert!(matches!(err, ApcliGroupError::ModeInvalid(_)));
864 }
865
866 #[test]
867 fn try_from_yaml_rejects_non_string_mode() {
868 let yaml: Value = serde_yaml::from_str("mode: 42").unwrap();
869 let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
870 assert!(matches!(err, ApcliGroupError::ModeNotString(_)));
871 }
872
873 #[test]
874 fn try_from_yaml_rejects_array_shape() {
875 let yaml: Value = serde_yaml::from_str("- a\n- b").unwrap();
876 let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
877 assert!(matches!(err, ApcliGroupError::InvalidShape(ref s) if s == "array"));
878 }
879
880 #[test]
881 fn try_from_yaml_rejects_string_shape() {
882 let yaml = Value::String("oops".into());
883 let err = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap_err();
884 assert!(matches!(err, ApcliGroupError::InvalidShape(ref s) if s == "string"));
885 }
886
887 #[test]
890 fn try_from_yaml_object_without_mode_is_auto() {
891 let _g = ENV_MUTEX.lock().unwrap();
892 clear_env();
893 let yaml: Value = serde_yaml::from_str("disable_env: true").unwrap();
894 let group = ApcliGroup::try_from_yaml(Some(yaml), false).unwrap();
895 assert_eq!(group.resolve_visibility(), "all");
897 assert!(group.disable_env());
898 }
899
900 #[test]
901 fn try_from_yaml_include_non_array_warns_and_empty() {
902 let yaml: Value = serde_yaml::from_str("mode: include\ninclude: not-a-list").unwrap();
903 let group = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap();
904 assert_eq!(group.resolve_visibility(), "include");
905 assert!(group.include().is_empty());
906 }
907
908 #[test]
909 fn try_from_yaml_unknown_include_entry_retained() {
910 let yaml: Value =
911 serde_yaml::from_str("mode: include\ninclude:\n - list\n - bogus").unwrap();
912 let group = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap();
913 assert_eq!(group.include(), &["list", "bogus"]);
915 }
916
917 #[test]
918 fn try_from_yaml_disable_env_non_bool_treated_as_false() {
919 let yaml: Value = serde_yaml::from_str("mode: none\ndisable_env: \"yes-please\"").unwrap();
920 let group = ApcliGroup::try_from_yaml(Some(yaml), true).unwrap();
921 assert!(!group.disable_env());
922 }
923}