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