@mbe24/99problems 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.github/ISSUE_TEMPLATE/01-feature.yml +65 -0
  2. package/.github/ISSUE_TEMPLATE/02-bug.yml +122 -0
  3. package/.github/ISSUE_TEMPLATE/99-custom.yml +33 -0
  4. package/.github/ISSUE_TEMPLATE/config.yml +1 -0
  5. package/.github/dependabot.yml +20 -0
  6. package/.github/scripts/publish.js +1 -1
  7. package/.github/workflows/ci.yml +32 -6
  8. package/.github/workflows/man-drift.yml +26 -0
  9. package/.github/workflows/release.yml +7 -7
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +109 -72
  14. package/artifacts/binary-aarch64-apple-darwin/99problems +0 -0
  15. package/artifacts/binary-aarch64-unknown-linux-gnu/99problems +0 -0
  16. package/artifacts/binary-x86_64-apple-darwin/99problems +0 -0
  17. package/artifacts/binary-x86_64-pc-windows-msvc/99problems.exe +0 -0
  18. package/artifacts/binary-x86_64-unknown-linux-gnu/99problems +0 -0
  19. package/docs/man/99problems-completions.1 +31 -0
  20. package/docs/man/99problems-config.1 +50 -0
  21. package/docs/man/99problems-get.1 +114 -0
  22. package/docs/man/99problems-help.1 +25 -0
  23. package/docs/man/99problems-man.1 +32 -0
  24. package/docs/man/99problems.1 +52 -0
  25. package/npm/install.js +90 -3
  26. package/package.json +7 -7
  27. package/rust-toolchain.toml +4 -0
  28. package/src/cmd/config/key.rs +126 -0
  29. package/src/cmd/config/mod.rs +218 -0
  30. package/src/cmd/config/render.rs +33 -0
  31. package/src/cmd/config/store.rs +318 -0
  32. package/src/cmd/config/write.rs +173 -0
  33. package/src/cmd/get.rs +658 -0
  34. package/src/cmd/man.rs +117 -0
  35. package/src/cmd/mod.rs +3 -0
  36. package/src/config.rs +641 -42
  37. package/src/error.rs +254 -0
  38. package/src/format/json.rs +59 -18
  39. package/src/format/jsonl.rs +52 -0
  40. package/src/format/mod.rs +25 -3
  41. package/src/format/text.rs +73 -0
  42. package/src/format/yaml.rs +64 -15
  43. package/src/lib.rs +1 -0
  44. package/src/logging.rs +54 -0
  45. package/src/main.rs +230 -91
  46. package/src/model.rs +9 -1
  47. package/src/source/bitbucket/cloud/api.rs +67 -0
  48. package/src/source/bitbucket/cloud/mod.rs +178 -0
  49. package/src/source/bitbucket/cloud/model.rs +211 -0
  50. package/src/source/bitbucket/datacenter/api.rs +74 -0
  51. package/src/source/bitbucket/datacenter/mod.rs +181 -0
  52. package/src/source/bitbucket/datacenter/model.rs +327 -0
  53. package/src/source/bitbucket/mod.rs +90 -0
  54. package/src/source/bitbucket/query.rs +169 -0
  55. package/src/source/bitbucket/shared/auth.rs +54 -0
  56. package/src/source/bitbucket/shared/http.rs +59 -0
  57. package/src/source/bitbucket/shared/mod.rs +5 -0
  58. package/src/source/github/api.rs +128 -0
  59. package/src/source/github/mod.rs +191 -0
  60. package/src/source/github/model.rs +84 -0
  61. package/src/source/github/query.rs +50 -0
  62. package/src/source/gitlab/api.rs +282 -0
  63. package/src/source/gitlab/mod.rs +225 -0
  64. package/src/source/gitlab/model.rs +102 -0
  65. package/src/source/gitlab/query.rs +177 -0
  66. package/src/source/jira/api.rs +222 -0
  67. package/src/source/jira/mod.rs +161 -0
  68. package/src/source/jira/model.rs +99 -0
  69. package/src/source/jira/query.rs +153 -0
  70. package/src/source/mod.rs +179 -24
  71. package/tests/integration.rs +406 -26
  72. package/src/source/github_issues.rs +0 -232
