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