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