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 /// The supplied spec contains a field or value not supported by this integration.
253 UnsupportedSpecField,
254}
255
256/// Advisory warning attached to a plan.
257#[derive(Debug, Clone, PartialEq, Eq)]
258#[non_exhaustive]
259pub struct PlanWarning {
260 /// Related path, when any.
261 pub path: Option<PathBuf>,
262 /// Human-readable warning.
263 pub message: String,
264}
265
266fn status_for_changes(changes: &[PlannedChange]) -> PlanStatus {
267 if has_refusal(changes) {
268 return PlanStatus::Refused;
269 }
270 if changes.is_empty()
271 || changes
272 .iter()
273 .all(|c| matches!(c, PlannedChange::NoOp { .. }))
274 {
275 return PlanStatus::NoOp;
276 }
277 PlanStatus::WillChange
278}
279
280/// Returns true when any planned change refuses the operation.
281pub(crate) fn has_refusal(changes: &[PlannedChange]) -> bool {
282 changes
283 .iter()
284 .any(|c| matches!(c, PlannedChange::Refuse { .. }))
285}