1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4
5use serde::de::{self, MapAccess, Visitor, value::MapAccessDeserializer};
6use serde::{Deserialize, Serialize};
7use toml::Spanned;
8
9use crate::context;
10use crate::discovery;
11use crate::path_filter::{IncludePatternIssue, invalid_include_pattern};
12use crate::paths::{self, UnsupportedPath};
13use crate::{EnvironmentInput, Error, Result, Worktree, WorktreeOptions};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct ConfigOptions {
18 pub cwd: Option<PathBuf>,
20 pub root: Option<PathBuf>,
22 pub environment: EnvironmentInput,
24 pub config: Option<PathBuf>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct LoadedConfig {
31 pub context: Worktree,
33 pub path: PathBuf,
35 pub config: Config,
37}
38
39pub type ConfigReport = LoadedConfig;
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44pub struct Config {
45 #[serde(flatten)]
47 pub options: ConfigRuntimeOptions,
48 pub files: Vec<FileOperation>,
50 pub commands: Vec<CommandOperation>,
52}
53
54impl Config {
55 pub fn load(path: &Path, context: &Worktree) -> Result<Self> {
65 let content = std::fs::read_to_string(path).map_err(|source| Error::ConfigIo {
66 path: path.to_path_buf(),
67 source,
68 })?;
69
70 Self::parse(path, &content, context)
71 }
72
73 pub fn parse(path: &Path, content: &str, context: &Worktree) -> Result<Self> {
82 parse_config(path, content, context)
83 }
84
85 pub fn discover_path(
95 context: &Worktree,
96 requested_config: Option<&Path>,
97 ) -> Result<Option<PathBuf>> {
98 discovery::discover_config(&context.worktree_path, requested_config)
99 }
100
101 pub fn load_discovered(
111 context: &Worktree,
112 requested_config: Option<&Path>,
113 ) -> Result<Option<LoadedConfig>> {
114 let Some(path) = Self::discover_path(context, requested_config)? else {
115 return Ok(None);
116 };
117 let config = Self::load(&path, context)?;
118
119 Ok(Some(LoadedConfig {
120 context: context.clone(),
121 path,
122 config,
123 }))
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
129pub struct FileOperation {
130 pub operation: FileOperationKind,
132 pub source: PathBuf,
134 pub target: PathBuf,
136 pub source_path: PathBuf,
138 pub target_path: PathBuf,
140 pub required: bool,
142 pub compare: Option<SyncCompare>,
144 pub delete: Option<bool>,
146 pub symlinks: Option<SymlinkMode>,
148 pub include: Vec<String>,
151 pub ignore: Vec<String>,
153 pub ignore_metadata: Vec<MetadataField>,
155 pub declaration: SourceSpan,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum FileOperationKind {
163 Copy,
165 Symlink,
167 Sync,
169}
170
171impl FileOperationKind {
172 #[must_use]
174 pub const fn as_str(self) -> &'static str {
175 match self {
176 Self::Copy => "copy",
177 Self::Symlink => "symlink",
178 Self::Sync => "sync",
179 }
180 }
181}
182
183impl fmt::Display for FileOperationKind {
184 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 formatter.write_str(self.as_str())
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum SyncCompare {
193 Metadata,
195 Checksum,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum SymlinkMode {
203 Preserve,
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum MetadataField {
211 Permissions,
213 Owner,
215 Group,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
220#[serde(rename_all = "snake_case")]
221pub(crate) enum RawMetadataField {
222 Permissions,
223 Owner,
224 Group,
225 Ownership,
226}
227
228impl RawMetadataField {
229 const fn expanded(self) -> &'static [MetadataField] {
230 match self {
231 Self::Permissions => &[MetadataField::Permissions],
232 Self::Owner => &[MetadataField::Owner],
233 Self::Group => &[MetadataField::Group],
234 Self::Ownership => &[MetadataField::Owner, MetadataField::Group],
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
241pub struct CommandOperation {
242 pub name: Option<String>,
244 pub command: CommandKind,
246 pub cwd: Option<PathBuf>,
248 pub cwd_path: Option<PathBuf>,
250 pub env: BTreeMap<String, String>,
252 pub allow_failure: bool,
254 pub declaration: SourceSpan,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
260#[serde(tag = "kind", rename_all = "snake_case")]
261pub enum CommandKind {
262 Shell {
264 run: String,
266 },
267 Direct {
269 program: String,
271 args: Vec<String>,
273 },
274}
275
276#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
278pub struct ConfigRuntimeOptions {
279 pub strict: bool,
281 pub default_ignore: Vec<String>,
283 pub dangerously_allow_sources_outside_root: bool,
285 pub dangerously_allow_targets_outside_worktree: bool,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
291pub struct SourceSpan {
292 pub start: usize,
294 pub end: usize,
296 pub line: usize,
298 pub column: usize,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub(crate) struct FileOperationSettingsInput {
304 pub(crate) compare: Option<SyncCompare>,
305 pub(crate) delete: Option<bool>,
306 pub(crate) symlinks: Option<SymlinkMode>,
307 pub(crate) include: Vec<String>,
308 pub(crate) ignore: Vec<String>,
309 pub(crate) ignore_metadata: Vec<RawMetadataField>,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub(crate) struct FileOperationSettings {
314 pub(crate) compare: Option<SyncCompare>,
315 pub(crate) delete: Option<bool>,
316 pub(crate) symlinks: Option<SymlinkMode>,
317 pub(crate) include: Vec<String>,
318 pub(crate) ignore: Vec<String>,
319 pub(crate) ignore_metadata: Vec<MetadataField>,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub(crate) enum InvalidFileOperationSettings {
324 Field(InvalidFileOperationField),
325 IncludeWithDelete,
326 IncludePattern {
327 pattern: String,
328 issue: IncludePatternIssue,
329 },
330}
331
332impl InvalidFileOperationSettings {
333 pub(crate) fn config_message(&self) -> String {
334 match self {
335 Self::Field(field) => format!(
336 "`{}` is only valid for {} file operations",
337 field.name(),
338 field.allowed_operations()
339 ),
340 Self::IncludeWithDelete => include_with_delete_message(),
341 Self::IncludePattern { pattern, issue } => issue.message(pattern),
342 }
343 }
344
345 pub(crate) fn manual_message(&self) -> String {
346 match self {
347 Self::Field(field) => format!(
348 "`{}` is only valid for {}",
349 field.name(),
350 field.allowed_operations()
351 ),
352 Self::IncludeWithDelete => include_with_delete_message(),
353 Self::IncludePattern { pattern, issue } => issue.message(pattern),
354 }
355 }
356}
357
358pub(crate) fn include_with_delete_message() -> String {
359 "`include` cannot be combined with `delete = true`; \
360 drop `delete` or narrow the operation source instead"
361 .to_owned()
362}
363
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub(crate) enum InvalidFileOperationField {
366 Compare,
367 Delete,
368 Symlinks,
369 Include,
370 Ignore,
371 IgnoreMetadata,
372}
373
374impl InvalidFileOperationField {
375 pub(crate) const fn name(self) -> &'static str {
376 match self {
377 Self::Compare => "compare",
378 Self::Delete => "delete",
379 Self::Symlinks => "symlinks",
380 Self::Include => "include",
381 Self::Ignore => "ignore",
382 Self::IgnoreMetadata => "ignore_metadata",
383 }
384 }
385
386 pub(crate) const fn allowed_operations(self) -> &'static str {
387 match self {
388 Self::Compare | Self::Delete => "sync",
389 Self::Symlinks | Self::Include | Self::Ignore | Self::IgnoreMetadata => "copy and sync",
390 }
391 }
392}
393
394pub(crate) fn normalize_file_operation_settings(
395 operation: FileOperationKind,
396 input: FileOperationSettingsInput,
397) -> std::result::Result<FileOperationSettings, InvalidFileOperationSettings> {
398 let compare = match operation {
399 FileOperationKind::Sync => Some(input.compare.unwrap_or(SyncCompare::Metadata)),
400 FileOperationKind::Copy | FileOperationKind::Symlink => {
401 if input.compare.is_some() {
402 return Err(InvalidFileOperationSettings::Field(
403 InvalidFileOperationField::Compare,
404 ));
405 }
406 None
407 }
408 };
409 let delete = match operation {
410 FileOperationKind::Sync => Some(input.delete.unwrap_or(false)),
411 FileOperationKind::Copy | FileOperationKind::Symlink => {
412 if input.delete.is_some() {
413 return Err(InvalidFileOperationSettings::Field(
414 InvalidFileOperationField::Delete,
415 ));
416 }
417 None
418 }
419 };
420 let symlinks = match operation {
421 FileOperationKind::Copy | FileOperationKind::Sync => {
422 Some(input.symlinks.unwrap_or(SymlinkMode::Preserve))
423 }
424 FileOperationKind::Symlink => {
425 if input.symlinks.is_some() {
426 return Err(InvalidFileOperationSettings::Field(
427 InvalidFileOperationField::Symlinks,
428 ));
429 }
430 None
431 }
432 };
433 let include = match operation {
434 FileOperationKind::Copy | FileOperationKind::Sync => {
435 for pattern in &input.include {
436 if let Some(issue) = invalid_include_pattern(pattern) {
437 return Err(InvalidFileOperationSettings::IncludePattern {
438 pattern: pattern.clone(),
439 issue,
440 });
441 }
442 }
443 input.include
444 }
445 FileOperationKind::Symlink => {
446 if !input.include.is_empty() {
447 return Err(InvalidFileOperationSettings::Field(
448 InvalidFileOperationField::Include,
449 ));
450 }
451 Vec::new()
452 }
453 };
454 if operation == FileOperationKind::Sync && delete == Some(true) && !include.is_empty() {
455 return Err(InvalidFileOperationSettings::IncludeWithDelete);
456 }
457 let ignore = match operation {
458 FileOperationKind::Copy | FileOperationKind::Sync => input.ignore,
459 FileOperationKind::Symlink => {
460 if !input.ignore.is_empty() {
461 return Err(InvalidFileOperationSettings::Field(
462 InvalidFileOperationField::Ignore,
463 ));
464 }
465 Vec::new()
466 }
467 };
468 let ignore_metadata = match operation {
469 FileOperationKind::Copy | FileOperationKind::Sync => {
470 normalize_ignored_metadata(input.ignore_metadata)
471 }
472 FileOperationKind::Symlink => {
473 if !input.ignore_metadata.is_empty() {
474 return Err(InvalidFileOperationSettings::Field(
475 InvalidFileOperationField::IgnoreMetadata,
476 ));
477 }
478 Vec::new()
479 }
480 };
481
482 Ok(FileOperationSettings {
483 compare,
484 delete,
485 symlinks,
486 include,
487 ignore,
488 ignore_metadata,
489 })
490}
491
492pub(crate) fn effective_ignore_patterns(
493 operation: FileOperationKind,
494 default_ignore: &[String],
495 ignore: Vec<String>,
496) -> Vec<String> {
497 match operation {
498 FileOperationKind::Copy | FileOperationKind::Sync => {
499 let mut effective = Vec::with_capacity(default_ignore.len() + ignore.len());
500 effective.extend(default_ignore.iter().cloned());
501 effective.extend(ignore);
502 effective
503 }
504 FileOperationKind::Symlink => ignore,
505 }
506}
507
508pub(crate) fn normalize_ignored_metadata(fields: Vec<RawMetadataField>) -> Vec<MetadataField> {
509 let mut normalized = Vec::new();
510 for field in fields {
511 for expanded in field.expanded() {
512 if !normalized.contains(expanded) {
513 normalized.push(*expanded);
514 }
515 }
516 }
517 normalized
518}
519
520pub fn inspect_config(options: ConfigOptions) -> Result<ConfigReport> {
528 let worktree_options = WorktreeOptions {
529 cwd: options.cwd,
530 root: options.root,
531 environment: options.environment,
532 };
533 let context = context::resolve(&worktree_options)?;
534 Config::load_discovered(&context, options.config.as_deref())?
535 .ok_or(Error::NoConfigDetectedStrict)
536}
537
538fn parse_config(path: &Path, content: &str, context: &Worktree) -> Result<Config> {
539 let raw: RawConfig = toml::from_str(content).map_err(|source| {
540 let message = parse_error_message(content, &source);
541 Error::ConfigParse {
542 path: path.to_path_buf(),
543 message,
544 }
545 })?;
546
547 let default_ignore = raw.default_ignore;
548 let mut files = Vec::new();
549 normalize_file_group(
550 path,
551 content,
552 context,
553 &mut files,
554 FileOperationKind::Copy,
555 raw.copy,
556 &default_ignore,
557 )?;
558 normalize_file_group(
559 path,
560 content,
561 context,
562 &mut files,
563 FileOperationKind::Symlink,
564 raw.symlink,
565 &default_ignore,
566 )?;
567 normalize_file_group(
568 path,
569 content,
570 context,
571 &mut files,
572 FileOperationKind::Sync,
573 raw.sync,
574 &default_ignore,
575 )?;
576 normalize_mixed_files(
577 path,
578 content,
579 context,
580 &mut files,
581 raw.files,
582 &default_ignore,
583 )?;
584 normalize_file_tables(
585 path,
586 content,
587 context,
588 &mut files,
589 raw.file,
590 &default_ignore,
591 )?;
592
593 let mut commands = Vec::new();
594 normalize_command_entries(path, content, context, &mut commands, raw.commands)?;
595 normalize_command_tables(path, content, context, &mut commands, raw.command)?;
596
597 Ok(Config {
598 options: ConfigRuntimeOptions {
599 strict: raw.strict,
600 default_ignore,
601 dangerously_allow_sources_outside_root: raw.dangerously_allow_sources_outside_root,
602 dangerously_allow_targets_outside_worktree: raw
603 .dangerously_allow_targets_outside_worktree,
604 },
605 files,
606 commands,
607 })
608}
609
610fn normalize_file_group(
611 path: &Path,
612 content: &str,
613 context: &Worktree,
614 files: &mut Vec<FileOperation>,
615 operation: FileOperationKind,
616 entries: Vec<Spanned<RawFileEntry>>,
617 default_ignore: &[String],
618) -> Result<()> {
619 for entry in entries {
620 let span = entry_span(content, &entry);
621 let entry = entry.into_inner();
622 let object = match entry {
623 RawFileEntry::Path(source) => RawFileObject {
624 operation: None,
625 source: Some(source),
626 target: None,
627 required: false,
628 compare: None,
629 delete: None,
630 symlinks: None,
631 include: Vec::new(),
632 ignore: Vec::new(),
633 ignore_metadata: Vec::new(),
634 },
635 RawFileEntry::Object(object) => object,
636 };
637
638 if object.operation.is_some() {
639 return invalid_config(
640 path,
641 content,
642 span,
643 "`operation` is only valid in `files` and `[[file]]` entries",
644 );
645 }
646
647 files.push(normalize_file_object(
648 path,
649 content,
650 context,
651 operation,
652 object,
653 span,
654 default_ignore,
655 )?);
656 }
657
658 Ok(())
659}
660
661fn normalize_mixed_files(
662 path: &Path,
663 content: &str,
664 context: &Worktree,
665 files: &mut Vec<FileOperation>,
666 entries: Vec<Spanned<RawFileObject>>,
667 default_ignore: &[String],
668) -> Result<()> {
669 for entry in entries {
670 let span = entry_span(content, &entry);
671 let object = entry.into_inner();
672 let operation = required_operation(path, content, span, object.operation)?;
673 files.push(normalize_file_object(
674 path,
675 content,
676 context,
677 operation,
678 object,
679 span,
680 default_ignore,
681 )?);
682 }
683
684 Ok(())
685}
686
687fn normalize_file_tables(
688 path: &Path,
689 content: &str,
690 context: &Worktree,
691 files: &mut Vec<FileOperation>,
692 entries: Vec<Spanned<RawFileObject>>,
693 default_ignore: &[String],
694) -> Result<()> {
695 normalize_mixed_files(path, content, context, files, entries, default_ignore)
696}
697
698fn normalize_file_object(
699 path: &Path,
700 content: &str,
701 context: &Worktree,
702 operation: FileOperationKind,
703 object: RawFileObject,
704 span: SourceSpan,
705 default_ignore: &[String],
706) -> Result<FileOperation> {
707 let source = object.source.ok_or_else(|| {
708 invalid_config_error(
709 path,
710 content,
711 span,
712 "file operation is missing required `source`",
713 )
714 })?;
715 let target = object.target.unwrap_or_else(|| source.clone());
716 let settings = normalize_file_operation_settings(
717 operation,
718 FileOperationSettingsInput {
719 compare: object.compare,
720 delete: object.delete,
721 symlinks: object.symlinks,
722 include: object.include,
723 ignore: object.ignore,
724 ignore_metadata: object.ignore_metadata,
725 },
726 )
727 .map_err(|invalid| invalid_config_error(path, content, span, invalid.config_message()))?;
728
729 Ok(FileOperation {
730 operation,
731 source_path: resolve_path(path, content, span, &context.root_path, Path::new(&source))?,
732 target_path: resolve_target_path(
733 path,
734 content,
735 span,
736 &context.worktree_path,
737 Path::new(&target),
738 )?,
739 source: PathBuf::from(source),
740 target: PathBuf::from(target),
741 required: object.required,
742 compare: settings.compare,
743 delete: settings.delete,
744 symlinks: settings.symlinks,
745 include: settings.include,
746 ignore: effective_ignore_patterns(operation, default_ignore, settings.ignore),
747 ignore_metadata: settings.ignore_metadata,
748 declaration: span,
749 })
750}
751
752fn required_operation(
753 path: &Path,
754 content: &str,
755 span: SourceSpan,
756 operation: Option<FileOperationKind>,
757) -> Result<FileOperationKind> {
758 operation.ok_or_else(|| {
759 invalid_config_error(
760 path,
761 content,
762 span,
763 "file operation is missing required `operation`",
764 )
765 })
766}
767
768fn normalize_command_entries(
769 path: &Path,
770 content: &str,
771 context: &Worktree,
772 commands: &mut Vec<CommandOperation>,
773 entries: Vec<Spanned<RawCommandEntry>>,
774) -> Result<()> {
775 for entry in entries {
776 let span = entry_span(content, &entry);
777 let object = match entry.into_inner() {
778 RawCommandEntry::Run(run) => RawCommandObject {
779 name: None,
780 run: Some(run),
781 program: None,
782 args: None,
783 cwd: None,
784 env: BTreeMap::new(),
785 allow_failure: false,
786 },
787 RawCommandEntry::Object(object) => object,
788 };
789
790 commands.push(normalize_command_object(
791 path, content, context, object, span,
792 )?);
793 }
794
795 Ok(())
796}
797
798fn normalize_command_tables(
799 path: &Path,
800 content: &str,
801 context: &Worktree,
802 commands: &mut Vec<CommandOperation>,
803 entries: Vec<Spanned<RawCommandObject>>,
804) -> Result<()> {
805 for entry in entries {
806 let span = entry_span(content, &entry);
807 commands.push(normalize_command_object(
808 path,
809 content,
810 context,
811 entry.into_inner(),
812 span,
813 )?);
814 }
815
816 Ok(())
817}
818
819fn normalize_command_object(
820 path: &Path,
821 content: &str,
822 context: &Worktree,
823 object: RawCommandObject,
824 span: SourceSpan,
825) -> Result<CommandOperation> {
826 let command = match (object.run, object.program) {
827 (Some(_), Some(_)) => {
828 return invalid_config(
829 path,
830 content,
831 span,
832 "`run` and `program` are mutually exclusive",
833 );
834 }
835 (Some(_), None) if object.args.is_some() => {
836 return invalid_config(path, content, span, "`args` requires `program`");
837 }
838 (Some(run), None) => CommandKind::Shell { run },
839 (None, Some(program)) => CommandKind::Direct {
840 program,
841 args: object.args.unwrap_or_default(),
842 },
843 (None, None) => {
844 return invalid_config(
845 path,
846 content,
847 span,
848 "command is missing required `run` or `program`",
849 );
850 }
851 };
852 let cwd_path = object
853 .cwd
854 .as_ref()
855 .map(|cwd| resolve_path(path, content, span, &context.worktree_path, Path::new(cwd)))
856 .transpose()?;
857
858 Ok(CommandOperation {
859 name: object.name,
860 command,
861 cwd: object.cwd.map(PathBuf::from),
862 cwd_path,
863 env: object.env,
864 allow_failure: object.allow_failure,
865 declaration: span,
866 })
867}
868
869fn resolve_path(
870 config_path: &Path,
871 content: &str,
872 span: SourceSpan,
873 base: &Path,
874 path: &Path,
875) -> Result<PathBuf> {
876 let resolved = paths::resolve_path(base, path).map_err(|source| {
877 invalid_config_error(
878 config_path,
879 content,
880 span,
881 unsupported_path_message(path, source),
882 )
883 })?;
884
885 paths::normalize_maybe_existing(&resolved).map_err(|source| {
886 invalid_config_error(
887 config_path,
888 content,
889 span,
890 normalize_path_message(path, source),
891 )
892 })
893}
894
895fn resolve_target_path(
896 config_path: &Path,
897 content: &str,
898 span: SourceSpan,
899 base: &Path,
900 path: &Path,
901) -> Result<PathBuf> {
902 let resolved = paths::resolve_path(base, path).map_err(|source| {
903 invalid_config_error(
904 config_path,
905 content,
906 span,
907 unsupported_path_message(path, source),
908 )
909 })?;
910
911 let Some(name) = resolved.file_name() else {
912 return paths::normalize_maybe_existing(&resolved).map_err(|source| {
913 invalid_config_error(
914 config_path,
915 content,
916 span,
917 normalize_path_message(path, source),
918 )
919 });
920 };
921 let parent = resolved.parent().unwrap_or_else(|| Path::new("."));
922 let mut normalized = paths::normalize_maybe_existing(parent).map_err(|source| {
923 invalid_config_error(
924 config_path,
925 content,
926 span,
927 normalize_path_message(path, source),
928 )
929 })?;
930 normalized.push(name);
931
932 Ok(paths::normalize_lexical(&normalized))
933}
934
935fn unsupported_path_message(path: &Path, source: UnsupportedPath) -> String {
936 format!("unsupported path `{}`: {}", path.display(), source.reason())
937}
938
939fn normalize_path_message(path: &Path, source: std::io::Error) -> String {
940 format!("failed to normalize path `{}`: {}", path.display(), source)
941}
942
943fn parse_error_message(content: &str, error: &toml::de::Error) -> String {
944 match error.span() {
945 Some(span) => format!("{} {}", error.message(), location_suffix(content, &span)),
946 None => error.message().to_owned(),
947 }
948}
949
950fn invalid_config<T>(
951 path: &Path,
952 content: &str,
953 span: SourceSpan,
954 message: impl Into<String>,
955) -> Result<T> {
956 Err(invalid_config_error(path, content, span, message))
957}
958
959fn invalid_config_error(
960 path: &Path,
961 content: &str,
962 span: SourceSpan,
963 message: impl Into<String>,
964) -> Error {
965 Error::ConfigInvalid {
966 path: path.to_path_buf(),
967 message: format!(
968 "{} {}",
969 message.into(),
970 location_suffix(content, &(span.start..span.end))
971 ),
972 }
973}
974
975fn entry_span<T>(content: &str, entry: &Spanned<T>) -> SourceSpan {
976 SourceSpan::from_range(content, entry.span())
977}
978
979fn location_suffix(content: &str, range: &std::ops::Range<usize>) -> String {
980 let span = SourceSpan::from_range(content, range.clone());
981 format!("at line {}, column {}", span.line, span.column)
982}
983
984impl SourceSpan {
985 fn from_range(content: &str, range: std::ops::Range<usize>) -> Self {
986 let (line, column) = line_column(content, range.start);
987
988 Self {
989 start: range.start,
990 end: range.end,
991 line,
992 column,
993 }
994 }
995}
996
997fn line_column(content: &str, offset: usize) -> (usize, usize) {
998 let mut line = 1;
999 let mut column = 1;
1000
1001 for character in content[..offset.min(content.len())].chars() {
1002 if character == '\n' {
1003 line += 1;
1004 column = 1;
1005 } else {
1006 column += 1;
1007 }
1008 }
1009
1010 (line, column)
1011}
1012
1013#[derive(Debug, Default, Deserialize)]
1014#[serde(default, deny_unknown_fields)]
1015struct RawConfig {
1016 strict: bool,
1017 default_ignore: Vec<String>,
1018 dangerously_allow_sources_outside_root: bool,
1019 dangerously_allow_targets_outside_worktree: bool,
1020 copy: Vec<Spanned<RawFileEntry>>,
1021 symlink: Vec<Spanned<RawFileEntry>>,
1022 sync: Vec<Spanned<RawFileEntry>>,
1023 files: Vec<Spanned<RawFileObject>>,
1024 file: Vec<Spanned<RawFileObject>>,
1025 commands: Vec<Spanned<RawCommandEntry>>,
1026 command: Vec<Spanned<RawCommandObject>>,
1027}
1028
1029#[derive(Debug)]
1030enum RawFileEntry {
1031 Path(String),
1032 Object(RawFileObject),
1033}
1034
1035impl<'de> Deserialize<'de> for RawFileEntry {
1036 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1037 where
1038 D: serde::Deserializer<'de>,
1039 {
1040 struct RawFileEntryVisitor;
1041
1042 impl<'de> Visitor<'de> for RawFileEntryVisitor {
1043 type Value = RawFileEntry;
1044
1045 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1046 formatter.write_str("a path string or file operation object")
1047 }
1048
1049 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
1050 where
1051 E: de::Error,
1052 {
1053 Ok(RawFileEntry::Path(value.to_owned()))
1054 }
1055
1056 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
1057 where
1058 E: de::Error,
1059 {
1060 Ok(RawFileEntry::Path(value))
1061 }
1062
1063 fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
1064 where
1065 M: MapAccess<'de>,
1066 {
1067 RawFileObject::deserialize(MapAccessDeserializer::new(map))
1068 .map(RawFileEntry::Object)
1069 }
1070 }
1071
1072 deserializer.deserialize_any(RawFileEntryVisitor)
1073 }
1074}
1075
1076#[derive(Debug, Default, Deserialize)]
1077#[serde(default, deny_unknown_fields)]
1078struct RawFileObject {
1079 operation: Option<FileOperationKind>,
1080 source: Option<String>,
1081 target: Option<String>,
1082 required: bool,
1083 compare: Option<SyncCompare>,
1084 delete: Option<bool>,
1085 symlinks: Option<SymlinkMode>,
1086 include: Vec<String>,
1087 ignore: Vec<String>,
1088 ignore_metadata: Vec<RawMetadataField>,
1089}
1090
1091#[derive(Debug)]
1092enum RawCommandEntry {
1093 Run(String),
1094 Object(RawCommandObject),
1095}
1096
1097impl<'de> Deserialize<'de> for RawCommandEntry {
1098 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1099 where
1100 D: serde::Deserializer<'de>,
1101 {
1102 struct RawCommandEntryVisitor;
1103
1104 impl<'de> Visitor<'de> for RawCommandEntryVisitor {
1105 type Value = RawCommandEntry;
1106
1107 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1108 formatter.write_str("a shell command string or command object")
1109 }
1110
1111 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
1112 where
1113 E: de::Error,
1114 {
1115 Ok(RawCommandEntry::Run(value.to_owned()))
1116 }
1117
1118 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
1119 where
1120 E: de::Error,
1121 {
1122 Ok(RawCommandEntry::Run(value))
1123 }
1124
1125 fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
1126 where
1127 M: MapAccess<'de>,
1128 {
1129 RawCommandObject::deserialize(MapAccessDeserializer::new(map))
1130 .map(RawCommandEntry::Object)
1131 }
1132 }
1133
1134 deserializer.deserialize_any(RawCommandEntryVisitor)
1135 }
1136}
1137
1138#[derive(Debug, Default, Deserialize)]
1139#[serde(default, deny_unknown_fields)]
1140struct RawCommandObject {
1141 name: Option<String>,
1142 run: Option<String>,
1143 program: Option<String>,
1144 args: Option<Vec<String>>,
1145 cwd: Option<String>,
1146 env: BTreeMap<String, String>,
1147 allow_failure: bool,
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use std::ffi::OsString;
1153
1154 use crate::test_support::symlink_dir;
1155
1156 use super::*;
1157
1158 fn context() -> Worktree {
1159 Worktree {
1160 root_path: PathBuf::from("/repo"),
1161 worktree_path: PathBuf::from("/repo-worktree"),
1162 default_branch: "main".to_owned(),
1163 environment: BTreeMap::from([(
1164 "TREEBOOT_ROOT_PATH".to_owned(),
1165 OsString::from("/repo"),
1166 )]),
1167 }
1168 }
1169
1170 fn parse(content: &str) -> Config {
1171 parse_config(Path::new(".treeboot.toml"), content, &context()).expect("config should parse")
1172 }
1173
1174 fn parse_error(content: &str) -> String {
1175 parse_config(Path::new(".treeboot.toml"), content, &context())
1176 .expect_err("config should fail")
1177 .to_string()
1178 }
1179
1180 fn assert_parse_error_contains(content: &str, expected: &str) {
1181 let error = parse_error(content);
1182
1183 assert!(
1184 error.contains(expected),
1185 "expected error to contain {expected:?}, got {error:?}"
1186 );
1187 }
1188
1189 fn toml_basic_string_path(path: &Path) -> String {
1190 path.display()
1191 .to_string()
1192 .replace('\\', "\\\\")
1193 .replace('"', "\\\"")
1194 }
1195
1196 #[test]
1197 fn parse_config_should_normalize_file_operations_in_spec_order() {
1198 let config = parse(
1199 r#"
1200sync = ["sync-dir"]
1201copy = [".env"]
1202symlink = [{ source = "shared/bin", target = "bin" }]
1203files = [{ operation = "copy", source = ".npmrc" }]
1204
1205[[file]]
1206operation = "sync"
1207source = "editor"
1208target = ".editor"
1209"#,
1210 );
1211
1212 let operations = config
1213 .files
1214 .iter()
1215 .map(|operation| (operation.operation, operation.source.as_path()))
1216 .collect::<Vec<_>>();
1217
1218 assert_eq!(
1219 operations,
1220 vec![
1221 (FileOperationKind::Copy, Path::new(".env")),
1222 (FileOperationKind::Symlink, Path::new("shared/bin")),
1223 (FileOperationKind::Sync, Path::new("sync-dir")),
1224 (FileOperationKind::Copy, Path::new(".npmrc")),
1225 (FileOperationKind::Sync, Path::new("editor")),
1226 ]
1227 );
1228 }
1229
1230 #[test]
1231 fn parse_config_should_apply_file_defaults() {
1232 let config = parse(
1233 r#"
1234copy = [{ source = ".env.local" }]
1235sync = ["shared/config"]
1236"#,
1237 );
1238
1239 let copy = &config.files[0];
1240 let sync = &config.files[1];
1241
1242 assert_eq!(copy.target, PathBuf::from(".env.local"));
1243 assert!(!copy.required);
1244 assert_eq!(copy.symlinks, Some(SymlinkMode::Preserve));
1245 assert!(copy.ignore.is_empty());
1246 assert!(copy.ignore_metadata.is_empty());
1247 assert_eq!(sync.compare, Some(SyncCompare::Metadata));
1248 assert_eq!(sync.delete, Some(false));
1249 assert!(sync.ignore.is_empty());
1250 assert!(sync.ignore_metadata.is_empty());
1251 }
1252
1253 #[test]
1254 fn parse_config_should_preserve_explicit_sync_options() {
1255 let config = parse(
1256 r#"
1257sync = [{
1258 source = "shared/config",
1259 compare = "checksum",
1260 delete = true,
1261 symlinks = "preserve",
1262}]
1263"#,
1264 );
1265
1266 let sync = &config.files[0];
1267
1268 assert_eq!(sync.compare, Some(SyncCompare::Checksum));
1269 assert_eq!(sync.delete, Some(true));
1270 assert_eq!(sync.symlinks, Some(SymlinkMode::Preserve));
1271 }
1272
1273 #[test]
1274 fn parse_config_should_normalize_ignored_metadata() {
1275 let config = parse(
1276 r#"
1277copy = [{ source = ".env", ignore_metadata = ["ownership", "permissions", "owner"] }]
1278sync = [{ source = "shared", ignore_metadata = ["group"] }]
1279"#,
1280 );
1281
1282 assert_eq!(
1283 config.files[0].ignore_metadata,
1284 vec![
1285 MetadataField::Owner,
1286 MetadataField::Group,
1287 MetadataField::Permissions,
1288 ]
1289 );
1290 assert_eq!(config.files[1].ignore_metadata, vec![MetadataField::Group]);
1291 }
1292
1293 #[test]
1294 fn parse_config_should_preserve_explicit_include_patterns() {
1295 let config = parse(
1296 r#"
1297copy = [{ source = "shared", include = ["docs/**", "*.toml"] }]
1298sync = [{ source = "editor", include = ["settings/**"] }]
1299"#,
1300 );
1301
1302 assert_eq!(config.files[0].include, vec!["docs/**", "*.toml"]);
1303 assert_eq!(config.files[1].include, vec!["settings/**"]);
1304 }
1305
1306 #[test]
1307 fn parse_config_should_default_include_to_empty() {
1308 let config = parse("copy = [\"shared\"]\n");
1309
1310 assert!(config.files[0].include.is_empty());
1311 }
1312
1313 #[test]
1314 fn parse_config_should_reject_include_on_symlink_file_operations() {
1315 assert_parse_error_contains(
1316 "symlink = [{ source = \"shared\", include = [\"docs/**\"] }]\n",
1317 "`include` is only valid for copy and sync file operations",
1318 );
1319 assert_parse_error_contains(
1320 r#"
1321[[file]]
1322operation = "symlink"
1323source = "shared"
1324include = ["docs/**"]
1325"#,
1326 "`include` is only valid for copy and sync file operations",
1327 );
1328 }
1329
1330 #[test]
1331 fn parse_config_should_reject_include_with_sync_delete() {
1332 assert_parse_error_contains(
1333 "sync = [{ source = \"shared\", include = [\"docs/**\"], delete = true }]\n",
1334 "`include` cannot be combined with `delete = true`",
1335 );
1336 assert_parse_error_contains(
1337 r#"
1338files = [{ operation = "sync", source = "shared", include = ["docs/**"], delete = true }]
1339"#,
1340 "`include` cannot be combined with `delete = true`",
1341 );
1342 }
1343
1344 #[test]
1345 fn parse_config_should_allow_empty_include_with_sync_delete() {
1346 let config = parse("sync = [{ source = \"shared\", include = [], delete = true }]\n");
1347
1348 assert!(config.files[0].include.is_empty());
1349 assert_eq!(config.files[0].delete, Some(true));
1350 }
1351
1352 #[test]
1353 fn parse_config_should_reject_inert_include_patterns() {
1354 assert_parse_error_contains(
1355 "copy = [{ source = \"shared\", include = [\"!docs\"] }]\n",
1356 "uses `!` negation",
1357 );
1358 assert_parse_error_contains(
1359 "copy = [{ source = \"shared\", include = [\"\"] }]\n",
1360 "include patterns cannot be blank",
1361 );
1362 assert_parse_error_contains(
1363 "copy = [{ source = \"shared\", include = [\"# docs\"] }]\n",
1364 "is a gitignore comment",
1365 );
1366 }
1367
1368 #[test]
1369 fn parse_config_should_accept_escaped_include_prefixes() {
1370 let config =
1371 parse(r#"copy = [{ source = "shared", include = ['\!literal', '\#literal'] }]"#);
1372
1373 assert_eq!(config.files[0].include, vec![r"\!literal", r"\#literal"]);
1374 }
1375
1376 #[test]
1377 fn parse_config_should_keep_comment_and_blank_patterns_valid_in_ignore() {
1378 let config =
1379 parse("copy = [{ source = \"shared\", ignore = [\"# comment\", \"\", \"!keep\"] }]\n");
1380
1381 assert_eq!(config.files[0].ignore, vec!["# comment", "", "!keep"]);
1382 }
1383
1384 #[test]
1385 fn parse_config_should_preserve_explicit_ignore_patterns() {
1386 let config = parse(
1387 r#"
1388copy = [{ source = ".env", ignore = ["**/vendor/**", "!**/vendor/keep/**"] }]
1389sync = [{ source = "shared", ignore = ["cache/", "!cache/keep"] }]
1390"#,
1391 );
1392
1393 assert_eq!(
1394 config.files[0].ignore,
1395 vec!["**/vendor/**", "!**/vendor/keep/**"]
1396 );
1397 assert_eq!(config.files[1].ignore, vec!["cache/", "!cache/keep"]);
1398 }
1399
1400 #[test]
1401 fn parse_config_should_prepend_default_ignore_to_copy_and_sync() {
1402 let config = parse(
1403 r#"
1404default_ignore = [".DS_Store", "Thumbs.db"]
1405copy = [{ source = ".env", ignore = ["!.DS_Store"] }]
1406sync = [{ source = "shared", ignore = ["cache/"] }]
1407"#,
1408 );
1409
1410 assert_eq!(
1411 config.options.default_ignore,
1412 vec![".DS_Store", "Thumbs.db"]
1413 );
1414 assert_eq!(
1415 config.files[0].ignore,
1416 vec![".DS_Store", "Thumbs.db", "!.DS_Store"]
1417 );
1418 assert_eq!(
1419 config.files[1].ignore,
1420 vec![".DS_Store", "Thumbs.db", "cache/"]
1421 );
1422 }
1423
1424 #[test]
1425 fn parse_config_should_prepend_default_ignore_to_mixed_file_entries() {
1426 let config = parse(
1427 r#"
1428default_ignore = [".DS_Store"]
1429files = [
1430 { operation = "copy", source = ".env", ignore = ["!.DS_Store"] },
1431 { operation = "symlink", source = "bin" },
1432]
1433
1434[[file]]
1435operation = "sync"
1436source = "shared"
1437ignore = ["cache/"]
1438"#,
1439 );
1440
1441 assert_eq!(config.files[0].ignore, vec![".DS_Store", "!.DS_Store"]);
1442 assert!(config.files[1].ignore.is_empty());
1443 assert_eq!(config.files[2].ignore, vec![".DS_Store", "cache/"]);
1444 }
1445
1446 #[test]
1447 fn parse_config_should_not_apply_default_ignore_to_symlink() {
1448 let config = parse(
1449 r#"
1450default_ignore = [".DS_Store"]
1451symlink = ["shared/bin"]
1452"#,
1453 );
1454
1455 assert!(config.files[0].ignore.is_empty());
1456 }
1457
1458 #[test]
1459 fn parse_config_should_reject_ignore_on_symlink_file_operations() {
1460 assert_parse_error_contains(
1461 r#"
1462symlink = [{ source = "link", ignore = ["**/tmp/**"] }]
1463"#,
1464 "`ignore` is only valid for copy and sync",
1465 );
1466 }
1467
1468 #[test]
1469 fn parse_config_should_reject_ignored_metadata_on_symlink_file_operations() {
1470 assert_parse_error_contains(
1471 r#"
1472symlink = [{ source = "link", ignore_metadata = ["ownership"] }]
1473"#,
1474 "`ignore_metadata` is only valid for copy and sync",
1475 );
1476 }
1477
1478 #[test]
1479 fn parse_config_should_apply_runtime_options() {
1480 let config = parse(
1481 r#"
1482strict = true
1483default_ignore = [".DS_Store"]
1484dangerously_allow_sources_outside_root = true
1485dangerously_allow_targets_outside_worktree = true
1486"#,
1487 );
1488
1489 assert!(config.options.strict);
1490 assert_eq!(config.options.default_ignore, vec![".DS_Store"]);
1491 assert!(config.options.dangerously_allow_sources_outside_root);
1492 assert!(config.options.dangerously_allow_targets_outside_worktree);
1493 }
1494
1495 #[test]
1496 fn parse_config_should_reject_nested_validation_options() {
1497 assert_parse_error_contains(
1498 r#"
1499[validation]
1500dangerously_allow_sources_outside_root = true
1501"#,
1502 "unknown field",
1503 );
1504 }
1505
1506 #[test]
1507 fn parse_config_should_resolve_absolute_paths_without_rebasing() {
1508 let temp = std::env::temp_dir().join("treeboot-config-absolute-paths");
1509 let source = temp.join("shared").join("..").join(".env");
1510 let target = temp.join("worktree").join("..").join("worktree/.env");
1511 let cwd = temp.join("worktree").join("..").join("worktree/app");
1512 let config = parse(&format!(
1513 r#"
1514copy = [{{ source = "{}", target = "{}" }}]
1515commands = [{{ program = "make", cwd = "{}" }}]
1516"#,
1517 toml_basic_string_path(&source),
1518 toml_basic_string_path(&target),
1519 toml_basic_string_path(&cwd),
1520 ));
1521
1522 assert_eq!(
1523 config.files[0].source_path,
1524 paths::normalize_maybe_existing(&temp.join(".env")).expect("source should normalize")
1525 );
1526 assert_eq!(
1527 config.files[0].target_path,
1528 paths::normalize_maybe_existing(&temp.join("worktree/.env"))
1529 .expect("target should normalize")
1530 );
1531 assert_eq!(
1532 config.commands[0].cwd_path,
1533 Some(
1534 paths::normalize_maybe_existing(&temp.join("worktree/app"))
1535 .expect("cwd should normalize")
1536 )
1537 );
1538 }
1539
1540 #[test]
1541 fn parse_config_should_normalize_relative_paths_through_existing_aliases() {
1542 let temp = tempfile::TempDir::new().expect("tempdir should be created");
1543 let actual = temp.path().join("actual");
1544 let root = actual.join("root");
1545 let worktree = actual.join("worktree");
1546 let alias = temp.path().join("alias");
1547 let alias_root = alias.join("root");
1548 let alias_worktree = alias.join("worktree");
1549 std::fs::create_dir_all(root.join("shared")).expect("root source dir should be created");
1550 std::fs::create_dir_all(worktree.join("app")).expect("worktree app dir should be created");
1551 std::fs::create_dir_all(&alias).expect("alias dir should be created");
1552 symlink_dir(&root, &alias_root).expect("root alias should be created");
1553 symlink_dir(&worktree, &alias_worktree).expect("worktree alias should be created");
1554
1555 let context = Worktree {
1556 root_path: alias_root,
1557 worktree_path: alias_worktree,
1558 default_branch: "main".to_owned(),
1559 environment: BTreeMap::new(),
1560 };
1561 let config = parse_config(
1562 Path::new(".treeboot.toml"),
1563 r#"
1564copy = [{ source = "shared/.env", target = ".env" }]
1565commands = [{ program = "make", cwd = "app" }]
1566"#,
1567 &context,
1568 )
1569 .expect("config should parse");
1570
1571 assert_eq!(
1572 config.files[0].source_path,
1573 paths::normalize_maybe_existing(&root.join("shared/.env"))
1574 .expect("source should normalize through alias")
1575 );
1576 assert_eq!(
1577 config.files[0].target_path,
1578 paths::normalize_maybe_existing(&worktree.join(".env"))
1579 .expect("target should normalize through alias")
1580 );
1581 assert_eq!(
1582 config.commands[0].cwd_path,
1583 Some(
1584 paths::normalize_maybe_existing(&worktree.join("app"))
1585 .expect("cwd should normalize through alias")
1586 )
1587 );
1588 }
1589
1590 #[cfg(windows)]
1591 #[test]
1592 fn parse_config_should_reject_drive_relative_windows_paths() {
1593 assert_parse_error_contains(
1594 r#"copy = [{ source = 'C:shared/.env' }]"#,
1595 "drive-relative paths are not supported",
1596 );
1597 }
1598
1599 #[cfg(windows)]
1600 #[test]
1601 fn parse_config_should_reject_root_relative_windows_paths() {
1602 assert_parse_error_contains(
1603 r#"commands = [{ program = "git", cwd = '\app' }]"#,
1604 "root-relative paths without a drive or share are not supported",
1605 );
1606 }
1607
1608 #[test]
1609 fn parse_config_should_normalize_command_forms() {
1610 let config = parse(
1611 r#"
1612commands = [
1613 "mise install",
1614 { run = "bundle install" },
1615]
1616
1617[[command]]
1618program = "npm"
1619args = ["install"]
1620cwd = "web"
1621allow_failure = true
1622"#,
1623 );
1624
1625 assert_eq!(config.commands.len(), 3);
1626 assert_eq!(
1627 config.commands[0].command,
1628 CommandKind::Shell {
1629 run: "mise install".to_owned()
1630 }
1631 );
1632 assert_eq!(
1633 config.commands[2].command,
1634 CommandKind::Direct {
1635 program: "npm".to_owned(),
1636 args: vec!["install".to_owned()]
1637 }
1638 );
1639 assert_eq!(
1640 config.commands[2].cwd_path,
1641 Some(
1642 paths::normalize_maybe_existing(&context().worktree_path.join("web"))
1643 .expect("expected cwd should normalize")
1644 )
1645 );
1646 }
1647
1648 #[test]
1649 fn parse_config_should_normalize_command_metadata_and_defaults() {
1650 let config = parse(
1651 r#"
1652commands = [{
1653 name = "Install",
1654 program = "npm",
1655 env = { NODE_ENV = "development" },
1656}]
1657"#,
1658 );
1659
1660 let command = &config.commands[0];
1661
1662 assert_eq!(command.name.as_deref(), Some("Install"));
1663 assert_eq!(command.env["NODE_ENV"], "development");
1664 assert!(!command.allow_failure);
1665 }
1666
1667 #[test]
1668 fn parse_config_should_reject_async_command_field() {
1669 assert_parse_error_contains(
1670 r#"commands = [{ run = "npm install", async = true }]"#,
1671 "unknown field",
1672 );
1673 assert_parse_error_contains(
1674 r#"commands = [{ run = "npm install", async = false }]"#,
1675 "unknown field",
1676 );
1677 }
1678
1679 #[test]
1680 fn parse_config_should_allow_program_without_args() {
1681 let config = parse(r#"commands = [{ program = "mise" }]"#);
1682
1683 assert_eq!(
1684 config.commands[0].command,
1685 CommandKind::Direct {
1686 program: "mise".to_owned(),
1687 args: Vec::new()
1688 }
1689 );
1690 }
1691
1692 #[test]
1693 fn parse_config_should_reject_mutually_exclusive_command_fields() {
1694 assert_parse_error_contains(
1695 r#"commands = [{ run = "npm install", program = "npm" }]"#,
1696 "mutually exclusive",
1697 );
1698 }
1699
1700 #[test]
1701 fn parse_config_should_reject_args_without_program() {
1702 assert_parse_error_contains(
1703 r#"commands = [{ run = "npm install", args = [] }]"#,
1704 "`args` requires `program`",
1705 );
1706 }
1707
1708 #[test]
1709 fn parse_config_should_reject_missing_command_invocation() {
1710 assert_parse_error_contains(
1711 r#"commands = [{ name = "Install" }]"#,
1712 "missing required `run` or `program`",
1713 );
1714 }
1715
1716 #[test]
1717 fn parse_config_should_reject_unknown_fields() {
1718 assert_parse_error_contains(
1719 r#"copy = [{ source = ".env", unknown = true }]"#,
1720 "unknown field",
1721 );
1722 }
1723
1724 #[test]
1725 fn parse_config_should_reject_missing_file_operation() {
1726 assert_parse_error_contains(
1727 r#"files = [{ source = ".env" }]"#,
1728 "missing required `operation`",
1729 );
1730 }
1731
1732 #[test]
1733 fn parse_config_should_reject_missing_file_source() {
1734 assert_parse_error_contains(
1735 r#"copy = [{ target = ".env" }]"#,
1736 "missing required `source`",
1737 );
1738 }
1739
1740 #[test]
1741 fn parse_config_should_reject_operation_in_specific_file_groups() {
1742 assert_parse_error_contains(
1743 r#"copy = [{ operation = "copy", source = ".env" }]"#,
1744 "`operation` is only valid in `files` and `[[file]]` entries",
1745 );
1746 }
1747
1748 #[test]
1749 fn parse_config_should_reject_compare_on_copy_file_operations() {
1750 assert_parse_error_contains(
1751 r#"copy = [{ source = ".env", compare = "checksum" }]"#,
1752 "`compare` is only valid for sync file operations",
1753 );
1754 }
1755
1756 #[test]
1757 fn parse_config_should_reject_delete_on_symlink_file_operations() {
1758 assert_parse_error_contains(
1759 r#"symlink = [{ source = ".env", delete = true }]"#,
1760 "`delete` is only valid for sync file operations",
1761 );
1762 }
1763
1764 #[test]
1765 fn parse_config_should_reject_legacy_delete_extra_field() {
1766 assert_parse_error_contains(
1767 r#"sync = [{ source = "shared", delete_extra = true }]"#,
1768 "unknown field `delete_extra`",
1769 );
1770 }
1771
1772 #[test]
1773 fn parse_config_should_reject_symlinks_on_symlink_file_operations() {
1774 assert_parse_error_contains(
1775 r#"symlink = [{ source = ".env", symlinks = "preserve" }]"#,
1776 "`symlinks` is only valid for copy and sync file operations",
1777 );
1778 }
1779
1780 #[test]
1781 fn parse_config_should_report_invalid_toml_location() {
1782 assert_parse_error_contains("commands = [\n", "line 1, column");
1783 }
1784}