1use crate::commands::{PresetPlatform, PresetReportFormat};
4use anyhow::Result;
5use shine_core::plan::{PermissionResolutionV1, PlanActionV1, PlanStepV1};
6use shine_core::runtime::{PresetAuthoringPlanReportV1, RuntimePlatform};
7use std::collections::BTreeMap;
8use std::fmt::Write as _;
9use std::path::Path;
10
11pub async fn handle_plan(
12 path: &Path,
13 platform: PresetPlatform,
14 format: PresetReportFormat,
15) -> Result<bool> {
16 let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
17 let report = shine_core::runtime::plan_preset_path(
18 &shine_core::runtime::RealHost,
19 &cwd,
20 path,
21 runtime_platform(platform),
22 )
23 .await;
24 match format {
25 PresetReportFormat::Text => print_text_report(&report),
26 PresetReportFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
27 }
28 Ok(report.valid)
29}
30
31fn runtime_platform(platform: PresetPlatform) -> RuntimePlatform {
32 match platform {
33 PresetPlatform::Macos => RuntimePlatform::Macos,
34 PresetPlatform::Linux => RuntimePlatform::Linux,
35 PresetPlatform::Windows => RuntimePlatform::Windows,
36 }
37}
38
39fn print_text_report(report: &PresetAuthoringPlanReportV1) {
40 print!("{}", authoring_text(report));
41}
42
43fn authoring_text(report: &PresetAuthoringPlanReportV1) -> String {
44 let mut output = String::new();
45 let target = report.target.as_deref().unwrap_or("invalid input");
46 let _ = writeln!(
47 output,
48 "{} {target} ({})",
49 crate::colors::bold("Preset authoring plan:"),
50 report.platform
51 );
52
53 let validation_failed = report
54 .diagnostics
55 .first()
56 .is_some_and(|diagnostic| diagnostic.code == "preset_validation_failed");
57 if report.valid || (report.target.is_some() && !validation_failed) {
58 let _ = writeln!(output);
59 let _ = writeln!(output, " {}", crate::colors::bold("Assumptions:"));
60 let _ = writeln!(
61 output,
62 " Lifecycle state {} · Environment {} · Secrets {}",
63 report.assumptions.lifecycle_state,
64 report.assumptions.environment,
65 report.assumptions.secrets
66 );
67 let _ = writeln!(
68 output,
69 " Trust grants {} · Commands {} · Administrator {}",
70 report.assumptions.trust_grants,
71 report.assumptions.detected_commands,
72 if report.assumptions.administrator {
73 "available"
74 } else {
75 "unavailable"
76 }
77 );
78 }
79
80 if let Some((validation, details)) = report
81 .diagnostics
82 .split_first()
83 .filter(|_| validation_failed)
84 {
85 let _ = writeln!(output);
86 let _ = writeln!(
87 output,
88 " {} {}",
89 crate::preset_report::diagnostic_symbol(validation.severity),
90 crate::colors::red("Static validation failed")
91 );
92 let _ = writeln!(
93 output,
94 " {} {}",
95 crate::colors::dim("code:"),
96 validation.code
97 );
98 for diagnostic in details {
99 crate::preset_report::write_diagnostic(&mut output, " ", diagnostic, true, false);
100 }
101 } else {
102 for diagnostic in &report.diagnostics {
103 let _ = writeln!(output);
104 crate::preset_report::write_diagnostic(&mut output, " ", diagnostic, true, false);
105 }
106 }
107 for plan in &report.plans {
108 let _ = writeln!(output);
109 let _ = writeln!(
110 output,
111 " {} {} · {}",
112 if plan.ready {
113 crate::colors::green("READY")
114 } else {
115 crate::colors::red("BLOCKED")
116 },
117 plan.kind,
118 plan.operation.as_str()
119 );
120 let _ = writeln!(output, " Target: {}", plan.target);
121 write_plan_blockers(&mut output, &plan.steps, &plan.permissions);
122 let _ = writeln!(output);
123 let _ = writeln!(
124 output,
125 " {}",
126 crate::colors::bold(&format!("Steps ({}):", plan.steps.len()))
127 );
128 if plan.steps.is_empty() {
129 let _ = writeln!(output, " - none");
130 }
131 for step in &plan.steps {
132 let _ = writeln!(
133 output,
134 " {} {}",
135 crate::lifecycle_plan::styled_action_name(step.action),
136 step_identity(step)
137 );
138 }
139 write_grouped_permissions(&mut output, &plan.permissions);
140 }
141 let result = if !report.valid {
142 crate::colors::red("invalid")
143 } else if report.ready {
144 crate::colors::green("hypothetical plan ready")
145 } else {
146 crate::colors::yellow("hypothetical plan blocked under these assumptions")
147 };
148 let _ = writeln!(output);
149 let _ = writeln!(output, "{} {result}", crate::colors::bold("Result:"));
150 output
151}
152
153fn write_plan_blockers(
154 output: &mut String,
155 steps: &[PlanStepV1],
156 permissions: &PermissionResolutionV1,
157) {
158 let blocked_steps = steps
159 .iter()
160 .filter(|step| step.action == PlanActionV1::Blocked)
161 .collect::<Vec<_>>();
162 if blocked_steps.is_empty()
163 && permissions.missing_declarations.is_empty()
164 && permissions.uncomputable_codes.is_empty()
165 {
166 return;
167 }
168
169 let _ = writeln!(output);
170 let _ = writeln!(output, " {}", crate::colors::red("Blockers:"));
171 for step in blocked_steps {
172 let _ = writeln!(output, " {} Blocked step", crate::colors::red("✗"));
173 let _ = writeln!(output, " {}", step_identity(step));
174 }
175 for permission in permissions.missing_declarations.iter() {
176 let _ = writeln!(
177 output,
178 " {} Missing declaration",
179 crate::colors::red("✗")
180 );
181 let _ = writeln!(
182 output,
183 " {}",
184 crate::lifecycle_plan::permission_name(permission)
185 );
186 }
187 for code in &permissions.uncomputable_codes {
188 let _ = writeln!(
189 output,
190 " {} Uncomputable permission",
191 crate::colors::red("✗")
192 );
193 let _ = writeln!(output, " {code}");
194 }
195}
196
197fn write_grouped_permissions(output: &mut String, permissions: &PermissionResolutionV1) {
198 let _ = writeln!(output);
199 let permission_count = permissions.required.iter().count();
200 let _ = writeln!(
201 output,
202 " {}",
203 crate::colors::bold(&format!("Required permissions ({permission_count}):"))
204 );
205 if permissions.required.is_empty() {
206 let _ = writeln!(output, " - none");
207 return;
208 }
209
210 let mut grouped = BTreeMap::<String, Vec<String>>::new();
211 for permission in permissions.required.iter() {
212 let (group, value) = crate::lifecycle_plan::permission_group(permission);
213 grouped.entry(group).or_default().push(value);
214 }
215 for (group, values) in grouped {
216 let _ = writeln!(output, " {group} ({})", values.len());
217 for value in values {
218 let _ = writeln!(output, " - {value}");
219 }
220 }
221}
222
223fn step_identity(step: &PlanStepV1) -> String {
224 let resource = step
225 .resource
226 .as_deref()
227 .map(|value| format!(" · {value}"))
228 .unwrap_or_default();
229 let diagnostics = if step.diagnostic_codes.is_empty() {
230 String::new()
231 } else {
232 format!(" [{}]", step.diagnostic_codes.join(", "))
233 };
234 format!("{}{resource}{diagnostics}", step.target)
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use shine_core::plan::{
241 EnvironmentSensitivityV1, FilesystemAccessV1, PermissionSetV1, PermissionV1,
242 PlanOperationV1,
243 };
244 use shine_core::runtime::{
245 PRESET_AUTHORING_PLAN_SCHEMA_VERSION, PresetAuthoringPlanAssumptionsV1,
246 PresetAuthoringPlanSectionV1, PresetDiagnostic, PresetDiagnosticSeverity,
247 };
248
249 #[test]
250 fn platform_mapping_is_explicit() {
251 assert_eq!(
252 runtime_platform(PresetPlatform::Macos),
253 RuntimePlatform::Macos
254 );
255 assert_eq!(
256 runtime_platform(PresetPlatform::Linux),
257 RuntimePlatform::Linux
258 );
259 assert_eq!(
260 runtime_platform(PresetPlatform::Windows),
261 RuntimePlatform::Windows
262 );
263 }
264
265 #[test]
266 fn invalid_text_shows_validation_details_without_irrelevant_assumptions() {
267 let report = PresetAuthoringPlanReportV1 {
268 schema_version: PRESET_AUTHORING_PLAN_SCHEMA_VERSION,
269 valid: false,
270 ready: false,
271 target: Some("shell/chrome".to_string()),
272 platform: "macos".to_string(),
273 assumptions: PresetAuthoringPlanAssumptionsV1::default(),
274 diagnostics: vec![
275 PresetDiagnostic {
276 severity: PresetDiagnosticSeverity::Error,
277 code: "preset_validation_failed".to_string(),
278 message: "shell/chrome failed static validation".to_string(),
279 path: None,
280 },
281 PresetDiagnostic {
282 severity: PresetDiagnosticSeverity::Error,
283 code: "invalid_permission_declaration".to_string(),
284 message: "shell/chrome/open-chrome has malformed permission fields".to_string(),
285 path: None,
286 },
287 ],
288 plans: Vec::new(),
289 };
290
291 let output = authoring_text(&report);
292
293 assert!(output.contains("Static validation failed"));
294 assert!(output.contains("shell/chrome/open-chrome has malformed permission fields"));
295 assert!(output.contains("code: invalid_permission_declaration"));
296 assert!(!output.contains("Assumptions:"));
297 assert!(output.contains("Result: invalid"));
298 }
299
300 #[test]
301 fn valid_text_wraps_all_hypothetical_assumptions_across_two_lines() {
302 let report = PresetAuthoringPlanReportV1 {
303 schema_version: PRESET_AUTHORING_PLAN_SCHEMA_VERSION,
304 valid: true,
305 ready: false,
306 target: Some("shell/chrome".to_string()),
307 platform: "macos".to_string(),
308 assumptions: PresetAuthoringPlanAssumptionsV1::default(),
309 diagnostics: Vec::new(),
310 plans: Vec::new(),
311 };
312
313 let output = authoring_text(&report);
314
315 assert!(output.contains("Lifecycle state empty · Environment absent · Secrets absent"));
316 assert!(output.contains("Trust grants none · Commands absent · Administrator unavailable"));
317 assert!(output.contains("hypothetical plan blocked under these assumptions"));
318 }
319
320 #[test]
321 fn blocked_plan_puts_blockers_before_grouped_exact_permissions() {
322 let missing_environment = PermissionV1::Environment {
323 name: "SURGE_PROFILE".to_string(),
324 sensitivity: EnvironmentSensitivityV1::Plain,
325 };
326 let permissions = PermissionResolutionV1 {
327 required: PermissionSetV1::new([
328 PermissionV1::Filesystem {
329 access: FilesystemAccessV1::Write,
330 path: "home:.zshrc".to_string(),
331 },
332 PermissionV1::Filesystem {
333 access: FilesystemAccessV1::Write,
334 path: "shine:bin/mytool".to_string(),
335 },
336 PermissionV1::Filesystem {
337 access: FilesystemAccessV1::Remove,
338 path: "home:.zshrc".to_string(),
339 },
340 missing_environment.clone(),
341 ]),
342 missing_declarations: PermissionSetV1::new([missing_environment]),
343 uncomputable_codes: Default::default(),
344 };
345 let report = PresetAuthoringPlanReportV1 {
346 schema_version: PRESET_AUTHORING_PLAN_SCHEMA_VERSION,
347 valid: true,
348 ready: false,
349 target: Some("shell/test".to_string()),
350 platform: "macos".to_string(),
351 assumptions: PresetAuthoringPlanAssumptionsV1::default(),
352 diagnostics: Vec::new(),
353 plans: vec![PresetAuthoringPlanSectionV1 {
354 kind: "lifecycle-install".to_string(),
355 target: "shell/test".to_string(),
356 operation: PlanOperationV1::Install,
357 ready: false,
358 steps: vec![
359 PlanStepV1::new("shell/test", Some("shared-snapshot"), PlanActionV1::Create),
360 PlanStepV1::new("shell/test/mytool", None::<String>, PlanActionV1::Create),
361 ],
362 permissions,
363 }],
364 };
365
366 let output = authoring_text(&report);
367 let blockers = output.find("Blockers:").unwrap();
368 let steps = output.find("Steps (2):").unwrap();
369 let permissions = output.find("Required permissions (4):").unwrap();
370
371 assert!(blockers < steps && steps < permissions);
372 assert!(output.contains("Missing declaration\n environment plain SURGE_PROFILE"));
373 assert!(output.contains("filesystem write (2)"));
374 assert!(output.contains("filesystem remove (1)"));
375 assert!(output.contains("environment plain (1)"));
376 assert!(output.contains("+ shell/test/mytool"));
377 }
378}