From 4747a28bf5c7e573a5a9637650e92f48f41f79a3 Mon Sep 17 00:00:00 2001
From: Casey Rodarmor <casey@rodarmor.com>
Date: Thu, 7 Mar 2024 16:11:03 -0800
Subject: [PATCH] Load config from default data dir and configure `ord env `
 using config (#3240)

---
 docs/src/guides/settings.md |  13 ++-
 src/lib.rs                  |   1 -
 src/settings.rs             | 210 ++++++++++++++++++++++++------------
 src/subcommand/env.rs       |  47 +++-----
 src/subcommand/wallet.rs    |  14 ++-
 src/test.rs                 |   1 +
 tests/settings.rs           |  15 +++
 7 files changed, 191 insertions(+), 110 deletions(-)

diff --git a/docs/src/guides/settings.md b/docs/src/guides/settings.md
index 90587d2a1a..453f28a8a4 100644
--- a/docs/src/guides/settings.md
+++ b/docs/src/guides/settings.md
@@ -8,10 +8,15 @@ The command line takes precedence over environment variables, which take
 precedence over the configuration file, which takes precedence over defaults.
 
 The path to the configuration file can be given with `--config <CONFIG_PATH>`.
-`ord` will error if `<CONFIG_PATH>` doesn't exist. The path to a configuration
-directory can be given with `--config-dir <CONFIG_DIR_PATH>`, in which case the
-config path is `<CONFIG_DIR_PATH>/ord.yaml`. It is not an error if
-`<CONFIG_DIR_PATH>/ord.yaml` does not exist.
+`ord` will error if `<CONFIG_PATH>` doesn't exist.
+
+The path to a directory containing a configuration file name named `ord.yaml`
+can be given with `--config-dir <CONFIG_DIR_PATH>` or `--data-dir
+<DATA_DIR_PATH>` in which case the config path is `<CONFIG_DIR_PATH>/ord.yaml`
+or `<DATA_DIR_PATH>/ord.yaml`. It is not an error if it does not exist.
+
+If none of `--config`, `--config-dir`, or `--data-dir` are given, and a file
+named `ord.yaml` exists in the default data directory, it will be loaded.
 
 For a setting named `--setting-name` on the command line, the environment
 variable will be named `ORD_SETTING_NAME`, and the config file field will be
diff --git a/src/lib.rs b/src/lib.rs
index 5775ae7e85..8ab414a50d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -190,7 +190,6 @@ pub fn parse_ord_server_args(args: &str) -> (Settings, subcommand::server::Serve
           vec![("INTEGRATION_TEST".into(), "1".into())]
             .into_iter()
             .collect(),
-          Default::default(),
         )
         .unwrap(),
         server,
diff --git a/src/settings.rs b/src/settings.rs
index 9953c15f2d..34d8e41309 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -9,6 +9,8 @@ pub struct Settings {
   bitcoin_rpc_username: Option<String>,
   chain: Option<Chain>,
   commit_interval: Option<usize>,
+  config: Option<PathBuf>,
+  config_dir: Option<PathBuf>,
   cookie_file: Option<PathBuf>,
   data_dir: Option<PathBuf>,
   first_inscription_height: Option<u32>,
@@ -23,39 +25,13 @@ pub struct Settings {
   integration_test: bool,
   no_index_inscriptions: bool,
   server_password: Option<String>,
+  server_url: Option<String>,
   server_username: Option<String>,
 }
 
 impl Settings {
   pub(crate) fn load(options: Options) -> Result<Settings> {
-    let config_path = match &options.config {
-      Some(path) => Some(path.into()),
-      None => match &options.config_dir {
-        Some(dir) => {
-          let path = dir.join("ord.yaml");
-          if path.exists() {
-            Some(path)
-          } else {
-            None
-          }
-        }
-        None => None,
-      },
-    };
-
-    let config = match config_path {
-      Some(config_path) => serde_yaml::from_reader(File::open(&config_path).context(anyhow!(
-        "failed to open config file `{}`",
-        config_path.display()
-      ))?)
-      .context(anyhow!(
-        "failed to deserialize config file `{}`",
-        config_path.display()
-      ))?,
-      None => Settings::default(),
-    };
-
-    let mut env: BTreeMap<String, String> = BTreeMap::new();
+    let mut env = BTreeMap::<String, String>::new();
 
     for (var, value) in env::vars_os() {
       let Some(var) = var.to_str() else {
@@ -77,18 +53,39 @@ impl Settings {
       );
     }
 
-    Self::merge(options, env, config)
+    Self::merge(options, env)
   }
 
-  pub(crate) fn merge(
-    options: Options,
-    env: BTreeMap<String, String>,
-    config: Settings,
-  ) -> Result<Self> {
-    let settings = Settings::from_options(options)
-      .or(Settings::from_env(env)?)
-      .or(config)
-      .or_defaults()?;
+  pub(crate) fn merge(options: Options, env: BTreeMap<String, String>) -> Result<Self> {
+    let settings = Settings::from_options(options).or(Settings::from_env(env)?);
+
+    let config_path = if let Some(path) = &settings.config {
+      Some(path.into())
+    } else {
+      let path = if let Some(dir) = settings.config_dir.clone().or(settings.data_dir.clone()) {
+        dir
+      } else {
+        Self::default_data_dir()?
+      }
+      .join("ord.yaml");
+
+      path.exists().then_some(path)
+    };
+
+    let config = if let Some(config_path) = config_path {
+      serde_yaml::from_reader(File::open(&config_path).context(anyhow!(
+        "failed to open config file `{}`",
+        config_path.display()
+      ))?)
+      .context(anyhow!(
+        "failed to deserialize config file `{}`",
+        config_path.display()
+      ))?
+    } else {
+      Settings::default()
+    };
+
+    let settings = settings.or(config).or_defaults()?;
 
     match (
       &settings.bitcoin_rpc_username,
@@ -116,6 +113,8 @@ impl Settings {
       bitcoin_rpc_username: self.bitcoin_rpc_username.or(source.bitcoin_rpc_username),
       chain: self.chain.or(source.chain),
       commit_interval: self.commit_interval.or(source.commit_interval),
+      config: self.config.or(source.config),
+      config_dir: self.config_dir.or(source.config_dir),
       cookie_file: self.cookie_file.or(source.cookie_file),
       data_dir: self.data_dir.or(source.data_dir),
       first_inscription_height: self
@@ -140,6 +139,7 @@ impl Settings {
       integration_test: self.integration_test || source.integration_test,
       no_index_inscriptions: self.no_index_inscriptions || source.no_index_inscriptions,
       server_password: self.server_password.or(source.server_password),
+      server_url: self.server_url.or(source.server_url),
       server_username: self.server_username.or(source.server_username),
     }
   }
@@ -157,6 +157,8 @@ impl Settings {
         .or(options.testnet.then_some(Chain::Testnet))
         .or(options.chain_argument),
       commit_interval: options.commit_interval,
+      config: options.config,
+      config_dir: options.config_dir,
       cookie_file: options.cookie_file,
       data_dir: options.data_dir,
       first_inscription_height: options.first_inscription_height,
@@ -171,6 +173,7 @@ impl Settings {
       integration_test: options.integration_test,
       no_index_inscriptions: options.no_index_inscriptions,
       server_password: options.server_password,
+      server_url: None,
       server_username: options.server_username,
     }
   }
@@ -232,6 +235,8 @@ impl Settings {
       bitcoin_rpc_username: get_string("BITCOIN_RPC_USERNAME"),
       chain: get_chain("CHAIN")?,
       commit_interval: get_usize("COMMIT_INTERVAL")?,
+      config: get_path("CONFIG"),
+      config_dir: get_path("CONFIG_DIR"),
       cookie_file: get_path("COOKIE_FILE"),
       data_dir: get_path("DATA_DIR"),
       first_inscription_height: get_u32("FIRST_INSCRIPTION_HEIGHT")?,
@@ -246,10 +251,40 @@ impl Settings {
       integration_test: get_bool("INTEGRATION_TEST"),
       no_index_inscriptions: get_bool("NO_INDEX_INSCRIPTIONS"),
       server_password: get_string("SERVER_PASSWORD"),
+      server_url: get_string("SERVER_URL"),
       server_username: get_string("SERVER_USERNAME"),
     })
   }
 
+  pub(crate) fn for_env(dir: &Path, rpc_url: &str, server_url: &str) -> Self {
+    Self {
+      bitcoin_data_dir: Some(dir.into()),
+      bitcoin_rpc_password: None,
+      bitcoin_rpc_url: Some(rpc_url.into()),
+      bitcoin_rpc_username: None,
+      chain: Some(Chain::Regtest),
+      commit_interval: None,
+      config: None,
+      config_dir: None,
+      cookie_file: None,
+      data_dir: Some(dir.into()),
+      first_inscription_height: None,
+      height_limit: None,
+      hidden: None,
+      index: None,
+      index_cache_size: None,
+      index_runes: true,
+      index_sats: true,
+      index_spent_sats: false,
+      index_transactions: false,
+      integration_test: false,
+      no_index_inscriptions: false,
+      server_password: None,
+      server_url: Some(server_url.into()),
+      server_username: None,
+    }
+  }
+
   pub(crate) fn or_defaults(self) -> Result<Self> {
     let chain = self.chain.unwrap_or_default();
 
@@ -275,9 +310,7 @@ impl Settings {
 
     let data_dir = chain.join_with_data_dir(match &self.data_dir {
       Some(data_dir) => data_dir.clone(),
-      None => dirs::data_dir()
-        .context("could not get data dir")?
-        .join("ord"),
+      None => Self::default_data_dir()?,
     });
 
     let index = match &self.index {
@@ -297,6 +330,8 @@ impl Settings {
       bitcoin_rpc_username: self.bitcoin_rpc_username,
       chain: Some(chain),
       commit_interval: Some(self.commit_interval.unwrap_or(5000)),
+      config: None,
+      config_dir: None,
       cookie_file: Some(cookie_file),
       data_dir: Some(data_dir),
       first_inscription_height: Some(if self.integration_test {
@@ -324,10 +359,19 @@ impl Settings {
       integration_test: self.integration_test,
       no_index_inscriptions: self.no_index_inscriptions,
       server_password: self.server_password,
+      server_url: self.server_url,
       server_username: self.server_username,
     })
   }
 
+  pub(crate) fn default_data_dir() -> Result<PathBuf> {
+    Ok(
+      dirs::data_dir()
+        .context("could not get data dir")?
+        .join("ord"),
+    )
+  }
+
   pub(crate) fn bitcoin_credentials(&self) -> Result<Auth> {
     if let Some((user, pass)) = &self
       .bitcoin_rpc_username
@@ -505,6 +549,10 @@ impl Settings {
       None => format!("{base_url}/"),
     }
   }
+
+  pub(crate) fn server_url(&self) -> Option<&str> {
+    self.server_url.as_deref()
+  }
 }
 
 #[cfg(test)]
@@ -544,7 +592,6 @@ mod tests {
           ..Default::default()
         },
         Default::default(),
-        Default::default(),
       )
       .unwrap_err()
       .to_string(),
@@ -561,7 +608,6 @@ mod tests {
           ..Default::default()
         },
         Default::default(),
-        Default::default(),
       )
       .unwrap_err()
       .to_string(),
@@ -850,11 +896,24 @@ mod tests {
 
   #[test]
   fn bitcoin_rpc_and_pass_setting() {
+    let config = Settings {
+      bitcoin_rpc_username: Some("config_user".into()),
+      bitcoin_rpc_password: Some("config_pass".into()),
+      ..Default::default()
+    };
+
+    let tempdir = TempDir::new().unwrap();
+
+    let config_path = tempdir.path().join("ord.yaml");
+
+    fs::write(&config_path, serde_yaml::to_string(&config).unwrap()).unwrap();
+
     assert_eq!(
       Settings::merge(
         Options {
           bitcoin_rpc_username: Some("option_user".into()),
           bitcoin_rpc_password: Some("option_pass".into()),
+          config: Some(config_path.clone()),
           ..Default::default()
         },
         vec![
@@ -863,11 +922,6 @@ mod tests {
         ]
         .into_iter()
         .collect(),
-        Settings {
-          bitcoin_rpc_username: Some("config_user".into()),
-          bitcoin_rpc_password: Some("config_pass".into()),
-          ..Default::default()
-        }
       )
       .unwrap()
       .bitcoin_credentials()
@@ -877,18 +931,16 @@ mod tests {
 
     assert_eq!(
       Settings::merge(
-        Default::default(),
+        Options {
+          config: Some(config_path.clone()),
+          ..Default::default()
+        },
         vec![
           ("BITCOIN_RPC_USERNAME".into(), "env_user".into()),
           ("BITCOIN_RPC_PASSWORD".into(), "env_pass".into()),
         ]
         .into_iter()
         .collect(),
-        Settings {
-          bitcoin_rpc_username: Some("config_user".into()),
-          bitcoin_rpc_password: Some("config_pass".into()),
-          ..Default::default()
-        }
       )
       .unwrap()
       .bitcoin_credentials()
@@ -898,13 +950,11 @@ mod tests {
 
     assert_eq!(
       Settings::merge(
-        Default::default(),
-        Default::default(),
-        Settings {
-          bitcoin_rpc_username: Some("config_user".into()),
-          bitcoin_rpc_password: Some("config_pass".into()),
+        Options {
+          config: Some(config_path),
           ..Default::default()
-        }
+        },
+        Default::default(),
       )
       .unwrap()
       .bitcoin_credentials()
@@ -913,7 +963,7 @@ mod tests {
     );
 
     assert_matches!(
-      Settings::merge(Default::default(), Default::default(), Default::default(),)
+      Settings::merge(Default::default(), Default::default())
         .unwrap()
         .bitcoin_credentials()
         .unwrap(),
@@ -935,6 +985,8 @@ mod tests {
       ("BITCOIN_RPC_USERNAME", "bitcoin username"),
       ("CHAIN", "signet"),
       ("COMMIT_INTERVAL", "1"),
+      ("CONFIG", "config"),
+      ("CONFIG_DIR", "config dir"),
       ("COOKIE_FILE", "cookie file"),
       ("DATA_DIR", "/data/dir"),
       ("FIRST_INSCRIPTION_HEIGHT", "2"),
@@ -949,6 +1001,7 @@ mod tests {
       ("INTEGRATION_TEST", "1"),
       ("NO_INDEX_INSCRIPTIONS", "1"),
       ("SERVER_PASSWORD", "server password"),
+      ("SERVER_URL", "server url"),
       ("SERVER_USERNAME", "server username"),
     ]
     .into_iter()
@@ -964,6 +1017,8 @@ mod tests {
         bitcoin_rpc_username: Some("bitcoin username".into()),
         chain: Some(Chain::Signet),
         commit_interval: Some(1),
+        config: Some("config".into()),
+        config_dir: Some("config dir".into()),
         cookie_file: Some("cookie file".into()),
         data_dir: Some("/data/dir".into()),
         first_inscription_height: Some(2),
@@ -989,6 +1044,7 @@ mod tests {
         integration_test: true,
         no_index_inscriptions: true,
         server_password: Some("server password".into()),
+        server_url: Some("server url".into()),
         server_username: Some("server username".into()),
       }
     );
@@ -1006,6 +1062,8 @@ mod tests {
           "--bitcoin-rpc-username=bitcoin username",
           "--chain=signet",
           "--commit-interval=1",
+          "--config=config",
+          "--config-dir=config dir",
           "--cookie-file=cookie file",
           "--data-dir=/data/dir",
           "--first-inscription-height=2",
@@ -1030,6 +1088,8 @@ mod tests {
         bitcoin_rpc_username: Some("bitcoin username".into()),
         chain: Some(Chain::Signet),
         commit_interval: Some(1),
+        config: Some("config".into()),
+        config_dir: Some("config dir".into()),
         cookie_file: Some("cookie file".into()),
         data_dir: Some("/data/dir".into()),
         first_inscription_height: Some(2),
@@ -1044,6 +1104,7 @@ mod tests {
         integration_test: true,
         no_index_inscriptions: true,
         server_password: Some("server password".into()),
+        server_url: None,
         server_username: Some("server username".into()),
       }
     );
@@ -1056,29 +1117,42 @@ mod tests {
       .map(|(key, value)| (key.into(), value.into()))
       .collect::<BTreeMap<String, String>>();
 
-    let options = Options::try_parse_from(["ord", "--index=option"]).unwrap();
-
     let config = Settings {
       index: Some("config".into()),
       ..Default::default()
     };
 
+    let tempdir = TempDir::new().unwrap();
+
+    let config_path = tempdir.path().join("ord.yaml");
+
+    fs::write(&config_path, serde_yaml::to_string(&config).unwrap()).unwrap();
+
+    let options =
+      Options::try_parse_from(["ord", "--config", config_path.to_str().unwrap()]).unwrap();
+
     pretty_assert_eq!(
-      Settings::merge(Default::default(), Default::default(), config.clone())
+      Settings::merge(options.clone(), Default::default())
         .unwrap()
         .index,
       Some("config".into()),
     );
 
     pretty_assert_eq!(
-      Settings::merge(Default::default(), env.clone(), config.clone())
-        .unwrap()
-        .index,
+      Settings::merge(options, env.clone()).unwrap().index,
       Some("env".into()),
     );
 
+    let options = Options::try_parse_from([
+      "ord",
+      "--index=option",
+      "--config",
+      config_path.to_str().unwrap(),
+    ])
+    .unwrap();
+
     pretty_assert_eq!(
-      Settings::merge(options, env, config.clone()).unwrap().index,
+      Settings::merge(options, env).unwrap().index,
       Some("option".into()),
     );
   }
diff --git a/src/subcommand/env.rs b/src/subcommand/env.rs
index c86924e133..b562c60a63 100644
--- a/src/subcommand/env.rs
+++ b/src/subcommand/env.rs
@@ -21,9 +21,9 @@ pub(crate) struct Env {
 
 #[derive(Serialize)]
 struct Info {
+  bitcoin_cli_command: Vec<String>,
   bitcoind_port: u16,
   ord_port: u16,
-  bitcoin_cli_command: Vec<String>,
   ord_wallet_command: Vec<String>,
 }
 
@@ -76,19 +76,23 @@ rpcport={bitcoind_port}
       }
     }
 
-    let ord = std::env::current_exe()?;
-
     let rpc_url = format!("http://localhost:{bitcoind_port}");
 
+    let server_url = format!("http://127.0.0.1:{ord_port}");
+
+    let config = absolute.join("ord.yaml");
+
+    fs::write(
+      config,
+      serde_yaml::to_string(&Settings::for_env(&absolute, &rpc_url, &server_url))?,
+    )?;
+
+    let ord = std::env::current_exe()?;
+
     let _ord = KillOnDrop(
       Command::new(&ord)
-        .arg("--regtest")
-        .arg("--bitcoin-data-dir")
-        .arg(&absolute)
         .arg("--data-dir")
         .arg(&absolute)
-        .arg("--bitcoin-rpc-url")
-        .arg(&rpc_url)
         .arg("server")
         .arg("--polling-interval=100ms")
         .arg("--http-port")
@@ -98,17 +102,10 @@ rpcport={bitcoind_port}
 
     thread::sleep(Duration::from_millis(250));
 
-    let server_url = format!("http://127.0.0.1:{ord_port}");
-
     if !absolute.join("regtest/wallets/ord").try_exists()? {
       let status = Command::new(&ord)
-        .arg("--regtest")
-        .arg("--bitcoin-data-dir")
-        .arg(&absolute)
         .arg("--data-dir")
         .arg(&absolute)
-        .arg("--bitcoin-rpc-url")
-        .arg(&rpc_url)
         .arg("wallet")
         .arg("create")
         .status()?;
@@ -116,16 +113,9 @@ rpcport={bitcoind_port}
       ensure!(status.success(), "failed to create wallet: {status}");
 
       let output = Command::new(&ord)
-        .arg("--regtest")
-        .arg("--bitcoin-data-dir")
-        .arg(&absolute)
         .arg("--data-dir")
         .arg(&absolute)
-        .arg("--bitcoin-rpc-url")
-        .arg(&rpc_url)
         .arg("wallet")
-        .arg("--server-url")
-        .arg(&server_url)
         .arg("receive")
         .output()?;
 
@@ -157,16 +147,9 @@ rpcport={bitcoind_port}
         bitcoin_cli_command: vec!["bitcoin-cli".into(), format!("-datadir={relative}")],
         ord_wallet_command: vec![
           ord.to_str().unwrap().into(),
-          "--regtest".into(),
-          "--bitcoin-data-dir".into(),
-          relative.clone(),
           "--data-dir".into(),
-          relative.clone(),
-          "--bitcoin-rpc-url".into(),
-          rpc_url.clone(),
+          absolute.to_str().unwrap().into(),
           "wallet".into(),
-          "--server-url".into(),
-          server_url.clone(),
         ],
       },
     )?;
@@ -175,12 +158,10 @@ rpcport={bitcoind_port}
       "{}
 bitcoin-cli -datadir='{relative}' getblockchaininfo
 {}
-{} --regtest --bitcoin-data-dir '{relative}' --data-dir '{relative}' --bitcoin-rpc-url '{}' wallet --server-url  {} balance",
+{} --data-dir '{relative}' wallet balance",
       "Example `bitcoin-cli` command:".blue().bold(),
       "Example `ord` command:".blue().bold(),
       ord.display(),
-      rpc_url,
-      server_url,
     );
 
     loop {
diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs
index 8518f50b35..59133dee65 100644
--- a/src/subcommand/wallet.rs
+++ b/src/subcommand/wallet.rs
@@ -30,10 +30,9 @@ pub(crate) struct WalletCommand {
   pub(crate) no_sync: bool,
   #[arg(
     long,
-    default_value = "http://127.0.0.1:80",
-    help = "Use ord running at <SERVER_URL>."
+    help = "Use ord running at <SERVER_URL>. [default: http://localhost:80]"
   )]
-  pub(crate) server_url: Url,
+  pub(crate) server_url: Option<Url>,
   #[command(subcommand)]
   pub(crate) subcommand: Subcommand,
 }
@@ -81,7 +80,14 @@ impl WalletCommand {
       self.name.clone(),
       self.no_sync,
       settings.clone(),
-      self.server_url,
+      self
+        .server_url
+        .as_ref()
+        .map(Url::as_str)
+        .or(settings.server_url())
+        .unwrap_or("http://127.0.0.1:80")
+        .parse::<Url>()
+        .context("invalid server URL")?,
     )?;
 
     match self.subcommand {
diff --git a/src/test.rs b/src/test.rs
index 8c4e623625..5e90c2c453 100644
--- a/src/test.rs
+++ b/src/test.rs
@@ -7,6 +7,7 @@ pub(crate) use {
   },
   pretty_assertions::assert_eq as pretty_assert_eq,
   std::iter,
+  tempfile::TempDir,
   test_bitcoincore_rpc::TransactionTemplate,
   unindent::Unindent,
 };
diff --git a/tests/settings.rs b/tests/settings.rs
index 78c2cc2e6e..bfd271d28b 100644
--- a/tests/settings.rs
+++ b/tests/settings.rs
@@ -12,6 +12,8 @@ fn default() {
   "bitcoin_rpc_username": null,
   "chain": "mainnet",
   "commit_interval": 5000,
+  "config": null,
+  "config_dir": null,
   "cookie_file": ".*\.cookie",
   "data_dir": ".*",
   "first_inscription_height": 767430,
@@ -26,6 +28,7 @@ fn default() {
   "integration_test": false,
   "no_index_inscriptions": false,
   "server_password": null,
+  "server_url": null,
   "server_username": null
 \}
 "#,
@@ -94,6 +97,18 @@ fn config_is_loaded_from_config_dir() {
   .run_and_extract_stdout();
 }
 
+#[test]
+fn config_is_loaded_from_data_dir() {
+  CommandBuilder::new("settings")
+    .write("ord.yaml", "chain: regtest")
+    .stdout_regex(
+      r#".*
+  "chain": "regtest",
+.*"#,
+    )
+    .run_and_extract_stdout();
+}
+
 #[test]
 fn env_is_loaded() {
   CommandBuilder::new("settings")