• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

payjoin / rust-payjoin / 24684607356

20 Apr 2026 06:55PM UTC coverage: 84.903% (+0.4%) from 84.502%
24684607356

Pull #1377

github

web-flow
Merge 716a65517 into f93247e3a
Pull Request #1377: Use internal Url struct in favor of url::Url to minimize the url dep in payjoin

467 of 487 new or added lines in 16 files covered. (95.89%)

2 existing lines in 2 files now uncovered.

11343 of 13360 relevant lines covered (84.9%)

401.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.66
/payjoin-cli/src/app/config.rs
1
use std::path::PathBuf;
2

3
use anyhow::Result;
4
use config::builder::DefaultState;
5
use config::{ConfigError, File, FileFormat};
6
use payjoin::bitcoin::FeeRate;
7
use payjoin::{Url, Version};
8
use serde::Deserialize;
9

10
use crate::cli::{Cli, Commands};
11
use crate::db;
12

13
const CONFIG_DIR: &str = "payjoin-cli";
14

15
type Builder = config::builder::ConfigBuilder<DefaultState>;
16

17
#[derive(Debug, Clone, Deserialize)]
18
pub struct BitcoindConfig {
19
    pub rpchost: Url,
20
    pub cookie: Option<PathBuf>,
21
    pub rpcuser: String,
22
    pub rpcpassword: String,
23
}
24

25
#[cfg(feature = "v1")]
26
#[derive(Debug, Clone, Deserialize)]
27
pub struct V1Config {
28
    pub port: u16,
29
    pub pj_endpoint: Url,
30
}
31

32
#[cfg(feature = "v2")]
33
#[derive(Debug, Clone, Deserialize)]
34
pub struct V2Config {
35
    #[serde(deserialize_with = "deserialize_ohttp_keys_from_path")]
36
    pub ohttp_keys: Option<payjoin::OhttpKeys>,
37
    pub ohttp_relays: Vec<Url>,
38
    pub pj_directory: Url,
39
}
40

41
#[allow(clippy::large_enum_variant)]
42
#[derive(Debug, Clone, Deserialize)]
43
#[serde(tag = "version")]
44
pub enum VersionConfig {
45
    #[cfg(feature = "v1")]
46
    #[serde(rename = "v1")]
47
    V1(V1Config),
48
    #[cfg(feature = "v2")]
49
    #[serde(rename = "v2")]
50
    V2(V2Config),
51
}
52

53
#[derive(Debug, Clone, Deserialize)]
54
pub struct Config {
55
    pub db_path: PathBuf,
56
    pub max_fee_rate: Option<FeeRate>,
57
    pub bitcoind: BitcoindConfig,
58
    #[serde(skip)]
59
    pub version: Option<VersionConfig>,
60
    #[cfg(feature = "_manual-tls")]
61
    pub root_certificate: Option<PathBuf>,
62
    #[cfg(feature = "_manual-tls")]
63
    pub certificate_key: Option<PathBuf>,
64
}
65

