1use std::path::PathBuf;
2use std::{env::current_dir, path::Path};
3
4use clap::ValueEnum;
5use config::Config;
6use miette::{Context, IntoDiagnostic};
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use crate::paths::{berg_config_dir, config_path};
11use crate::render::table::TableWrapper;
12use crate::types::git::Git;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
15pub enum ConfigLocation {
16 Global,
17 Local,
18 Stdout,
19}
20
21impl ConfigLocation {
22 pub fn path(self) -> miette::Result<PathBuf> {
23 match self {
24 ConfigLocation::Global => {
25 let global_path = config_path()?;
26 let global_path_dir = global_path
27 .parent()
28 .context("config path has parent directory")?;
29 Ok(global_path_dir.to_path_buf())
30 }
31 ConfigLocation::Local => current_dir()
32 .into_diagnostic()
33 .context("Getting current directory failed!"),
34 ConfigLocation::Stdout => unreachable!(
35 "Path of Stdout doesn't make sense. Impossible codepath. Please report this as an issue!"
36 ),
37 }
38 }
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct BergConfig {
44 pub base_url: String,
46 pub enable_https: bool,
54 pub fancy_tables: bool,
57 pub no_color: bool,
62 pub editor: String,
64 pub max_width: i32,
75}
76
77impl BergConfig {
78 fn iter_default_field_value() -> impl Iterator<Item = (&'static str, config::Value)> {
79 let BergConfig {
80 base_url,
81 enable_https,
82 fancy_tables,
83 no_color,
84 editor,
85 max_width,
86 } = Default::default();
87 [
88 ("base_url", config::Value::from(base_url)),
89 ("enable_https", config::Value::from(enable_https)),
90 ("fancy_tables", config::Value::from(fancy_tables)),
91 ("no_color", config::Value::from(no_color)),
92 ("editor", config::Value::from(editor)),
93 ("max_width", config::Value::from(max_width)),
94 ]
95 .into_iter()
96 }
97}
98
99impl Default for BergConfig {
100 fn default() -> Self {
101 let base_url = Git::default()
102 .origin()
103 .and_then(|origin| origin.url().map(strip_base_url));
104 Self {
105 base_url: base_url.unwrap_or(String::from("codeberg.org/")),
106 enable_https: true,
107 fancy_tables: true,
108 no_color: false,
109 editor: default_editor(),
110 max_width: 80,
111 }
112 }
113}
114
115impl BergConfig {
116 pub fn new() -> miette::Result<Self> {
129 let config = Self::raw()?
130 .try_deserialize::<BergConfig>()
131 .into_diagnostic()?;
132
133 if !config.enable_https {
134 eprintln!(
135 "You are using 'http' as protocol. Please note that this can influence the functionality of some API calls! You should only use it for testing purposes or internal network traffic!"
136 );
137 }
138
139 Ok(config)
140 }
141
142 pub fn raw() -> miette::Result<Config> {
143 let local_config_path = current_dir().map(add_berg_config_file).into_diagnostic()?;
144 let global_config_path = berg_config_dir().map(add_berg_config_file)?;
145 let mut config_builder = Config::builder();
146
147 config_builder = config_builder.add_source(file_from_path(global_config_path.as_path()));
156 tracing::debug!("config search in: {global_config_path:?}");
157
158 let mut walk_up = local_config_path.clone();
159 let walking_up = std::iter::from_fn(move || {
160 walk_up
161 .parent()
162 .and_then(|parent| parent.parent())
163 .map(add_berg_config_file)
164 .inspect(|parent| {
165 walk_up = parent.clone();
166 })
167 });
168
169 let pwd = std::iter::once(local_config_path);
170 let local_paths = pwd.chain(walking_up).collect::<Vec<_>>();
171
172 for path in local_paths.iter().rev() {
173 tracing::debug!("config search in: {path:?}");
174 config_builder = config_builder.add_source(file_from_path(path));
175 }
176
177 config_builder = config_builder.add_source(config::Environment::with_prefix("BERG"));
178 for (field_name, default_value) in BergConfig::iter_default_field_value() {
181 config_builder = config_builder
182 .set_default(field_name, default_value)
183 .into_diagnostic()?;
184 }
185
186 config_builder.build().into_diagnostic()
187 }
188}
189
190impl BergConfig {
191 pub fn url(&self) -> miette::Result<Url> {
192 let url = format!(
193 "{protoc}://{url}",
194 protoc = if self.enable_https { "https" } else { "http" },
195 url = self.base_url
196 );
197 Url::parse(url.as_str())
198 .into_diagnostic()
199 .context("The protocol + base url in the config don't add up to a valid url")
200 }
201
202 pub fn make_table(&self) -> TableWrapper {
203 let mut table = TableWrapper::default();
204
205 table.max_width = match self.max_width.cmp(&0) {
206 std::cmp::Ordering::Less => None,
207 std::cmp::Ordering::Equal => termsize::get().map(|size| size.cols - 2),
208 std::cmp::Ordering::Greater => Some(self.max_width as u16),
209 };
210
211 let preset = if self.fancy_tables {
212 comfy_table::presets::UTF8_FULL
213 } else {
214 comfy_table::presets::NOTHING
215 };
216
217 table.load_preset(preset)
218 }
219}
220
221fn file_from_path(
222 path: impl AsRef<Path>,
223) -> config::File<config::FileSourceFile, config::FileFormat> {
224 config::File::new(
225 path.as_ref().to_str().unwrap_or_default(),
226 config::FileFormat::Toml,
227 )
228 .required(false)
229}
230
231fn add_berg_config_file(dir: impl AsRef<Path>) -> PathBuf {
232 dir.as_ref().join("berg.toml")
233}
234
235fn default_editor() -> String {
236 std::env::var("EDITOR")
237 .or(std::env::var("VISUAL"))
238 .unwrap_or_else(|_| {
239 let os_native_editor = if cfg!(target_os = "windows") {
240 "notepad"
242 } else if cfg!(target_os = "macos") {
243 "textedit"
245 } else {
246 "vi"
248 };
249 String::from(os_native_editor)
250 })
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn make_config(path: PathBuf, config: BergConfig) -> miette::Result<()> {
258 let config_path = add_berg_config_file(path);
259 let toml = toml::to_string(&config)
260 .into_diagnostic()
261 .context("Failed to create config string!")?;
262 std::fs::write(config_path, toml)
263 .into_diagnostic()
264 .context("Failed to write config file!")?;
265 Ok(())
266 }
267
268 fn delete_config(path: PathBuf) -> miette::Result<()> {
269 let config_path = add_berg_config_file(path);
270 std::fs::remove_file(config_path)
271 .into_diagnostic()
272 .context("Failed to remove file")?;
273 Ok(())
274 }
275
276 #[test]
277 #[ignore = "doesn't work on nix in 'ci' because of no r/w permissions on the system"]
278 fn berg_config_integration_test() -> miette::Result<()> {
279 let local_dir = current_dir().into_diagnostic()?;
282 std::fs::create_dir_all(local_dir).into_diagnostic()?;
283 let global_dir = berg_config_dir()?;
284 std::fs::create_dir_all(global_dir).into_diagnostic()?;
285
286 let config = BergConfig {
288 base_url: String::from("local"),
289 ..Default::default()
290 };
291 make_config(current_dir().into_diagnostic()?, config)?;
292 let config = BergConfig::new();
293 assert!(config.is_ok(), "{config:?}");
294 let config = config.unwrap();
295 assert_eq!(config.base_url.as_str(), "local");
296 delete_config(current_dir().into_diagnostic()?)?;
297
298 let config = BergConfig {
300 base_url: String::from("global"),
301 ..Default::default()
302 };
303 make_config(berg_config_dir()?, config)?;
304 let config = BergConfig::new();
305 assert!(config.is_ok(), "{config:?}");
306 let config = config.unwrap();
307 assert_eq!(config.base_url.as_str(), "global", "{0:?}", config.base_url);
308 delete_config(berg_config_dir()?)?;
309
310 let config = BergConfig::new();
312 assert!(config.is_ok(), "{config:?}");
313 let config = config.unwrap();
314 assert_eq!(config.base_url.as_str(), "codeberg.org/");
315
316 {
318 let config = BergConfig {
320 base_url: String::from("local"),
321 ..Default::default()
322 };
323 make_config(current_dir().into_diagnostic()?, config)?;
324 }
325 {
326 let config = BergConfig {
328 base_url: String::from("global"),
329 ..Default::default()
330 };
331 make_config(berg_config_dir()?, config)?;
332 }
333 let config = BergConfig::new();
334 assert!(config.is_ok(), "{config:?}");
335 let config = config.unwrap();
336 assert_eq!(config.base_url.as_str(), "local");
337 delete_config(current_dir().into_diagnostic()?)?;
338 delete_config(berg_config_dir()?)?;
339
340 Ok(())
341 }
342}
343
344fn strip_base_url(url: impl AsRef<str>) -> String {
351 let url = url.as_ref();
352 let url = url.split_once("//").map(|(_proto, url)| url).unwrap_or(url);
353 let url = url.split_once("@").map(|(_user, url)| url).unwrap_or(url);
354 let url = url.split_once("/").map(|(base, _path)| base).unwrap_or(url);
355 let url = url.split_once(":").map(|(base, _user)| base).unwrap_or(url);
356 format!("{url}/")
357}
358
359#[cfg(test)]
360mod strip_base_url {
361 use super::strip_base_url;
362 #[test]
363 fn https_works() {
364 assert_eq!(
365 strip_base_url("https://codeberg.org/example.com"),
366 "codeberg.org/"
367 );
368 }
369
370 #[test]
371 fn ssh_works() {
372 assert_eq!(
373 strip_base_url("git@codeberg.org:Aviac/codeberg-cli.git"),
374 "codeberg.org/"
375 );
376 }
377
378 #[test]
379 fn ssh_detailed_works() {
380 assert_eq!(
381 strip_base_url("ssh://git@example.com:1312/foo/bar.git"),
382 "example.com/"
383 );
384 }
385
386 #[test]
387 fn domain_and_port_no_protoc() {
388 assert_eq!(strip_base_url("example.com:1312"), "example.com/");
389 }
390}