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