@mbe24/99problems 0.2.0 → 0.3.1

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 +49 -9
  10. package/CONTRIBUTING.md +38 -50
  11. package/Cargo.lock +151 -108
  12. package/Cargo.toml +8 -3
  13. package/README.md +107 -85
  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 +618 -118
  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 +225 -138
  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 +65 -7
  71. package/tests/integration.rs +404 -33
  72. package/src/source/github_issues.rs +0 -227
package/src/config.rs CHANGED
@@ -1,119 +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
- /// Per-platform credentials and settings.
6
6
  #[derive(Debug, Default, Deserialize, Clone)]
7
- pub struct PlatformConfig {
7
+ pub struct InstanceConfig {
8
+ pub platform: Option<String>,
8
9
  pub token: Option<String>,
9
- /// Base URL override for self-hosted instances (e.g. GitLab)
10
+ pub account_email: Option<String>,
10
11
  pub url: Option<String>,
11
- }
12
-
13
- /// Top-level dotfile structure (.99problems).
14
- #[derive(Debug, Default, Deserialize)]
15
- pub struct DotfileConfig {
16
- /// Default platform (overridden by --platform flag)
17
- pub platform: Option<String>,
18
- /// Default type: "issue" or "pr" (overridden by --type flag)
19
- #[serde(rename = "type")]
20
- pub kind: Option<String>,
21
12
  pub repo: Option<String>,
22
13
  pub state: Option<String>,
14
+ #[serde(rename = "type")]
15
+ pub kind: Option<String>,
16
+ pub deployment: Option<String>,
23
17
  pub per_page: Option<u32>,
24
- pub github: Option<PlatformConfig>,
25
- pub gitlab: Option<PlatformConfig>,
26
- pub bitbucket: Option<PlatformConfig>,
27
18
  }
28
19
 
29
- /// Fully resolved config after merging home + local dotfiles + env vars.
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
+
30
27
  #[derive(Debug, Default)]
31
28
  pub struct Config {
32
29
  pub platform: String,
33
30
  pub kind: String,
31
+ pub kind_explicit: bool,
34
32
  pub token: Option<String>,
33
+ pub account_email: Option<String>,
35
34
  pub repo: Option<String>,
36
35
  pub state: Option<String>,
36
+ pub deployment: Option<String>,
37
37
  pub per_page: u32,
38
- /// Base URL for the active platform (for self-hosted instances)
39
- #[allow(dead_code)]
40
38
  pub platform_url: Option<String>,
41
39
  }
42
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>,
52
+ }
53
+
43
54
  impl Config {
44
- /// Load and merge: home dotfile (base) local dotfile (override) → env vars.
45
- /// CLI flags are applied on top in main.rs.
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.
46
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> {
47
72
  let home = load_dotfile(home_dotfile_path())?;
48
73
  let local = load_dotfile(local_dotfile_path())?;
74
+ resolve_from_dotfiles(home, local, opts)
75
+ }
76
+ }
49
77
 
50
- let platform = local
78
+ #[must_use]
79
+ fn merge_instance(base: &InstanceConfig, override_cfg: &InstanceConfig) -> InstanceConfig {
80
+ InstanceConfig {
81
+ platform: override_cfg
51
82
  .platform
52
83
  .clone()
53
- .or_else(|| home.platform.clone())
54
- .unwrap_or_else(|| "github".into());
55
-
56
- let kind = local
57
- .kind
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
58
88
  .clone()
59
- .or_else(|| home.kind.clone())
60
- .unwrap_or_else(|| "issue".into());
61
-
62
- let repo = local.repo.clone().or(home.repo.clone());
63
- let state = local.state.clone().or(home.state.clone());
64
- let per_page = local.per_page.or(home.per_page).unwrap_or(100);
65
-
66
- // Resolve token: env var → local dotfile → home dotfile
67
- let env_var = match platform.as_str() {
68
- "github" => "GITHUB_TOKEN",
69
- "gitlab" => "GITLAB_TOKEN",
70
- "bitbucket" => "BITBUCKET_TOKEN",
71
- _ => "GITHUB_TOKEN",
72
- };
73
- let (dotfile_token, platform_url) = resolve_platform_token(&platform, &local, &home);
74
- let token = std::env::var(env_var).ok().or(dotfile_token);
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);
75
124
 
76
- Ok(Self {
77
- platform,
78
- kind,
79
- token,
80
- repo,
81
- state,
82
- per_page,
83
- platform_url,
84
- })
125
+ if !merged.instances.is_empty() {
126
+ return resolve_instance_mode(&merged, opts);
85
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)
86
140
  }
87
141
 
88
- /// Resolve token and optional URL for the given platform from dotfiles only.
89
- fn resolve_platform_token(
90
- platform: &str,
91
- local: &DotfileConfig,
92
- home: &DotfileConfig,
93
- ) -> (Option<String>, Option<String>) {
94
- let local_platform = platform_section(platform, local);
95
- let home_platform = platform_section(platform, home);
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
+ }
96
150
 
97
- let token = local_platform
98
- .as_ref()
99
- .and_then(|p| p.token.clone())
100
- .or_else(|| home_platform.as_ref().and_then(|p| p.token.clone()));
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
+ };
101
175
 
102
- let url = local_platform
103
- .as_ref()
104
- .and_then(|p| p.url.clone())
105
- .or_else(|| home_platform.as_ref().and_then(|p| p.url.clone()));
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
+ })?;
106
183
 
107
- (token, url)
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
+ };
214
+
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
+ })
108
236
  }
109
237
 
110
- fn platform_section<'a>(platform: &str, cfg: &'a DotfileConfig) -> Option<&'a PlatformConfig> {
111
- match platform {
112
- "github" => cfg.github.as_ref(),
113
- "gitlab" => cfg.gitlab.as_ref(),
114
- "bitbucket" => cfg.bitbucket.as_ref(),
115
- _ => None,
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."));
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
+ ));
116
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(", ")
117
305
  }
118
306
 
119
307
  fn home_dotfile_path() -> Option<PathBuf> {
@@ -130,71 +318,383 @@ fn load_dotfile(path: Option<PathBuf>) -> Result<DotfileConfig> {
130
318
  _ => return Ok(DotfileConfig::default()),
131
319
  };
132
320
  let content = std::fs::read_to_string(&path)?;
133
- 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)?;
134
331
  Ok(cfg)
135
332
  }
136
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
+
137
416
  #[cfg(test)]
138
417
  mod tests {
139
418
  use super::*;
140
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
+
426
+ #[test]
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"));
439
+ }
440
+
441
+ #[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
+
141
462
  #[test]
142
- fn dotfile_config_defaults_when_missing() {
143
- let cfg = load_dotfile(None).unwrap();
144
- assert!(cfg.github.is_none());
145
- assert!(cfg.repo.is_none());
146
- assert!(cfg.per_page.is_none());
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"));
147
475
  }
148
476
 
149
477
  #[test]
150
- fn dotfile_config_parses_toml() {
151
- let toml = r#"
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"
152
519
  repo = "owner/repo"
153
- state = "closed"
154
- per_page = 50
520
+ "#,
521
+ ResolveOptions::default(),
522
+ )
523
+ .unwrap();
524
+ assert_eq!(cfg.platform, "gitlab");
525
+ assert_eq!(cfg.repo.as_deref(), Some("group/work"));
526
+ }
155
527
 
156
- [github]
157
- token = "ghp_test"
158
- "#;
159
- let cfg: DotfileConfig = toml::from_str(toml).unwrap();
160
- assert_eq!(cfg.repo.as_deref(), Some("owner/repo"));
161
- assert_eq!(cfg.per_page, Some(50));
162
- assert_eq!(
163
- cfg.github.as_ref().and_then(|g| g.token.as_deref()),
164
- Some("ghp_test")
165
- );
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"));
166
543
  }
167
544
 
168
545
  #[test]
169
- fn config_per_page_defaults_to_100() {
170
- let home = DotfileConfig::default();
171
- let local = DotfileConfig::default();
172
- let per_page = local.per_page.or(home.per_page).unwrap_or(100);
173
- assert_eq!(per_page, 100);
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"));
174
560
  }
175
561
 
176
562
  #[test]
177
- fn config_platform_defaults_to_github() {
178
- let home = DotfileConfig::default();
179
- let local = DotfileConfig::default();
180
- let platform = local
181
- .platform
182
- .or(home.platform)
183
- .unwrap_or_else(|| "github".into());
184
- assert_eq!(platform, "github");
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"));
185
578
  }
186
579
 
187
580
  #[test]
188
- fn resolve_token_uses_platform_section() {
189
- let home = DotfileConfig::default();
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");
617
+ assert_eq!(cfg.repo.as_deref(), Some("owner/repo"));
618
+ assert_eq!(cfg.kind, "issue");
619
+ assert!(!cfg.kind_explicit);
620
+ }
621
+
622
+ #[test]
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
+ };
190
641
  let local = DotfileConfig {
191
- github: Some(PlatformConfig {
192
- token: Some("ghp_section".into()),
193
- url: None,
194
- }),
195
- ..Default::default()
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
+ )]),
196
657
  };
197
- let (token, _) = resolve_platform_token("github", &local, &home);
198
- assert_eq!(token.as_deref(), Some("ghp_section"));
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"));
199
699
  }
200
700
  }