just playing with tangled
1// Copyright 2022 The Jujutsu Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::borrow::Cow;
16use std::collections::{HashMap, HashSet};
17use std::path::{Path, PathBuf};
18use std::process::Command;
19use std::{env, fmt};
20
21use config::Source;
22use itertools::Itertools;
23use jj_lib::settings::ConfigResultExt as _;
24use thiserror::Error;
25use tracing::instrument;
26
27#[derive(Error, Debug)]
28pub enum ConfigError {
29 #[error(transparent)]
30 ConfigReadError(#[from] config::ConfigError),
31 #[error("Both {0} and {1} exist. Please consolidate your configs in one of them.")]
32 AmbiguousSource(PathBuf, PathBuf),
33 #[error(transparent)]
34 ConfigCreateError(#[from] std::io::Error),
35}
36
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub enum ConfigSource {
39 Default,
40 Env,
41 // TODO: Track explicit file paths, especially for when user config is a dir.
42 User,
43 Repo,
44 CommandArg,
45}
46
47#[derive(Clone, Debug, PartialEq)]
48pub struct AnnotatedValue {
49 pub path: Vec<String>,
50 pub value: config::Value,
51 pub source: ConfigSource,
52 pub is_overridden: bool,
53}
54
55/// Set of configs which can be merged as needed.
56///
57/// Sources from the lowest precedence:
58/// 1. Default
59/// 2. Base environment variables
60/// 3. User config `~/.jjconfig.toml` or `$JJ_CONFIG`
61/// 4. Repo config `.jj/repo/config.toml`
62/// 5. TODO: Workspace config `.jj/config.toml`
63/// 6. Override environment variables
64/// 7. Command-line arguments `--config-toml`
65#[derive(Clone, Debug)]
66pub struct LayeredConfigs {
67 default: config::Config,
68 env_base: config::Config,
69 user: Option<config::Config>,
70 repo: Option<config::Config>,
71 env_overrides: config::Config,
72 arg_overrides: Option<config::Config>,
73}
74
75impl LayeredConfigs {
76 /// Initializes configs with infallible sources.
77 pub fn from_environment() -> Self {
78 LayeredConfigs {
79 default: default_config(),
80 env_base: env_base(),
81 user: None,
82 repo: None,
83 env_overrides: env_overrides(),
84 arg_overrides: None,
85 }
86 }
87
88 #[instrument]
89 pub fn read_user_config(&mut self) -> Result<(), ConfigError> {
90 self.user = existing_config_path()?
91 .map(|path| read_config_path(&path))
92 .transpose()?;
93 Ok(())
94 }
95
96 #[instrument]
97 pub fn read_repo_config(&mut self, repo_path: &Path) -> Result<(), ConfigError> {
98 self.repo = Some(read_config_file(&repo_path.join("config.toml"))?);
99 Ok(())
100 }
101
102 pub fn parse_config_args(&mut self, toml_strs: &[String]) -> Result<(), ConfigError> {
103 let config = toml_strs
104 .iter()
105 .fold(config::Config::builder(), |builder, s| {
106 builder.add_source(config::File::from_str(s, config::FileFormat::Toml))
107 })
108 .build()?;
109 self.arg_overrides = Some(config);
110 Ok(())
111 }
112
113 /// Creates new merged config.
114 pub fn merge(&self) -> config::Config {
115 self.sources()
116 .into_iter()
117 .map(|(_, config)| config)
118 .fold(config::Config::builder(), |builder, source| {
119 builder.add_source(source.clone())
120 })
121 .build()
122 .expect("loaded configs should be merged without error")
123 }
124
125 pub fn sources(&self) -> Vec<(ConfigSource, &config::Config)> {
126 let config_sources = [
127 (ConfigSource::Default, Some(&self.default)),
128 (ConfigSource::Env, Some(&self.env_base)),
129 (ConfigSource::User, self.user.as_ref()),
130 (ConfigSource::Repo, self.repo.as_ref()),
131 (ConfigSource::Env, Some(&self.env_overrides)),
132 (ConfigSource::CommandArg, self.arg_overrides.as_ref()),
133 ];
134 config_sources
135 .into_iter()
136 .filter_map(|(source, config)| config.map(|c| (source, c)))
137 .collect_vec()
138 }
139
140 pub fn resolved_config_values(
141 &self,
142 filter_prefix: &[&str],
143 ) -> Result<Vec<AnnotatedValue>, ConfigError> {
144 // Collect annotated values from each config.
145 let mut config_vals = vec![];
146
147 let prefix_key = match filter_prefix {
148 &[] => None,
149 _ => Some(filter_prefix.join(".")),
150 };
151 for (source, config) in self.sources() {
152 let top_value = match prefix_key {
153 Some(ref key) => {
154 if let Some(val) = config.get(key).optional()? {
155 val
156 } else {
157 continue;
158 }
159 }
160 None => config.collect()?.into(),
161 };
162 let mut config_stack: Vec<(Vec<&str>, &config::Value)> =
163 vec![(filter_prefix.to_vec(), &top_value)];
164 while let Some((path, value)) = config_stack.pop() {
165 match &value.kind {
166 config::ValueKind::Table(table) => {
167 // TODO: Remove sorting when config crate maintains deterministic ordering.
168 for (k, v) in table.iter().sorted_by_key(|(k, _)| *k).rev() {
169 let mut key_path = path.to_vec();
170 key_path.push(k);
171 config_stack.push((key_path, v));
172 }
173 }
174 _ => {
175 config_vals.push(AnnotatedValue {
176 path: path.iter().map(|&s| s.to_owned()).collect_vec(),
177 value: value.to_owned(),
178 source: source.to_owned(),
179 // Note: Value updated below.
180 is_overridden: false,
181 });
182 }
183 }
184 }
185 }
186
187 // Walk through config values in reverse order and mark each overridden value as
188 // overridden.
189 let mut keys_found = HashSet::new();
190 for val in config_vals.iter_mut().rev() {
191 val.is_overridden = !keys_found.insert(&val.path);
192 }
193
194 Ok(config_vals)
195 }
196}
197
198enum ConfigPath {
199 /// Existing config file path.
200 Existing(PathBuf),
201 /// Could not find any config file, but a new file can be created at the
202 /// specified location.
203 New(PathBuf),
204 /// Could not find any config file.
205 Unavailable,
206}
207
208impl ConfigPath {
209 fn new(path: Option<PathBuf>) -> Self {
210 match path {
211 Some(path) if path.exists() => ConfigPath::Existing(path),
212 Some(path) => ConfigPath::New(path),
213 None => ConfigPath::Unavailable,
214 }
215 }
216}
217
218/// Like std::fs::create_dir_all but creates new directories to be accessible to
219/// the user only on Unix (chmod 700).
220fn create_dir_all(path: &Path) -> std::io::Result<()> {
221 let mut dir = std::fs::DirBuilder::new();
222 dir.recursive(true);
223 #[cfg(unix)]
224 {
225 use std::os::unix::fs::DirBuilderExt;
226 dir.mode(0o700);
227 }
228 dir.create(path)
229}
230
231fn create_config_file(path: &Path) -> std::io::Result<std::fs::File> {
232 if let Some(parent) = path.parent() {
233 create_dir_all(parent)?;
234 }
235 // TODO: Use File::create_new once stabilized.
236 std::fs::OpenOptions::new()
237 .read(true)
238 .write(true)
239 .create_new(true)
240 .open(path)
241}
242
243// The struct exists so that we can mock certain global values in unit tests.
244#[derive(Clone, Default, Debug)]
245struct ConfigEnv {
246 config_dir: Option<PathBuf>,
247 home_dir: Option<PathBuf>,
248 jj_config: Option<String>,
249}
250
251impl ConfigEnv {
252 fn new() -> Self {
253 ConfigEnv {
254 config_dir: dirs::config_dir(),
255 home_dir: dirs::home_dir(),
256 jj_config: env::var("JJ_CONFIG").ok(),
257 }
258 }
259
260 fn config_path(self) -> Result<ConfigPath, ConfigError> {
261 if let Some(path) = self.jj_config {
262 // TODO: We should probably support colon-separated (std::env::split_paths)
263 return Ok(ConfigPath::new(Some(PathBuf::from(path))));
264 }
265 // TODO: Should we drop the final `/config.toml` and read all files in the
266 // directory?
267 let platform_config_path = ConfigPath::new(self.config_dir.map(|mut config_dir| {
268 config_dir.push("jj");
269 config_dir.push("config.toml");
270 config_dir
271 }));
272 let home_config_path = ConfigPath::new(self.home_dir.map(|mut home_dir| {
273 home_dir.push(".jjconfig.toml");
274 home_dir
275 }));
276 use ConfigPath::*;
277 match (platform_config_path, home_config_path) {
278 (Existing(platform_config_path), Existing(home_config_path)) => Err(
279 ConfigError::AmbiguousSource(platform_config_path, home_config_path),
280 ),
281 (Existing(path), _) | (_, Existing(path)) => Ok(Existing(path)),
282 (New(path), _) | (_, New(path)) => Ok(New(path)),
283 (Unavailable, Unavailable) => Ok(Unavailable),
284 }
285 }
286
287 fn existing_config_path(self) -> Result<Option<PathBuf>, ConfigError> {
288 match self.config_path()? {
289 ConfigPath::Existing(path) => Ok(Some(path)),
290 _ => Ok(None),
291 }
292 }
293
294 fn new_config_path(self) -> Result<Option<PathBuf>, ConfigError> {
295 match self.config_path()? {
296 ConfigPath::Existing(path) => Ok(Some(path)),
297 ConfigPath::New(path) => {
298 create_config_file(&path)?;
299 Ok(Some(path))
300 }
301 ConfigPath::Unavailable => Ok(None),
302 }
303 }
304}
305
306pub fn existing_config_path() -> Result<Option<PathBuf>, ConfigError> {
307 ConfigEnv::new().existing_config_path()
308}
309
310/// Returns a path to the user-specific config file. If no config file is found,
311/// tries to guess a reasonable new location for it. If a path to a new config
312/// file is returned, the parent directory may be created as a result of this
313/// call.
314pub fn new_config_path() -> Result<Option<PathBuf>, ConfigError> {
315 ConfigEnv::new().new_config_path()
316}
317
318/// Environment variables that should be overridden by config values
319fn env_base() -> config::Config {
320 let mut builder = config::Config::builder();
321 if env::var("NO_COLOR").is_ok() {
322 // "User-level configuration files and per-instance command-line arguments
323 // should override $NO_COLOR." https://no-color.org/
324 builder = builder.set_override("ui.color", "never").unwrap();
325 }
326 if let Ok(value) = env::var("PAGER") {
327 builder = builder.set_override("ui.pager", value).unwrap();
328 }
329 if let Ok(value) = env::var("VISUAL") {
330 builder = builder.set_override("ui.editor", value).unwrap();
331 } else if let Ok(value) = env::var("EDITOR") {
332 builder = builder.set_override("ui.editor", value).unwrap();
333 }
334
335 builder.build().unwrap()
336}
337
338pub fn default_config() -> config::Config {
339 // Syntax error in default config isn't a user error. That's why defaults are
340 // loaded by separate builder.
341 macro_rules! from_toml {
342 ($file:literal) => {
343 config::File::from_str(include_str!($file), config::FileFormat::Toml)
344 };
345 }
346 config::Config::builder()
347 .add_source(from_toml!("config/colors.toml"))
348 .add_source(from_toml!("config/merge_tools.toml"))
349 .add_source(from_toml!("config/misc.toml"))
350 .add_source(from_toml!("config/templates.toml"))
351 .build()
352 .unwrap()
353}
354
355/// Environment variables that override config values
356fn env_overrides() -> config::Config {
357 let mut builder = config::Config::builder();
358 if let Ok(value) = env::var("JJ_USER") {
359 builder = builder.set_override("user.name", value).unwrap();
360 }
361 if let Ok(value) = env::var("JJ_EMAIL") {
362 builder = builder.set_override("user.email", value).unwrap();
363 }
364 if let Ok(value) = env::var("JJ_TIMESTAMP") {
365 builder = builder
366 .set_override("debug.commit-timestamp", value)
367 .unwrap();
368 }
369 if let Ok(value) = env::var("JJ_RANDOMNESS_SEED") {
370 builder = builder
371 .set_override("debug.randomness-seed", value)
372 .unwrap();
373 }
374 if let Ok(value) = env::var("JJ_OP_TIMESTAMP") {
375 builder = builder
376 .set_override("debug.operation-timestamp", value)
377 .unwrap();
378 }
379 if let Ok(value) = env::var("JJ_OP_HOSTNAME") {
380 builder = builder.set_override("operation.hostname", value).unwrap();
381 }
382 if let Ok(value) = env::var("JJ_OP_USERNAME") {
383 builder = builder.set_override("operation.username", value).unwrap();
384 }
385 if let Ok(value) = env::var("JJ_EDITOR") {
386 builder = builder.set_override("ui.editor", value).unwrap();
387 }
388 builder.build().unwrap()
389}
390
391fn read_config_file(path: &Path) -> Result<config::Config, config::ConfigError> {
392 config::Config::builder()
393 .add_source(
394 config::File::from(path)
395 .required(false)
396 .format(config::FileFormat::Toml),
397 )
398 .build()
399}
400
401fn read_config_path(config_path: &Path) -> Result<config::Config, config::ConfigError> {
402 let mut files = vec![];
403 if config_path.is_dir() {
404 if let Ok(read_dir) = config_path.read_dir() {
405 // TODO: Walk the directory recursively?
406 for dir_entry in read_dir.flatten() {
407 let path = dir_entry.path();
408 if path.is_file() {
409 files.push(path);
410 }
411 }
412 }
413 files.sort();
414 } else {
415 files.push(config_path.to_owned());
416 }
417
418 files
419 .iter()
420 .fold(config::Config::builder(), |builder, path| {
421 // TODO: Accept other formats and/or accept only certain file extensions?
422 builder.add_source(
423 config::File::from(path.as_ref())
424 .required(false)
425 .format(config::FileFormat::Toml),
426 )
427 })
428 .build()
429}
430
431/// Command name and arguments specified by config.
432#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize)]
433#[serde(untagged)]
434pub enum CommandNameAndArgs {
435 String(String),
436 Vec(NonEmptyCommandArgsVec),
437 Structured {
438 env: HashMap<String, String>,
439 command: NonEmptyCommandArgsVec,
440 },
441}
442
443impl CommandNameAndArgs {
444 /// Returns command name and arguments.
445 ///
446 /// The command name may be an empty string (as well as each argument.)
447 pub fn split_name_and_args(&self) -> (Cow<str>, Cow<[String]>) {
448 match self {
449 CommandNameAndArgs::String(s) => {
450 // Handle things like `EDITOR=emacs -nw` (TODO: parse shell escapes)
451 let mut args = s.split(' ').map(|s| s.to_owned());
452 (args.next().unwrap().into(), args.collect())
453 }
454 CommandNameAndArgs::Vec(NonEmptyCommandArgsVec(a)) => {
455 (Cow::Borrowed(&a[0]), Cow::Borrowed(&a[1..]))
456 }
457 CommandNameAndArgs::Structured {
458 env: _,
459 command: cmd,
460 } => (Cow::Borrowed(&cmd.0[0]), Cow::Borrowed(&cmd.0[1..])),
461 }
462 }
463
464 /// Returns process builder configured with this.
465 pub fn to_command(&self) -> Command {
466 let (name, args) = self.split_name_and_args();
467 let mut cmd = Command::new(name.as_ref());
468 if let CommandNameAndArgs::Structured { env, .. } = self {
469 cmd.envs(env);
470 }
471 cmd.args(args.as_ref());
472 cmd
473 }
474}
475
476impl<T: AsRef<str> + ?Sized> From<&T> for CommandNameAndArgs {
477 fn from(s: &T) -> Self {
478 CommandNameAndArgs::String(s.as_ref().to_owned())
479 }
480}
481
482impl fmt::Display for CommandNameAndArgs {
483 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484 match self {
485 CommandNameAndArgs::String(s) => write!(f, "{s}"),
486 // TODO: format with shell escapes
487 CommandNameAndArgs::Vec(a) => write!(f, "{}", a.0.join(" ")),
488 CommandNameAndArgs::Structured { env, command } => {
489 for (k, v) in env.iter() {
490 write!(f, "{k}={v} ")?;
491 }
492 write!(f, "{}", command.0.join(" "))
493 }
494 }
495 }
496}
497
498/// Wrapper to reject an array without command name.
499// Based on https://github.com/serde-rs/serde/issues/939
500#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize)]
501#[serde(try_from = "Vec<String>")]
502pub struct NonEmptyCommandArgsVec(Vec<String>);
503
504impl TryFrom<Vec<String>> for NonEmptyCommandArgsVec {
505 type Error = &'static str;
506
507 fn try_from(args: Vec<String>) -> Result<Self, Self::Error> {
508 if args.is_empty() {
509 Err("command arguments should not be empty")
510 } else {
511 Ok(NonEmptyCommandArgsVec(args))
512 }
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use maplit::hashmap;
519
520 use super::*;
521
522 #[test]
523 fn test_command_args() {
524 let config = config::Config::builder()
525 .set_override("empty_array", Vec::<String>::new())
526 .unwrap()
527 .set_override("empty_string", "")
528 .unwrap()
529 .set_override("array", vec!["emacs", "-nw"])
530 .unwrap()
531 .set_override("string", "emacs -nw")
532 .unwrap()
533 .set_override("structured.env.KEY1", "value1")
534 .unwrap()
535 .set_override("structured.env.KEY2", "value2")
536 .unwrap()
537 .set_override("structured.command", vec!["emacs", "-nw"])
538 .unwrap()
539 .build()
540 .unwrap();
541
542 assert!(config.get::<CommandNameAndArgs>("empty_array").is_err());
543
544 let command_args: CommandNameAndArgs = config.get("empty_string").unwrap();
545 assert_eq!(command_args, CommandNameAndArgs::String("".to_owned()));
546 let (name, args) = command_args.split_name_and_args();
547 assert_eq!(name, "");
548 assert!(args.is_empty());
549
550 let command_args: CommandNameAndArgs = config.get("array").unwrap();
551 assert_eq!(
552 command_args,
553 CommandNameAndArgs::Vec(NonEmptyCommandArgsVec(
554 ["emacs", "-nw",].map(|s| s.to_owned()).to_vec()
555 ))
556 );
557 let (name, args) = command_args.split_name_and_args();
558 assert_eq!(name, "emacs");
559 assert_eq!(args, ["-nw"].as_ref());
560
561 let command_args: CommandNameAndArgs = config.get("string").unwrap();
562 assert_eq!(
563 command_args,
564 CommandNameAndArgs::String("emacs -nw".to_owned())
565 );
566 let (name, args) = command_args.split_name_and_args();
567 assert_eq!(name, "emacs");
568 assert_eq!(args, ["-nw"].as_ref());
569
570 let command_args: CommandNameAndArgs = config.get("structured").unwrap();
571 assert_eq!(
572 command_args,
573 CommandNameAndArgs::Structured {
574 env: hashmap! {
575 "KEY1".to_string() => "value1".to_string(),
576 "KEY2".to_string() => "value2".to_string(),
577 },
578 command: NonEmptyCommandArgsVec(["emacs", "-nw",].map(|s| s.to_owned()).to_vec())
579 }
580 );
581 let (name, args) = command_args.split_name_and_args();
582 assert_eq!(name, "emacs");
583 assert_eq!(args, ["-nw"].as_ref());
584 }
585
586 #[test]
587 fn test_layered_configs_resolved_config_values_empty() {
588 let empty_config = config::Config::default();
589 let layered_configs = LayeredConfigs {
590 default: empty_config.to_owned(),
591 env_base: empty_config.to_owned(),
592 user: None,
593 repo: None,
594 env_overrides: empty_config,
595 arg_overrides: None,
596 };
597 assert_eq!(layered_configs.resolved_config_values(&[]).unwrap(), []);
598 }
599
600 #[test]
601 fn test_layered_configs_resolved_config_values_single_key() {
602 let empty_config = config::Config::default();
603 let env_base_config = config::Config::builder()
604 .set_override("user.name", "base-user-name")
605 .unwrap()
606 .set_override("user.email", "base@user.email")
607 .unwrap()
608 .build()
609 .unwrap();
610 let repo_config = config::Config::builder()
611 .set_override("user.email", "repo@user.email")
612 .unwrap()
613 .build()
614 .unwrap();
615 let layered_configs = LayeredConfigs {
616 default: empty_config.to_owned(),
617 env_base: env_base_config,
618 user: None,
619 repo: Some(repo_config),
620 env_overrides: empty_config,
621 arg_overrides: None,
622 };
623 // Note: "email" is alphabetized, before "name" from same layer.
624 insta::assert_debug_snapshot!(
625 layered_configs.resolved_config_values(&[]).unwrap(),
626 @r###"
627 [
628 AnnotatedValue {
629 path: [
630 "user",
631 "email",
632 ],
633 value: Value {
634 origin: None,
635 kind: String(
636 "base@user.email",
637 ),
638 },
639 source: Env,
640 is_overridden: true,
641 },
642 AnnotatedValue {
643 path: [
644 "user",
645 "name",
646 ],
647 value: Value {
648 origin: None,
649 kind: String(
650 "base-user-name",
651 ),
652 },
653 source: Env,
654 is_overridden: false,
655 },
656 AnnotatedValue {
657 path: [
658 "user",
659 "email",
660 ],
661 value: Value {
662 origin: None,
663 kind: String(
664 "repo@user.email",
665 ),
666 },
667 source: Repo,
668 is_overridden: false,
669 },
670 ]
671 "###
672 );
673 }
674
675 #[test]
676 fn test_layered_configs_resolved_config_values_filter_path() {
677 let empty_config = config::Config::default();
678 let user_config = config::Config::builder()
679 .set_override("test-table1.foo", "user-FOO")
680 .unwrap()
681 .set_override("test-table2.bar", "user-BAR")
682 .unwrap()
683 .build()
684 .unwrap();
685 let repo_config = config::Config::builder()
686 .set_override("test-table1.bar", "repo-BAR")
687 .unwrap()
688 .build()
689 .unwrap();
690 let layered_configs = LayeredConfigs {
691 default: empty_config.to_owned(),
692 env_base: empty_config.to_owned(),
693 user: Some(user_config),
694 repo: Some(repo_config),
695 env_overrides: empty_config,
696 arg_overrides: None,
697 };
698 insta::assert_debug_snapshot!(
699 layered_configs
700 .resolved_config_values(&["test-table1"])
701 .unwrap(),
702 @r###"
703 [
704 AnnotatedValue {
705 path: [
706 "test-table1",
707 "foo",
708 ],
709 value: Value {
710 origin: None,
711 kind: String(
712 "user-FOO",
713 ),
714 },
715 source: User,
716 is_overridden: false,
717 },
718 AnnotatedValue {
719 path: [
720 "test-table1",
721 "bar",
722 ],
723 value: Value {
724 origin: None,
725 kind: String(
726 "repo-BAR",
727 ),
728 },
729 source: Repo,
730 is_overridden: false,
731 },
732 ]
733 "###
734 );
735 }
736
737 #[test]
738 fn test_config_path_home_dir_existing() -> anyhow::Result<()> {
739 TestCase {
740 files: vec!["home/.jjconfig.toml"],
741 cfg: ConfigEnv {
742 home_dir: Some("home".into()),
743 ..Default::default()
744 },
745 want: Want::ExistingAndNew("home/.jjconfig.toml"),
746 }
747 .run()
748 }
749
750 #[test]
751 fn test_config_path_home_dir_new() -> anyhow::Result<()> {
752 TestCase {
753 files: vec![],
754 cfg: ConfigEnv {
755 home_dir: Some("home".into()),
756 ..Default::default()
757 },
758 want: Want::New("home/.jjconfig.toml"),
759 }
760 .run()
761 }
762
763 #[test]
764 fn test_config_path_config_dir_existing() -> anyhow::Result<()> {
765 TestCase {
766 files: vec!["config/jj/config.toml"],
767 cfg: ConfigEnv {
768 config_dir: Some("config".into()),
769 ..Default::default()
770 },
771 want: Want::ExistingAndNew("config/jj/config.toml"),
772 }
773 .run()
774 }
775
776 #[test]
777 fn test_config_path_config_dir_new() -> anyhow::Result<()> {
778 TestCase {
779 files: vec![],
780 cfg: ConfigEnv {
781 config_dir: Some("config".into()),
782 ..Default::default()
783 },
784 want: Want::New("config/jj/config.toml"),
785 }
786 .run()
787 }
788
789 #[test]
790 fn test_config_path_new_prefer_config_dir() -> anyhow::Result<()> {
791 TestCase {
792 files: vec![],
793 cfg: ConfigEnv {
794 config_dir: Some("config".into()),
795 home_dir: Some("home".into()),
796 ..Default::default()
797 },
798 want: Want::New("config/jj/config.toml"),
799 }
800 .run()
801 }
802
803 #[test]
804 fn test_config_path_jj_config_existing() -> anyhow::Result<()> {
805 TestCase {
806 files: vec!["custom.toml"],
807 cfg: ConfigEnv {
808 jj_config: Some("custom.toml".into()),
809 ..Default::default()
810 },
811 want: Want::ExistingAndNew("custom.toml"),
812 }
813 .run()
814 }
815
816 #[test]
817 fn test_config_path_jj_config_new() -> anyhow::Result<()> {
818 TestCase {
819 files: vec![],
820 cfg: ConfigEnv {
821 jj_config: Some("custom.toml".into()),
822 ..Default::default()
823 },
824 want: Want::New("custom.toml"),
825 }
826 .run()
827 }
828
829 #[test]
830 fn test_config_path_config_pick_config_dir() -> anyhow::Result<()> {
831 TestCase {
832 files: vec!["config/jj/config.toml"],
833 cfg: ConfigEnv {
834 home_dir: Some("home".into()),
835 config_dir: Some("config".into()),
836 ..Default::default()
837 },
838 want: Want::ExistingAndNew("config/jj/config.toml"),
839 }
840 .run()
841 }
842
843 #[test]
844 fn test_config_path_config_pick_home_dir() -> anyhow::Result<()> {
845 TestCase {
846 files: vec!["home/.jjconfig.toml"],
847 cfg: ConfigEnv {
848 home_dir: Some("home".into()),
849 config_dir: Some("config".into()),
850 ..Default::default()
851 },
852 want: Want::ExistingAndNew("home/.jjconfig.toml"),
853 }
854 .run()
855 }
856
857 #[test]
858 fn test_config_path_none() -> anyhow::Result<()> {
859 TestCase {
860 files: vec![],
861 cfg: Default::default(),
862 want: Want::None,
863 }
864 .run()
865 }
866
867 #[test]
868 fn test_config_path_ambiguous() -> anyhow::Result<()> {
869 let tmp = setup_config_fs(&vec!["home/.jjconfig.toml", "config/jj/config.toml"])?;
870 let cfg = ConfigEnv {
871 home_dir: Some(tmp.path().join("home")),
872 config_dir: Some(tmp.path().join("config")),
873 ..Default::default()
874 };
875 use assert_matches::assert_matches;
876 assert_matches!(
877 cfg.clone().existing_config_path(),
878 Err(ConfigError::AmbiguousSource(_, _))
879 );
880 assert_matches!(
881 cfg.clone().new_config_path(),
882 Err(ConfigError::AmbiguousSource(_, _))
883 );
884 Ok(())
885 }
886
887 fn setup_config_fs(files: &Vec<&'static str>) -> anyhow::Result<tempfile::TempDir> {
888 let tmp = testutils::new_temp_dir();
889 for file in files {
890 let path = tmp.path().join(file);
891 if let Some(parent) = path.parent() {
892 std::fs::create_dir_all(parent)?;
893 }
894 std::fs::File::create(path)?;
895 }
896 Ok(tmp)
897 }
898
899 enum Want {
900 None,
901 New(&'static str),
902 ExistingAndNew(&'static str),
903 }
904
905 struct TestCase {
906 files: Vec<&'static str>,
907 cfg: ConfigEnv,
908 want: Want,
909 }
910
911 impl TestCase {
912 fn config(&self, root: &Path) -> ConfigEnv {
913 ConfigEnv {
914 config_dir: self.cfg.config_dir.as_ref().map(|p| root.join(p)),
915 home_dir: self.cfg.home_dir.as_ref().map(|p| root.join(p)),
916 jj_config: self
917 .cfg
918 .jj_config
919 .as_ref()
920 .map(|p| root.join(p).to_str().unwrap().to_string()),
921 }
922 }
923
924 fn run(self) -> anyhow::Result<()> {
925 use anyhow::anyhow;
926 let tmp = setup_config_fs(&self.files)?;
927
928 let check = |name, f: fn(ConfigEnv) -> Result<_, _>, want: Option<_>| {
929 let got = f(self.config(tmp.path())).map_err(|e| anyhow!("{name}: {e}"))?;
930 let want = want.map(|p| tmp.path().join(p));
931 if got != want {
932 Err(anyhow!("{name}: got {got:?}, want {want:?}"))
933 } else {
934 Ok(got)
935 }
936 };
937
938 let (want_existing, want_new) = match self.want {
939 Want::None => (None, None),
940 Want::New(want) => (None, Some(want)),
941 Want::ExistingAndNew(want) => (Some(want.clone()), Some(want)),
942 };
943
944 check(
945 "existing_config_path",
946 ConfigEnv::existing_config_path,
947 want_existing,
948 )?;
949 let got = check("new_config_path", ConfigEnv::new_config_path, want_new)?;
950 if let Some(path) = got {
951 if !Path::new(&path).is_file() {
952 return Err(anyhow!(
953 "new_config_path returned {path:?} which is not a file"
954 ));
955 }
956 }
957 Ok(())
958 }
959 }
960}