66
impl Config {
67
    /// Check for multiple version flags and return the highest precedence version
68
    fn determine_version(cli: &Cli) -> Result<Version, ConfigError> {
13✔
69
        let mut selected_version = None;
13✔
70

71
        // Check for BIP77 (v2)
72
        if cli.flags.bip77.unwrap_or(false) {
13✔
73
            selected_version = Some(Version::Two);
×
74
        }
13✔
75

76
        // Check for BIP78 (v1)
77
        if cli.flags.bip78.unwrap_or(false) {
13✔
78
            if selected_version.is_some() {
5✔
79
                return Err(ConfigError::Message(
×
80
                    "Multiple version flags specified. Please use only one of: --bip77, --bip78"
×
81
                        .to_string(),
×
82
                ));
×
83
            }
5✔
84
            selected_version = Some(Version::One);
5✔
85
        }
8✔
86

87
        if let Some(version) = selected_version {
13✔
88
            return Ok(version);
5✔
89
        };
8✔
90

91
        // If no version explicitly selected, use default based on available features
92
        #[cfg(feature = "v2")]
93
        return Ok(Version::Two);
8✔
94
        #[cfg(all(feature = "v1", not(feature = "v2")))]
95
        return Ok(Version::One);
×
96
        #[cfg(not(any(feature = "v1", feature = "v2")))]
97
        return Err(ConfigError::Message(
98
            "No valid version available - must compile with v1 or v2 feature".to_string(),
99
        ));
100
    }
13✔
101

102
    pub(crate) fn new(cli: &Cli) -> Result<Self, ConfigError> {
13✔
103
        let mut config = config::Config::builder();
13✔
104
        config = add_bitcoind_defaults(config, cli)?;
13✔
105
        config = add_common_defaults(config, cli)?;
13✔
106

107
        let version = Self::determine_version(cli)?;
13✔
108

109
        match version {
13✔
110
            Version::One => {
111
                #[cfg(feature = "v1")]
112
                {
113
                    config = add_v1_defaults(config, cli)?;
5✔
114
                }
115
                #[cfg(not(feature = "v1"))]
116
                return Err(ConfigError::Message(
117
                    "BIP78 (v1) selected but v1 feature not enabled".to_string(),
118
                ));
119
            }
120
            Version::Two => {
121
                #[cfg(feature = "v2")]
122
                {
123
                    config = add_v2_defaults(config, cli)?;
8✔
124
                }
125
                #[cfg(not(feature = "v2"))]
126
                return Err(ConfigError::Message(
×
127
                    "BIP77 (v2) selected but v2 feature not enabled".to_string(),
×
128
                ));
×
129
            }
130
        }
131

132
        config = handle_subcommands(config, cli)?;
13✔
133

134
        if let Some(config_dir) = dirs::config_dir() {
13✔
135
            let global_config_path = config_dir.join(CONFIG_DIR).join("config.toml");
13✔
136
            config = config.add_source(File::from(global_config_path).required(false));
13✔
137
        }
13✔
138

139
        config = config.add_source(File::new("config.toml", FileFormat::Toml).required(false));
13✔
140
        let built_config = config.build()?;
13✔
141

142
        let mut config = Config {
13✔
143
            db_path: built_config.get("db_path")?,
13✔
144
            max_fee_rate: built_config.get("max_fee_rate").ok(),
13✔
145
            bitcoind: built_config.get("bitcoind")?,
13✔
146
            version: None,
13✔
147
            #[cfg(feature = "_manual-tls")]
148
            root_certificate: built_config.get("root_certificate").ok(),
13✔
149
            #[cfg(feature = "_manual-tls")]
150
            certificate_key: built_config.get("certificate_key").ok(),
13✔
151
        };
152

153
        match version {
13✔
154
            Version::One => {
155
                #[cfg(feature = "v1")]
156
                {
157
                    match built_config.get::<V1Config>("v1") {
5✔
158
                        Ok(v1) => {
5✔
159
                            if v1.pj_endpoint.port().is_none() != (v1.port == 0) {
5✔
160
                                return Err(ConfigError::Message(
×
161
                                    "If --port is 0, --pj-endpoint may not have a port".to_owned(),
×
162
                                ));
×
163
                            }
5✔
164

165
                            config.version = Some(VersionConfig::V1(v1))
5✔
166
                        }
167
                        Err(e) =>
×
168
                            return Err(ConfigError::Message(format!(
×
169
                                "Valid V1 configuration is required for BIP78 mode: {e}"
×
170
                            ))),
×
171
                    }
172
                }
173
                #[cfg(not(feature = "v1"))]
174
                return Err(ConfigError::Message(
175
                    "BIP78 (v1) selected but v1 feature not enabled".to_string(),
176
                ));
177
            }
178
            Version::Two => {
179
                #[cfg(feature = "v2")]
180
                {
181
                    match built_config.get::<V2Config>("v2") {
8✔
182
                        Ok(v2) => config.version = Some(VersionConfig::V2(v2)),
8✔
183
                        Err(e) =>
×
184
                            return Err(ConfigError::Message(format!(
×
185
                                "Valid V2 configuration is required for BIP77 mode: {e}"
×
186
                            ))),
×
187
                    }
188
                }
189
                #[cfg(not(feature = "v2"))]
190
                return Err(ConfigError::Message(
×
191
                    "BIP77 (v2) selected but v2 feature not enabled".to_string(),
×
192
                ));
×
193
            }
194
        }
195

196
        if config.version.is_none() {
13✔
197
            return Err(ConfigError::Message(
×
198
                "No valid version configuration found for the specified mode".to_string(),
×
199
            ));
×
200
        }
13✔
201

202
        tracing::trace!("App config: {config:?}");
13✔
203
        Ok(config)
13✔
204
    }
13✔
205

206
    #[cfg(feature = "v1")]
207
    pub fn v1(&self) -> Result<&V1Config, anyhow::Error> {
6✔
208
        match &self.version {
6✔
209
            Some(VersionConfig::V1(v1_config)) => Ok(v1_config),
6✔
210
            #[allow(unreachable_patterns)]
211
            _ => Err(anyhow::anyhow!("V1 configuration is required for BIP78 mode")),
×
212
        }
213
    }
6✔
214

215
    #[cfg(feature = "v2")]
216
    pub fn v2(&self) -> Result<&V2Config, anyhow::Error> {
13✔
217
        match &self.version {
13✔
218
            Some(VersionConfig::V2(v2_config)) => Ok(v2_config),
13✔
219
            #[allow(unreachable_patterns)]
220
            _ => Err(anyhow::anyhow!("V2 configuration is required for v2 mode")),
×
221
        }
222
    }
13✔
223
}
224

