Skip to main content

bot_forge/
model.rs

1//! Shared domain models exchanged by configuration, planning, execution, state, and output.
2//!
3//! These types describe data contracts rather than implementation state. Serialized models use
4//! stable names because they are consumed by CLI JSON output and the persistent registry.
5
6use std::path::PathBuf;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::config::schema::{
12    AptMirrorDef, CheckSpec, ConfigDocument, EnvironmentMutation, InstallSpec, OriginMap,
13};
14use crate::util::valid_config_id;
15
16mod output;
17
18pub use crate::model::output::{
19    InstallOutcome, InstallPreview, InstallReport, SkillStatus, ToolInstallOutcome,
20    ToolInstallResult, ToolStatus,
21};
22
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24/// A built-in or configuration-defined installation profile.
25pub enum Profile {
26    /// Small bootstrap profile.
27    Minimal,
28    /// Default development profile.
29    #[default]
30    Standard,
31    /// Extended development profile.
32    Advanced,
33    /// Configuration-defined profile identifier.
34    Custom(String),
35}
36
37impl Profile {
38    /// Parse a profile identifier, accepting built-ins and valid custom configuration IDs.
39    pub fn parse(value: &str) -> Option<Self> {
40        match value {
41            "minimal" => Some(Self::Minimal),
42            "standard" => Some(Self::Standard),
43            "advanced" => Some(Self::Advanced),
44            value if valid_config_id(value) => Some(Self::Custom(value.to_string())),
45            _ => None,
46        }
47    }
48
49    /// Return the canonical configuration identifier for this profile.
50    pub fn as_str(&self) -> &str {
51        match self {
52            Self::Minimal => "minimal",
53            Self::Standard => "standard",
54            Self::Advanced => "advanced",
55            Self::Custom(value) => value,
56        }
57    }
58}
59
60#[cfg(test)]
61mod tests {
62    use crate::model::Profile;
63
64    #[test]
65    fn cli_profiles_use_the_config_id_contract() {
66        assert!(Profile::parse("ci-agent-2").is_some());
67        for invalid in ["CI", "ci_agent", "-ci", "ci-", "ci--agent"] {
68            assert!(Profile::parse(invalid).is_none(), "accepted {invalid}");
69        }
70    }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
74#[serde(rename_all = "lowercase")]
75/// Agent integration target for an installed skill.
76pub enum Agent {
77    /// Claude skill directory layout.
78    Claude,
79    /// OpenCode skill directory layout.
80    OpenCode,
81}
82
83impl Agent {
84    /// Return the serialized agent identifier.
85    pub fn as_str(self) -> &'static str {
86        match self {
87            Self::Claude => "claude",
88            Self::OpenCode => "opencode",
89        }
90    }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
94#[serde(rename_all = "snake_case")]
95/// Whether a registry entry represents a tool or a skill.
96pub enum InstallKind {
97    /// Executable or development tool.
98    Tool,
99    /// Agent skill payload.
100    Skill,
101}
102
103impl InstallKind {
104    /// Return the serialized installation-kind identifier.
105    pub fn as_str(self) -> &'static str {
106        match self {
107            Self::Tool => "tool",
108            Self::Skill => "skill",
109        }
110    }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "lowercase")]
115/// Typed installation backend selected by a component definition.
116pub enum BackendKind {
117    /// Debian-family APT package manager.
118    Apt,
119    /// Homebrew package manager.
120    Brew,
121    /// Cargo package installer.
122    Cargo,
123    /// Rustup toolchain manager.
124    Rustup,
125    /// npm package installer.
126    Npm,
127    /// Python pip package installer.
128    Pip,
129    /// uv isolated tool installer.
130    #[serde(rename = "uv-tool")]
131    UvTool,
132    /// Windows Package Manager.
133    Winget,
134    /// Verified downloadable archive or file.
135    Archive,
136    /// Pinned Git source build.
137    Git,
138    /// Policy-gated shell operation.
139    Shell,
140}
141
142impl BackendKind {
143    /// Return the serialized backend identifier.
144    pub fn as_str(self) -> &'static str {
145        match self {
146            Self::Apt => "apt",
147            Self::Brew => "brew",
148            Self::Cargo => "cargo",
149            Self::Rustup => "rustup",
150            Self::Npm => "npm",
151            Self::Pip => "pip",
152            Self::UvTool => "uv-tool",
153            Self::Winget => "winget",
154            Self::Archive => "archive",
155            Self::Git => "git",
156            Self::Shell => "shell",
157        }
158    }
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(default, deny_unknown_fields)]
163/// Minimal tool declaration used by the normalized install model.
164pub(crate) struct ToolDef {
165    pub name: String,
166    pub display_name: Option<String>,
167    pub version: Option<String>,
168    pub optional: bool,
169    pub allow_insecure_hosts: Vec<String>,
170    pub detect: Option<CheckSpec>,
171    pub install: Option<InstallSpec>,
172    pub verify: Option<CheckSpec>,
173}
174
175#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
176#[serde(rename_all = "snake_case")]
177/// Archive payload format understood by archive backends.
178pub enum ArchiveFormat {
179    /// Single-file payload.
180    #[default]
181    File,
182    /// Gzip-compressed tar archive.
183    TarGz,
184    /// XZ-compressed tar archive.
185    TarXz,
186    /// ZIP archive.
187    Zip,
188}
189
190impl ToolDef {
191    pub(crate) fn backend(&self) -> Option<BackendKind> {
192        match self.install.as_ref()? {
193            InstallSpec::Apt(_) => Some(BackendKind::Apt),
194            InstallSpec::Brew(_) => Some(BackendKind::Brew),
195            InstallSpec::Cargo(_) => Some(BackendKind::Cargo),
196            InstallSpec::Rustup(_) => Some(BackendKind::Rustup),
197            InstallSpec::Npm(_) => Some(BackendKind::Npm),
198            InstallSpec::Pip(_) => Some(BackendKind::Pip),
199            InstallSpec::UvTool(_) => Some(BackendKind::UvTool),
200            InstallSpec::Winget(_) => Some(BackendKind::Winget),
201            InstallSpec::Archive(_) => Some(BackendKind::Archive),
202            InstallSpec::Git(_) => Some(BackendKind::Git),
203            InstallSpec::Shell(_) => Some(BackendKind::Shell),
204        }
205    }
206}
207
208#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210/// Skill declaration and its agent destinations.
211pub(crate) struct SkillDef {
212    pub name: String,
213    pub display_name: Option<String>,
214    pub optional: bool,
215    pub source: String,
216    pub agents: Vec<Agent>,
217    pub revision: Option<String>,
218}
219
220#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(default, deny_unknown_fields)]
222/// Normalized collection of environment, tools, and skills.
223pub(crate) struct InstallConfig {
224    pub environment: EnvironmentDef,
225    pub apt_mirror: Option<AptMirrorDef>,
226    pub tools: Vec<ToolDef>,
227    pub skills: Vec<SkillDef>,
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
231#[serde(default, deny_unknown_fields)]
232/// Environment mutations selected by configuration.
233pub(crate) struct EnvironmentDef {
234    pub mutations: Vec<EnvironmentMutation>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
238/// Parsed configuration together with field origins and its source path.
239pub struct LoadedConfig {
240    /// Canonical configuration after catalog expansion, overlays, and reference resolution.
241    pub document: ConfigDocument,
242    /// Per-field provenance retained for explanation and plan construction.
243    pub origins: OriginMap,
244    /// Selected primary file, or `None` when only the embedded catalog was loaded.
245    pub path: Option<PathBuf>,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
249#[serde(deny_unknown_fields)]
250/// Persisted record of a managed installation and its activation targets.
251pub struct RegistryEntry {
252    /// Canonical component or skill name.
253    pub name: String,
254    /// Whether the entry represents a tool or a skill.
255    pub kind: InstallKind,
256    /// Canonical source identity recorded for status and recovery.
257    pub source: String,
258    /// Profile that requested the installation.
259    pub profile: String,
260    /// Managed filesystem targets owned by this entry.
261    pub targets: Vec<RegistryTarget>,
262    /// Unix timestamp at which the entry was committed.
263    pub installed_at: u64,
264    #[serde(deserialize_with = "deserialize_explicit_option")]
265    #[schemars(schema_with = "nullable_string_schema", required)]
266    /// Immutable artifact currently activated for the entry, when applicable.
267    pub artifact_id: Option<String>,
268    #[serde(deserialize_with = "deserialize_explicit_option")]
269    #[schemars(schema_with = "nullable_string_schema", required)]
270    /// Artifact that can be restored when the current activation is removed.
271    pub previous_artifact_id: Option<String>,
272    #[serde(deserialize_with = "deserialize_explicit_option")]
273    #[schemars(schema_with = "nullable_string_schema", required)]
274    /// Canonical configuration hash associated with the installation run.
275    pub config_hash: Option<String>,
276    #[serde(deserialize_with = "deserialize_explicit_option")]
277    #[schemars(schema_with = "nullable_string_schema", required)]
278    /// Immutable execution-plan hash associated with the installation run.
279    pub plan_hash: Option<String>,
280    #[serde(deserialize_with = "deserialize_explicit_option")]
281    #[schemars(schema_with = "nullable_string_schema", required)]
282    /// Resolved upstream revision, when the backend has one.
283    pub source_revision: Option<String>,
284    #[serde(deserialize_with = "deserialize_explicit_option")]
285    #[schemars(schema_with = "nullable_backend_schema", required)]
286    /// Typed backend responsible for the managed tool lifecycle.
287    pub backend: Option<BackendKind>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
291#[serde(deny_unknown_fields)]
292/// One filesystem target associated with a registry entry.
293pub struct RegistryTarget {
294    /// Managed filesystem path owned by the registry entry.
295    pub path: PathBuf,
296    #[serde(deserialize_with = "deserialize_explicit_option")]
297    #[schemars(schema_with = "nullable_string_schema", required)]
298    /// Logical executable name for activation restoration, when this target is a binary.
299    pub binary: Option<String>,
300}
301
302fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
303    generator.subschema_for::<Option<String>>()
304}
305
306fn nullable_backend_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
307    generator.subschema_for::<Option<BackendKind>>()
308}
309
310fn deserialize_explicit_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
311where
312    D: serde::Deserializer<'de>,
313    T: Deserialize<'de>,
314{
315    Option::<T>::deserialize(deserializer)
316}
317
318impl RegistryEntry {
319    /// Return the stable `kind:name` identity used for registry replacement.
320    pub fn stable_id(&self) -> String {
321        format!("{}:{}", self.kind.as_str(), self.name)
322    }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326/// User-selected inputs used to load and plan an installation.
327pub struct InstallOptions {
328    /// Profile to resolve.
329    pub profile: Profile,
330    /// Optional primary configuration path.
331    pub config_path: Option<PathBuf>,
332    /// Ordered overlay paths applied after the primary configuration.
333    pub overlay_paths: Vec<PathBuf>,
334    /// Whether interactive terminal progress is enabled.
335    pub status_bar: bool,
336    /// Whether selection and confirmation prompts are bypassed.
337    pub yes: bool,
338    /// Optional inclusive component filters.
339    pub only: Vec<String>,
340    /// Component filters removed after profile resolution.
341    pub exclude: Vec<String>,
342}
343
344impl Default for InstallOptions {
345    fn default() -> Self {
346        Self {
347            profile: Profile::Standard,
348            config_path: None,
349            overlay_paths: Vec::new(),
350            status_bar: true,
351            yes: false,
352            only: Vec::new(),
353            exclude: Vec::new(),
354        }
355    }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
359/// One timed diagnostic finding emitted by `doctor`.
360pub(crate) struct DiagnosticCheck {
361    /// Stable check identifier used by machine consumers.
362    pub(crate) id: String,
363    /// Machine-readable severity label.
364    pub(crate) severity: String,
365    /// Human-readable finding.
366    pub(crate) summary: String,
367    /// Optional corrective action.
368    pub(crate) suggestion: Option<String>,
369    /// Check execution duration in milliseconds.
370    pub(crate) duration_ms: u128,
371}