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 if step
175 .diagnostic_codes
176 .iter()
177 .any(|code| code == "shell_template_inputs_missing")
178 {
179 let _ = writeln!(
180 output,
181 " Shell template inputs are missing under the report assumptions."
182 );
183 }
184 }
185 for permission in permissions.missing_declarations.iter() {
186 let _ = writeln!(
187 output,
188 " {} Missing declaration",
189 crate::colors::red("✗")
190 );
191 let _ = writeln!(
192 output,
193 " {}",
194 crate::lifecycle_plan::permission_name(permission)
195 );
196 }
197 for code in &permissions.uncomputable_codes {
198 let _ = writeln!(
199 output,
200 " {} Uncomputable permission",
201 crate::colors::red("✗")
202 );
203 let _ = writeln!(output, " {code}");
204 }
205}
206
207fn write_grouped_permissions(output: &mut String, permissions: &PermissionResolutionV1) {
208 let _ = writeln!(output);
209 let permission_count = permissions.required.iter().count();
210 let _ = writeln!(
211 output,
212 " {}",
213 crate::colors::bold(&format!("Required permissions ({permission_count}):"))
214 );
215 if permissions.required.is_empty() {
216 let _ = writeln!(output, " - none");
217 return;
218 }
219
220 let mut grouped = BTreeMap::<String, Vec<String>>::new();
221 for permission in permissions.required.iter() {
222 let (group, value) = crate::lifecycle_plan::permission_group(permission);
223 grouped.entry(group).or_default().push(value);
224 }
225 for (group, values) in grouped {
226 let _ = writeln!(output, " {group} ({})", values.len());
227 for value in values {
228 let _ = writeln!(output, " - {value}");
229 }
230 }
231}
232
233fn step_identity(step: &PlanStepV1) -> String {
234 let resource = step
235 .resource
236 .as_deref()
237 .map(|value| format!(" · {value}"))
238 .unwrap_or_default();
239 let diagnostics = if step.diagnostic_codes.is_empty() {
240 String::new()
241 } else {
242 format!(" [{}]", step.diagnostic_codes.join(", "))
243 };
244 format!("{}{resource}{diagnostics}", step.target)
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use shine_core::plan::{
251 EnvironmentSensitivityV1, FilesystemAccessV1, PermissionSetV1, PermissionV1,
252 PlanOperationV1,
253 };
254 use shine_core::runtime::{
255 PRESET_AUTHORING_PLAN_SCHEMA_VERSION, PresetAuthoringPlanAssumptionsV1,
256 PresetAuthoringPlanSectionV1, PresetDiagnostic, PresetDiagnosticSeverity,
257 };
258
259 #[test]
260 fn platform_mapping_is_explicit() {
261 assert_eq!(
262 runtime_platform(PresetPlatform::Macos),
263 RuntimePlatform::Macos
264 );
265 assert_eq!(
266 runtime_platform(PresetPlatform::Linux),
267 RuntimePlatform::Linux
268 );
269 assert_eq!(
270 runtime_platform(PresetPlatform::Windows),
271 RuntimePlatform::Windows
272 );
273 }
274
275 #[test]
276 fn invalid_text_shows_validation_details_without_irrelevant_assumptions() {
277 let report = PresetAuthoringPlanReportV1 {
278 schema_version: PRESET_AUTHORING_PLAN_SCHEMA_VERSION,
279 valid: false,
280 ready: false,
281 target: Some("shell/chrome".to_string()),
282 platform: "macos".to_string(),
283 assumptions: PresetAuthoringPlanAssumptionsV1::default(),
284 diagnostics: vec![
285 PresetDiagnostic {
286 severity: PresetDiagnosticSeverity::Error,
287 code: "preset_validation_failed".to_string(),
288 message: "shell/chrome failed static validation".to_string(),
289 path: None,
290 },
291 PresetDiagnostic {
292 severity: PresetDiagnosticSeverity::Error,
293 code: "invalid_permission_declaration".to_string(),
294 message: "shell/chrome/open-chrome has malformed permission fields".to_string(),
295 path: None,
296 },
297 ],
298 plans: Vec::new(),
299 };
300
301 let output = authoring_text(&report);
302
303 assert!(output.contains("Static validation failed"));
304 assert!(output.contains("shell/chrome/open-chrome has malformed permission fields"));
305 assert!(output.contains("code: invalid_permission_declaration"));
306 assert!(!output.contains("Assumptions:"));
307 assert!(output.contains("Result: invalid"));
308 }
309
310 #[test]
311 fn valid_text_wraps_all_hypothetical_assumptions_across_two_lines() {
312 let report = PresetAuthoringPlanReportV1 {
313 schema_version: PRESET_AUTHORING_PLAN_SCHEMA_VERSION,
314 valid: true,
315 ready: false,
316 target: Some("shell/chrome".to_string()),
317 platform: "macos".to_string(),
318 assumptions: PresetAuthoringPlanAssumptionsV1::default(),
319 diagnostics: Vec::new(),
320 plans: Vec::new(),
321 };
322
323 let output = authoring_text(&report);
324
325 assert!(output.contains("Lifecycle state empty · Environment absent · Secrets absent"));
326 assert!(output.contains("Trust grants none · Commands absent · Administrator unavailable"));
327 assert!(output.contains("hypothetical plan blocked under these assumptions"));
328 }
329
330 #[test]
331 fn missing_template_inputs_have_safe_actionable_text() {
332 let step = PlanStepV1::new(
333 "shell/proxy/setproxy",
334 Some("rendered-output"),
335 PlanActionV1::Blocked,
336 )
337 .with_diagnostic_code("shell_template_inputs_missing");
338 let mut output = String::new();
339 write_plan_blockers(
340 &mut output,
341 &[step],
342 &PermissionResolutionV1 {
343 required: PermissionSetV1::default(),
344 missing_declarations: PermissionSetV1::default(),
345 uncomputable_codes: Default::default(),
346 },
347 );
348 assert!(output.contains("shell_template_inputs_missing"));
349 assert!(output.contains("Shell template inputs are missing under the report assumptions."));
350 }
351
352 #[test]
353 fn blocked_plan_puts_blockers_before_grouped_exact_permissions() {
354 let missing_environment = PermissionV1::Environment {
355 name: "SURGE_PROFILE".to_string(),
356 sensitivity: EnvironmentSensitivityV1::Plain,
357 };
358 let permissions = PermissionResolutionV1 {
359 required: PermissionSetV1::new([
360 PermissionV1::Filesystem {
361 access: FilesystemAccessV1::Write,
362 path: "home:.zshrc".to_string(),
363 },
364 PermissionV1::Filesystem {
365 access: FilesystemAccessV1::Write,
366 path: "shine:bin/mytool".to_string(),
367 },
368 PermissionV1::Filesystem {
369 access: FilesystemAccessV1::Remove,
370 path: "home:.zshrc".to_string(),
371 },
372 missing_environment.clone(),
373 ]),
374 missing_declarations: PermissionSetV1::new([missing_environment]),
375 uncomputable_codes: Default::default(),
376 };
377 let report = PresetAuthoringPlanReportV1 {
378 schema_version: PRESET_AUTHORING_PLAN_SCHEMA_VERSION,
379 valid: true,
380 ready: false,
381 target: Some("shell/test".to_string()),
382 platform: "macos".to_string(),
383 assumptions: PresetAuthoringPlanAssumptionsV1::default(),
384 diagnostics: Vec::new(),
385 plans: vec![PresetAuthoringPlanSectionV1 {
386 kind: "lifecycle-install".to_string(),
387 target: "shell/test".to_string(),
388 operation: PlanOperationV1::Install,
389 ready: false,
390 steps: vec![
391 PlanStepV1::new("shell/test", Some("shared-snapshot"), PlanActionV1::Create),
392 PlanStepV1::new("shell/test/mytool", None::<String>, PlanActionV1::Create),
393 ],
394 permissions,
395 }],
396 };
397
398 let output = authoring_text(&report);
399 let blockers = output.find("Blockers:").unwrap();
400 let steps = output.find("Steps (2):").unwrap();
401 let permissions = output.find("Required permissions (4):").unwrap();
402
403 assert!(blockers < steps && steps < permissions);
404 assert!(output.contains("Missing declaration\n environment plain SURGE_PROFILE"));
405 assert!(output.contains("filesystem write (2)"));
406 assert!(output.contains("filesystem remove (1)"));
407 assert!(output.contains("environment plain (1)"));
408 assert!(output.contains("+ shell/test/mytool"));
409 }
410}