1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6use crate::api::ApiError;
7use crate::output::OutputConfig;
8
9#[derive(Debug, Deserialize, Default, Clone)]
10pub struct ProfileConfig {
11 pub host: Option<String>,
12 pub email: Option<String>,
13 pub token: Option<String>,
14}
15
16#[derive(Debug, Deserialize, Default)]
17struct RawConfig {
18 #[serde(default)]
19 default: ProfileConfig,
20 #[serde(default)]
21 profiles: BTreeMap<String, ProfileConfig>,
22 host: Option<String>,
23 email: Option<String>,
24 token: Option<String>,
25}
26
27impl RawConfig {
28 fn default_profile(&self) -> ProfileConfig {
29 ProfileConfig {
30 host: self.default.host.clone().or_else(|| self.host.clone()),
31 email: self.default.email.clone().or_else(|| self.email.clone()),
32 token: self.default.token.clone().or_else(|| self.token.clone()),
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
39pub struct Config {
40 pub host: String,
41 pub email: String,
42 pub token: String,
43}
44
45impl Config {
46 pub fn load(
52 host_arg: Option<String>,
53 email_arg: Option<String>,
54 profile_arg: Option<String>,
55 ) -> Result<Self, ApiError> {
56 let file_profile = load_file_profile(profile_arg.as_deref())?;
57
58 let host = normalize_value(host_arg)
59 .or_else(|| env_var("JIRA_HOST"))
60 .or_else(|| normalize_value(file_profile.host))
61 .ok_or_else(|| {
62 ApiError::InvalidInput(
63 "No Jira host configured. Set JIRA_HOST or run `jira config init`.".into(),
64 )
65 })?;
66
67 let email = normalize_value(email_arg)
68 .or_else(|| env_var("JIRA_EMAIL"))
69 .or_else(|| normalize_value(file_profile.email))
70 .ok_or_else(|| {
71 ApiError::InvalidInput(
72 "No email configured. Set JIRA_EMAIL or run `jira config init`.".into(),
73 )
74 })?;
75
76 let token = env_var("JIRA_TOKEN")
77 .or_else(|| normalize_value(file_profile.token))
78 .ok_or_else(|| {
79 ApiError::InvalidInput(
80 "No API token configured. Set JIRA_TOKEN or run `jira config init`.".into(),
81 )
82 })?;
83
84 Ok(Self { host, email, token })
85 }
86}
87
88fn config_path() -> PathBuf {
89 config_dir()
90 .unwrap_or_else(|| PathBuf::from(".config"))
91 .join("jira")
92 .join("config.toml")
93}
94
95pub fn schema_config_path() -> String {
96 config_path().display().to_string()
97}
98
99pub fn schema_config_path_description() -> &'static str {
100 #[cfg(target_os = "windows")]
101 {
102 "Resolved at runtime to %APPDATA%\\jira\\config.toml by default."
103 }
104
105 #[cfg(not(target_os = "windows"))]
106 {
107 "Resolved at runtime to $XDG_CONFIG_HOME/jira/config.toml when set, otherwise ~/.config/jira/config.toml."
108 }
109}
110
111pub fn recommended_permissions(path: &std::path::Path) -> String {
112 #[cfg(target_os = "windows")]
113 {
114 format!(
115 "Store this file in your per-user AppData directory ({}) and keep it out of shared folders; Windows applies per-user ACLs there by default.",
116 path.display()
117 )
118 }
119
120 #[cfg(not(target_os = "windows"))]
121 {
122 format!("chmod 600 {}", path.display())
123 }
124}
125
126pub fn schema_recommended_permissions_example() -> &'static str {
127 #[cfg(target_os = "windows")]
128 {
129 "Keep the file in your per-user %APPDATA% directory and out of shared folders."
130 }
131
132 #[cfg(not(target_os = "windows"))]
133 {
134 "chmod 600 /path/to/config.toml"
135 }
136}
137
138fn config_dir() -> Option<PathBuf> {
139 #[cfg(target_os = "windows")]
140 {
141 dirs::config_dir()
142 }
143
144 #[cfg(not(target_os = "windows"))]
145 {
146 std::env::var_os("XDG_CONFIG_HOME")
147 .filter(|value| !value.is_empty())
148 .map(PathBuf::from)
149 .or_else(|| dirs::home_dir().map(|home| home.join(".config")))
150 }
151}
152
153fn load_file_profile(profile: Option<&str>) -> Result<ProfileConfig, ApiError> {
154 let path = config_path();
155 let content = match std::fs::read_to_string(&path) {
156 Ok(c) => c,
157 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ProfileConfig::default()),
158 Err(e) => return Err(ApiError::Other(format!("Failed to read config: {e}"))),
159 };
160
161 let raw: RawConfig = toml::from_str(&content)
162 .map_err(|e| ApiError::Other(format!("Failed to parse config: {e}")))?;
163
164 let profile_name = normalize_str(profile)
165 .map(str::to_owned)
166 .or_else(|| env_var("JIRA_PROFILE"));
167
168 match profile_name {
169 Some(name) => {
170 let available: Vec<&str> = raw.profiles.keys().map(String::as_str).collect();
172 raw.profiles.get(&name).cloned().ok_or_else(|| {
173 ApiError::Other(format!(
174 "Profile '{name}' not found in config. Available: {}",
175 available.join(", ")
176 ))
177 })
178 }
179 None => Ok(raw.default_profile()),
180 }
181}
182
183pub fn show(
185 out: &OutputConfig,
186 host_arg: Option<String>,
187 email_arg: Option<String>,
188 profile_arg: Option<String>,
189) -> Result<(), ApiError> {
190 let path = config_path();
191 let cfg = Config::load(host_arg, email_arg, profile_arg)?;
192 let masked = mask_token(&cfg.token);
193
194 if out.json {
195 out.print_data(
196 &serde_json::to_string_pretty(&serde_json::json!({
197 "configPath": path,
198 "host": cfg.host,
199 "email": cfg.email,
200 "tokenMasked": masked,
201 }))
202 .expect("failed to serialize JSON"),
203 );
204 } else {
205 out.print_message(&format!("Config file: {}", path.display()));
206 out.print_data(&format!(
207 "host: {}\nemail: {}\ntoken: {masked}",
208 cfg.host, cfg.email
209 ));
210 }
211 Ok(())
212}
213
214pub fn init(out: &OutputConfig) {
216 let path = config_path();
217 let path_resolution = schema_config_path_description();
218 let permission_advice = recommended_permissions(&path);
219 let example = serde_json::json!({
220 "default": {
221 "host": "mycompany.atlassian.net",
222 "email": "me@example.com",
223 "token": "your-api-token",
224 },
225 "profiles": {
226 "work": {
227 "host": "work.atlassian.net",
228 "email": "me@work.com",
229 "token": "work-token",
230 }
231 }
232 });
233
234 if out.json {
235 out.print_data(
236 &serde_json::to_string_pretty(&serde_json::json!({
237 "configPath": path,
238 "pathResolution": path_resolution,
239 "tokenInstructions": "https://id.atlassian.com/manage-profile/security/api-tokens",
240 "recommendedPermissions": permission_advice,
241 "example": example,
242 }))
243 .expect("failed to serialize JSON"),
244 );
245 return;
246 }
247
248 out.print_data(&format!(
249 "Create or edit: {}\nPath resolution: {}\n\nExample config:\n\n[default]\nhost = \"mycompany.atlassian.net\"\nemail = \"me@example.com\"\ntoken = \"your-api-token\"\n\n# Optional named profiles:\n# [profiles.work]\n# host = \"work.atlassian.net\"\n# email = \"me@work.com\"\n# token = \"work-token\"\n\nGet your API token at: https://id.atlassian.com/manage-profile/security/api-tokens\n\nPermissions: {}",
250 path.display(),
251 path_resolution,
252 permission_advice,
253 ));
254}
255
256fn mask_token(token: &str) -> String {
261 let n = token.chars().count();
262 if n > 4 {
263 let suffix: String = token.chars().skip(n - 4).collect();
264 format!("***{suffix}")
265 } else {
266 "***".into()
267 }
268}
269
270fn env_var(name: &str) -> Option<String> {
271 std::env::var(name)
272 .ok()
273 .and_then(|value| normalize_value(Some(value)))
274}
275
276fn normalize_value(value: Option<String>) -> Option<String> {
277 value.and_then(|value| {
278 let trimmed = value.trim();
279 if trimmed.is_empty() {
280 None
281 } else {
282 Some(trimmed.to_string())
283 }
284 })
285}
286
287fn normalize_str(value: Option<&str>) -> Option<&str> {
288 value.and_then(|value| {
289 let trimmed = value.trim();
290 if trimmed.is_empty() {
291 None
292 } else {
293 Some(trimmed)
294 }
295 })
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::test_support::{EnvVarGuard, ProcessEnvLock, set_config_dir_env, write_config};
302 use tempfile::TempDir;
303
304 #[test]
305 fn mask_token_long() {
306 let masked = mask_token("ATATxxx1234abcd");
307 assert!(masked.starts_with("***"));
308 assert!(masked.ends_with("abcd"));
309 }
310
311 #[test]
312 fn mask_token_short() {
313 assert_eq!(mask_token("abc"), "***");
314 }
315
316 #[test]
317 fn mask_token_unicode_safe() {
318 let token = "token-日本語-end";
320 let result = mask_token(token);
321 assert!(result.starts_with("***"));
322 }
323
324 #[test]
325 #[cfg(not(target_os = "windows"))]
326 fn config_path_prefers_xdg_config_home() {
327 let _env = ProcessEnvLock::acquire().unwrap();
328 let dir = TempDir::new().unwrap();
329 let _config_dir = set_config_dir_env(dir.path());
330
331 assert_eq!(config_path(), dir.path().join("jira").join("config.toml"));
332 }
333
334 #[test]
335 fn load_ignores_blank_env_vars_and_falls_back_to_file() {
336 let _env = ProcessEnvLock::acquire().unwrap();
337 let dir = TempDir::new().unwrap();
338 write_config(
339 dir.path(),
340 r#"
341[default]
342host = "work.atlassian.net"
343email = "me@example.com"
344token = "secret-token"
345"#,
346 )
347 .unwrap();
348
349 let _config_dir = set_config_dir_env(dir.path());
350 let _host = EnvVarGuard::set("JIRA_HOST", " ");
351 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
352 let _token = EnvVarGuard::set("JIRA_TOKEN", " ");
353 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
354
355 let cfg = Config::load(None, None, None).unwrap();
356 assert_eq!(cfg.host, "work.atlassian.net");
357 assert_eq!(cfg.email, "me@example.com");
358 assert_eq!(cfg.token, "secret-token");
359 }
360
361 #[test]
362 fn load_accepts_documented_default_section() {
363 let _env = ProcessEnvLock::acquire().unwrap();
364 let dir = TempDir::new().unwrap();
365 write_config(
366 dir.path(),
367 r#"
368[default]
369host = "example.atlassian.net"
370email = "me@example.com"
371token = "secret-token"
372"#,
373 )
374 .unwrap();
375
376 let _config_dir = set_config_dir_env(dir.path());
377 let _host = EnvVarGuard::unset("JIRA_HOST");
378 let _email = EnvVarGuard::unset("JIRA_EMAIL");
379 let _token = EnvVarGuard::unset("JIRA_TOKEN");
380 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
381
382 let cfg = Config::load(None, None, None).unwrap();
383 assert_eq!(cfg.host, "example.atlassian.net");
384 assert_eq!(cfg.email, "me@example.com");
385 assert_eq!(cfg.token, "secret-token");
386 }
387
388 #[test]
389 fn load_treats_blank_env_vars_as_missing_when_no_file_exists() {
390 let _env = ProcessEnvLock::acquire().unwrap();
391 let dir = TempDir::new().unwrap();
392 let _config_dir = set_config_dir_env(dir.path());
393 let _host = EnvVarGuard::set("JIRA_HOST", "");
394 let _email = EnvVarGuard::set("JIRA_EMAIL", "");
395 let _token = EnvVarGuard::set("JIRA_TOKEN", "");
396 let _profile = EnvVarGuard::unset("JIRA_PROFILE");
397
398 let err = Config::load(None, None, None).unwrap_err();
399 assert!(matches!(err, ApiError::InvalidInput(_)));
400 assert!(err.to_string().contains("No Jira host configured"));
401 }
402
403 #[test]
404 fn permission_guidance_matches_platform() {
405 let guidance = recommended_permissions(std::path::Path::new("/tmp/jira/config.toml"));
406
407 #[cfg(target_os = "windows")]
408 assert!(guidance.contains("AppData"));
409
410 #[cfg(not(target_os = "windows"))]
411 assert!(guidance.starts_with("chmod 600 "));
412 }
413}