Skip to main content

cli/
preset_validation.rs

1//! Terminal adapter for Core-owned preset discovery and validation.
2
3use crate::commands::PresetReportFormat;
4use anyhow::Result;
5use std::fmt::Write as _;
6use std::path::Path;
7#[cfg(test)]
8use std::path::PathBuf;
9
10pub use shine_core::runtime::{
11    PRESET_VALIDATION_SCHEMA_VERSION, PresetCategoryValidation, PresetDiagnostic,
12    PresetDiagnosticSeverity, PresetValidationReportV1, PresetValidationSummary,
13};
14
15pub async fn handle_validate(path: &Path, format: PresetReportFormat) -> Result<bool> {
16    let report = validate_path(path).await;
17    match format {
18        PresetReportFormat::Text => print_text_report(&report),
19        PresetReportFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
20    }
21    Ok(report.valid)
22}
23
24pub async fn validate_path(path: &Path) -> PresetValidationReportV1 {
25    let cwd = std::env::current_dir().unwrap_or_else(|_| Path::new(".").to_path_buf());
26    shine_core::runtime::validate_preset_path(&shine_core::runtime::RealHost, &cwd, path).await
27}
28
29#[cfg(test)]
30fn finish_report(
31    path: PathBuf,
32    diagnostics: Vec<PresetDiagnostic>,
33    categories: Vec<PresetCategoryValidation>,
34) -> PresetValidationReportV1 {
35    let (errors, warnings) = diagnostics
36        .iter()
37        .chain(categories.iter().flat_map(|category| &category.diagnostics))
38        .fold((0, 0), |(errors, warnings), diagnostic| {
39            match diagnostic.severity {
40                PresetDiagnosticSeverity::Error => (errors + 1, warnings),
41                PresetDiagnosticSeverity::Warning => (errors, warnings + 1),
42            }
43        });
44    PresetValidationReportV1 {
45        schema_version: PRESET_VALIDATION_SCHEMA_VERSION,
46        valid: errors == 0,
47        path,
48        summary: PresetValidationSummary {
49            categories: categories.len(),
50            errors,
51            warnings,
52        },
53        diagnostics,
54        categories,
55    }
56}
57fn print_text_report(report: &PresetValidationReportV1) {
58    print!("{}", validation_text(report));
59}
60
61fn validation_text(report: &PresetValidationReportV1) -> String {
62    let mut output = String::new();
63    let status = if report.valid {
64        crate::colors::green("valid")
65    } else {
66        crate::colors::red("invalid")
67    };
68    let _ = writeln!(
69        output,
70        "{} {status}",
71        crate::colors::bold("Preset validation:")
72    );
73    let _ = writeln!(
74        output,
75        "  {} {}",
76        crate::colors::dim("Source:"),
77        report.path.display()
78    );
79    for diagnostic in &report.diagnostics {
80        let _ = writeln!(output);
81        crate::preset_report::write_diagnostic(
82            &mut output,
83            "  ",
84            diagnostic,
85            true,
86            diagnostic.path.as_deref() != Some(report.path.as_path()),
87        );
88    }
89    for category in &report.categories {
90        let _ = writeln!(output);
91        let _ = writeln!(
92            output,
93            "  {} {}/{}",
94            if category.valid {
95                crate::colors::symbol("✓")
96            } else {
97                crate::colors::symbol("✗")
98            },
99            category.kind,
100            category.name
101        );
102        for diagnostic in &category.diagnostics {
103            crate::preset_report::write_diagnostic(
104                &mut output,
105                "    ",
106                diagnostic,
107                false,
108                diagnostic.path.as_deref() != Some(report.path.as_path()),
109            );
110        }
111    }
112    if !report.diagnostics.is_empty() || !report.categories.is_empty() {
113        let _ = writeln!(output);
114    }
115    let categories =
116        crate::preset_report::count_phrase(report.summary.categories, "category", "categories");
117    let errors = crate::preset_report::count_phrase(report.summary.errors, "error", "errors");
118    let warnings =
119        crate::preset_report::count_phrase(report.summary.warnings, "warning", "warnings");
120    let _ = writeln!(
121        output,
122        "{} {} · {} · {}",
123        crate::colors::bold("Summary:"),
124        crate::colors::dim(&categories),
125        if report.summary.errors > 0 {
126            crate::colors::red(&errors)
127        } else {
128            crate::colors::dim(&errors)
129        },
130        if report.summary.warnings > 0 {
131            crate::colors::yellow(&warnings)
132        } else {
133            crate::colors::dim(&warnings)
134        }
135    );
136    output
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn write(path: impl AsRef<Path>, content: &str) {
144        let path = path.as_ref();
145        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
146        std::fs::write(path, content).unwrap();
147    }
148
149    async fn fixture_root(name: &str) -> PathBuf {
150        crate::test_support::make_temp_dir(name).await
151    }
152
153    #[test]
154    fn text_report_uses_singular_counts_and_omits_a_duplicate_diagnostic_path() {
155        let path = PathBuf::from("/preset/root/shell/chrome/shine.toml");
156        let report = finish_report(
157            path.clone(),
158            Vec::new(),
159            vec![PresetCategoryValidation {
160                kind: "shell".to_string(),
161                name: "chrome".to_string(),
162                path: path.parent().unwrap().to_path_buf(),
163                valid: false,
164                diagnostics: vec![PresetDiagnostic {
165                    severity: PresetDiagnosticSeverity::Error,
166                    code: "invalid_permission_declaration".to_string(),
167                    message: "shell/chrome/open-chrome has malformed permission fields".to_string(),
168                    path: Some(path.clone()),
169                }],
170            }],
171        );
172
173        let output = validation_text(&report);
174
175        assert!(output.contains("Preset validation: invalid"));
176        assert_eq!(output.matches(path.to_str().unwrap()).count(), 1);
177        assert!(output.contains("  ✗ shell/chrome"));
178        assert!(output.contains("code: invalid_permission_declaration"));
179        assert!(output.contains("Summary: 1 category · 1 error · 0 warnings"));
180        assert!(!output.contains("1 categories"));
181    }
182
183    #[tokio::test]
184    async fn missing_path_is_a_structured_input_error() {
185        let path = std::env::temp_dir().join("shine-preset-validation-does-not-exist");
186        let report = validate_path(&path).await;
187        assert!(!report.valid);
188        assert_eq!(report.schema_version, 1);
189        assert_eq!(report.summary.errors, 1);
190        assert_eq!(report.diagnostics[0].code, "invalid_input");
191    }
192
193    #[test]
194    fn json_contract_matches_schema_v1_golden() {
195        let report = finish_report(
196            PathBuf::from("/preset/root"),
197            Vec::new(),
198            vec![PresetCategoryValidation {
199                kind: "shell".to_string(),
200                name: "my-tools".to_string(),
201                path: PathBuf::from("/preset/root/shell/my-tools"),
202                valid: true,
203                diagnostics: Vec::new(),
204            }],
205        );
206        assert_eq!(
207            serde_json::to_string_pretty(&report).unwrap(),
208            r#"{
209  "schema_version": 1,
210  "valid": true,
211  "path": "/preset/root",
212  "summary": {
213    "categories": 1,
214    "errors": 0,
215    "warnings": 0
216  },
217  "categories": [
218    {
219      "kind": "shell",
220      "name": "my-tools",
221      "path": "/preset/root/shell/my-tools",
222      "valid": true,
223      "diagnostics": []
224    }
225  ]
226}"#
227        );
228    }
229
230    #[tokio::test]
231    async fn validates_repository_category_and_manifest_inputs() {
232        let root = fixture_root("preset-validation-valid").await;
233        write(
234            root.join("app/editor/shine.toml"),
235            r#"description = "Editor"
236dest = { unix = "~/.config/editor", windows = "~/AppData/Roaming/editor" }
237[[files]]
238source = "config.toml"
239"#,
240        );
241        write(root.join("app/editor/config.toml"), "theme = 'dark'\n");
242        write(
243            root.join("shell/tools/shine.toml"),
244            r#"description = "Tools"
245[[files]]
246source = "tool.sh"
247target = "tool"
248platforms = ["unix"]
249[[files]]
250source = "tool.ps1"
251target = "tool"
252platforms = ["windows"]
253"#,
254        );
255        write(root.join("shell/tools/tool.sh"), "#!/bin/sh\n");
256        write(root.join("shell/tools/tool.ps1"), "exit 0\n");
257        write(
258            root.join("sys/test-os/shine.toml"),
259            r#"version = 2
260default_profile = "recommended"
261[[items]]
262id = "git"
263label = "Git"
264detect = { kind = "command", command = "git" }
265install = { kind = "package", provider = "apt", package = "git" }
266[profiles.recommended]
267items = ["git"]
268"#,
269        );
270
271        let repository = validate_path(&root).await;
272        assert!(repository.valid, "{repository:#?}");
273        assert_eq!(repository.summary.categories, 3);
274
275        let category = validate_path(&root.join("shell/tools")).await;
276        assert!(category.valid, "{category:#?}");
277        assert_eq!(category.categories[0].kind, "shell");
278
279        let manifest = validate_path(&root.join("sys/test-os/shine.toml")).await;
280        assert!(manifest.valid, "{manifest:#?}");
281        assert_eq!(manifest.categories[0].name, "test-os");
282        std::fs::remove_dir_all(root).unwrap();
283    }
284
285    #[tokio::test]
286    async fn all_built_in_presets_pass_static_validation() {
287        let presets = Path::new(env!("CARGO_MANIFEST_DIR")).join("presets");
288
289        let report = validate_path(&presets).await;
290
291        assert!(report.valid, "{report:#?}");
292        assert_eq!(report.schema_version, PRESET_VALIDATION_SCHEMA_VERSION);
293        assert_eq!(report.summary.errors, 0);
294        assert_eq!(report.summary.warnings, 0, "{report:#?}");
295        for kind in ["app", "shell", "sys"] {
296            assert!(
297                report
298                    .categories
299                    .iter()
300                    .any(|category| category.kind == kind),
301                "built-in validation did not discover any {kind} categories"
302            );
303        }
304    }
305
306    #[tokio::test]
307    async fn reports_other_platform_errors_and_partial_repository_failure() {
308        let root = fixture_root("preset-validation-invalid").await;
309        write(
310            root.join("app/editor/shine.toml"),
311            r#"dest = "~/.config/editor"
312[[files]]
313source = "missing.toml"
314"#,
315        );
316        write(
317            root.join("shell/tools/shine.toml"),
318            r#"[[files]]
319source = "tool.sh"
320platforms = ["plan9"]
321"#,
322        );
323        write(root.join("shell/tools/tool.sh"), "#!/bin/sh\n");
324        write(
325            root.join("sys/test-os/shine.toml"),
326            r#"version = 2
327default_profile = "missing"
328"#,
329        );
330
331        let report = validate_path(&root).await;
332        assert!(!report.valid);
333        assert_eq!(report.summary.categories, 3);
334        assert_eq!(report.summary.errors, 3);
335        assert_eq!(
336            report.categories[0].diagnostics[0].code,
337            "missing_reference"
338        );
339        assert_eq!(report.categories[1].diagnostics[0].code, "invalid_metadata");
340        assert_eq!(report.categories[2].diagnostics[0].code, "invalid_metadata");
341        std::fs::remove_dir_all(root).unwrap();
342    }
343
344    #[tokio::test]
345    async fn validation_never_executes_declared_code() {
346        let root = fixture_root("preset-validation-no-exec").await;
347        let category = root.join("app/tool");
348        let marker = category.join("executed");
349        write(
350            category.join("shine.toml"),
351            r#"dest = "~/.config/tool"
352post_install = { command = "./danger.sh" }
353[artifact]
354script = "danger.sh"
355runtime = "native"
356[[files]]
357source = "config.toml"
358generator = { script = "generate.sh", env = ["SOURCE"], when_env = "SOURCE" }
359"#,
360        );
361        write(category.join("config.toml"), "enabled = true\n");
362        write(
363            category.join("danger.sh"),
364            &format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
365        );
366        write(
367            category.join("generate.sh"),
368            &format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
369        );
370
371        let report = validate_path(&category).await;
372        assert!(report.valid, "{report:#?}");
373        assert!(!marker.exists());
374        std::fs::remove_dir_all(root).unwrap();
375    }
376
377    #[tokio::test]
378    async fn enforces_duplicate_commands_and_locked_bun_pair() {
379        let root = fixture_root("preset-validation-shell-policy").await;
380        let category = root.join("shell/tools");
381        write(
382            category.join("shine.toml"),
383            r#"[[files]]
384source = "one.ts"
385target = "tool"
386runtime = "bun"
387[[files]]
388source = "two.ts"
389target = "tool"
390runtime = "bun"
391"#,
392        );
393        write(category.join("one.ts"), "console.log('one')\n");
394        write(category.join("two.ts"), "console.log('two')\n");
395        write(category.join("package.json"), "{\"dependencies\":{}}\n");
396
397        let missing_lock = validate_path(&category).await;
398        assert!(!missing_lock.valid);
399        assert_eq!(
400            missing_lock.categories[0].diagnostics[0].code,
401            "duplicate_command"
402        );
403
404        // Make targets unique so the dependency policy becomes the next stable
405        // diagnostic.
406        write(
407            category.join("shine.toml"),
408            r#"[[files]]
409source = "one.ts"
410target = "one"
411runtime = "bun"
412[[files]]
413source = "two.ts"
414target = "two"
415runtime = "bun"
416"#,
417        );
418        let missing_lock = validate_path(&category).await;
419        assert_eq!(
420            missing_lock.categories[0].diagnostics[0].code,
421            "bun_dependency_policy"
422        );
423        std::fs::remove_dir_all(root).unwrap();
424    }
425
426    #[tokio::test]
427    async fn validates_all_app_platform_destinations_and_duplicate_targets() {
428        let root = fixture_root("preset-validation-app-platforms").await;
429        let category = root.join("app/editor");
430        write(
431            category.join("shine.toml"),
432            r#"dest = { unix = "~/.config/editor", windows = "relative/windows" }
433[[files]]
434source = "one.toml"
435"#,
436        );
437        write(category.join("one.toml"), "one = true\n");
438
439        let invalid_windows = validate_path(&category).await;
440        assert!(!invalid_windows.valid);
441        assert_eq!(
442            invalid_windows.categories[0].diagnostics[0].code,
443            "invalid_metadata"
444        );
445
446        // Every declared branch is validated even when exact OS destinations
447        // shadow the Unix compatibility fallback on both Unix operating systems.
448        write(
449            category.join("shine.toml"),
450            r#"dest = { macos = "~/Library/Editor", linux = "~/.config/editor", unix = "relative/shadowed" }
451[[files]]
452source = "one.toml"
453"#,
454        );
455        let invalid_shadowed_unix = validate_path(&category).await;
456        assert!(!invalid_shadowed_unix.valid);
457        assert_eq!(
458            invalid_shadowed_unix.categories[0].diagnostics[0].code,
459            "invalid_metadata"
460        );
461
462        write(
463            category.join("shine.toml"),
464            r#"dest = "~/.config/editor"
465[[files]]
466source = "one.toml"
467target = "same.toml"
468[[files]]
469source = "two.toml"
470target = "same.toml"
471"#,
472        );
473        write(category.join("two.toml"), "two = true\n");
474        let duplicate = validate_path(&category).await;
475        assert_eq!(
476            duplicate.categories[0].diagnostics[0].code,
477            "duplicate_target"
478        );
479        std::fs::remove_dir_all(root).unwrap();
480    }
481
482    #[tokio::test]
483    async fn validates_exact_platforms_and_rejects_empty_platform_lists() {
484        let root = fixture_root("preset-validation-exact-platforms").await;
485        let category = root.join("shell/tools");
486        write(
487            category.join("shine.toml"),
488            r#"[[files]]
489source = "mac.sh"
490target = "tool"
491platforms = ["macos"]
492[files.permissions]
493schema_version = 1
494[[files]]
495source = "linux.sh"
496target = "tool"
497platforms = ["linux"]
498[files.permissions]
499schema_version = 1
500[[files]]
501source = "windows.ps1"
502target = "tool"
503platforms = ["windows"]
504[files.permissions]
505schema_version = 1
506"#,
507        );
508        write(category.join("mac.sh"), "#!/bin/sh\n");
509        write(category.join("linux.sh"), "#!/bin/sh\n");
510        write(category.join("windows.ps1"), "exit 0\n");
511
512        let valid = validate_path(&category).await;
513        assert!(valid.valid, "{valid:#?}");
514        assert_eq!(valid.summary.warnings, 0, "{valid:#?}");
515
516        write(
517            category.join("shine.toml"),
518            r#"[[files]]
519source = "mac.sh"
520target = "tool"
521platforms = []
522"#,
523        );
524        let empty = validate_path(&category).await;
525        assert!(!empty.valid);
526        assert_eq!(empty.categories[0].diagnostics[0].code, "invalid_metadata");
527
528        std::fs::remove_dir_all(root).unwrap();
529    }
530
531    #[tokio::test]
532    async fn legacy_app_and_shell_categories_keep_only_the_legacy_warning() {
533        let root = fixture_root("preset-validation-legacy-permissions").await;
534        write(
535            root.join("app/editor/config.toml"),
536            "# shine-dest: ~/.config/editor/config.toml\ntheme = 'dark'\n",
537        );
538        write(root.join("shell/tools/tool.sh"), "#!/bin/sh\necho tool\n");
539
540        let report = validate_path(&root).await;
541        assert!(report.valid, "{report:#?}");
542        assert_eq!(report.summary.warnings, 2, "{report:#?}");
543        assert!(report.categories.iter().all(|category| {
544            category.diagnostics.len() == 1 && category.diagnostics[0].code == "legacy_metadata"
545        }));
546
547        std::fs::remove_dir_all(root).unwrap();
548    }
549
550    #[tokio::test]
551    async fn unix_and_exact_shell_selectors_conflict_on_the_exact_os() {
552        let root = fixture_root("preset-validation-overlapping-platforms").await;
553        let category = root.join("shell/tools");
554        write(
555            category.join("shine.toml"),
556            r#"[[files]]
557source = "unix.sh"
558target = "tool"
559platforms = ["unix"]
560[[files]]
561source = "mac.sh"
562target = "tool"
563platforms = ["macos"]
564"#,
565        );
566        write(category.join("unix.sh"), "#!/bin/sh\n");
567        write(category.join("mac.sh"), "#!/bin/sh\n");
568
569        let report = validate_path(&category).await;
570        assert!(!report.valid);
571        assert_eq!(
572            report.categories[0].diagnostics[0].code,
573            "duplicate_command"
574        );
575        assert!(
576            report.categories[0].diagnostics[0]
577                .message
578                .contains("macos")
579        );
580
581        std::fs::remove_dir_all(root).unwrap();
582    }
583
584    #[tokio::test]
585    async fn missing_permission_declarations_warn_without_blocking_compatibility() {
586        let root = fixture_root("preset-validation-permission-warning").await;
587        let category = root.join("app/editor");
588        write(
589            category.join("shine.toml"),
590            "dest = '~/.config/editor'\n[[files]]\nsource = 'config.toml'\n",
591        );
592        write(category.join("config.toml"), "theme = 'dark'\n");
593
594        let report = validate_path(&category).await;
595        assert!(report.valid, "{report:#?}");
596        assert_eq!(report.summary.warnings, 1);
597        assert_eq!(
598            report.categories[0].diagnostics[0].code,
599            "missing_permission_declaration"
600        );
601        std::fs::remove_dir_all(root).unwrap();
602    }
603
604    #[tokio::test]
605    async fn permission_schema_errors_have_stable_diagnostic_codes() {
606        let root = fixture_root("preset-validation-permission-errors").await;
607        let category = root.join("app/editor");
608        write(
609            category.join("shine.toml"),
610            r#"dest = "~/.config/editor"
611[permissions]
612schema_version = 2
613[[files]]
614source = "config.toml"
615"#,
616        );
617        write(category.join("config.toml"), "theme = 'dark'\n");
618
619        let unsupported = validate_path(&category).await;
620        assert!(!unsupported.valid);
621        assert_eq!(
622            unsupported.categories[0].diagnostics[0].code,
623            "unsupported_permission_schema"
624        );
625
626        write(
627            category.join("shine.toml"),
628            r#"dest = "~/.config/editor"
629[permissions]
630schema_version = 1
631commands = ["bun", "bun"]
632[[files]]
633source = "config.toml"
634"#,
635        );
636        let duplicate = validate_path(&category).await;
637        assert!(!duplicate.valid);
638        assert_eq!(
639            duplicate.categories[0].diagnostics[0].code,
640            "duplicate_permission"
641        );
642        std::fs::remove_dir_all(root).unwrap();
643    }
644
645    #[tokio::test]
646    async fn permission_declarations_must_use_the_domain_target_placement() {
647        let root = fixture_root("preset-validation-permission-placement").await;
648        let category = root.join("shell/tools");
649        write(
650            category.join("shine.toml"),
651            r#"[permissions]
652schema_version = 1
653[[files]]
654source = "tool.sh"
655target = "tool"
656[files.permissions]
657schema_version = 1
658"#,
659        );
660        write(category.join("tool.sh"), "#!/bin/sh\n");
661
662        let report = validate_path(&category).await;
663        assert!(!report.valid);
664        assert_eq!(
665            report.categories[0].diagnostics[0].code,
666            "invalid_permission_declaration"
667        );
668        std::fs::remove_dir_all(root).unwrap();
669    }
670}