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        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}