225
/// Set up default values and CLI overrides for Bitcoin RPC connection settings
226
fn add_bitcoind_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
13✔
227
    // Set default values
228
    let config = config
13✔
229
        .set_default("bitcoind.rpchost", "http://localhost:18443")?
13✔
230
        .set_default("bitcoind.cookie", None::<String>)?
13✔
231
        .set_default("bitcoind.rpcuser", "bitcoin")?
13✔
232
        .set_default("bitcoind.rpcpassword", "")?;
13✔
233

234
    // Override config values with command line arguments if applicable
235
    let rpchost = cli.rpchost.as_ref().map(|s| s.as_str());
13✔
236
    let cookie_file = cli.cookie_file.as_ref().map(|p| p.to_string_lossy().into_owned());
13✔
237
    let rpcuser = cli.rpcuser.as_deref();
13✔
238
    let rpcpassword = cli.rpcpassword.as_deref();
13✔
239

240
    config
13✔
241
        .set_override_option("bitcoind.rpchost", rpchost)?
13✔
242
        .set_override_option("bitcoind.cookie", cookie_file)?
13✔
243
        .set_override_option("bitcoind.rpcuser", rpcuser)?
13✔
244
        .set_override_option("bitcoind.rpcpassword", rpcpassword)
13✔
245
}
13✔
246

247
fn add_common_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
13✔
248
    let db_path = cli.db_path.as_ref().map(|p| p.to_string_lossy().into_owned());
13✔
249
    config.set_default("db_path", db::DB_PATH)?.set_override_option("db_path", db_path)
13✔
250
}
13✔
251

252
#[cfg(feature = "v1")]
253
fn add_v1_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
5✔
254
    // Set default values
255
    let config = config
5✔
256
        .set_default("v1.port", 3000_u16)?
5✔
257
        .set_default("v1.pj_endpoint", "https://localhost:3000")?;
5✔
258

259
    // Override config values with command line arguments if applicable
260
    let pj_endpoint = cli.pj_endpoint.as_ref().map(|s| s.as_str());
5✔
261

262
    config
5✔
263
        .set_override_option("v1.port", cli.port)?
5✔
264
        .set_override_option("v1.pj_endpoint", pj_endpoint)
5✔
265
}
5✔
266

