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