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_metadata: Vec<MetadataField>,
148 pub declaration: SourceSpan,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
154#[serde(rename_all = "snake_case")]
155pub enum FileOperationKind {
156 Copy,
158 Symlink,
160 Sync,
162}
163
164impl FileOperationKind {
165 #[must_use]
167 pub const fn as_str(self) -> &'static str {
168 match self {
169 Self::Copy => "copy",
170 Self::Symlink => "symlink",
171 Self::Sync => "sync",
172 }
173 }
174}
175
176impl fmt::Display for FileOperationKind {
177 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178 formatter.write_str(self.as_str())
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum SyncCompare {
186 Metadata,
188 Checksum,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "snake_case")]
195pub enum SymlinkMode {
196 Preserve,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum MetadataField {
204 Permissions,
206 Owner,
208 Group,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub(crate) enum RawMetadataField {
215 Permissions,
216 Owner,
217 Group,
218 Ownership,
219}
220
221impl RawMetadataField {
222 const fn expanded(self) -> &'static [MetadataField] {
223 match self {
224 Self::Permissions => &[MetadataField::Permissions],
225 Self::Owner => &[MetadataField::Owner],
226 Self::Group => &[MetadataField::Group],
227 Self::Ownership => &[MetadataField::Owner, MetadataField::Group],
228 }
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234pub struct CommandOperation {
235 pub name: Option<String>,
237 pub command: CommandKind,
239 pub cwd: Option<PathBuf>,
241 pub cwd_path: Option<PathBuf>,
243 pub env: BTreeMap<String, String>,
245 pub allow_failure: bool,
247 pub declaration: SourceSpan,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
253#[serde(tag = "kind", rename_all = "snake_case")]
254pub enum CommandKind {
255 Shell {
257 run: String,
259 },
260 Direct {
262 program: String,
264 args: Vec<String>,
266 },
267}
268
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
271pub struct ConfigRuntimeOptions {
272 pub strict: bool,
274 pub dangerously_allow_sources_outside_root: bool,
276 pub dangerously_allow_targets_outside_worktree: bool,
278}
279
280#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
282pub struct RuntimeOptionOverrides {
283 pub strict: Option<bool>,
285 pub dangerously_allow_sources_outside_root: Option<bool>,
287 pub dangerously_allow_targets_outside_worktree: Option<bool>,
289}
290
291impl RuntimeOptionOverrides {
292 pub fn from_environment(environment: &EnvironmentInput) -> Result<Self> {
298 Ok(Self {
299 strict: env_bool("TREEBOOT_STRICT", environment.treeboot_strict.as_deref())?,
300 dangerously_allow_sources_outside_root: env_bool(
301 "TREEBOOT_DANGEROUSLY_ALLOW_SOURCES_OUTSIDE_ROOT",
302 environment
303 .treeboot_dangerously_allow_sources_outside_root
304 .as_deref(),
305 )?,
306 dangerously_allow_targets_outside_worktree: env_bool(
307 "TREEBOOT_DANGEROUSLY_ALLOW_TARGETS_OUTSIDE_WORKTREE",
308 environment
309 .treeboot_dangerously_allow_targets_outside_worktree
310 .as_deref(),
311 )?,
312 })
313 }
314
315 pub fn from_process_env() -> Result<Self> {
321 Self::from_environment(&EnvironmentInput::from_process_env())
322 }
323
324 #[must_use]
326 pub const fn pre_config_strict(self, cli_strict: bool) -> bool {
327 cli_strict || matches!(self.strict, Some(true))
328 }
329
330 #[must_use]
332 pub fn resolve(self, config: &ConfigRuntimeOptions, cli_strict: bool) -> ConfigRuntimeOptions {
333 let mut resolved = *config;
334
335 if let Some(strict) = self.strict {
336 resolved.strict = strict;
337 }
338 if let Some(allow) = self.dangerously_allow_sources_outside_root {
339 resolved.dangerously_allow_sources_outside_root = allow;
340 }
341 if let Some(allow) = self.dangerously_allow_targets_outside_worktree {
342 resolved.dangerously_allow_targets_outside_worktree = allow;
343 }
344 if cli_strict {
345 resolved.strict = true;
346 }
347
348 resolved
349 }
350}
351
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
354pub struct SourceSpan {
355 pub start: usize,
357 pub end: usize,
359 pub line: usize,
361 pub column: usize,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub(crate) struct FileOperationSettingsInput {
367 pub(crate) compare: Option<SyncCompare>,
368 pub(crate) delete: Option<bool>,
369 pub(crate) symlinks: Option<SymlinkMode>,
370 pub(crate) ignore_metadata: Vec<RawMetadataField>,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub(crate) struct FileOperationSettings {
375 pub(crate) compare: Option<SyncCompare>,
376 pub(crate) delete: Option<bool>,
377 pub(crate) symlinks: Option<SymlinkMode>,
378 pub(crate) ignore_metadata: Vec<MetadataField>,
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382pub(crate) enum InvalidFileOperationField {
383 Compare,
384 Delete,
385 Symlinks,
386 IgnoreMetadata,
387}
388
389impl InvalidFileOperationField {
390 pub(crate) const fn name(self) -> &'static str {
391 match self {
392 Self::Compare => "compare",
393 Self::Delete => "delete",
394 Self::Symlinks => "symlinks",
395 Self::IgnoreMetadata => "ignore_metadata",
396 }
397 }
398
399 pub(crate) const fn allowed_operations(self) -> &'static str {
400 match self {
401 Self::Compare | Self::Delete => "sync",
402 Self::Symlinks | Self::IgnoreMetadata => "copy and sync",
403 }
404 }
405}
406
407pub(crate) fn normalize_file_operation_settings(
408 operation: FileOperationKind,
409 input: FileOperationSettingsInput,
410) -> std::result::Result<FileOperationSettings, InvalidFileOperationField> {
411 let compare = match operation {
412 FileOperationKind::Sync => Some(input.compare.unwrap_or(SyncCompare::Metadata)),
413 FileOperationKind::Copy | FileOperationKind::Symlink => {
414 if input.compare.is_some() {
415 return Err(InvalidFileOperationField::Compare);
416 }
417 None
418 }
419 };
420 let delete = match operation {
421 FileOperationKind::Sync => Some(input.delete.unwrap_or(false)),
422 FileOperationKind::Copy | FileOperationKind::Symlink => {
423 if input.delete.is_some() {
424 return Err(InvalidFileOperationField::Delete);
425 }
426 None
427 }
428 };
429 let symlinks = match operation {
430 FileOperationKind::Copy | FileOperationKind::Sync => {
431 Some(input.symlinks.unwrap_or(SymlinkMode::Preserve))
432 }
433 FileOperationKind::Symlink => {
434 if input.symlinks.is_some() {
435 return Err(InvalidFileOperationField::Symlinks);
436 }
437 None
438 }
439 };
440 let ignore_metadata = match operation {
441 FileOperationKind::Copy | FileOperationKind::Sync => {
442 normalize_ignored_metadata(input.ignore_metadata)
443 }
444 FileOperationKind::Symlink => {
445 if !input.ignore_metadata.is_empty() {
446 return Err(InvalidFileOperationField::IgnoreMetadata);
447 }
448 Vec::new()
449 }
450 };
451
452 Ok(FileOperationSettings {
453 compare,
454 delete,
455 symlinks,
456 ignore_metadata,
457 })
458}
459
460pub(crate) fn normalize_ignored_metadata(fields: Vec<RawMetadataField>) -> Vec<MetadataField> {
461 let mut normalized = Vec::new();
462 for field in fields {
463 for expanded in field.expanded() {
464 if !normalized.contains(expanded) {
465 normalized.push(*expanded);
466 }
467 }
468 }
469 normalized
470}
471
472pub fn inspect_config(options: ConfigOptions) -> Result<ConfigReport> {
480 let worktree_options = WorktreeOptions {
481 cwd: options.cwd,
482 root: options.root,
483 environment: options.environment,
484 };
485 let context = context::resolve(&worktree_options)?;
486 Config::load_discovered(&context, options.config.as_deref())?
487 .ok_or(Error::NoConfigDetectedStrict)
488}
489
490fn parse_config(path: &Path, content: &str, context: &Worktree) -> Result<Config> {
491 let raw: RawConfig = toml::from_str(content).map_err(|source| {
492 let message = parse_error_message(content, &source);
493 Error::ConfigParse {
494 path: path.to_path_buf(),
495 message,
496 }
497 })?;
498
499 let mut files = Vec::new();
500 normalize_file_group(
501 path,
502 content,
503 context,
504 &mut files,
505 FileOperationKind::Copy,
506 raw.copy,
507 )?;
508 normalize_file_group(
509 path,
510 content,
511 context,
512 &mut files,
513 FileOperationKind::Symlink,
514 raw.symlink,
515 )?;
516 normalize_file_group(
517 path,
518 content,
519 context,
520 &mut files,
521 FileOperationKind::Sync,
522 raw.sync,
523 )?;
524 normalize_mixed_files(path, content, context, &mut files, raw.files)?;
525 normalize_file_tables(path, content, context, &mut files, raw.file)?;
526
527 let mut commands = Vec::new();
528 normalize_command_entries(path, content, context, &mut commands, raw.commands)?;
529 normalize_command_tables(path, content, context, &mut commands, raw.command)?;
530
531 Ok(Config {
532 options: ConfigRuntimeOptions {
533 strict: raw.strict,
534 dangerously_allow_sources_outside_root: raw.dangerously_allow_sources_outside_root,
535 dangerously_allow_targets_outside_worktree: raw
536 .dangerously_allow_targets_outside_worktree,
537 },
538 files,
539 commands,
540 })
541}
542
543fn normalize_file_group(
544 path: &Path,
545 content: &str,
546 context: &Worktree,
547 files: &mut Vec<FileOperation>,
548 operation: FileOperationKind,
549 entries: Vec<Spanned<RawFileEntry>>,
550) -> Result<()> {
551 for entry in entries {
552 let span = entry_span(content, &entry);
553 let entry = entry.into_inner();
554 let object = match entry {
555 RawFileEntry::Path(source) => RawFileObject {
556 operation: None,
557 source: Some(source),
558 target: None,
559 required: false,
560 compare: None,
561 delete: None,
562 symlinks: None,
563 ignore_metadata: Vec::new(),
564 },
565 RawFileEntry::Object(object) => object,
566 };
567
568 if object.operation.is_some() {
569 return invalid_config(
570 path,
571 content,
572 span,
573 "`operation` is only valid in `files` and `[[file]]` entries",
574 );
575 }
576
577 files.push(normalize_file_object(
578 path, content, context, operation, object, span,
579 )?);
580 }
581
582 Ok(())
583}
584
585fn normalize_mixed_files(
586 path: &Path,
587 content: &str,
588 context: &Worktree,
589 files: &mut Vec<FileOperation>,
590 entries: Vec<Spanned<RawFileObject>>,
591) -> Result<()> {
592 for entry in entries {
593 let span = entry_span(content, &entry);
594 let object = entry.into_inner();
595 let operation = required_operation(path, content, span, object.operation)?;
596 files.push(normalize_file_object(
597 path, content, context, operation, object, span,
598 )?);
599 }
600
601 Ok(())
602}
603
604fn normalize_file_tables(
605 path: &Path,
606 content: &str,
607 context: &Worktree,
608 files: &mut Vec<FileOperation>,
609 entries: Vec<Spanned<RawFileObject>>,
610) -> Result<()> {
611 normalize_mixed_files(path, content, context, files, entries)
612}
613
614fn normalize_file_object(
615 path: &Path,
616 content: &str,
617 context: &Worktree,
618 operation: FileOperationKind,
619 object: RawFileObject,
620 span: SourceSpan,
621) -> Result<FileOperation> {
622 let source = object.source.ok_or_else(|| {
623 invalid_config_error(
624 path,
625 content,
626 span,
627 "file operation is missing required `source`",
628 )
629 })?;
630 let target = object.target.unwrap_or_else(|| source.clone());
631 let settings = normalize_file_operation_settings(
632 operation,
633 FileOperationSettingsInput {
634 compare: object.compare,
635 delete: object.delete,
636 symlinks: object.symlinks,
637 ignore_metadata: object.ignore_metadata,
638 },
639 )
640 .map_err(|field| {
641 invalid_config_error(
642 path,
643 content,
644 span,
645 format!(
646 "`{}` is only valid for {} file operations",
647 field.name(),
648 field.allowed_operations()
649 ),
650 )
651 })?;
652
653 Ok(FileOperation {
654 operation,
655 source_path: resolve_path(&context.root_path, Path::new(&source)),
656 target_path: resolve_path(&context.worktree_path, Path::new(&target)),
657 source: PathBuf::from(source),
658 target: PathBuf::from(target),
659 required: object.required,
660 compare: settings.compare,
661 delete: settings.delete,
662 symlinks: settings.symlinks,
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 dangerously_allow_sources_outside_root: bool,
867 dangerously_allow_targets_outside_worktree: bool,
868 copy: Vec<Spanned<RawFileEntry>>,
869 symlink: Vec<Spanned<RawFileEntry>>,
870 sync: Vec<Spanned<RawFileEntry>>,
871 files: Vec<Spanned<RawFileObject>>,
872 file: Vec<Spanned<RawFileObject>>,
873 commands: Vec<Spanned<RawCommandEntry>>,
874 command: Vec<Spanned<RawCommandObject>>,
875}
876
877#[derive(Debug)]
878enum RawFileEntry {
879 Path(String),
880 Object(RawFileObject),
881}
882
883impl<'de> Deserialize<'de> for RawFileEntry {
884 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
885 where
886 D: serde::Deserializer<'de>,
887 {
888 struct RawFileEntryVisitor;
889
890 impl<'de> Visitor<'de> for RawFileEntryVisitor {
891 type Value = RawFileEntry;
892
893 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
894 formatter.write_str("a path string or file operation object")
895 }
896
897 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
898 where
899 E: de::Error,
900 {
901 Ok(RawFileEntry::Path(value.to_owned()))
902 }
903
904 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
905 where
906 E: de::Error,
907 {
908 Ok(RawFileEntry::Path(value))
909 }
910
911 fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
912 where
913 M: MapAccess<'de>,
914 {
915 RawFileObject::deserialize(MapAccessDeserializer::new(map))
916 .map(RawFileEntry::Object)
917 }
918 }
919
920 deserializer.deserialize_any(RawFileEntryVisitor)
921 }
922}
923
924#[derive(Debug, Default, Deserialize)]
925#[serde(default, deny_unknown_fields)]
926struct RawFileObject {
927 operation: Option<FileOperationKind>,
928 source: Option<String>,
929 target: Option<String>,
930 required: bool,
931 compare: Option<SyncCompare>,
932 delete: Option<bool>,
933 symlinks: Option<SymlinkMode>,
934 ignore_metadata: Vec<RawMetadataField>,
935}
936
937#[derive(Debug)]
938enum RawCommandEntry {
939 Run(String),
940 Object(RawCommandObject),
941}
942
943impl<'de> Deserialize<'de> for RawCommandEntry {
944 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
945 where
946 D: serde::Deserializer<'de>,
947 {
948 struct RawCommandEntryVisitor;
949
950 impl<'de> Visitor<'de> for RawCommandEntryVisitor {
951 type Value = RawCommandEntry;
952
953 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
954 formatter.write_str("a shell command string or command object")
955 }
956
957 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
958 where
959 E: de::Error,
960 {
961 Ok(RawCommandEntry::Run(value.to_owned()))
962 }
963
964 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
965 where
966 E: de::Error,
967 {
968 Ok(RawCommandEntry::Run(value))
969 }
970
971 fn visit_map<M>(self, map: M) -> std::result::Result<Self::Value, M::Error>
972 where
973 M: MapAccess<'de>,
974 {
975 RawCommandObject::deserialize(MapAccessDeserializer::new(map))
976 .map(RawCommandEntry::Object)
977 }
978 }
979
980 deserializer.deserialize_any(RawCommandEntryVisitor)
981 }
982}
983
984#[derive(Debug, Default, Deserialize)]
985#[serde(default, deny_unknown_fields)]
986struct RawCommandObject {
987 name: Option<String>,
988 run: Option<String>,
989 program: Option<String>,
990 args: Option<Vec<String>>,
991 cwd: Option<String>,
992 env: BTreeMap<String, String>,
993 allow_failure: bool,
994}
995
996fn env_bool(name: &'static str, value: Option<&std::ffi::OsStr>) -> Result<Option<bool>> {
997 let Some(value) = value else {
998 return Ok(None);
999 };
1000
1001 let Some(value) = value.to_str() else {
1002 return Err(Error::InvalidBooleanEnv {
1003 name,
1004 value: value.to_string_lossy().into_owned(),
1005 });
1006 };
1007
1008 parse_bool(value)
1009 .ok_or_else(|| Error::InvalidBooleanEnv {
1010 name,
1011 value: value.to_owned(),
1012 })
1013 .map(Some)
1014}
1015
1016fn parse_bool(value: &str) -> Option<bool> {
1017 match value.to_ascii_lowercase().as_str() {
1018 "1" | "true" | "yes" | "on" => Some(true),
1019 "0" | "false" | "no" | "off" => Some(false),
1020 _ => None,
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use std::ffi::OsString;
1027
1028 use super::*;
1029
1030 fn context() -> Worktree {
1031 Worktree {
1032 root_path: PathBuf::from("/repo"),
1033 worktree_path: PathBuf::from("/repo-worktree"),
1034 default_branch: "main".to_owned(),
1035 environment: BTreeMap::from([(
1036 "TREEBOOT_ROOT_PATH".to_owned(),
1037 OsString::from("/repo"),
1038 )]),
1039 }
1040 }
1041
1042 fn parse(content: &str) -> Config {
1043 parse_config(Path::new(".treeboot.toml"), content, &context()).expect("config should parse")
1044 }
1045
1046 fn parse_error(content: &str) -> String {
1047 parse_config(Path::new(".treeboot.toml"), content, &context())
1048 .expect_err("config should fail")
1049 .to_string()
1050 }
1051
1052 fn assert_parse_error_contains(content: &str, expected: &str) {
1053 let error = parse_error(content);
1054
1055 assert!(
1056 error.contains(expected),
1057 "expected error to contain {expected:?}, got {error:?}"
1058 );
1059 }
1060
1061 #[test]
1062 fn parse_config_should_normalize_file_operations_in_spec_order() {
1063 let config = parse(
1064 r#"
1065sync = ["sync-dir"]
1066copy = [".env"]
1067symlink = [{ source = "shared/bin", target = "bin" }]
1068files = [{ operation = "copy", source = ".npmrc" }]
1069
1070[[file]]
1071operation = "sync"
1072source = "editor"
1073target = ".editor"
1074"#,
1075 );
1076
1077 let operations = config
1078 .files
1079 .iter()
1080 .map(|operation| (operation.operation, operation.source.as_path()))
1081 .collect::<Vec<_>>();
1082
1083 assert_eq!(
1084 operations,
1085 vec![
1086 (FileOperationKind::Copy, Path::new(".env")),
1087 (FileOperationKind::Symlink, Path::new("shared/bin")),
1088 (FileOperationKind::Sync, Path::new("sync-dir")),
1089 (FileOperationKind::Copy, Path::new(".npmrc")),
1090 (FileOperationKind::Sync, Path::new("editor")),
1091 ]
1092 );
1093 }
1094
1095 #[test]
1096 fn parse_config_should_apply_file_defaults() {
1097 let config = parse(
1098 r#"
1099copy = [{ source = ".env.local" }]
1100sync = ["shared/config"]
1101"#,
1102 );
1103
1104 let copy = &config.files[0];
1105 let sync = &config.files[1];
1106
1107 assert_eq!(copy.target, PathBuf::from(".env.local"));
1108 assert!(!copy.required);
1109 assert_eq!(copy.symlinks, Some(SymlinkMode::Preserve));
1110 assert!(copy.ignore_metadata.is_empty());
1111 assert_eq!(sync.compare, Some(SyncCompare::Metadata));
1112 assert_eq!(sync.delete, Some(false));
1113 assert!(sync.ignore_metadata.is_empty());
1114 }
1115
1116 #[test]
1117 fn parse_config_should_preserve_explicit_sync_options() {
1118 let config = parse(
1119 r#"
1120sync = [{
1121 source = "shared/config",
1122 compare = "checksum",
1123 delete = true,
1124 symlinks = "preserve",
1125}]
1126"#,
1127 );
1128
1129 let sync = &config.files[0];
1130
1131 assert_eq!(sync.compare, Some(SyncCompare::Checksum));
1132 assert_eq!(sync.delete, Some(true));
1133 assert_eq!(sync.symlinks, Some(SymlinkMode::Preserve));
1134 }
1135
1136 #[test]
1137 fn parse_config_should_normalize_ignored_metadata() {
1138 let config = parse(
1139 r#"
1140copy = [{ source = ".env", ignore_metadata = ["ownership", "permissions", "owner"] }]
1141sync = [{ source = "shared", ignore_metadata = ["group"] }]
1142"#,
1143 );
1144
1145 assert_eq!(
1146 config.files[0].ignore_metadata,
1147 vec![
1148 MetadataField::Owner,
1149 MetadataField::Group,
1150 MetadataField::Permissions,
1151 ]
1152 );
1153 assert_eq!(config.files[1].ignore_metadata, vec![MetadataField::Group]);
1154 }
1155
1156 #[test]
1157 fn parse_config_should_reject_ignored_metadata_on_symlink_file_operations() {
1158 assert_parse_error_contains(
1159 r#"
1160symlink = [{ source = "link", ignore_metadata = ["ownership"] }]
1161"#,
1162 "`ignore_metadata` is only valid for copy and sync",
1163 );
1164 }
1165
1166 #[test]
1167 fn parse_config_should_apply_runtime_options() {
1168 let config = parse(
1169 r#"
1170strict = true
1171dangerously_allow_sources_outside_root = true
1172dangerously_allow_targets_outside_worktree = true
1173"#,
1174 );
1175
1176 assert!(config.options.strict);
1177 assert!(config.options.dangerously_allow_sources_outside_root);
1178 assert!(config.options.dangerously_allow_targets_outside_worktree);
1179 }
1180
1181 #[test]
1182 fn parse_config_should_reject_nested_validation_options() {
1183 assert_parse_error_contains(
1184 r#"
1185[validation]
1186dangerously_allow_sources_outside_root = true
1187"#,
1188 "unknown field",
1189 );
1190 }
1191
1192 #[test]
1193 fn runtime_option_overrides_should_parse_explicit_environment_input() {
1194 let overrides = RuntimeOptionOverrides::from_environment(&EnvironmentInput {
1195 treeboot_strict: Some(OsString::from("yes")),
1196 treeboot_dangerously_allow_sources_outside_root: Some(OsString::from("true")),
1197 treeboot_dangerously_allow_targets_outside_worktree: Some(OsString::from("0")),
1198 ..EnvironmentInput::empty()
1199 })
1200 .expect("environment should parse");
1201
1202 assert_eq!(
1203 overrides,
1204 RuntimeOptionOverrides {
1205 strict: Some(true),
1206 dangerously_allow_sources_outside_root: Some(true),
1207 dangerously_allow_targets_outside_worktree: Some(false),
1208 }
1209 );
1210 }
1211
1212 #[test]
1213 fn runtime_option_overrides_should_reject_invalid_explicit_environment_input() {
1214 let error = RuntimeOptionOverrides::from_environment(&EnvironmentInput {
1215 treeboot_strict: Some(OsString::from("sometimes")),
1216 ..EnvironmentInput::empty()
1217 })
1218 .expect_err("environment should fail");
1219
1220 assert!(matches!(
1221 error,
1222 Error::InvalidBooleanEnv {
1223 name: "TREEBOOT_STRICT",
1224 ..
1225 }
1226 ));
1227 }
1228
1229 #[cfg(unix)]
1230 #[test]
1231 fn runtime_option_overrides_should_reject_non_utf8_explicit_environment_input() {
1232 use std::os::unix::ffi::OsStringExt;
1233
1234 let error = RuntimeOptionOverrides::from_environment(&EnvironmentInput {
1235 treeboot_strict: Some(OsString::from_vec(vec![0xFF])),
1236 ..EnvironmentInput::empty()
1237 })
1238 .expect_err("environment should fail");
1239
1240 assert!(matches!(
1241 error,
1242 Error::InvalidBooleanEnv {
1243 name: "TREEBOOT_STRICT",
1244 ..
1245 }
1246 ));
1247 }
1248
1249 #[test]
1250 fn parse_bool_should_accept_supported_true_values() {
1251 for value in ["1", "true", "TRUE", "yes", "on"] {
1252 assert_eq!(parse_bool(value), Some(true), "value {value:?}");
1253 }
1254 }
1255
1256 #[test]
1257 fn parse_bool_should_accept_supported_false_values() {
1258 for value in ["0", "false", "FALSE", "no", "off"] {
1259 assert_eq!(parse_bool(value), Some(false), "value {value:?}");
1260 }
1261 }
1262
1263 #[test]
1264 fn parse_bool_should_reject_unsupported_values() {
1265 assert_eq!(parse_bool("sometimes"), None);
1266 }
1267
1268 #[test]
1269 fn parse_config_should_resolve_absolute_paths_without_rebasing() {
1270 let config = parse(
1271 r#"
1272copy = [{ source = "/shared/.env", target = "/worktree/.env" }]
1273commands = [{ program = "make", cwd = "/worktree/app" }]
1274"#,
1275 );
1276
1277 assert_eq!(config.files[0].source_path, PathBuf::from("/shared/.env"));
1278 assert_eq!(config.files[0].target_path, PathBuf::from("/worktree/.env"));
1279 assert_eq!(
1280 config.commands[0].cwd_path,
1281 Some(PathBuf::from("/worktree/app"))
1282 );
1283 }
1284
1285 #[test]
1286 fn parse_config_should_normalize_command_forms() {
1287 let config = parse(
1288 r#"
1289commands = [
1290 "mise install",
1291 { run = "bundle install" },
1292]
1293
1294[[command]]
1295program = "npm"
1296args = ["install"]
1297cwd = "web"
1298allow_failure = true
1299"#,
1300 );
1301
1302 assert_eq!(config.commands.len(), 3);
1303 assert_eq!(
1304 config.commands[0].command,
1305 CommandKind::Shell {
1306 run: "mise install".to_owned()
1307 }
1308 );
1309 assert_eq!(
1310 config.commands[2].command,
1311 CommandKind::Direct {
1312 program: "npm".to_owned(),
1313 args: vec!["install".to_owned()]
1314 }
1315 );
1316 assert_eq!(
1317 config.commands[2].cwd_path,
1318 Some(PathBuf::from("/repo-worktree/web"))
1319 );
1320 }
1321
1322 #[test]
1323 fn parse_config_should_normalize_command_metadata_and_defaults() {
1324 let config = parse(
1325 r#"
1326commands = [{
1327 name = "Install",
1328 program = "npm",
1329 env = { NODE_ENV = "development" },
1330}]
1331"#,
1332 );
1333
1334 let command = &config.commands[0];
1335
1336 assert_eq!(command.name.as_deref(), Some("Install"));
1337 assert_eq!(command.env["NODE_ENV"], "development");
1338 assert!(!command.allow_failure);
1339 }
1340
1341 #[test]
1342 fn parse_config_should_reject_async_command_field() {
1343 assert_parse_error_contains(
1344 r#"commands = [{ run = "npm install", async = true }]"#,
1345 "unknown field",
1346 );
1347 assert_parse_error_contains(
1348 r#"commands = [{ run = "npm install", async = false }]"#,
1349 "unknown field",
1350 );
1351 }
1352
1353 #[test]
1354 fn parse_config_should_allow_program_without_args() {
1355 let config = parse(r#"commands = [{ program = "mise" }]"#);
1356
1357 assert_eq!(
1358 config.commands[0].command,
1359 CommandKind::Direct {
1360 program: "mise".to_owned(),
1361 args: Vec::new()
1362 }
1363 );
1364 }
1365
1366 #[test]
1367 fn parse_config_should_reject_mutually_exclusive_command_fields() {
1368 assert_parse_error_contains(
1369 r#"commands = [{ run = "npm install", program = "npm" }]"#,
1370 "mutually exclusive",
1371 );
1372 }
1373
1374 #[test]
1375 fn parse_config_should_reject_args_without_program() {
1376 assert_parse_error_contains(
1377 r#"commands = [{ run = "npm install", args = [] }]"#,
1378 "`args` requires `program`",
1379 );
1380 }
1381
1382 #[test]
1383 fn parse_config_should_reject_missing_command_invocation() {
1384 assert_parse_error_contains(
1385 r#"commands = [{ name = "Install" }]"#,
1386 "missing required `run` or `program`",
1387 );
1388 }
1389
1390 #[test]
1391 fn parse_config_should_reject_unknown_fields() {
1392 assert_parse_error_contains(
1393 r#"copy = [{ source = ".env", unknown = true }]"#,
1394 "unknown field",
1395 );
1396 }
1397
1398 #[test]
1399 fn parse_config_should_reject_missing_file_operation() {
1400 assert_parse_error_contains(
1401 r#"files = [{ source = ".env" }]"#,
1402 "missing required `operation`",
1403 );
1404 }
1405
1406 #[test]
1407 fn parse_config_should_reject_missing_file_source() {
1408 assert_parse_error_contains(
1409 r#"copy = [{ target = ".env" }]"#,
1410 "missing required `source`",
1411 );
1412 }
1413
1414 #[test]
1415 fn parse_config_should_reject_operation_in_specific_file_groups() {
1416 assert_parse_error_contains(
1417 r#"copy = [{ operation = "copy", source = ".env" }]"#,
1418 "`operation` is only valid in `files` and `[[file]]` entries",
1419 );
1420 }
1421
1422 #[test]
1423 fn parse_config_should_reject_compare_on_copy_file_operations() {
1424 assert_parse_error_contains(
1425 r#"copy = [{ source = ".env", compare = "checksum" }]"#,
1426 "`compare` is only valid for sync file operations",
1427 );
1428 }
1429
1430 #[test]
1431 fn parse_config_should_reject_delete_on_symlink_file_operations() {
1432 assert_parse_error_contains(
1433 r#"symlink = [{ source = ".env", delete = true }]"#,
1434 "`delete` is only valid for sync file operations",
1435 );
1436 }
1437
1438 #[test]
1439 fn parse_config_should_reject_legacy_delete_extra_field() {
1440 assert_parse_error_contains(
1441 r#"sync = [{ source = "shared", delete_extra = true }]"#,
1442 "unknown field `delete_extra`",
1443 );
1444 }
1445
1446 #[test]
1447 fn parse_config_should_reject_symlinks_on_symlink_file_operations() {
1448 assert_parse_error_contains(
1449 r#"symlink = [{ source = ".env", symlinks = "preserve" }]"#,
1450 "`symlinks` is only valid for copy and sync file operations",
1451 );
1452 }
1453
1454 #[test]
1455 fn parse_config_should_report_invalid_toml_location() {
1456 assert_parse_error_contains("commands = [\n", "line 1, column");
1457 }
1458}