Skip to main content

tftio_cli_common/
app.rs

1//! Shared CLI application metadata.
2
3use crate::{AgentSurfaceSpec, LicenseType, RepoInfo};
4
5/// Default repository metadata for binaries that ship from the shared tools workspace.
6pub const WORKSPACE_REPO: RepoInfo = RepoInfo::new("tftio-stuff", "tools");
7
8/// Errors raised when validating a tool's adoption of the shared CLI contract.
9#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
10pub enum ContractError {
11    /// The tool still relies on legacy advisory capability flags.
12    #[error("tool spec does not opt into the authoritative cli-common base contract")]
13    NotAuthoritative,
14}
15
16/// Declares whether a tool has opted into the authoritative shared CLI contract.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ToolContract {
19    /// The tool exposes the full shared CLI contract.
20    CliCommonBase,
21    /// The tool still uses legacy advisory capability flags.
22    Legacy {
23        /// Whether the tool supports JSON output in base commands.
24        supports_json: bool,
25        /// Whether the tool exposes doctor checks.
26        supports_doctor: bool,
27    },
28}
29
30impl ToolContract {
31    /// Build a contract marker from legacy capability flags.
32    #[must_use]
33    pub const fn from_support_flags(supports_json: bool, supports_doctor: bool) -> Self {
34        if supports_json && supports_doctor {
35            Self::CliCommonBase
36        } else {
37            Self::Legacy {
38                supports_json,
39                supports_doctor,
40            }
41        }
42    }
43
44    /// Whether the tool supports JSON output in base commands.
45    #[must_use]
46    pub const fn supports_json(self) -> bool {
47        match self {
48            Self::CliCommonBase => true,
49            Self::Legacy { supports_json, .. } => supports_json,
50        }
51    }
52
53    /// Whether the tool exposes doctor checks.
54    #[must_use]
55    pub const fn supports_doctor(self) -> bool {
56        match self {
57            Self::CliCommonBase => true,
58            Self::Legacy {
59                supports_doctor, ..
60            } => supports_doctor,
61        }
62    }
63
64    /// Whether the tool has opted into the authoritative shared CLI contract.
65    #[must_use]
66    pub const fn is_authoritative(self) -> bool {
67        matches!(self, Self::CliCommonBase)
68    }
69
70    /// Validate that the tool has opted into the authoritative shared CLI contract.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`ContractError::NotAuthoritative`] when the tool still relies on
75    /// legacy advisory capability flags.
76    pub const fn validate(self) -> Result<(), ContractError> {
77        match self {
78            Self::CliCommonBase => Ok(()),
79            Self::Legacy { .. } => Err(ContractError::NotAuthoritative),
80        }
81    }
82}
83
84/// Shared metadata for a CLI binary.
85#[derive(Debug, Clone)]
86pub struct ToolSpec {
87    /// Binary name shown in version output and help text.
88    pub(crate) bin_name: &'static str,
89    /// Human-readable tool name.
90    #[allow(
91        dead_code,
92        reason = "set by the public ToolSpec constructor and read via the display_name() accessor; dead_code fires only in binaries that never call that accessor"
93    )]
94    pub(crate) display_name: &'static str,
95    /// Binary version.
96    pub(crate) version: &'static str,
97    /// License rendered by the shared license command.
98    pub(crate) license: LicenseType,
99    /// Repository metadata used by workspace tooling.
100    #[allow(
101        dead_code,
102        reason = "set by the public ToolSpec constructor and read via its accessor; dead_code fires only in binaries that never read it"
103    )]
104    pub(crate) repo: RepoInfo,
105    /// Shared CLI contract adoption marker.
106    pub(crate) contract: ToolContract,
107    /// Declarative agent-mode surface for the tool.
108    pub(crate) agent_surface: Option<&'static AgentSurfaceSpec>,
109}
110
111impl ToolSpec {
112    /// Create a new [`ToolSpec`].
113    #[must_use]
114    pub const fn new(
115        bin_name: &'static str,
116        display_name: &'static str,
117        version: &'static str,
118        license: LicenseType,
119        repo: RepoInfo,
120        supports_json: bool,
121        supports_doctor: bool,
122    ) -> Self {
123        Self {
124            bin_name,
125            display_name,
126            version,
127            license,
128            repo,
129            contract: ToolContract::from_support_flags(supports_json, supports_doctor),
130            agent_surface: None,
131        }
132    }
133
134    /// Create a new [`ToolSpec`] using [`WORKSPACE_REPO`].
135    #[must_use]
136    pub const fn workspace(
137        bin_name: &'static str,
138        display_name: &'static str,
139        version: &'static str,
140        license: LicenseType,
141        supports_json: bool,
142        supports_doctor: bool,
143    ) -> Self {
144        Self::new(
145            bin_name,
146            display_name,
147            version,
148            license,
149            WORKSPACE_REPO,
150            supports_json,
151            supports_doctor,
152        )
153    }
154
155    /// Attach an agent surface to the tool specification.
156    #[must_use]
157    pub const fn with_agent_surface(self, agent_surface: &'static AgentSurfaceSpec) -> Self {
158        Self {
159            agent_surface: Some(agent_surface),
160            ..self
161        }
162    }
163
164    /// Return the binary name.
165    #[must_use]
166    pub const fn bin_name(&self) -> &'static str {
167        self.bin_name
168    }
169
170    /// Return the human-readable tool name.
171    #[must_use]
172    pub const fn display_name(&self) -> &'static str {
173        self.display_name
174    }
175
176    /// Return the tool version.
177    #[must_use]
178    pub const fn version(&self) -> &'static str {
179        self.version
180    }
181
182    /// Return the agent surface, if configured.
183    #[must_use]
184    pub const fn agent_surface(&self) -> Option<&'static AgentSurfaceSpec> {
185        self.agent_surface
186    }
187
188    /// Whether the tool supports JSON output in base commands.
189    #[must_use]
190    pub const fn supports_json(&self) -> bool {
191        self.contract.supports_json()
192    }
193
194    /// Whether the tool exposes doctor checks.
195    #[must_use]
196    pub const fn supports_doctor(&self) -> bool {
197        self.contract.supports_doctor()
198    }
199
200    /// Whether the tool has opted into the authoritative shared CLI contract.
201    #[must_use]
202    pub const fn has_authoritative_contract(&self) -> bool {
203        self.contract.is_authoritative()
204    }
205
206    /// Validate that the tool uses the authoritative shared CLI contract.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`ContractError::NotAuthoritative`] when the tool still relies on
211    /// legacy advisory flags.
212    pub const fn validate_authoritative_contract(&self) -> Result<(), ContractError> {
213        self.contract.validate()
214    }
215}
216
217/// Create a [`ToolSpec`] for a binary shipped from the shared tools workspace.
218#[must_use]
219pub const fn workspace_tool(
220    bin_name: &'static str,
221    display_name: &'static str,
222    version: &'static str,
223    license: LicenseType,
224    supports_json: bool,
225    supports_doctor: bool,
226) -> ToolSpec {
227    ToolSpec::workspace(
228        bin_name,
229        display_name,
230        version,
231        license,
232        supports_json,
233        supports_doctor,
234    )
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn tool_spec_new_preserves_fields() {
243        let spec = ToolSpec::new(
244            "tool",
245            "Tool",
246            "1.2.3",
247            LicenseType::MIT,
248            RepoInfo::new("owner", "repo"),
249            true,
250            false,
251        );
252
253        assert_eq!(spec.bin_name, "tool");
254        assert_eq!(spec.display_name, "Tool");
255        assert_eq!(spec.version, "1.2.3");
256        assert_eq!(spec.license, LicenseType::MIT);
257        assert_eq!(spec.repo.owner, "owner");
258        assert_eq!(spec.repo.name, "repo");
259        assert_eq!(
260            spec.contract,
261            ToolContract::Legacy {
262                supports_json: true,
263                supports_doctor: false,
264            }
265        );
266        assert!(spec.supports_json());
267        assert!(!spec.supports_doctor());
268        assert!(!spec.has_authoritative_contract());
269        assert!(spec.validate_authoritative_contract().is_err());
270        assert!(spec.agent_surface().is_none());
271    }
272
273    #[test]
274    fn workspace_tool_uses_workspace_repo_defaults() {
275        let spec = workspace_tool("tool", "Tool", "1.2.3", LicenseType::MIT, true, false);
276
277        assert_eq!(spec.repo.owner, WORKSPACE_REPO.owner);
278        assert_eq!(spec.repo.name, WORKSPACE_REPO.name);
279        assert_eq!(spec.bin_name, "tool");
280        assert_eq!(spec.display_name, "Tool");
281    }
282
283    #[test]
284    fn tool_spec_with_all_mandatory_capabilities_is_authoritative() {
285        let spec = workspace_tool("tool", "Tool", "1.2.3", LicenseType::MIT, true, true);
286
287        assert_eq!(spec.contract, ToolContract::CliCommonBase);
288        assert!(spec.has_authoritative_contract());
289        assert!(spec.validate_authoritative_contract().is_ok());
290    }
291}