Skip to main content

cli/
preset_validation.rs

1//! Read-only preset discovery, validation reporting, and stable JSON output.
2//!
3//! This module deliberately does not depend on [`crate::config::Config`]. The
4//! command is routed here before normal configuration loading so validation can
5//! inspect untrusted preset source without initializing Shine or executing it.
6
7use crate::commands::PresetValidationFormat;
8use anyhow::Result;
9use serde::Serialize;
10use std::path::{Path, PathBuf};
11
12pub const PRESET_VALIDATION_SCHEMA_VERSION: u32 = 1;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum PresetDiagnosticSeverity {
17    Error,
18    Warning,
19}
20
21#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
22pub struct PresetDiagnostic {
23    pub severity: PresetDiagnosticSeverity,
24    pub code: String,
25    pub message: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub path: Option<PathBuf>,
28}
29
30impl PresetDiagnostic {
31    fn error(code: &str, message: impl Into<String>, path: Option<PathBuf>) -> Self {
32        Self {
33            severity: PresetDiagnosticSeverity::Error,
34            code: code.to_string(),
35            message: message.into(),
36            path,
37        }
38    }
39
40    fn warning(code: &str, message: impl Into<String>, path: Option<PathBuf>) -> Self {
41        Self {
42            severity: PresetDiagnosticSeverity::Warning,
43            code: code.to_string(),
44            message: message.into(),
45            path,
46        }
47    }
48}
49
50#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
51pub struct PresetValidationSummary {
52    pub categories: usize,
53    pub errors: usize,
54    pub warnings: usize,
55}
56
57#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
58pub struct PresetCategoryValidation {
59    pub kind: String,
60    pub name: String,
61    pub path: PathBuf,
62    pub valid: bool,
63    pub diagnostics: Vec<PresetDiagnostic>,
64}
65
66#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
67pub struct PresetValidationReportV1 {
68    pub schema_version: u32,
69    pub valid: bool,
70    pub path: PathBuf,
71    pub summary: PresetValidationSummary,
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub diagnostics: Vec<PresetDiagnostic>,
74    pub categories: Vec<PresetCategoryValidation>,
75}
76
77#[derive(Debug)]
78pub(crate) struct PresetValidationFailure {
79    pub(crate) code: &'static str,
80    pub(crate) message: String,
81    pub(crate) path: Option<PathBuf>,
82}
83
84impl PresetValidationFailure {
85    pub(crate) fn new(code: &'static str, message: impl Into<String>) -> Self {
86        Self {
87            code,
88            message: message.into(),
89            path: None,
90        }
91    }
92
93    pub(crate) fn at(
94        code: &'static str,
95        message: impl Into<String>,
96        path: impl Into<PathBuf>,
97    ) -> Self {
98        Self {
99            code,
100            message: message.into(),
101            path: Some(path.into()),
102        }
103    }
104}
105
106#[derive(Clone, Debug)]
107struct CategoryPath {
108    kind: &'static str,
109    name: String,
110    root: PathBuf,
111}
112
113pub async fn handle_validate(path: &Path, format: PresetValidationFormat) -> Result<bool> {
114    let report = validate_path(path).await;
115    match format {
116        PresetValidationFormat::Text => print_text_report(&report),
117        PresetValidationFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
118    }
119    Ok(report.valid)
120}
121
122pub async fn validate_path(path: &Path) -> PresetValidationReportV1 {
123    let display_path = absolute_path(path);
124    let canonical = match std::fs::canonicalize(path) {
125        Ok(path) => path,
126        Err(error) => {
127            return report_with_input_error(
128                display_path.clone(),
129                format!(
130                    "cannot resolve preset path {}: {error}",
131                    display_path.display()
132                ),
133            );
134        }
135    };
136
137    let categories = match discover_categories(&canonical) {
138        Ok(categories) if !categories.is_empty() => categories,
139        Ok(_) => {
140            return report_with_input_error(
141                canonical,
142                "no preset categories found directly under app/, shell/, or sys/",
143            );
144        }
145        Err(failure) => return report_from_failure(canonical, failure),
146    };
147
148    let mut reports = Vec::with_capacity(categories.len());
149    for category in categories {
150        let result = match category.kind {
151            "app" => crate::apps::validate_preset_category(&category.name, &category.root),
152            "shell" => crate::shells::validate_preset_category(&category.name, &category.root),
153            "sys" => crate::sys::validate_preset_category(&category.name, &category.root),
154            _ => unreachable!(),
155        };
156        let mut diagnostics = Vec::new();
157        match result {
158            Ok(has_metadata) => {
159                if !has_metadata {
160                    diagnostics.push(PresetDiagnostic::warning(
161                        "legacy_metadata",
162                        format!(
163                            "{}/{} has no shine.toml; compatibility auto-discovery is accepted, but explicit metadata is recommended",
164                            category.kind, category.name
165                        ),
166                        Some(category.root.clone()),
167                    ));
168                }
169            }
170            Err(failure) => diagnostics.push(PresetDiagnostic::error(
171                failure.code,
172                failure.message,
173                failure.path.or_else(|| Some(category.root.clone())),
174            )),
175        }
176        let valid = diagnostics
177            .iter()
178            .all(|diagnostic| diagnostic.severity != PresetDiagnosticSeverity::Error);
179        reports.push(PresetCategoryValidation {
180            kind: category.kind.to_string(),
181            name: category.name,
182            path: category.root,
183            valid,
184            diagnostics,
185        });
186    }
187
188    finish_report(canonical, Vec::new(), reports)
189}
190
191fn discover_categories(path: &Path) -> Result<Vec<CategoryPath>, PresetValidationFailure> {
192    if path.is_file() {
193        if path.file_name().and_then(|name| name.to_str()) != Some("shine.toml") {
194            return Err(PresetValidationFailure::at(
195                "invalid_input",
196                "preset manifest input must be named shine.toml",
197                path,
198            ));
199        }
200        let root = path.parent().expect("a canonical file has a parent");
201        return Ok(vec![category_from_root(root)?]);
202    }
203    if !path.is_dir() {
204        return Err(PresetValidationFailure::at(
205            "invalid_input",
206            "preset path must be a directory or shine.toml",
207            path,
208        ));
209    }
210
211    if path
212        .parent()
213        .and_then(Path::file_name)
214        .and_then(|name| name.to_str())
215        .is_some_and(is_kind)
216    {
217        return Ok(vec![category_from_root(path)?]);
218    }
219
220    let mut categories = Vec::new();
221    for kind in ["app", "shell", "sys"] {
222        let kind_root = path.join(kind);
223        if !kind_root.is_dir() {
224            continue;
225        }
226        let entries = std::fs::read_dir(&kind_root).map_err(|error| {
227            PresetValidationFailure::at(
228                "read_failed",
229                format!("cannot read {}: {error}", kind_root.display()),
230                &kind_root,
231            )
232        })?;
233        for entry in entries {
234            let entry = entry.map_err(|error| {
235                PresetValidationFailure::at(
236                    "read_failed",
237                    format!("cannot read {}: {error}", kind_root.display()),
238                    &kind_root,
239                )
240            })?;
241            if !entry
242                .file_type()
243                .map_err(|error| {
244                    PresetValidationFailure::at(
245                        "read_failed",
246                        format!("cannot inspect {}: {error}", entry.path().display()),
247                        entry.path(),
248                    )
249                })?
250                .is_dir()
251            {
252                continue;
253            }
254            categories.push(CategoryPath {
255                kind,
256                name: entry.file_name().to_string_lossy().to_string(),
257                root: std::fs::canonicalize(entry.path()).map_err(|error| {
258                    PresetValidationFailure::at(
259                        "read_failed",
260                        format!("cannot resolve preset category: {error}"),
261                        entry.path(),
262                    )
263                })?,
264            });
265        }
266    }
267    categories.sort_by(|left, right| {
268        (left.kind, left.name.as_str()).cmp(&(right.kind, right.name.as_str()))
269    });
270    Ok(categories)
271}
272
273fn category_from_root(root: &Path) -> Result<CategoryPath, PresetValidationFailure> {
274    let kind = root
275        .parent()
276        .and_then(Path::file_name)
277        .and_then(|name| name.to_str())
278        .filter(|kind| is_kind(kind))
279        .ok_or_else(|| {
280            PresetValidationFailure::at(
281                "invalid_input",
282                "category directory must be app/<name>, shell/<name>, or sys/<name>",
283                root,
284            )
285        })?;
286    let name = root
287        .file_name()
288        .and_then(|name| name.to_str())
289        .filter(|name| !name.is_empty())
290        .ok_or_else(|| {
291            PresetValidationFailure::at(
292                "invalid_input",
293                "preset category name must be valid UTF-8",
294                root,
295            )
296        })?;
297    Ok(CategoryPath {
298        kind: match kind {
299            "app" => "app",
300            "shell" => "shell",
301            "sys" => "sys",
302            _ => unreachable!(),
303        },
304        name: name.to_string(),
305        root: root.to_path_buf(),
306    })
307}
308
309fn is_kind(value: &str) -> bool {
310    matches!(value, "app" | "shell" | "sys")
311}
312
313fn absolute_path(path: &Path) -> PathBuf {
314    if path.is_absolute() {
315        path.to_path_buf()
316    } else {
317        std::env::current_dir()
318            .map(|current| current.join(path))
319            .unwrap_or_else(|_| path.to_path_buf())
320    }
321}
322
323fn report_with_input_error(path: PathBuf, message: impl Into<String>) -> PresetValidationReportV1 {
324    report_from_failure(path, PresetValidationFailure::new("invalid_input", message))
325}
326
327fn report_from_failure(
328    path: PathBuf,
329    failure: PresetValidationFailure,
330) -> PresetValidationReportV1 {
331    finish_report(
332        path,
333        vec![PresetDiagnostic::error(
334            failure.code,
335            failure.message,
336            failure.path,
337        )],
338        Vec::new(),
339    )
340}
341
342fn finish_report(
343    path: PathBuf,
344    diagnostics: Vec<PresetDiagnostic>,
345    categories: Vec<PresetCategoryValidation>,
346) -> PresetValidationReportV1 {
347    let all_diagnostics = diagnostics
348        .iter()
349        .chain(categories.iter().flat_map(|category| &category.diagnostics));
350    let (errors, warnings) = all_diagnostics.fold((0, 0), |(errors, warnings), diagnostic| {
351        match diagnostic.severity {
352            PresetDiagnosticSeverity::Error => (errors + 1, warnings),
353            PresetDiagnosticSeverity::Warning => (errors, warnings + 1),
354        }
355    });
356    PresetValidationReportV1 {
357        schema_version: PRESET_VALIDATION_SCHEMA_VERSION,
358        valid: errors == 0,
359        path,
360        summary: PresetValidationSummary {
361            categories: categories.len(),
362            errors,
363            warnings,
364        },
365        diagnostics,
366        categories,
367    }
368}
369
370fn print_text_report(report: &PresetValidationReportV1) {
371    println!(
372        "Preset validation: {} ({})",
373        report.path.display(),
374        if report.valid { "valid" } else { "invalid" }
375    );
376    for diagnostic in &report.diagnostics {
377        print_diagnostic("  ", diagnostic);
378    }
379    for category in &report.categories {
380        println!(
381            "  {} {}/{}",
382            if category.valid { "OK" } else { "ERROR" },
383            category.kind,
384            category.name
385        );
386        for diagnostic in &category.diagnostics {
387            print_diagnostic("    ", diagnostic);
388        }
389    }
390    println!(
391        "Summary: {} categories, {} errors, {} warnings",
392        report.summary.categories, report.summary.errors, report.summary.warnings
393    );
394}
395
396fn print_diagnostic(prefix: &str, diagnostic: &PresetDiagnostic) {
397    let severity = match diagnostic.severity {
398        PresetDiagnosticSeverity::Error => "error",
399        PresetDiagnosticSeverity::Warning => "warning",
400    };
401    if let Some(path) = &diagnostic.path {
402        println!(
403            "{prefix}{severity}[{}]: {} ({})",
404            diagnostic.code,
405            diagnostic.message,
406            path.display()
407        );
408    } else {
409        println!(
410            "{prefix}{severity}[{}]: {}",
411            diagnostic.code, diagnostic.message
412        );
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn write(path: impl AsRef<Path>, content: &str) {
421        let path = path.as_ref();
422        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
423        std::fs::write(path, content).unwrap();
424    }
425
426    async fn fixture_root(name: &str) -> PathBuf {
427        crate::test_support::make_temp_dir(name).await
428    }
429
430    #[tokio::test]
431    async fn missing_path_is_a_structured_input_error() {
432        let path = std::env::temp_dir().join("shine-preset-validation-does-not-exist");
433        let report = validate_path(&path).await;
434        assert!(!report.valid);
435        assert_eq!(report.schema_version, 1);
436        assert_eq!(report.summary.errors, 1);
437        assert_eq!(report.diagnostics[0].code, "invalid_input");
438    }
439
440    #[test]
441    fn json_contract_matches_schema_v1_golden() {
442        let report = finish_report(
443            PathBuf::from("/preset/root"),
444            Vec::new(),
445            vec![PresetCategoryValidation {
446                kind: "shell".to_string(),
447                name: "my-tools".to_string(),
448                path: PathBuf::from("/preset/root/shell/my-tools"),
449                valid: true,
450                diagnostics: Vec::new(),
451            }],
452        );
453        assert_eq!(
454            serde_json::to_string_pretty(&report).unwrap(),
455            r#"{
456  "schema_version": 1,
457  "valid": true,
458  "path": "/preset/root",
459  "summary": {
460    "categories": 1,
461    "errors": 0,
462    "warnings": 0
463  },
464  "categories": [
465    {
466      "kind": "shell",
467      "name": "my-tools",
468      "path": "/preset/root/shell/my-tools",
469      "valid": true,
470      "diagnostics": []
471    }
472  ]
473}"#
474        );
475    }
476
477    #[tokio::test]
478    async fn validates_repository_category_and_manifest_inputs() {
479        let root = fixture_root("preset-validation-valid").await;
480        write(
481            root.join("app/editor/shine.toml"),
482            r#"description = "Editor"
483dest = { unix = "~/.config/editor", windows = "~/AppData/Roaming/editor" }
484[[files]]
485source = "config.toml"
486"#,
487        );
488        write(root.join("app/editor/config.toml"), "theme = 'dark'\n");
489        write(
490            root.join("shell/tools/shine.toml"),
491            r#"description = "Tools"
492[[files]]
493source = "tool.sh"
494target = "tool"
495platforms = ["unix"]
496[[files]]
497source = "tool.ps1"
498target = "tool"
499platforms = ["windows"]
500"#,
501        );
502        write(root.join("shell/tools/tool.sh"), "#!/bin/sh\n");
503        write(root.join("shell/tools/tool.ps1"), "exit 0\n");
504        write(
505            root.join("sys/test-os/shine.toml"),
506            r#"version = 2
507default_profile = "recommended"
508[[items]]
509id = "git"
510label = "Git"
511detect = { kind = "command", command = "git" }
512install = { kind = "package", provider = "apt", package = "git" }
513[profiles.recommended]
514items = ["git"]
515"#,
516        );
517
518        let repository = validate_path(&root).await;
519        assert!(repository.valid, "{repository:#?}");
520        assert_eq!(repository.summary.categories, 3);
521
522        let category = validate_path(&root.join("shell/tools")).await;
523        assert!(category.valid, "{category:#?}");
524        assert_eq!(category.categories[0].kind, "shell");
525
526        let manifest = validate_path(&root.join("sys/test-os/shine.toml")).await;
527        assert!(manifest.valid, "{manifest:#?}");
528        assert_eq!(manifest.categories[0].name, "test-os");
529        std::fs::remove_dir_all(root).unwrap();
530    }
531
532    #[tokio::test]
533    async fn reports_other_platform_errors_and_partial_repository_failure() {
534        let root = fixture_root("preset-validation-invalid").await;
535        write(
536            root.join("app/editor/shine.toml"),
537            r#"dest = "~/.config/editor"
538[[files]]
539source = "missing.toml"
540"#,
541        );
542        write(
543            root.join("shell/tools/shine.toml"),
544            r#"[[files]]
545source = "tool.sh"
546platforms = ["plan9"]
547"#,
548        );
549        write(root.join("shell/tools/tool.sh"), "#!/bin/sh\n");
550        write(
551            root.join("sys/test-os/shine.toml"),
552            r#"version = 2
553default_profile = "missing"
554"#,
555        );
556
557        let report = validate_path(&root).await;
558        assert!(!report.valid);
559        assert_eq!(report.summary.categories, 3);
560        assert_eq!(report.summary.errors, 3);
561        assert_eq!(
562            report.categories[0].diagnostics[0].code,
563            "missing_reference"
564        );
565        assert_eq!(report.categories[1].diagnostics[0].code, "invalid_metadata");
566        assert_eq!(report.categories[2].diagnostics[0].code, "invalid_metadata");
567        std::fs::remove_dir_all(root).unwrap();
568    }
569
570    #[tokio::test]
571    async fn validation_never_executes_declared_code() {
572        let root = fixture_root("preset-validation-no-exec").await;
573        let category = root.join("app/tool");
574        let marker = category.join("executed");
575        write(
576            category.join("shine.toml"),
577            r#"dest = "~/.config/tool"
578post_install = { command = "./danger.sh" }
579[artifact]
580script = "danger.sh"
581runtime = "native"
582[[files]]
583source = "config.toml"
584generator = { script = "generate.sh", env = ["SOURCE"], when_env = "SOURCE" }
585"#,
586        );
587        write(category.join("config.toml"), "enabled = true\n");
588        write(
589            category.join("danger.sh"),
590            &format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
591        );
592        write(
593            category.join("generate.sh"),
594            &format!("#!/bin/sh\ntouch '{}'\n", marker.display()),
595        );
596
597        let report = validate_path(&category).await;
598        assert!(report.valid, "{report:#?}");
599        assert!(!marker.exists());
600        std::fs::remove_dir_all(root).unwrap();
601    }
602
603    #[tokio::test]
604    async fn enforces_duplicate_commands_and_locked_bun_pair() {
605        let root = fixture_root("preset-validation-shell-policy").await;
606        let category = root.join("shell/tools");
607        write(
608            category.join("shine.toml"),
609            r#"[[files]]
610source = "one.ts"
611target = "tool"
612runtime = "bun"
613[[files]]
614source = "two.ts"
615target = "tool"
616runtime = "bun"
617"#,
618        );
619        write(category.join("one.ts"), "console.log('one')\n");
620        write(category.join("two.ts"), "console.log('two')\n");
621        write(category.join("package.json"), "{\"dependencies\":{}}\n");
622
623        let missing_lock = validate_path(&category).await;
624        assert!(!missing_lock.valid);
625        assert_eq!(
626            missing_lock.categories[0].diagnostics[0].code,
627            "duplicate_command"
628        );
629
630        // Make targets unique so the dependency policy becomes the next stable
631        // diagnostic.
632        write(
633            category.join("shine.toml"),
634            r#"[[files]]
635source = "one.ts"
636target = "one"
637runtime = "bun"
638[[files]]
639source = "two.ts"
640target = "two"
641runtime = "bun"
642"#,
643        );
644        let missing_lock = validate_path(&category).await;
645        assert_eq!(
646            missing_lock.categories[0].diagnostics[0].code,
647            "bun_dependency_policy"
648        );
649        std::fs::remove_dir_all(root).unwrap();
650    }
651
652    #[tokio::test]
653    async fn validates_all_app_platform_destinations_and_duplicate_targets() {
654        let root = fixture_root("preset-validation-app-platforms").await;
655        let category = root.join("app/editor");
656        write(
657            category.join("shine.toml"),
658            r#"dest = { unix = "~/.config/editor", windows = "relative/windows" }
659[[files]]
660source = "one.toml"
661"#,
662        );
663        write(category.join("one.toml"), "one = true\n");
664
665        let invalid_windows = validate_path(&category).await;
666        assert!(!invalid_windows.valid);
667        assert_eq!(
668            invalid_windows.categories[0].diagnostics[0].code,
669            "invalid_metadata"
670        );
671
672        // Every declared branch is validated even when exact OS destinations
673        // shadow the Unix compatibility fallback on both Unix operating systems.
674        write(
675            category.join("shine.toml"),
676            r#"dest = { macos = "~/Library/Editor", linux = "~/.config/editor", unix = "relative/shadowed" }
677[[files]]
678source = "one.toml"
679"#,
680        );
681        let invalid_shadowed_unix = validate_path(&category).await;
682        assert!(!invalid_shadowed_unix.valid);
683        assert_eq!(
684            invalid_shadowed_unix.categories[0].diagnostics[0].code,
685            "invalid_metadata"
686        );
687
688        write(
689            category.join("shine.toml"),
690            r#"dest = "~/.config/editor"
691[[files]]
692source = "one.toml"
693target = "same.toml"
694[[files]]
695source = "two.toml"
696target = "same.toml"
697"#,
698        );
699        write(category.join("two.toml"), "two = true\n");
700        let duplicate = validate_path(&category).await;
701        assert_eq!(
702            duplicate.categories[0].diagnostics[0].code,
703            "duplicate_target"
704        );
705        std::fs::remove_dir_all(root).unwrap();
706    }
707
708    #[tokio::test]
709    async fn validates_exact_platforms_and_rejects_empty_platform_lists() {
710        let root = fixture_root("preset-validation-exact-platforms").await;
711        let category = root.join("shell/tools");
712        write(
713            category.join("shine.toml"),
714            r#"[[files]]
715source = "mac.sh"
716target = "tool"
717platforms = ["macos"]
718[[files]]
719source = "linux.sh"
720target = "tool"
721platforms = ["linux"]
722[[files]]
723source = "windows.ps1"
724target = "tool"
725platforms = ["windows"]
726"#,
727        );
728        write(category.join("mac.sh"), "#!/bin/sh\n");
729        write(category.join("linux.sh"), "#!/bin/sh\n");
730        write(category.join("windows.ps1"), "exit 0\n");
731
732        let valid = validate_path(&category).await;
733        assert!(valid.valid, "{valid:#?}");
734
735        write(
736            category.join("shine.toml"),
737            r#"[[files]]
738source = "mac.sh"
739target = "tool"
740platforms = []
741"#,
742        );
743        let empty = validate_path(&category).await;
744        assert!(!empty.valid);
745        assert_eq!(empty.categories[0].diagnostics[0].code, "invalid_metadata");
746
747        std::fs::remove_dir_all(root).unwrap();
748    }
749
750    #[tokio::test]
751    async fn unix_and_exact_shell_selectors_conflict_on_the_exact_os() {
752        let root = fixture_root("preset-validation-overlapping-platforms").await;
753        let category = root.join("shell/tools");
754        write(
755            category.join("shine.toml"),
756            r#"[[files]]
757source = "unix.sh"
758target = "tool"
759platforms = ["unix"]
760[[files]]
761source = "mac.sh"
762target = "tool"
763platforms = ["macos"]
764"#,
765        );
766        write(category.join("unix.sh"), "#!/bin/sh\n");
767        write(category.join("mac.sh"), "#!/bin/sh\n");
768
769        let report = validate_path(&category).await;
770        assert!(!report.valid);
771        assert_eq!(
772            report.categories[0].diagnostics[0].code,
773            "duplicate_command"
774        );
775        assert!(
776            report.categories[0].diagnostics[0]
777                .message
778                .contains("macos")
779        );
780
781        std::fs::remove_dir_all(root).unwrap();
782    }
783}