267
/// Set up default values and CLI overrides for v2-specific settings
268
#[cfg(feature = "v2")]
269
fn add_v2_defaults(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
8✔
270
    // Set default values
271
    let config = config
8✔
272
        .set_default("v2.pj_directory", "https://payjo.in")?
8✔
273
        .set_default("v2.ohttp_keys", None::<String>)?;
8✔
274

275
    // Override config values with command line arguments if applicable
276
    let pj_directory = cli.pj_directory.as_ref().map(|s| s.as_str());
8✔
277
    let ohttp_keys = cli.ohttp_keys.as_ref().map(|p| p.to_string_lossy().into_owned());
8✔
278
    let ohttp_relays = cli
8✔
279
        .ohttp_relays
8✔
280
        .as_ref()
8✔
281
        .map(|urls| urls.iter().map(|url| url.as_str()).collect::<Vec<_>>());
8✔
282

283
    config
8✔
284
        .set_override_option("v2.pj_directory", pj_directory)?
8✔
285
        .set_override_option("v2.ohttp_keys", ohttp_keys)?
8✔
286
        .set_override_option("v2.ohttp_relays", ohttp_relays)
8✔
287
}
8✔
288

289
/// Handles configuration overrides based on CLI subcommands
290
fn handle_subcommands(config: Builder, cli: &Cli) -> Result<Builder, ConfigError> {
13✔
291
    #[cfg(feature = "_manual-tls")]
292
    let config = {
13✔
293
        config
13✔
294
            .set_override_option(
13✔
295
                "root_certificate",
296
                Some(cli.root_certificate.as_ref().map(|s| s.to_string_lossy().into_owned())),
13✔
297
            )?
×
298
            .set_override_option(
13✔
299
                "certificate_key",
300
                Some(cli.certificate_key.as_ref().map(|s| s.to_string_lossy().into_owned())),
13✔
301
            )?
×
302
    };
303
    match &cli.command {
13✔
304
        Commands::Send { .. } => Ok(config),
5✔
305
        Commands::Receive {
306
            #[cfg(feature = "v1")]
307
            port,
4✔
308
            #[cfg(feature = "v1")]
309
            pj_endpoint,
4✔
310
            #[cfg(feature = "v2")]
311
            pj_directory,
3✔
312
            #[cfg(feature = "v2")]
313
            ohttp_keys,
3✔
314
            ..
315
        } => {
316
            #[cfg(feature = "v1")]
317
            let config = config
4✔
318
                .set_override_option("v1.port", port.map(|p| p.to_string()))?
4✔
319
                .set_override_option(
4✔
320
                    "v1.pj_endpoint",
321
                    pj_endpoint.clone().map(|s| s.to_string()),
4✔
NEW
322
                )?;
×
323
            #[cfg(feature = "v2")]
324
            let config = config
3✔
325
                .set_override_option(
3✔
326
                    "v2.pj_directory",
327
                    pj_directory.clone().map(|s| s.to_string()),
3✔
NEW
328
                )?
×
329
                .set_override_option(
3✔
330
                    "v2.ohttp_keys",
331
                    ohttp_keys.as_ref().map(|s| s.to_string_lossy().into_owned()),
3✔
332
                )?;
×
333
            Ok(config)
4✔
334
        }
335
        #[cfg(feature = "v2")]
336
        Commands::Resume => Ok(config),
4✔
337
        #[cfg(feature = "v2")]
338
        Commands::History => Ok(config),
×
339
    }
340
}
13✔
341

342
#[cfg(feature = "v2")]
343
fn deserialize_ohttp_keys_from_path<'de, D>(
8✔
344
    deserializer: D,
8✔
345
) -> Result<Option<payjoin::OhttpKeys>, D::Error>
8✔
346
where
8✔
347
    D: serde::Deserializer<'de>,
8✔
348
{
349
    let path_str: Option<String> = Option::deserialize(deserializer)?;
8✔
350

351
    match path_str {
8✔
352
        None => Ok(None),
7✔
353
        Some(path) => std::fs::read(path)
1✔
354
            .map_err(|e| serde::de::Error::custom(format!("Failed to read ohttp_keys file: {e}")))
1✔
355
            .and_then(|bytes| {
1✔
356
                payjoin::OhttpKeys::decode(&bytes).map_err(|e| {
1✔
357
                    serde::de::Error::custom(format!("Failed to decode ohttp keys: {e}"))
×
358
                })
×
359
            })
1✔
360
            .map(Some),
1✔
361
    }
362
}
8✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc