Skip to main content

apcore_cli/
strategy.rs

1// apcore-cli -- Pipeline strategy commands (FE-11).
2// Provides describe-pipeline subcommand showing execution pipeline steps.
3
4use apcore::StrategyInfo;
5use clap::{Arg, Command};
6use serde_json::Value;
7use std::io::IsTerminal;
8
9// ---------------------------------------------------------------------------
10// Strategy info — delegates to apcore preset builders
11// ---------------------------------------------------------------------------
12
13/// Return a `StrategyInfo` for the named preset strategy by invoking the
14/// canonical builder from the `apcore` crate. This ensures the CLI always
15/// reflects the same steps that the `apcore` executor actually uses, rather
16/// than maintaining a parallel hardcoded list that can drift out of sync.
17///
18/// Returns `None` for unknown strategy names.
19fn get_strategy_info(strategy: &str) -> Option<StrategyInfo> {
20    match strategy {
21        "standard" => Some(apcore::build_standard_strategy().info()),
22        "internal" => Some(apcore::build_internal_strategy().info()),
23        "testing" => Some(apcore::build_testing_strategy().info()),
24        "performance" => Some(apcore::build_performance_strategy().info()),
25        "minimal" => Some(apcore::build_minimal_strategy().info()),
26        _ => None,
27    }
28}
29
30// ---------------------------------------------------------------------------
31// Command builder
32// ---------------------------------------------------------------------------
33
34/// Build the `describe-pipeline` clap subcommand.
35pub fn describe_pipeline_command() -> Command {
36    Command::new("describe-pipeline")
37        .about("Show the execution pipeline steps for a strategy")
38        .arg(
39            Arg::new("strategy")
40                .long("strategy")
41                .value_parser(["standard", "internal", "testing", "performance", "minimal"])
42                .default_value("standard")
43                .value_name("STRATEGY")
44                .help("Strategy to describe (default: standard)."),
45        )
46        .arg(
47            Arg::new("format")
48                .long("format")
49                .value_parser(["table", "json"])
50                .value_name("FORMAT")
51                .help("Output format."),
52        )
53}
54
55/// Register the describe-pipeline subcommand on the root command.
56pub(crate) fn register_pipeline_command(cli: Command) -> Command {
57    cli.subcommand(describe_pipeline_command())
58}
59
60// ---------------------------------------------------------------------------
61// Dispatch
62// ---------------------------------------------------------------------------
63
64/// Dispatch the `describe-pipeline` subcommand.
65pub fn dispatch_describe_pipeline(matches: &clap::ArgMatches) {
66    let strategy = matches
67        .get_one::<String>("strategy")
68        .map(|s| s.as_str())
69        .unwrap_or("standard");
70    let format = matches.get_one::<String>("format").map(|s| s.as_str());
71    let fmt = crate::output::resolve_format(format);
72
73    let info = match get_strategy_info(strategy) {
74        Some(info) => info,
75        None => {
76            eprintln!("Error: Unknown strategy: {strategy}");
77            std::process::exit(2);
78        }
79    };
80
81    // Step metadata: which steps are pure and which are non-removable.
82    let pure_steps = [
83        "context_creation",
84        "call_chain_guard",
85        "module_lookup",
86        "acl_check",
87        "input_validation",
88    ];
89    let non_removable = [
90        "context_creation",
91        "module_lookup",
92        "execute",
93        "return_result",
94    ];
95
96    if fmt == "json" || !std::io::stdout().is_terminal() {
97        let steps_json: Vec<Value> = info
98            .step_names
99            .iter()
100            .enumerate()
101            .map(|(i, s)| {
102                serde_json::json!({
103                    "index": i + 1,
104                    "name": s,
105                    "pure": pure_steps.contains(&s.as_str()),
106                    "removable": !non_removable.contains(&s.as_str()),
107                })
108            })
109            .collect();
110        let payload = serde_json::json!({
111            "strategy": info.name,
112            "step_count": info.step_count,
113            "steps": steps_json,
114        });
115        println!(
116            "{}",
117            serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
118        );
119    } else {
120        println!("Pipeline: {} ({} steps)\n", info.name, info.step_count);
121        println!("  #    Step                         Pure   Removable   Timeout");
122        println!("  ---- ---------------------------- ------ ----------- --------");
123        for (i, s) in info.step_names.iter().enumerate() {
124            let pure = if pure_steps.contains(&s.as_str()) {
125                "yes"
126            } else {
127                "no"
128            };
129            let removable = if non_removable.contains(&s.as_str()) {
130                "no"
131            } else {
132                "yes"
133            };
134            println!("  {:<4} {:<28} {:<6} {:<11} --", i + 1, s, pure, removable);
135        }
136    }
137
138    std::process::exit(0);
139}
140
141// ---------------------------------------------------------------------------
142// Unit tests
143// ---------------------------------------------------------------------------
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_get_strategy_info_standard() {
151        let info = get_strategy_info("standard").expect("standard strategy must exist");
152        assert_eq!(info.step_count, 11);
153        assert_eq!(info.step_names[0], "context_creation");
154        assert!(info.step_names.contains(&"execute".to_string()));
155        assert_eq!(info.name, "standard");
156    }
157
158    #[test]
159    fn test_get_strategy_info_internal() {
160        let info = get_strategy_info("internal").expect("internal strategy must exist");
161        assert_eq!(info.step_count, 9);
162        assert!(!info.step_names.contains(&"acl_check".to_string()));
163    }
164
165    #[test]
166    fn test_get_strategy_info_testing() {
167        let info = get_strategy_info("testing").expect("testing strategy must exist");
168        assert_eq!(info.step_count, 8);
169        assert!(!info.step_names.contains(&"call_chain_guard".to_string()));
170    }
171
172    #[test]
173    fn test_get_strategy_info_performance() {
174        let info = get_strategy_info("performance").expect("performance strategy must exist");
175        assert_eq!(info.step_count, 9);
176        assert!(!info.step_names.contains(&"middleware_before".to_string()));
177    }
178
179    #[test]
180    fn test_get_strategy_info_minimal() {
181        let info = get_strategy_info("minimal").expect("minimal strategy must exist");
182        assert!(info.step_count <= 4);
183        assert!(info.step_names.contains(&"execute".to_string()));
184    }
185
186    #[test]
187    fn test_get_strategy_info_unknown_returns_none() {
188        assert!(get_strategy_info("unknown").is_none());
189        assert!(get_strategy_info("").is_none());
190    }
191
192    #[test]
193    fn test_describe_pipeline_command_builder() {
194        let cmd = describe_pipeline_command();
195        assert_eq!(cmd.get_name(), "describe-pipeline");
196        let opts: Vec<&str> = cmd.get_opts().filter_map(|a| a.get_long()).collect();
197        assert!(opts.contains(&"strategy"));
198        assert!(opts.contains(&"format"));
199    }
200
201    #[test]
202    fn test_register_pipeline_command() {
203        let root = Command::new("test");
204        let root = register_pipeline_command(root);
205        let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
206        assert!(subs.contains(&"describe-pipeline"));
207    }
208}