package/src/config.rs CHANGED
@@ -1,49 +1,307 @@
1
- use anyhow::Result;
1
+ use anyhow::{Result, anyhow};
2
2
  use serde::Deserialize;
3
+ use std::collections::HashMap;
3
4
  use std::path::PathBuf;
4
5
 
5
- /// Represents values that can be set in a .99problems TOML dotfile.
6
- #[derive(Debug, Default, Deserialize)]
7
- pub struct DotfileConfig {
6
+ #[derive(Debug, Default, Deserialize, Clone)]
7
+ pub struct InstanceConfig {
8
+ pub platform: Option<String>,
8
9
  pub token: Option<String>,
10
+ pub account_email: Option<String>,
11
+ pub url: Option<String>,
9
12
  pub repo: Option<String>,
10
13
  pub state: Option<String>,
14
+ #[serde(rename = "type")]
15
+ pub kind: Option<String>,
16
+ pub deployment: Option<String>,
11
17
  pub per_page: Option<u32>,
12
18
  }
13
19
 
14
- /// Fully resolved config after merging home + local dotfiles + env var.
20
+ #[derive(Debug, Default, Deserialize, Clone)]
21
+ pub struct DotfileConfig {
22
+ pub default_instance: Option<String>,
23
+ #[serde(default)]
24
+ pub instances: HashMap<String, InstanceConfig>,
25
+ }
26
+
15
27
  #[derive(Debug, Default)]
16
28
  pub struct Config {
29
+ pub platform: String,
30
+ pub kind: String,
31
+ pub kind_explicit: bool,
17
32
  pub token: Option<String>,
33
+ pub account_email: Option<String>,
18
34
  pub repo: Option<String>,
19
35
  pub state: Option<String>,
36
+ pub deployment: Option<String>,
20
37
  pub per_page: u32,
38
+ pub platform_url: Option<String>,
39
+ }
40
+
41
+ #[derive(Debug, Default, Clone, Copy)]
42
+ pub struct ResolveOptions<'a> {
43
+ pub platform: Option<&'a str>,
44
+ pub instance: Option<&'a str>,
45
+ pub url: Option<&'a str>,
46
+ pub kind: Option<&'a str>,
47
+ pub deployment: Option<&'a str>,
48
+ pub token: Option<&'a str>,
49
+ pub account_email: Option<&'a str>,
50
+ pub repo: Option<&'a str>,
51
+ pub state: Option<&'a str>,
21
52
  }
22
53
 
