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