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

payjoin / rust-payjoin / 13350991407

16 Feb 2025 02:43AM UTC coverage: 79.468% (+0.2%) from 79.269%
13350991407

Pull #538

github

web-flow
Merge 0e46f80b5 into 2627ef20f
Pull Request #538: Make payjoin-cli v1 / v2 features additive

363 of 422 new or added lines in 6 files covered. (86.02%)

2 existing lines in 1 file now uncovered.

4122 of 5187 relevant lines covered (79.47%)

887.41 hits per line

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

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

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

11
use crate::db;
12

13
type Builder = config::builder::ConfigBuilder<DefaultState>;
14

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

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

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

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

51
#[derive(Debug, Clone, Deserialize)]
52
pub struct Config {
53
    pub db_path: PathBuf,
54
    pub max_fee_rate: Option<FeeRate>,
55
    pub bitcoind: BitcoindConfig,
56
    #[serde(skip)]
57
    mode: Option<VersionConfig>,
58
}
59

60
impl Config {
61
    /// Version flags in order of precedence (newest to oldest)
62
    const VERSION_FLAGS: &'static [(&'static str, u8)] = &[("bip77", 2), ("bip78", 1)];
63

64
    /// Check for multiple version flags and return the highest precedence version
65
    fn determine_version(matches: &ArgMatches) -> Result<u8, ConfigError> {
8✔
66
        let mut selected_version = None;
8✔
67
        for &(flag, version) in Self::VERSION_FLAGS.iter() {
16✔
68
            if matches.get_flag(flag) {
16✔
69
                if selected_version.is_some() {
4✔
NEW
70
                    return Err(ConfigError::Message(format!(
×
NEW
71
                        "Multiple version flags specified. Please use only one of: {}",
×
NEW
72
                        Self::VERSION_FLAGS
×
NEW
73
                            .iter()
×
NEW
74
                            .map(|(flag, _)| format!("--{}", flag))
×
NEW
75
                            .collect::<Vec<_>>()
×
NEW
76
                            .join(", ")
×
NEW
77
                    )));
×
78
                }
4✔
79
                selected_version = Some(version);
4✔
80
            }
12✔
81
        }
82

83
        if let Some(version) = selected_version {
8✔
84
            return Ok(version);
4✔
85
        }
4✔
86

4✔
87
        #[cfg(feature = "v2")]
4✔
88
        return Ok(2);
4✔
NEW
89
        #[cfg(all(feature = "v1", not(feature = "v2")))]
×
NEW
90
        return Ok(1);
×
91

92
        #[cfg(not(any(feature = "v1", feature = "v2")))]
93
        return Err(ConfigError::Message(
94
            "No valid version available - must compile with v1 or v2 feature".to_string(),
95
        ));
96
    }
8✔
97

