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