23
54
  impl Config {
24
- /// Load and merge: home dotfile (base) local dotfile (override) → env var.
25
- /// CLI flags are merged later in main.rs on top of this.
55
+ /// Load and resolve config from dotfiles, env vars, and defaults.
56
+ ///
57
+ /// # Errors
58
+ ///
59
+ /// Returns an error if reading/parsing dotfiles fails, if unsupported
60
+ /// dotfile keys are present, or if instance selection/validation fails.
26
61
  pub fn load() -> Result<Self> {
62
+ Self::load_with_options(ResolveOptions::default())
63
+ }
64
+
65
+ /// Load config with CLI-provided overrides and selectors.
66
+ ///
67
+ /// # Errors
68
+ ///
69
+ /// Returns an error if reading/parsing dotfiles fails, if unsupported
70
+ /// dotfile keys are present, or if instance selection/validation fails.
71
+ pub fn load_with_options(opts: ResolveOptions<'_>) -> Result<Self> {
27
72
  let home = load_dotfile(home_dotfile_path())?;
28
73
  let local = load_dotfile(local_dotfile_path())?;
74
+ resolve_from_dotfiles(home, local, opts)
75
+ }
76
+ }
77
+
78
+ #[must_use]
79
+ fn merge_instance(base: &InstanceConfig, override_cfg: &InstanceConfig) -> InstanceConfig {
80
+ InstanceConfig {
81
+ platform: override_cfg
82
+ .platform
83
+ .clone()
84
+ .or_else(|| base.platform.clone()),
85
+ token: override_cfg.token.clone().or_else(|| base.token.clone()),
86
+ account_email: override_cfg
87
+ .account_email
88
+ .clone()
89
+ .or_else(|| base.account_email.clone()),
90
+ url: override_cfg.url.clone().or_else(|| base.url.clone()),
91
+ repo: override_cfg.repo.clone().or_else(|| base.repo.clone()),
92
+ state: override_cfg.state.clone().or_else(|| base.state.clone()),
93
+ kind: override_cfg.kind.clone().or_else(|| base.kind.clone()),
94
+ deployment: override_cfg
95
+ .deployment
96
+ .clone()
97
+ .or_else(|| base.deployment.clone()),
98
+ per_page: override_cfg.per_page.or(base.per_page),
99
+ }
100
+ }
101
+
102
+ #[must_use]
103
+ fn merge_dotfiles(home: DotfileConfig, local: DotfileConfig) -> DotfileConfig {
104
+ let mut merged_instances = home.instances;
105
+ for (alias, local_cfg) in local.instances {
106
+ merged_instances
107
+ .entry(alias)
108
+ .and_modify(|home_cfg| *home_cfg = merge_instance(home_cfg, &local_cfg))
109
+ .or_insert(local_cfg);
110
+ }
111
+
112
+ DotfileConfig {
113
+ default_instance: local.default_instance.or(home.default_instance),
114
+ instances: merged_instances,
115
+ }
116
+ }
117
+
118
+ fn resolve_from_dotfiles(
119
+ home: DotfileConfig,
120
+ local: DotfileConfig,
121
+ opts: ResolveOptions<'_>,
122
+ ) -> Result<Config> {
123
+ let merged = merge_dotfiles(home, local);
124
+
125
+ if !merged.instances.is_empty() {
126
+ return resolve_instance_mode(&merged, opts);
127
+ }
128
+
129
+ if let Some(alias) = opts.instance {
130
+ return Err(anyhow!("Instance '{alias}' not found."));
131
+ }
132
+
133
+ if let Some(default_instance) = merged.default_instance {
134
+ return Err(anyhow!(
135
+ "default_instance is set to '{default_instance}', but no instances are configured."
136
+ ));
137
+ }
138
+
139
+ resolve_cli_only_mode(opts)
140
+ }
29
141
 
30
- // Merge: local wins over home
31
- let token = std::env::var("GITHUB_TOKEN")
32
- .ok()
33
- .or_else(|| local.token.clone())
34
- .or_else(|| home.token.clone());
142
+ fn resolve_instance_mode(merged: &DotfileConfig, opts: ResolveOptions<'_>) -> Result<Config> {
143
+ if let Some(default_instance) = merged.default_instance.as_deref()
144
+ && !merged.instances.contains_key(default_instance)
145
+ {
146
+ return Err(anyhow!(
147
+ "default_instance '{default_instance}' was not found in instances."
148
+ ));
149
+ }
150
+
151
+ let selected_alias = if let Some(alias) = opts.instance {
152
+ if merged.instances.contains_key(alias) {
153
+ alias.to_string()
154
+ } else {
155
+ return Err(anyhow!(
156
+ "Instance '{alias}' not found. Available: {}",
157
+ available_instances(&merged.instances)
158
+ ));
159
+ }
160
+ } else if merged.instances.len() == 1 {
161
+ merged
162
+ .instances
163
+ .keys()
164
+ .next()
165
+ .ok_or_else(|| anyhow!("No instances configured."))?
166
+ .clone()
167
+ } else if let Some(default_instance) = merged.default_instance.as_deref() {
168
+ default_instance.to_string()
169
+ } else {
170
+ return Err(anyhow!(
171
+ "Multiple instances configured. Specify --instance or set default_instance. Available: {}",
172
+ available_instances(&merged.instances)
173
+ ));
174
+ };
175
+
176
+ let instance = merged
177
+ .instances
178
+ .get(&selected_alias)
179
+ .ok_or_else(|| anyhow!("Instance '{selected_alias}' not found."))?;
180
+ let instance_platform = instance.platform.as_deref().ok_or_else(|| {
181
+ anyhow!("Instance '{selected_alias}' is missing required field 'platform'.")
182
+ })?;
183
+
184
+ if let Some(cli_platform) = opts.platform
185
+ && cli_platform != instance_platform
186
+ {
187
+ return Err(anyhow!(
188
+ "Platform mismatch: --instance '{selected_alias}' uses '{instance_platform}', but --platform was '{cli_platform}'."
189
+ ));
190
+ }
191
+
192
+ let platform = instance_platform.to_string();
193
+ let deployment = normalize_deployment(
194
+ &platform,
195
+ opts.deployment.or(instance.deployment.as_deref()),
196
+ )?;
197
+ let token = opts
198
+ .token
199
+ .map(std::borrow::ToOwned::to_owned)
200
+ .or_else(|| std::env::var(token_env_var(&platform)).ok())
201
+ .or_else(|| instance.token.clone());
202
+ let account_email = opts
203
+ .account_email
204
+ .map(std::borrow::ToOwned::to_owned)
205
+ .or_else(|| account_email_env_var(&platform).and_then(|var| std::env::var(var).ok()))
206
+ .or_else(|| instance.account_email.clone());
207
+ let (kind, kind_explicit) = if let Some(kind) = opts.kind {
208
+ (kind.to_string(), true)
209
+ } else if let Some(kind) = instance.kind.as_deref() {
210
+ (kind.to_string(), true)
211
+ } else {
212
+ ("issue".to_string(), false)
213
+ };
35
214
 
36
- let repo = local.repo.or(home.repo);
37
- let state = local.state.or(home.state);
38
- let per_page = local.per_page.or(home.per_page).unwrap_or(100);
215
+ Ok(Config {
216
+ platform,
217
+ kind,
218
+ kind_explicit,
219
+ token,
220
+ account_email,
221
+ repo: opts
222
+ .repo
223
+ .map(std::borrow::ToOwned::to_owned)
224
+ .or_else(|| instance.repo.clone()),
225
+ state: opts
226
+ .state
227
+ .map(std::borrow::ToOwned::to_owned)
228
+ .or_else(|| instance.state.clone()),
229
+ deployment,
230
+ per_page: instance.per_page.unwrap_or(100),
231
+ platform_url: opts
232
+ .url
233
+ .map(std::borrow::ToOwned::to_owned)
234
+ .or_else(|| instance.url.clone()),
235
+ })
236
+ }
39
237
 
40
- Ok(Self {
41
- token,
42
- repo,
43
- state,
44
- per_page,
45
- })
238
+ fn resolve_cli_only_mode(opts: ResolveOptions<'_>) -> Result<Config> {
239
+ let platform = opts.platform.unwrap_or("github").to_string();
240
+ if !matches!(
241
+ platform.as_str(),
242
+ "github" | "gitlab" | "jira" | "bitbucket"
243
+ ) {
244
+ return Err(anyhow!("Platform '{platform}' is not yet supported."));
46
245
  }
246
+ let deployment = normalize_deployment(&platform, opts.deployment)?;
247
+
248
+ let token = opts
249
+ .token
250
+ .map(std::borrow::ToOwned::to_owned)
251
+ .or_else(|| std::env::var(token_env_var(&platform)).ok());
252
+ let account_email = opts
253
+ .account_email
254
+ .map(std::borrow::ToOwned::to_owned)
255
+ .or_else(|| account_email_env_var(&platform).and_then(|var| std::env::var(var).ok()));
256
+ let (kind, kind_explicit) = if let Some(kind) = opts.kind {
257
+ (kind.to_string(), true)
258
+ } else {
259
+ ("issue".to_string(), false)
260
+ };
261
+
262
+ Ok(Config {
263
+ platform,
264
+ kind,
265
+ kind_explicit,
266
+ token,
267
+ account_email,
268
+ repo: opts.repo.map(std::borrow::ToOwned::to_owned),
269
+ state: opts.state.map(std::borrow::ToOwned::to_owned),
270
+ deployment,
271
+ per_page: 100,
272
+ platform_url: opts.url.map(std::borrow::ToOwned::to_owned),
273
+ })
274
+ }
275
+
276
+ fn normalize_deployment(platform: &str, deployment: Option<&str>) -> Result<Option<String>> {
277
+ if platform == "bitbucket" {
278
+ let deployment = deployment.ok_or_else(|| {
279
+ anyhow!(
280
+ "Bitbucket deployment is required. Set [instances.<alias>].deployment or pass --deployment (cloud|selfhosted)."
281
+ )
282
+ })?;
283
+ return match deployment {
284
+ "cloud" | "selfhosted" => Ok(Some(deployment.to_string())),
285
+ other => Err(anyhow!(
286
+ "Invalid bitbucket deployment '{other}'. Supported: cloud, selfhosted."
287
+ )),
288
+ };
289
+ }
290
+
291
+ if let Some(value) = deployment {
292
+ return Err(anyhow!(
293
+ "Deployment is only supported for platform 'bitbucket', got '{platform}' with deployment '{value}'."
294
+ ));
295
+ }
296
+
297
+ Ok(None)
298
+ }
299
+
300
+ #[must_use]
301
+ fn available_instances(instances: &HashMap<String, InstanceConfig>) -> String {
302
+ let mut keys: Vec<&str> = instances.keys().map(std::string::String::as_str).collect();
303
+ keys.sort_unstable();
304
+ keys.join(", ")
47
305
  }
48
306
 
49
307
  fn home_dotfile_path() -> Option<PathBuf> {
@@ -60,42 +318,383 @@ fn load_dotfile(path: Option<PathBuf>) -> Result<DotfileConfig> {
60
318
  _ => return Ok(DotfileConfig::default()),
61
319
  };
62
320
  let content = std::fs::read_to_string(&path)?;
63
- let cfg: DotfileConfig = toml::from_str(&content)?;
321
+ parse_dotfile_content(&content)
322
+ }
323
+
324
+ fn parse_dotfile_content(content: &str) -> Result<DotfileConfig> {
325
+ let value: toml::Value = toml::from_str(content)?;
326
+ let table = value
327
+ .as_table()
328
+ .ok_or_else(|| anyhow!("Invalid .99problems: expected top-level TOML table."))?;
329
+ validate_dotfile_keys(table)?;
330
+ let cfg: DotfileConfig = toml::from_str(content)?;
64
331
  Ok(cfg)
65
332
  }
66
333
 
334
+ fn validate_dotfile_keys(table: &toml::value::Table) -> Result<()> {
335
+ for key in [
336
+ "platform",
337
+ "repo",
338
+ "state",
339
+ "type",
340
+ "per_page",
341
+ "account_email",
342
+ "deployment",
343
+ ] {
344
+ if table.contains_key(key) {
345
+ return Err(anyhow!("Unsupported top-level key '{key}' in .99problems."));
346
+ }
347
+ }
348
+ for key in ["github", "gitlab", "jira", "bitbucket"] {
349
+ if table.contains_key(key) {
350
+ return Err(anyhow!("Legacy section '[{key}]' is not supported."));
351
+ }
352
+ }
353
+ validate_instance_keys(table)?;
354
+ Ok(())
355
+ }
356
+
357
+ fn validate_instance_keys(table: &toml::value::Table) -> Result<()> {
358
+ let Some(instances) = table.get("instances") else {
359
+ return Ok(());
360
+ };
361
+ let instance_entries = instances
362
+ .as_table()
363
+ .ok_or_else(|| anyhow!("Invalid .99problems: 'instances' must be a TOML table."))?;
364
+
365
+ for (alias, value) in instance_entries {
366
+ let cfg_table = value.as_table().ok_or_else(|| {
367
+ anyhow!("Invalid .99problems: instances.{alias} must be a TOML table.")
368
+ })?;
369
+ for key in cfg_table.keys() {
370
+ if key == "email" {
371
+ return Err(anyhow!(
372
+ "Unsupported key 'instances.{alias}.email'. Use 'instances.{alias}.account_email' instead."
373
+ ));
374
+ }
375
+ if !matches!(
376
+ key.as_str(),
377
+ "platform"
378
+ | "token"
379
+ | "account_email"
380
+ | "url"
381
+ | "repo"
382
+ | "state"
383
+ | "type"
384
+ | "deployment"
385
+ | "per_page"
386
+ ) {
387
+ return Err(anyhow!(
388
+ "Unsupported key 'instances.{alias}.{key}' in .99problems."
389
+ ));
390
+ }
391
+ }
392
+ }
393
+
394
+ Ok(())
395
+ }
396
+
397
+ #[must_use]
398
+ pub fn token_env_var(platform: &str) -> &'static str {
399
+ match platform {
400
+ "github" => "GITHUB_TOKEN",
401
+ "gitlab" => "GITLAB_TOKEN",
402
+ "jira" => "JIRA_TOKEN",
403
+ "bitbucket" => "BITBUCKET_TOKEN",
404
+ _ => "TOKEN",
405
+ }
406
+ }
407
+
408
+ #[must_use]
409
+ pub fn account_email_env_var(platform: &str) -> Option<&'static str> {
410
+ match platform {
411
+ "jira" => Some("JIRA_ACCOUNT_EMAIL"),
412
+ _ => None,
413
+ }
414
+ }
415
+
67
416
  #[cfg(test)]
68
417
  mod tests {
69
418
  use super::*;
70
419
 
420
+ fn resolve_with_opts(dotfile: &str, opts: ResolveOptions<'_>) -> Result<Config> {
421
+ let home = DotfileConfig::default();
422
+ let local = parse_dotfile_content(dotfile)?;
423
+ resolve_from_dotfiles(home, local, opts)
424
+ }
425
+
71
426
  #[test]
72
- fn dotfile_config_defaults_when_missing() {
73
- let cfg = load_dotfile(None).unwrap();
74
- assert!(cfg.token.is_none());
75
- assert!(cfg.repo.is_none());
76
- assert!(cfg.per_page.is_none());
427
+ fn parse_instance_only_dotfile() {
428
+ let cfg = parse_dotfile_content(
429
+ r#"
430
+ default_instance = "work"
431
+ [instances.work]
432
+ platform = "gitlab"
433
+ repo = "group/project"
434
+ "#,
435
+ )
436
+ .unwrap();
437
+ assert_eq!(cfg.default_instance.as_deref(), Some("work"));
438
+ assert!(cfg.instances.contains_key("work"));
77
439
  }
78
440
 
79
441
  #[test]
80
- fn dotfile_config_parses_toml() {
81
- let toml = r#"
82
- token = "ghp_test"
442
+ fn rejects_legacy_section() {
443
+ let err = parse_dotfile_content(
444
+ r#"
445
+ [github]
446
+ token = "x"
447
+ "#,
448
+ )
449
+ .unwrap_err()
450
+ .to_string();
451
+ assert!(err.contains("Legacy section '[github]'"));
452
+ }
453
+
454
+ #[test]
455
+ fn rejects_top_level_runtime_key() {
456
+ let err = parse_dotfile_content(r#"platform = "github""#)
457
+ .unwrap_err()
458
+ .to_string();
459
+ assert!(err.contains("Unsupported top-level key 'platform'"));
460
+ }
461
+
462
+ #[test]
463
+ fn rejects_legacy_instance_email_key() {
464
+ let err = parse_dotfile_content(
465
+ r#"
466
+ [instances.work]
467
+ platform = "jira"
468
+ email = "user@example.com"
469
+ "#,
470
+ )
471
+ .unwrap_err()
472
+ .to_string();
473
+ assert!(err.contains("instances.work.email"));
474
+ assert!(err.contains("account_email"));
475
+ }
476
+
477
+ #[test]
478
+ fn rejects_unknown_instance_key() {
479
+ let err = parse_dotfile_content(
480
+ r#"
481
+ [instances.work]
482
+ platform = "github"
483
+ foo = "bar"
484
+ "#,
485
+ )
486
+ .unwrap_err()
487
+ .to_string();
488
+ assert!(err.contains("instances.work.foo"));
489
+ }
490
+
491
+ #[test]
492
+ fn auto_selects_single_instance() {
493
+ let cfg = resolve_with_opts(
494
+ r#"
495
+ [instances.only]
496
+ platform = "gitlab"
497
+ repo = "group/project"
498
+ "#,
499
+ ResolveOptions::default(),
500
+ )
501
+ .unwrap();
502
+ assert_eq!(cfg.platform, "gitlab");
503
+ assert_eq!(cfg.repo.as_deref(), Some("group/project"));
504
+ assert_eq!(cfg.kind, "issue");
505
+ assert!(!cfg.kind_explicit);
506
+ }
507
+
508
+ #[test]
509
+ fn uses_default_instance_when_multiple() {
510
+ let cfg = resolve_with_opts(
511
+ r#"
512
+ default_instance = "work"
513
+ [instances.work]
514
+ platform = "gitlab"
515
+ repo = "group/work"
516
+
517
+ [instances.public]
518
+ platform = "github"
83
519
  repo = "owner/repo"
84
- state = "closed"
85
- per_page = 50
86
- "#;
87
- let cfg: DotfileConfig = toml::from_str(toml).unwrap();
88
- assert_eq!(cfg.token.as_deref(), Some("ghp_test"));
520
+ "#,
521
+ ResolveOptions::default(),
522
+ )
523
+ .unwrap();
524
+ assert_eq!(cfg.platform, "gitlab");
525
+ assert_eq!(cfg.repo.as_deref(), Some("group/work"));
526
+ }
527
+
528
+ #[test]
529
+ fn errors_on_ambiguous_instances_without_default() {
530
+ let err = resolve_with_opts(
531
+ r#"
532
+ [instances.work]
533
+ platform = "gitlab"
534
+
535
+ [instances.public]
536
+ platform = "github"
537
+ "#,
538
+ ResolveOptions::default(),
539
+ )
540
+ .unwrap_err()
541
+ .to_string();
542
+ assert!(err.contains("Multiple instances configured"));
543
+ }
544
+
545
+ #[test]
546
+ fn errors_on_unknown_instance() {
547
+ let err = resolve_with_opts(
548
+ r#"
549
+ [instances.work]
550
+ platform = "gitlab"
551
+ "#,
552
+ ResolveOptions {
553
+ instance: Some("missing"),
554
+ ..ResolveOptions::default()
555
+ },
556
+ )
557
+ .unwrap_err()
558
+ .to_string();
559
+ assert!(err.contains("Instance 'missing' not found"));
560
+ }
561
+
562
+ #[test]
563
+ fn errors_on_platform_mismatch() {
564
+ let err = resolve_with_opts(
565
+ r#"
566
+ [instances.work]
567
+ platform = "gitlab"
568
+ "#,
569
+ ResolveOptions {
570
+ instance: Some("work"),
571
+ platform: Some("github"),
572
+ ..ResolveOptions::default()
573
+ },
574
+ )
575
+ .unwrap_err()
576
+ .to_string();
577
+ assert!(err.contains("Platform mismatch"));
578
+ }
579
+
580
+ #[test]
581
+ fn cli_overrides_instance_fields() {
582
+ let cfg = resolve_with_opts(
583
+ r#"
584
+ [instances.work]
585
+ platform = "gitlab"
586
+ repo = "group/project"
587
+ state = "opened"
588
+ type = "issue"
589
+ "#,
590
+ ResolveOptions {
591
+ repo: Some("override/repo"),
592
+ state: Some("closed"),
593
+ kind: Some("pr"),
594
+ ..ResolveOptions::default()
595
+ },
596
+ )
597
+ .unwrap();
598
+ assert_eq!(cfg.repo.as_deref(), Some("override/repo"));
599
+ assert_eq!(cfg.state.as_deref(), Some("closed"));
600
+ assert_eq!(cfg.kind, "pr");
601
+ assert!(cfg.kind_explicit);
602
+ }
603
+
604
+ #[test]
605
+ fn cli_only_mode_without_instances_works() {
606
+ let cfg = resolve_from_dotfiles(
607
+ DotfileConfig::default(),
608
+ DotfileConfig::default(),
609
+ ResolveOptions {
610
+ platform: Some("github"),
611
+ repo: Some("owner/repo"),
612
+ ..ResolveOptions::default()
613
+ },
614
+ )
615
+ .unwrap();
616
+ assert_eq!(cfg.platform, "github");
89
617
  assert_eq!(cfg.repo.as_deref(), Some("owner/repo"));
90
- assert_eq!(cfg.per_page, Some(50));
618
+ assert_eq!(cfg.kind, "issue");
619
+ assert!(!cfg.kind_explicit);
91
620
  }
92
621
 
93
622
  #[test]
94
- fn config_per_page_defaults_to_100() {
95
- // Simulate both dotfiles missing, no env var
96
- let home = DotfileConfig::default();
97
- let local = DotfileConfig::default();
98
- let per_page = local.per_page.or(home.per_page).unwrap_or(100);
99
- assert_eq!(per_page, 100);
623
+ fn merge_instances_deep_merges_fields() {
624
+ let home = DotfileConfig {
625
+ default_instance: None,
626
+ instances: HashMap::from([(
627
+ "work".to_string(),
628
+ InstanceConfig {
629
+ platform: Some("gitlab".to_string()),
630
+ token: Some("home-token".to_string()),
631
+ account_email: None,
632
+ url: Some("https://home.example".to_string()),
633
+ repo: Some("group/home".to_string()),
634
+ state: None,
635
+ kind: None,
636
+ deployment: None,
637
+ per_page: Some(20),
638
+ },
639
+ )]),
640
+ };
641
+ let local = DotfileConfig {
642
+ default_instance: Some("work".to_string()),
643
+ instances: HashMap::from([(
644
+ "work".to_string(),
645
+ InstanceConfig {
646
+ platform: None,
647
+ token: Some("local-token".to_string()),
648
+ account_email: None,
649
+ url: None,
650
+ repo: Some("group/local".to_string()),
651
+ state: Some("opened".to_string()),
652
+ kind: Some("pr".to_string()),
653
+ deployment: None,
654
+ per_page: None,
655
+ },
656
+ )]),
657
+ };
658
+ let merged = merge_dotfiles(home, local);
659
+ let work = merged.instances.get("work").unwrap();
660
+ assert_eq!(work.platform.as_deref(), Some("gitlab"));
661
+ assert_eq!(work.token.as_deref(), Some("local-token"));
662
+ assert_eq!(work.url.as_deref(), Some("https://home.example"));
663
+ assert_eq!(work.repo.as_deref(), Some("group/local"));
664
+ assert_eq!(work.state.as_deref(), Some("opened"));
665
+ assert_eq!(work.kind.as_deref(), Some("pr"));
666
+ assert_eq!(work.per_page, Some(20));
667
+ assert_eq!(merged.default_instance.as_deref(), Some("work"));
668
+ }
669
+
670
+ #[test]
671
+ fn bitbucket_requires_deployment() {
672
+ let err = resolve_with_opts(
673
+ r#"
674
+ [instances.work]
675
+ platform = "bitbucket"
676
+ repo = "workspace/repo"
677
+ "#,
678
+ ResolveOptions::default(),
679
+ )
680
+ .unwrap_err()
681
+ .to_string();
682
+ assert!(err.contains("Bitbucket deployment is required"));
683
+ }
684
+
685
+ #[test]
686
+ fn deployment_rejected_for_non_bitbucket_platforms() {
687
+ let err = resolve_from_dotfiles(
688
+ DotfileConfig::default(),
689
+ DotfileConfig::default(),
690
+ ResolveOptions {
691
+ platform: Some("github"),
692
+ deployment: Some("cloud"),
693
+ ..ResolveOptions::default()
694
+ },
695
+ )
696
+ .unwrap_err()
697
+ .to_string();
698
+ assert!(err.contains("Deployment is only supported"));
100
699
  }
101
700
  }