98
    pub(crate) fn new(matches: &ArgMatches) -> Result<Self, ConfigError> {
8✔
99
        let mut builder = config::Config::builder();
8✔
100
        builder = add_bitcoind_defaults(builder, matches)?;
8✔
101
        builder = add_common_defaults(builder, matches)?;
8✔
102

103
        let version = Self::determine_version(matches)?;
8✔
104

105
        match version {
8✔
106
            1 => {
107
                #[cfg(feature = "v1")]
108
                {
109
                    builder = add_v1_defaults(builder)?;
4✔
110
                }
111
                #[cfg(not(feature = "v1"))]
112
                return Err(ConfigError::Message(
113
                    "BIP78 (v1) selected but v1 feature not enabled".to_string(),
114
                ));
115
            }
116
            2 => {
117
                #[cfg(feature = "v2")]
118
                {
119
                    builder = add_v2_defaults(builder, matches)?;
4✔
120
                }
121
                #[cfg(not(feature = "v2"))]
NEW
122
                return Err(ConfigError::Message(
×
NEW
123
                    "BIP77 (v2) selected but v2 feature not enabled".to_string(),
×
NEW
124
                ));
×
125
            }
NEW
126
            _ => unreachable!("determine_version() should only return 1 or 2"),
×
127
        }
128

129
        builder = handle_subcommands(builder, matches)?;
8✔
130
        builder = builder.add_source(File::new("config.toml", FileFormat::Toml).required(false));
8✔
131

132
        let built_config = builder.build()?;
8✔
133

134
        let mut config = Config {
8✔
135
            db_path: built_config.get("db_path")?,
8✔
136
            max_fee_rate: built_config.get("max_fee_rate").ok(),
8✔
137
            bitcoind: built_config.get("bitcoind")?,
8✔
138
            mode: None,
8✔
139
        };
8✔
140

8✔
141
        match version {
8✔
142
            1 => {
143
                #[cfg(feature = "v1")]
144
                {
145
                    if let Ok(v1) = built_config.get::<V1Config>("v1") {
4✔
146
                        config.mode = Some(VersionConfig::V1(v1));
4✔
147
                    } else {
4✔
NEW
148
                        return Err(ConfigError::Message(
×
NEW
149
                            "V1 configuration is required for BIP78 mode".to_string(),
×
NEW
150
                        ));
×
151
                    }
152
                }
153
                #[cfg(not(feature = "v1"))]
154
                return Err(ConfigError::Message(
155
                    "BIP78 (v1) selected but v1 feature not enabled".to_string(),
156
                ));
157
            }
158
            2 => {
159
                #[cfg(feature = "v2")]
160
                {
161
                    if let Ok(v2) = built_config.get::<V2Config>("v2") {
4✔
162
                        config.mode = Some(VersionConfig::V2(v2));
4✔
163
                    } else {
4✔
NEW
164
                        return Err(ConfigError::Message(
×
NEW
165
                            "V2 configuration is required for BIP77 mode".to_string(),
×
NEW
166
                        ));
×
167
                    }
168
                }
169
                #[cfg(not(feature = "v2"))]
NEW
170
                return Err(ConfigError::Message(
×
NEW
171
                    "BIP77 (v2) selected but v2 feature not enabled".to_string(),
×
NEW
172
                ));
×
173
            }
NEW
174
            _ => unreachable!("determine_version() should only return 1 or 2"),
×
175
        }
176

177
        if config.mode.is_none() {
8✔
NEW
178
            return Err(ConfigError::Message(
×
NEW
179
                "No valid version configuration found for the specified mode".to_string(),
×
NEW
180
            ));
×
181
        }
8✔
182

8✔
183
        log::debug!("App config: {:?}", config);
8✔
184
        Ok(config)
8✔
185
    }
8✔
186

187
    #[cfg(feature = "v1")]
188
    pub fn v1(&self) -> Result<&V1Config, anyhow::Error> {
5✔
189
        match &self.mode {
5✔
190
            Some(VersionConfig::V1(v1_config)) => Ok(v1_config),
10✔
191
            #[allow(unreachable_patterns)]
NEW
192
            _ => Err(anyhow::anyhow!("V1 configuration is required for BIP78 mode")),
×
193
        }
194
    }
10✔
195

196
    #[cfg(feature = "v2")]
197
    pub fn v2(&self) -> Result<&V2Config, anyhow::Error> {
10✔
198
        match &self.mode {
10✔
199
            Some(VersionConfig::V2(v2_config)) => Ok(v2_config),
10✔
200
            #[allow(unreachable_patterns)]
NEW
201
            _ => Err(anyhow::anyhow!("V2 configuration is required for v2 mode")),
×
202
        }
203
    }
10✔
204
}
205

206
/// Set up default values and CLI overrides for Bitcoin RPC connection settings
207
fn add_bitcoind_defaults(builder: Builder, matches: &ArgMatches) -> Result<Builder, ConfigError> {
8✔
208
    builder
8✔
209
        .set_default("bitcoind.rpchost", "http://localhost:18443")?
8✔
210
        .set_override_option(
8✔
211
            "bitcoind.rpchost",
8✔
212
            matches.get_one::<Url>("rpchost").map(|s| s.as_str()),
8✔
213
        )?
8✔
214
        .set_default("bitcoind.cookie", None::<String>)?
8✔
215
        .set_override_option(
8✔
216
            "bitcoind.cookie",
8✔
217
            matches.get_one::<String>("cookie_file").map(|s| s.as_str()),
8✔
218
        )?
8✔
219
        .set_default("bitcoind.rpcuser", "bitcoin")?
8✔
220
        .set_override_option(
8✔
221
            "bitcoind.rpcuser",
8✔
222
            matches.get_one::<String>("rpcuser").map(|s| s.as_str()),
8✔
223
        )?
8✔
224
        .set_default("bitcoind.rpcpassword", "")?
8✔
225
        .set_override_option(
8✔
226
            "bitcoind.rpcpassword",
8✔
227
            matches.get_one::<String>("rpcpassword").map(|s| s.as_str()),
8✔
228
        )
8✔
229
}
8✔
230

231
/// Set up default values and CLI overrides for common settings shared between v1 and v2
232
fn add_common_defaults(builder: Builder, matches: &ArgMatches) -> Result<Builder, ConfigError> {
8✔
233
    builder
8✔
234
        .set_default("db_path", db::DB_PATH)?
8✔
235
        .set_override_option("db_path", matches.get_one::<String>("db_path").map(|s| s.as_str()))
8✔
236
}
8✔
237

