@mbe24/99problems 0.2.0 → 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 +150 -107
  12. package/Cargo.toml +7 -2
  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
@@ -0,0 +1,318 @@
1
+ use anyhow::{Result, anyhow};
2
+ use std::path::PathBuf;
3
+
4
+ use crate::cmd::config::key::{ConfigKey, InstanceField};
5
+ use crate::config::{DotfileConfig, InstanceConfig, account_email_env_var, token_env_var};
6
+
7
+ #[derive(Debug, Clone, Copy)]
8
+ pub(crate) enum ReadScope {
9
+ Home,
10
+ Local,
11
+ Resolved,
12
+ }
13
+
14
+ #[derive(Debug, Clone, Copy)]
15
+ pub(crate) enum WriteScope {
16
+ Home,
17
+ Local,
18
+ }
19
+
20
+ pub(crate) fn path_for_read_scope(scope: ReadScope) -> Result<PathBuf> {
21
+ match scope {
22
+ ReadScope::Home => home_dotfile_path(),
23
+ ReadScope::Local | ReadScope::Resolved => local_dotfile_path(),
24
+ }
25
+ }
26
+
27
+ pub(crate) fn path_for_write_scope(scope: WriteScope) -> Result<PathBuf> {
28
+ match scope {
29
+ WriteScope::Home => home_dotfile_path(),
30
+ WriteScope::Local => local_dotfile_path(),
31
+ }
32
+ }
33
+
34
+ pub(crate) fn load_dotfile_scope(scope: ReadScope) -> Result<DotfileConfig> {
35
+ match scope {
36
+ ReadScope::Home => load_single_dotfile(home_dotfile_path()?),
37
+ ReadScope::Local => load_single_dotfile(local_dotfile_path()?),
38
+ ReadScope::Resolved => {
39
+ let home = load_single_dotfile(home_dotfile_path()?)?;
40
+ let local = load_single_dotfile(local_dotfile_path()?)?;
41
+ let mut merged = merge_dotfiles(home, local);
42
+ apply_env_overrides(&mut merged);
43
+ Ok(merged)
44
+ }
45
+ }
46
+ }
47
+
48
+ pub(crate) fn list_entries(cfg: &DotfileConfig) -> Vec<(String, String, bool)> {
49
+ let mut entries = Vec::new();
50
+ if let Some(default_instance) = cfg.default_instance.as_ref() {
51
+ entries.push((
52
+ "default_instance".to_string(),
53
+ default_instance.clone(),
54
+ false,
55
+ ));
56
+ }
57
+
58
+ let mut aliases: Vec<&str> = cfg
59
+ .instances
60
+ .keys()
61
+ .map(std::string::String::as_str)
62
+ .collect();
63
+ aliases.sort_unstable();
64
+
65
+ for alias in aliases {
66
+ if let Some(inst) = cfg.instances.get(alias) {
67
+ push_field(
68
+ &mut entries,
69
+ alias,
70
+ "platform",
71
+ inst.platform.as_deref(),
72
+ false,
73
+ );
74
+ push_field(&mut entries, alias, "url", inst.url.as_deref(), false);
75
+ push_field(&mut entries, alias, "token", inst.token.as_deref(), true);
76
+ push_field(
77
+ &mut entries,
78
+ alias,
79
+ "account_email",
80
+ inst.account_email.as_deref(),
81
+ false,
82
+ );
83
+ push_field(&mut entries, alias, "repo", inst.repo.as_deref(), false);
84
+ push_field(&mut entries, alias, "state", inst.state.as_deref(), false);
85
+ push_field(&mut entries, alias, "type", inst.kind.as_deref(), false);
86
+ push_field(
87
+ &mut entries,
88
+ alias,
89
+ "deployment",
90
+ inst.deployment.as_deref(),
91
+ false,
92
+ );
93
+ if let Some(per_page) = inst.per_page {
94
+ entries.push((
95
+ format!("instances.{alias}.per_page"),
96
+ per_page.to_string(),
97
+ false,
98
+ ));
99
+ }
100
+ }
101
+ }
102
+
103
+ entries
104
+ }
105
+
106
+ pub(crate) fn get_key_value(cfg: &DotfileConfig, key: &ConfigKey) -> Option<String> {
107
+ match key {
108
+ ConfigKey::DefaultInstance => cfg.default_instance.clone(),
109
+ ConfigKey::InstanceField { alias, field } => {
110
+ let inst = cfg.instances.get(alias)?;
111
+ match field {
112
+ InstanceField::Platform => inst.platform.clone(),
113
+ InstanceField::Url => inst.url.clone(),
114
+ InstanceField::Token => inst.token.clone(),
115
+ InstanceField::AccountEmail => inst.account_email.clone(),
116
+ InstanceField::Repo => inst.repo.clone(),
117
+ InstanceField::State => inst.state.clone(),
118
+ InstanceField::Type => inst.kind.clone(),
119
+ InstanceField::Deployment => inst.deployment.clone(),
120
+ InstanceField::PerPage => inst.per_page.map(|v| v.to_string()),
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ fn push_field(
127
+ entries: &mut Vec<(String, String, bool)>,
128
+ alias: &str,
129
+ field: &str,
130
+ value: Option<&str>,
131
+ is_secret: bool,
132
+ ) {
133
+ if let Some(value) = value {
134
+ entries.push((
135
+ format!("instances.{alias}.{field}"),
136
+ value.to_string(),
137
+ is_secret,
138
+ ));
139
+ }
140
+ }
141
+
142
+ fn home_dotfile_path() -> Result<PathBuf> {
143
+ dirs::home_dir()
144
+ .map(|h| h.join(".99problems"))
145
+ .ok_or_else(|| anyhow!("Could not determine home directory."))
146
+ }
147
+
148
+ fn local_dotfile_path() -> Result<PathBuf> {
149
+ Ok(std::env::current_dir()?.join(".99problems"))
150
+ }
151
+
152
+ fn load_single_dotfile(path: PathBuf) -> Result<DotfileConfig> {
153
+ if !path.exists() {
154
+ return Ok(DotfileConfig::default());
155
+ }
156
+ let content = std::fs::read_to_string(path)?;
157
+ parse_and_validate_dotfile(&content)
158
+ }
159
+
160
+ fn parse_and_validate_dotfile(content: &str) -> Result<DotfileConfig> {
161
+ let value: toml::Value = toml::from_str(content)?;
162
+ let table = value
163
+ .as_table()
164
+ .ok_or_else(|| anyhow!("Invalid .99problems: expected top-level TOML table."))?;
165
+ validate_dotfile_keys(table)?;
166
+ let cfg: DotfileConfig = toml::from_str(content)?;
167
+ Ok(cfg)
168
+ }
169
+
170
+ fn validate_dotfile_keys(table: &toml::value::Table) -> Result<()> {
171
+ for key in [
172
+ "platform",
173
+ "repo",
174
+ "state",
175
+ "type",
176
+ "per_page",
177
+ "account_email",
178
+ "deployment",
179
+ ] {
180
+ if table.contains_key(key) {
181
+ return Err(anyhow!("Unsupported top-level key '{key}' in .99problems."));
182
+ }
183
+ }
184
+ for key in ["github", "gitlab", "jira", "bitbucket"] {
185
+ if table.contains_key(key) {
186
+ return Err(anyhow!("Legacy section '[{key}]' is not supported."));
187
+ }
188
+ }
189
+ validate_instance_keys(table)?;
190
+ Ok(())
191
+ }
192
+
193
+ fn validate_instance_keys(table: &toml::value::Table) -> Result<()> {
194
+ let Some(instances) = table.get("instances") else {
195
+ return Ok(());
196
+ };
197
+ let instance_entries = instances
198
+ .as_table()
199
+ .ok_or_else(|| anyhow!("Invalid .99problems: 'instances' must be a TOML table."))?;
200
+
201
+ for (alias, value) in instance_entries {
202
+ let cfg_table = value.as_table().ok_or_else(|| {
203
+ anyhow!("Invalid .99problems: instances.{alias} must be a TOML table.")
204
+ })?;
205
+ for key in cfg_table.keys() {
206
+ if key == "email" {
207
+ return Err(anyhow!(
208
+ "Unsupported key 'instances.{alias}.email'. Use 'instances.{alias}.account_email' instead."
209
+ ));
210
+ }
211
+ if !matches!(
212
+ key.as_str(),
213
+ "platform"
214
+ | "token"
215
+ | "account_email"
216
+ | "url"
217
+ | "repo"
218
+ | "state"
219
+ | "type"
220
+ | "deployment"
221
+ | "per_page"
222
+ ) {
223
+ return Err(anyhow!(
224
+ "Unsupported key 'instances.{alias}.{key}' in .99problems."
225
+ ));
226
+ }
227
+ }
228
+ }
229
+
230
+ Ok(())
231
+ }
232
+
233
+ fn merge_instance(base: &InstanceConfig, override_cfg: &InstanceConfig) -> InstanceConfig {
234
+ InstanceConfig {
235
+ platform: override_cfg
236
+ .platform
237
+ .clone()
238
+ .or_else(|| base.platform.clone()),
239
+ token: override_cfg.token.clone().or_else(|| base.token.clone()),
240
+ account_email: override_cfg
241
+ .account_email
242
+ .clone()
243
+ .or_else(|| base.account_email.clone()),
244
+ url: override_cfg.url.clone().or_else(|| base.url.clone()),
245
+ repo: override_cfg.repo.clone().or_else(|| base.repo.clone()),
246
+ state: override_cfg.state.clone().or_else(|| base.state.clone()),
247
+ kind: override_cfg.kind.clone().or_else(|| base.kind.clone()),
248
+ deployment: override_cfg
249
+ .deployment
250
+ .clone()
251
+ .or_else(|| base.deployment.clone()),
252
+ per_page: override_cfg.per_page.or(base.per_page),
253
+ }
254
+ }
255
+
256
+ fn merge_dotfiles(home: DotfileConfig, local: DotfileConfig) -> DotfileConfig {
257
+ let mut merged_instances = home.instances;
258
+ for (alias, local_cfg) in local.instances {
259
+ merged_instances
260
+ .entry(alias)
261
+ .and_modify(|home_cfg| *home_cfg = merge_instance(home_cfg, &local_cfg))
262
+ .or_insert(local_cfg);
263
+ }
264
+
265
+ DotfileConfig {
266
+ default_instance: local.default_instance.or(home.default_instance),
267
+ instances: merged_instances,
268
+ }
269
+ }
270
+
271
+ fn apply_env_overrides(cfg: &mut DotfileConfig) {
272
+ for instance in cfg.instances.values_mut() {
273
+ if let Some(platform) = instance.platform.as_deref()
274
+ && let Ok(token) = std::env::var(token_env_var(platform))
275
+ {
276
+ instance.token = Some(token);
277
+ }
278
+ if let Some(platform) = instance.platform.as_deref()
279
+ && let Some(env_key) = account_email_env_var(platform)
280
+ && let Ok(account_email) = std::env::var(env_key)
281
+ {
282
+ instance.account_email = Some(account_email);
283
+ }
284
+ }
285
+ }
286
+
287
+ #[cfg(test)]
288
+ mod tests {
289
+ use super::*;
290
+ use std::collections::HashMap;
291
+
292
+ #[test]
293
+ fn list_entries_is_sorted_by_alias() {
294
+ let cfg = DotfileConfig {
295
+ default_instance: None,
296
+ instances: HashMap::from([
297
+ (
298
+ "zeta".to_string(),
299
+ InstanceConfig {
300
+ platform: Some("gitlab".to_string()),
301
+ ..InstanceConfig::default()
302
+ },
303
+ ),
304
+ (
305
+ "alpha".to_string(),
306
+ InstanceConfig {
307
+ platform: Some("github".to_string()),
308
+ ..InstanceConfig::default()
309
+ },
310
+ ),
311
+ ]),
312
+ };
313
+
314
+ let entries = list_entries(&cfg);
315
+ assert_eq!(entries[0].0, "instances.alpha.platform");
316
+ assert_eq!(entries[1].0, "instances.zeta.platform");
317
+ }
318
+ }
@@ -0,0 +1,173 @@
1
+ use anyhow::{Result, anyhow};
2
+ use std::fs;
3
+ use std::io::Write;
4
+ use std::path::Path;
5
+ use toml_edit::{DocumentMut, Item, Table, value};
6
+
7
+ use crate::cmd::config::key::{ConfigKey, InstanceField};
8
+ use crate::cmd::config::store::{WriteScope, path_for_write_scope};
9
+
10
+ /// Set a config key in the selected write scope.
11
+ ///
12
+ /// # Errors
13
+ ///
14
+ /// Returns an error if key/value validation fails, document parsing fails,
15
+ /// or writing to disk fails.
16
+ pub(crate) fn set(scope: WriteScope, key: &ConfigKey, raw_value: &str) -> Result<()> {
17
+ validate_set_value(key, raw_value)?;
18
+
19
+ let path = path_for_write_scope(scope)?;
20
+ let mut doc = load_or_create_doc(&path)?;
21
+
22
+ match key {
23
+ ConfigKey::DefaultInstance => {
24
+ doc["default_instance"] = value(raw_value);
25
+ }
26
+ ConfigKey::InstanceField { alias, field } => {
27
+ let instances_item = doc.entry("instances").or_insert(Item::Table(Table::new()));
28
+ let instances_table = instances_item
29
+ .as_table_mut()
30
+ .ok_or_else(|| anyhow!("Invalid TOML: 'instances' must be a table."))?;
31
+
32
+ if !instances_table.contains_key(alias) {
33
+ if *field == InstanceField::Platform {
34
+ instances_table.insert(alias, Item::Table(Table::new()));
35
+ } else {
36
+ return Err(anyhow!(
37
+ "Instance '{alias}' does not exist. Set 'instances.{alias}.platform' first."
38
+ ));
39
+ }
40
+ }
41
+
42
+ let instance_fields = instances_table
43
+ .get_mut(alias)
44
+ .and_then(Item::as_table_mut)
45
+ .ok_or_else(|| anyhow!("Invalid TOML: 'instances.{alias}' must be a table."))?;
46
+
47
+ match field {
48
+ InstanceField::PerPage => {
49
+ let parsed: u32 = raw_value.parse()?;
50
+ instance_fields["per_page"] = value(i64::from(parsed));
51
+ }
52
+ _ => {
53
+ instance_fields[field.as_str()] = value(raw_value);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ atomic_write(&path, doc.to_string().as_bytes())
60
+ }
61
+
62
+ /// Unset a config key in the selected write scope.
63
+ ///
64
+ /// # Errors
65
+ ///
66
+ /// Returns an error if the file cannot be loaded or written.
67
+ pub(crate) fn unset(scope: WriteScope, key: &ConfigKey) -> Result<()> {
68
+ let path = path_for_write_scope(scope)?;
69
+ let mut doc = load_or_create_doc(&path)?;
70
+
71
+ match key {
72
+ ConfigKey::DefaultInstance => {
73
+ let _ = doc.as_table_mut().remove("default_instance");
74
+ }
75
+ ConfigKey::InstanceField { alias, field } => {
76
+ if let Some(instances_table) = doc.get_mut("instances").and_then(Item::as_table_mut) {
77
+ if let Some(instance_item) = instances_table.get_mut(alias)
78
+ && let Some(instance_table) = instance_item.as_table_mut()
79
+ {
80
+ let _ = instance_table.remove(field.as_str());
81
+ if instance_table.is_empty() {
82
+ let _ = instances_table.remove(alias);
83
+ }
84
+ }
85
+
86
+ if instances_table.is_empty() {
87
+ let _ = doc.as_table_mut().remove("instances");
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ atomic_write(&path, doc.to_string().as_bytes())
94
+ }
95
+
96
+ fn validate_set_value(key: &ConfigKey, raw_value: &str) -> Result<()> {
97
+ if raw_value.trim().is_empty() {
98
+ return Err(anyhow!("Value cannot be empty."));
99
+ }
100
+
101
+ if let ConfigKey::InstanceField { field, .. } = key {
102
+ match field {
103
+ InstanceField::Platform => match raw_value {
104
+ "github" | "gitlab" | "jira" | "bitbucket" => {}
105
+ _ => {
106
+ return Err(anyhow!(
107
+ "Invalid platform '{raw_value}'. Supported: github, gitlab, jira, bitbucket."
108
+ ));
109
+ }
110
+ },
111
+ InstanceField::Type => match raw_value {
112
+ "issue" | "pr" => {}
113
+ _ => return Err(anyhow!("Invalid type '{raw_value}'. Supported: issue, pr.")),
114
+ },
115
+ InstanceField::PerPage => {
116
+ let parsed: u32 = raw_value.parse()?;
117
+ if parsed == 0 {
118
+ return Err(anyhow!("per_page must be >= 1."));
119
+ }
120
+ }
121
+ InstanceField::Deployment => match raw_value {
122
+ "cloud" | "selfhosted" => {}
123
+ _ => {
124
+ return Err(anyhow!(
125
+ "Invalid deployment '{raw_value}'. Supported: cloud, selfhosted."
126
+ ));
127
+ }
128
+ },
129
+ InstanceField::Url
130
+ | InstanceField::Token
131
+ | InstanceField::AccountEmail
132
+ | InstanceField::Repo
133
+ | InstanceField::State => {}
134
+ }
135
+ }
136
+
137
+ Ok(())
138
+ }
139
+
140
+ fn load_or_create_doc(path: &Path) -> Result<DocumentMut> {
141
+ if !path.exists() {
142
+ return Ok(DocumentMut::new());
143
+ }
144
+ let raw = std::fs::read_to_string(path)?;
145
+ if raw.trim().is_empty() {
146
+ return Ok(DocumentMut::new());
147
+ }
148
+ Ok(raw.parse::<DocumentMut>()?)
149
+ }
150
+
151
+ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
152
+ if let Some(parent) = path.parent() {
153
+ fs::create_dir_all(parent)?;
154
+ }
155
+
156
+ let tmp = path.with_extension(format!(
157
+ "tmp.{}.{}",
158
+ std::process::id(),
159
+ std::time::SystemTime::now()
160
+ .duration_since(std::time::UNIX_EPOCH)?
161
+ .as_nanos()
162
+ ));
163
+ {
164
+ let mut f = fs::File::create(&tmp)?;
165
+ f.write_all(bytes)?;
166
+ f.flush()?;
167
+ }
168
+ if path.exists() {
169
+ fs::remove_file(path)?;
170
+ }
171
+ fs::rename(tmp, path)?;
172
+ Ok(())
173
+ }