Skip to main content

tftio_lib/
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    fn recording_runner(
179        called: &Cell<bool>,
180    ) -> impl FnOnce(TestCli) -> Result<i32, FatalCliError> + '_ {
181        move |_cli| {
182            called.set(true);
183            Ok(9)
184        }
185    }
186
187    #[test]
188    fn run_cli_from_routes_metadata_before_domain_runner() {
189        let _guard = env_lock();
190        set_tokens(None, None);
191        let env = env_from_detected();
192        let called = Cell::new(false);
193
194        let exit_code = run_cli_from::<TestCli, _, TestDoctor, _, _>(
195            &TOOL_SPEC,
196            &env,
197            ["tool", "meta", "doctor", "--json"],
198            &TestDoctor,
199            metadata_command,
200            recording_runner(&called),
201        );
202
203        assert_eq!(exit_code, 0);
204        assert!(!called.get());
205    }
206
207    #[test]
208    fn run_cli_from_short_circuits_agent_help_output() {
209        let _guard = env_lock();
210        set_tokens(Some("shared-token"), Some("shared-token"));
211        let env = env_from_detected();
212        let called = Cell::new(false);
213
214        let exit_code = run_cli_from::<TestCli, _, TestDoctor, _, _>(
215            &TOOL_SPEC,
216            &env,
217            ["tool", "--agent-help"],
218            &TestDoctor,
219            metadata_command,
220            recording_runner(&called),
221        );
222
223        assert_eq!(exit_code, 0);
224        assert!(!called.get());
225        set_tokens(None, None);
226    }
227
228    #[test]
229    fn run_cli_from_wraps_domain_errors_with_shared_fatal_handler() {
230        let _guard = env_lock();
231        set_tokens(None, None);
232        let env = env_from_detected();
233        let exit_code = run_cli_from::<TestCli, _, TestDoctor, _, _>(
234            &TOOL_SPEC,
235            &env,
236            ["tool", "query", "--fail"],
237            &TestDoctor,
238            metadata_command,
239            |_cli| {
240                Err(FatalCliError::new(
241                    "query",
242                    JsonOutput::Text,
243                    "domain failure",
244                ))
245            },
246        );
247
248        assert_eq!(exit_code, 1);
249    }
250
251    #[test]
252    fn run_cli_no_doctor_from_routes_metadata_without_domain_runner() {
253        let _guard = env_lock();
254        set_tokens(None, None);
255        let env = env_from_detected();
256        let called = Cell::new(false);
257
258        let exit_code = run_cli_no_doctor_from::<TestCli, _, _, _>(
259            &TOOL_SPEC,
260            &env,
261            ["tool", "meta", "license"],
262            metadata_command,
263            recording_runner(&called),
264        );
265
266        assert_eq!(exit_code, 0);
267        assert!(!called.get());
268    }
269
270    #[test]
271    fn run_cli_no_doctor_from_runs_domain_handler_for_non_metadata_command() {
272        let _guard = env_lock();
273        set_tokens(None, None);
274        let env = env_from_detected();
275        let called = Cell::new(false);
276
277        let exit_code = run_cli_no_doctor_from::<TestCli, _, _, _>(
278            &TOOL_SPEC,
279            &env,
280            ["tool", "query"],
281            metadata_command,
282            recording_runner(&called),
283        );
284
285        assert_eq!(exit_code, 9);
286        assert!(called.get());
287    }
288
289    #[test]
290    fn run_cli_no_doctor_from_short_circuits_agent_help_output() {
291        let _guard = env_lock();
292        set_tokens(Some("shared-token"), Some("shared-token"));
293        let env = env_from_detected();
294        let called = Cell::new(false);
295
296        let exit_code = run_cli_no_doctor_from::<TestCli, _, _, _>(
297            &TOOL_SPEC,
298            &env,
299            ["tool", "--agent-help"],
300            metadata_command,
301            recording_runner(&called),
302        );
303
304        assert_eq!(exit_code, 0);
305        assert!(!called.get());
306        set_tokens(None, None);
307    }
308}