Skip to main content

agent_config/
plan.rs

1//! Side-effect-free install and uninstall planning.
2//!
3//! Plans describe what an install or uninstall would do before any user-owned
4//! files are touched. They are intended for downstream CLIs that want to show a
5//! precise preview or refuse unsafe operations before calling the mutating API.
6
7use std::path::PathBuf;
8
9use crate::scope::Scope;
10
11/// Side-effect-free preview of an install operation.
12#[must_use]
13#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub struct InstallPlan {
16    /// What install target this plan describes.
17    pub target: PlanTarget,
18    /// File, directory, permission, ledger, no-op, or refusal changes.
19    pub changes: Vec<PlannedChange>,
20    /// High-level outcome of the plan.
21    pub status: PlanStatus,
22    /// Advisory information that does not alter the status.
23    pub warnings: Vec<PlanWarning>,
24}
25
26impl InstallPlan {
27    /// Construct a plan and derive its status from `changes`.
28    pub(crate) fn from_changes(target: PlanTarget, changes: Vec<PlannedChange>) -> Self {
29        Self {
30            target,
31            status: status_for_changes(&changes),
32            changes,
33            warnings: Vec::new(),
34        }
35    }
36
37    /// Construct a refused install plan.
38    pub(crate) fn refused(
39        target: PlanTarget,
40        path: Option<PathBuf>,
41        reason: RefusalReason,
42    ) -> Self {
43        Self::from_changes(target, vec![PlannedChange::Refuse { path, reason }])
44    }
45}
46
47/// Side-effect-free preview of an uninstall operation.
48#[must_use]
49#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub struct UninstallPlan {
52    /// What uninstall target this plan describes.
53    pub target: PlanTarget,
54    /// File, directory, permission, ledger, no-op, or refusal changes.
55    pub changes: Vec<PlannedChange>,
56    /// High-level outcome of the plan.
57    pub status: PlanStatus,
58    /// Advisory information that does not alter the status.
59    pub warnings: Vec<PlanWarning>,
60}
61
62impl UninstallPlan {
63    /// Construct a plan and derive its status from `changes`.
64    pub(crate) fn from_changes(target: PlanTarget, changes: Vec<PlannedChange>) -> Self {
65        Self {
66            target,
67            status: status_for_changes(&changes),
68            changes,
69            warnings: Vec::new(),
70        }
71    }
72
73    /// Construct a refused uninstall plan.
74    pub(crate) fn refused(
75        target: PlanTarget,
76        path: Option<PathBuf>,
77        reason: RefusalReason,
78    ) -> Self {
79        Self::from_changes(target, vec![PlannedChange::Refuse { path, reason }])
80    }
81}
82
83/// The operation target described by an install/uninstall plan.
84#[derive(Debug, Clone)]
85#[non_exhaustive]
86pub enum PlanTarget {
87    /// Hook target for one integration, scope, and consumer tag.
88    Hook {
89        /// Stable integration id.
90        integration_id: &'static str,
91        /// Target scope.
92        scope: Scope,
93        /// Consumer tag.
94        tag: String,
95    },
96    /// MCP target for one integration, scope, server name, and owner tag.
97    Mcp {
98        /// Stable integration id.
99        integration_id: &'static str,
100        /// Target scope.
101        scope: Scope,
102        /// MCP server name.
103        name: String,
104        /// Expected owner tag.
105        owner: String,
106    },
107    /// Skill target for one integration, scope, skill name, and owner tag.
108    Skill {
109        /// Stable integration id.
110        integration_id: &'static str,
111        /// Target scope.
112        scope: Scope,
113        /// Skill name.
114        name: String,
115        /// Expected owner tag.
116        owner: String,
117    },
118    /// Instruction target for one integration, scope, instruction name, and owner.
119    Instruction {
120        /// Stable integration id.
121        integration_id: &'static str,
122        /// Target scope.
123        scope: Scope,
124        /// Instruction name.
125        name: String,
126        /// Expected owner tag.
127        owner: String,
128    },
129}
130
131/// High-level status for a dry-run install or uninstall plan.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133#[non_exhaustive]
134pub enum PlanStatus {
135    /// The operation would change filesystem or ledger state.
136    WillChange,
137    /// The operation would not change anything.
138    NoOp,
139    /// The operation is predictable but refused for safety.
140    Refused,
141}
142
143/// One concrete change in a dry-run plan.
144#[derive(Debug, Clone, PartialEq, Eq)]
145#[non_exhaustive]
146pub enum PlannedChange {
147    /// Create a file.
148    CreateFile {
149        /// File path.
150        path: PathBuf,
151    },
152    /// Patch an existing file in place.
153    PatchFile {
154        /// File path.
155        path: PathBuf,
156    },
157    /// Remove a file.
158    RemoveFile {
159        /// File path.
160        path: PathBuf,
161    },
162    /// Restore a backup file over its original target.
163    RestoreBackup {
164        /// Backup path.
165        backup: PathBuf,
166        /// Restore target path.
167        target: PathBuf,
168    },
169    /// Create a backup file before patching a target.
170    CreateBackup {
171        /// Backup path.
172        backup: PathBuf,
173        /// Original target path.
174        target: PathBuf,
175    },
176    /// Create a directory.
177    CreateDir {
178        /// Directory path.
179        path: PathBuf,
180    },
181    /// Remove a directory.
182    RemoveDir {
183        /// Directory path.
184        path: PathBuf,
185    },
186    /// Write or update an ownership ledger entry.
187    WriteLedger {
188        /// Ledger file path.
189        path: PathBuf,
190        /// Ledger key.
191        key: String,
192        /// Owner tag.
193        owner: String,
194    },
195    /// Remove an ownership ledger entry.
196    RemoveLedgerEntry {
197        /// Ledger file path.
198        path: PathBuf,
199        /// Ledger key.
200        key: String,
201    },
202    /// Set file permissions.
203    SetPermissions {
204        /// File path.
205        path: PathBuf,
206        /// Unix mode, no-op on non-Unix platforms.
207        mode: u32,
208    },
209    /// No filesystem or ledger change is needed for this path.
210    NoOp {
211        /// Path checked by the planner.
212        path: PathBuf,
213        /// Human-readable no-op reason.
214        reason: String,
215    },
216    /// Refuse the operation before mutation.
217    Refuse {
218        /// Path that caused the refusal, when one exists.
219        path: Option<PathBuf>,
220        /// Refusal reason.
221        reason: RefusalReason,
222    },
223}
224
225/// A predictable dry-run refusal reason.
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227#[non_exhaustive]
228pub enum RefusalReason {
229    /// A sidecar ledger records a different owner.
230    OwnerMismatch,
231    /// The config entry exists without an agent-config ledger entry.
232    UserInstalledEntry,
233    /// Existing config could not be parsed or had an unsupported shape.
234    InvalidConfig,
235    /// A required first-touch backup already exists.
236    ///
237    /// Retained for compatibility; current planners preserve existing backups
238    /// and patch without creating another one.
239    BackupAlreadyExists,
240    /// The integration does not support the requested scope.
241    UnsupportedScope,
242    /// The supplied spec is missing a field required by this integration.
243    MissingRequiredSpecField,
244    /// Local-scope MCP install would write likely secret material inline.
245    InlineSecretInLocalScope,
246    /// The integration does not support this transport kind on this surface.
247    UnsupportedTransport,
248    /// The integration's surface requires a runtime not present on the
249    /// current host (for example, a POSIX shell for a `bash`-script hook).
250    /// Refused before any mutation.
251    UnsupportedPlatform,
252}
253
254/// Advisory warning attached to a plan.
255#[derive(Debug, Clone, PartialEq, Eq)]
256#[non_exhaustive]
257pub struct PlanWarning {
258    /// Related path, when any.
259    pub path: Option<PathBuf>,
260    /// Human-readable warning.
261    pub message: String,
262}
263
264fn status_for_changes(changes: &[PlannedChange]) -> PlanStatus {
265    if has_refusal(changes) {
266        return PlanStatus::Refused;
267    }
268    if changes.is_empty()
269        || changes
270            .iter()
271            .all(|c| matches!(c, PlannedChange::NoOp { .. }))
272    {
273        return PlanStatus::NoOp;
274    }
275    PlanStatus::WillChange
276}
277
278/// Returns true when any planned change refuses the operation.
279pub(crate) fn has_refusal(changes: &[PlannedChange]) -> bool {
280    changes
281        .iter()
282        .any(|c| matches!(c, PlannedChange::Refuse { .. }))
283}