Skip to main content

cli/
preset_authoring.rs

1//! Terminal adapter for hypothetical Preset authoring plans.
2
3use 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}