238
/// Set up default values for v1-specific settings when v2 is not enabled
239
#[cfg(feature = "v1")]
240
fn add_v1_defaults(builder: Builder) -> Result<Builder, ConfigError> {
4✔
241
    builder
4✔
242
        .set_default("v1.port", 3000_u16)?
4✔
243
        .set_default("v1.pj_endpoint", "https://localhost:3000")
4✔
244
}
4✔
245

246
/// Set up default values and CLI overrides for v2-specific settings
247
#[cfg(feature = "v2")]
248
fn add_v2_defaults(builder: Builder, matches: &ArgMatches) -> Result<Builder, ConfigError> {
4✔
249
    builder
4✔
250
        .set_override_option(
4✔
251
            "v2.ohttp_relay",
4✔
252
            matches.get_one::<Url>("ohttp_relay").map(|s| s.as_str()),
4✔
253
        )?
4✔
254
        .set_default("v2.pj_directory", "https://payjo.in")?
4✔
255
        .set_default("v2.ohttp_keys", None::<String>)
4✔
256
}
4✔
257

258
/// Handles configuration overrides based on CLI subcommands
259
fn handle_subcommands(builder: Builder, matches: &ArgMatches) -> Result<Builder, ConfigError> {
8✔
260
    match matches.subcommand() {
8✔
261
        Some(("send", _)) => Ok(builder),
8✔
262
        Some(("receive", matches)) => {
4✔
263
            let builder = handle_receive_command(builder, matches)?;
3✔
264
            let max_fee_rate = matches.get_one::<FeeRate>("max_fee_rate");
3✔
265
            builder.set_override_option("max_fee_rate", max_fee_rate.map(|f| f.to_string()))
3✔
266
        }
267
        #[cfg(feature = "v2")]
268
        Some(("resume", _)) => Ok(builder),
1✔
NEW
269
        _ => unreachable!(), // If all subcommands are defined above, anything else is unreachabe!()
×
270
    }
271
}
8✔
272

273
/// Handle configuration overrides specific to the receive command
274
fn handle_receive_command(builder: Builder, matches: &ArgMatches) -> Result<Builder, ConfigError> {
3✔
275
    #[cfg(feature = "v1")]
276
    let builder = {
3✔
277
        let port = matches
3✔
278
            .get_one::<String>("port")
3✔
279
            .map(|port| port.parse::<u16>())
3✔
280
            .transpose()
3✔
281
            .map_err(|_| ConfigError::Message("\"port\" must be a valid number".to_string()))?;
3✔
282
        builder.set_override_option("v1.port", port)?.set_override_option(
3✔
283
            "v1.pj_endpoint",
3✔
284
            matches.get_one::<Url>("pj_endpoint").map(|s| s.as_str()),
3✔
285
        )?
3✔
286
    };
287

288
    #[cfg(feature = "v2")]
289
    let builder = {
2✔
290
        builder
2✔
291
            .set_override_option(
2✔
292
                "v2.pj_directory",
2✔
293
                matches.get_one::<Url>("pj_directory").map(|s| s.as_str()),
2✔
294
            )?
2✔
295
            .set_override_option(
2✔
296
                "v2.ohttp_keys",
2✔
297
                matches.get_one::<String>("ohttp_keys").map(|s| s.as_str()),
2✔
298
            )?
2✔
299
    };
300

301
    Ok(builder)
3✔
302
}
3✔
303

304
#[cfg(feature = "v2")]
305
fn deserialize_ohttp_keys_from_path<'de, D>(
4✔
306
    deserializer: D,
4✔
307
) -> Result<Option<payjoin::OhttpKeys>, D::Error>
4✔
308
where
4✔
309
    D: serde::Deserializer<'de>,
4✔
310
{
4✔
311
    let path_str: Option<String> = Option::deserialize(deserializer)?;
4✔
312

313
    match path_str {
4✔
314
        None => Ok(None),
3✔
315
        Some(path) => std::fs::read(path)
1✔
316
            .map_err(|e| serde::de::Error::custom(format!("Failed to read ohttp_keys file: {}", e)))
1✔
317
            .and_then(|bytes| {
1✔
318
                payjoin::OhttpKeys::decode(&bytes).map_err(|e| {
1✔
319
                    serde::de::Error::custom(format!("Failed to decode ohttp keys: {}", e))
×
320
                })
1✔
321
            })
1✔
322
            .map(Some),
1✔
323
    }
324
}
4✔
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