Skip to main content

tftio_cli_common/
binary.rs

1//! Shared binary entrypoint helpers for workspace CLI tools.
2
3use clap::{CommandFactory, FromArgMatches};
4
5use crate::{
6    AgentDispatch, DoctorChecks, FatalCliError, ProcessEnv, ToolSpec,
7    command::run_standard_command, parse_command_with_agent_surface_from,
8    run_standard_command_no_doctor, run_with_fatal_handler,
9};
10
11fn run_cli_with_parsed<T, D, M, F>(
12    spec: &'static ToolSpec,
13    env: &ProcessEnv,
14    parsed: AgentDispatch<T>,
15    doctor: Option<&D>,
16    metadata_command: M,
17    run: F,
18) -> i32
19where
20    T: CommandFactory,
21    D: DoctorChecks,
22    M: FnOnce(&T) -> Option<crate::StandardCommand>,
23    F: FnOnce(T) -> Result<i32, FatalCliError>,
24{
25    match parsed {
26        AgentDispatch::Cli(cli) => {
27            if let Some(command) = metadata_command(&cli) {
28                return run_standard_command::<T, D>(spec, env, &command, doctor);
29            }
30            run_with_fatal_handler(|| run(cli))
31        }
32        AgentDispatch::Printed(code) => code,
33    }
34}
35
36/// Parse CLI arguments from a caller-provided argv, route shared metadata commands, and run the domain handler.
37///
38/// `env` carries the process-edge environment reads (agent tokens, `HOME`)
39/// performed in the binary's `main` (`REPO_INVARIANTS.md` #5).
40#[must_use]
41pub fn run_cli_from<T, I, D, M, F>(
42    spec: &'static ToolSpec,
43    env: &ProcessEnv,
44    argv: I,
45    doctor: &D,
46    metadata_command: M,
47    run: F,
48) -> i32
49where
50    T: CommandFactory + FromArgMatches,
51    I: IntoIterator,
52    I::Item: Into<std::ffi::OsString> + Clone,
53    D: DoctorChecks,
54    M: FnOnce(&T) -> Option<crate::StandardCommand>,
55    F: FnOnce(T) -> Result<i32, FatalCliError>,
56{
57    match parse_command_with_agent_surface_from::<T, I>(spec, &env.agent, argv) {
58        Ok(parsed) => run_cli_with_parsed(spec, env, parsed, Some(doctor), metadata_command, run),
59        Err(error) => error.exit(),
60    }
61}
62
63/// Parse caller-provided argv, route shared metadata commands, and run the domain handler for a tool without doctor support.
64///
65/// `env` carries the process-edge environment reads (agent tokens, `HOME`)
66/// performed in the binary's `main` (`REPO_INVARIANTS.md` #5).
67#[must_use]
68pub fn run_cli_no_doctor_from<T, I, M, F>(
69    spec: &'static ToolSpec,
70    env: &ProcessEnv,
71    argv: I,
72    metadata_command: M,
73    run: F,
74) -> i32
75where
76    T: CommandFactory + FromArgMatches,
77    I: IntoIterator,
78    I::Item: Into<std::ffi::OsString> + Clone,
79    M: FnOnce(&T) -> Option<crate::StandardCommand>,
80    F: FnOnce(T) -> Result<i32, FatalCliError>,
81{
82    match parse_command_with_agent_surface_from::<T, I>(spec, &env.agent, argv) {
83        Ok(AgentDispatch::Cli(cli)) => {
84            if let Some(command) = metadata_command(&cli) {
85                return run_standard_command_no_doctor::<T>(spec, env, &command);
86            }
87            run_with_fatal_handler(|| run(cli))
88        }
89        Ok(AgentDispatch::Printed(code)) => code,
90        Err(error) => error.exit(),
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use std::cell::Cell;
97
98    use clap::{Parser, Subcommand};
99
100    use super::*;
101    use crate::{
102        AGENT_TOKEN_ENV, AGENT_TOKEN_EXPECTED_ENV, AgentCapability, AgentSurfaceSpec,
103        CommandSelector, FatalCliError, FlagSelector, JsonOutput, LicenseType, MetaCommand,
104        RepoInfo, StandardCommand, map_standard_command, test_support::env_lock, workspace_tool,
105    };
106
107    const QUERY_COMMAND: CommandSelector = CommandSelector::new(&["query"]);
108    const QUERY_FAIL_FLAG: FlagSelector = FlagSelector::new(&["query"], "fail");
109    const QUERY_CAPABILITY: AgentCapability =
110        AgentCapability::minimal("query", &[QUERY_COMMAND], &[QUERY_FAIL_FLAG]);
111    const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[QUERY_CAPABILITY]);
112    const TOOL_SPEC: ToolSpec =
113        workspace_tool("tool", "Tool", "1.2.3", LicenseType::MIT, true, true)
114            .with_agent_surface(&AGENT_SURFACE);
115
116    #[derive(Debug, Parser)]
117    #[command(name = "tool")]
118    struct TestCli {
119        #[command(subcommand)]
120        command: TestCommand,
121    }
122
123    #[derive(Debug, Subcommand)]
124    enum TestCommand {
125        Meta {
126            #[command(subcommand)]
127            command: MetaCommand,
128        },
129        Query {
130            #[arg(long)]
131            fail: bool,
132        },
133    }
134
135    struct TestDoctor;
136
137    impl DoctorChecks for TestDoctor {
138        fn repo_info() -> RepoInfo {
139            RepoInfo::new("tftio-stuff", "tool")
140        }
141
142        fn current_version() -> &'static str {
143            "1.2.3"
144        }
145    }
146
147    fn metadata_command(cli: &TestCli) -> Option<StandardCommand> {
148        match &cli.command {
149            TestCommand::Meta { command } => Some(map_standard_command(command, JsonOutput::Text)),
150            TestCommand::Query { .. } => None,
151        }
152    }
153
154    #[allow(unsafe_code)]
155    fn set_tokens(presented: Option<&str>, expected: Option<&str>) {
156        unsafe {
157            std::env::remove_var(AGENT_TOKEN_ENV);
158            std::env::remove_var(AGENT_TOKEN_EXPECTED_ENV);
159            if let Some(presented) = presented {
160                std::env::set_var(AGENT_TOKEN_ENV, presented);
161            }
162            if let Some(expected) = expected {
163                std::env::set_var(AGENT_TOKEN_EXPECTED_ENV, expected);
164            }
165        }
166    }
167
168    fn env_from_detected() -> ProcessEnv {
169        ProcessEnv {
170            agent: crate::AgentModeContext::from_tokens(
171                std::env::var(AGENT_TOKEN_ENV).ok(),
172                std::env::var(AGENT_TOKEN_EXPECTED_ENV).ok(),
173            ),
174            home: None,
175        }
176    }
177
178    #[test]
179    fn run_cli_from_routes_metadata_before_domain_runner() {
180        let _guard = env_lock();
181        set_tokens(None, None);
182        let env = env_from_detected();
183        let called = Cell::new(false);
184
185        let exit_code = run_cli_from::<TestCli, _, TestDoctor, _, _>(
186            &TOOL_SPEC,
187            &env,
188            ["tool", "meta", "doctor", "--json"],
189            &TestDoctor,
190            metadata_command,
191            |_cli| {
192                called.set(true);
193                Ok(9)
194            },
195        );
196
197        assert_eq!(exit_code, 0);
198        assert!(!called.get());
199    }
200
201    #[test]
202    fn run_cli_from_short_circuits_agent_help_output() {
203        let _guard = env_lock();
204        set_tokens(Some("shared-token"), Some("shared-token"));
205        let env = env_from_detected();
206        let called = Cell::new(false);
207
208        let exit_code = run_cli_from::<TestCli, _, TestDoctor, _, _>(
209            &TOOL_SPEC,
210            &env,
211            ["tool", "--agent-help"],
212            &TestDoctor,
213            metadata_command,
214            |_cli| {
215                called.set(true);
216                Ok(9)
217            },
218        );
219
220        assert_eq!(exit_code, 0);
221        assert!(!called.get());
222        set_tokens(None, None);
223    }
224
225    #[test]
226    fn run_cli_from_wraps_domain_errors_with_shared_fatal_handler() {
227        let _guard = env_lock();
228        set_tokens(None, None);
229        let env = env_from_detected();
230        let exit_code = run_cli_from::<TestCli, _, TestDoctor, _, _>(
231            &TOOL_SPEC,
232            &env,
233            ["tool", "query", "--fail"],
234            &TestDoctor,
235            metadata_command,
236            |_cli| {
237                Err(FatalCliError::new(
238                    "query",
239                    JsonOutput::Text,
240                    "domain failure",
241                ))
242            },
243        );
244
245        assert_eq!(exit_code, 1);
246    }
247
248    #[test]
249    fn run_cli_no_doctor_from_routes_metadata_without_domain_runner() {
250        let _guard = env_lock();
251        set_tokens(None, None);
252        let env = env_from_detected();
253        let called = Cell::new(false);
254
255        let exit_code = run_cli_no_doctor_from::<TestCli, _, _, _>(
256            &TOOL_SPEC,
257            &env,
258            ["tool", "meta", "license"],
259            metadata_command,
260            |_cli| {
261                called.set(true);
262                Ok(9)
263            },
264        );
265
266        assert_eq!(exit_code, 0);
267        assert!(!called.get());
268    }
269}