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