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