Skip to content

Commit 1ffa9dc

Browse files
committed
Merge branch 'main' into release/0.1.14
Resolve conflicts by adopting JWT session flow and API client from main. Apply clippy-friendly follow-ups: format_fail_message in api, split_once and print_row before tests in auth, Default derives in config, jwt ensure_access_token branch, util api_error before tests module.
2 parents 23f1966 + 8546919 commit 1ffa9dc

8 files changed

Lines changed: 1598 additions & 694 deletions

File tree

src/api.rs

Lines changed: 77 additions & 199 deletions
Large diffs are not rendered by default.

src/auth.rs

Lines changed: 292 additions & 312 deletions
Large diffs are not rendered by default.

src/config.rs

Lines changed: 76 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ pub struct WorkspaceEntry {
3131
}
3232

3333
#[derive(Debug, Clone, Default, Serialize)]
34-
pub struct AppUrl(Option<String>);
34+
pub struct AppUrl(pub(crate) Option<String>);
3535

3636
impl Deref for AppUrl {
3737
type Target = str;
@@ -86,6 +86,10 @@ impl<'de> Deserialize<'de> for ApiUrl {
8686

8787
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
8888
pub struct ProfileConfig {
89+
// Transient only: populated from `--api-key` and `HOTDATA_API_KEY`,
90+
// never persisted to or read from YAML. Auth state on disk lives
91+
// entirely in session.json.
92+
#[serde(skip)]
8993
pub api_key: Option<String>,
9094
#[serde(skip)]
9195
pub api_url: ApiUrl,
@@ -111,45 +115,22 @@ fn write_config(config_path: &std::path::Path, content: &str) -> Result<(), Stri
111115
fs::write(config_path, content).map_err(|e| format!("error writing config file: {e}"))
112116
}
113117

114-
pub fn save_api_key(profile: &str, api_key: &str) -> Result<(), String> {
115-
let config_path = config_path()?;
116-
117-
let mut config_file: ConfigFile = if config_path.exists() {
118-
let content = fs::read_to_string(&config_path)
119-
.map_err(|e| format!("error reading config file: {e}"))?;
120-
serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?
121-
} else {
122-
ConfigFile {
123-
profiles: HashMap::new(),
124-
}
125-
};
126-
127-
config_file
128-
.profiles
129-
.entry(profile.to_string())
130-
.or_default()
131-
.api_key = Some(api_key.to_string());
132-
133-
let content = serde_yaml::to_string(&config_file)
134-
.map_err(|e| format!("error serializing config: {e}"))?;
135-
136-
write_config(&config_path, &content)
137-
}
138-
139-
pub fn remove_api_key(profile: &str) -> Result<(), String> {
118+
/// Wipe the workspace cache for a profile. Paired with
119+
/// `jwt::clear_session()` in `auth::logout` — together they reset the
120+
/// on-disk state that login populates.
121+
pub fn clear_workspaces(profile: &str) -> Result<(), String> {
140122
let config_path = config_path()?;
141123

142124
if !config_path.exists() {
143125
return Ok(());
144126
}
145127

146-
let content =
147-
fs::read_to_string(&config_path).map_err(|e| format!("error reading config file: {e}"))?;
128+
let content = fs::read_to_string(&config_path)
129+
.map_err(|e| format!("error reading config file: {e}"))?;
148130
let mut config_file: ConfigFile =
149131
serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?;
150132

151133
if let Some(entry) = config_file.profiles.get_mut(profile) {
152-
entry.api_key = None;
153134
entry.workspaces.clear();
154135
}
155136

@@ -191,15 +172,11 @@ pub fn save_default_workspace(profile: &str, workspace: WorkspaceEntry) -> Resul
191172
.map_err(|e| format!("error reading config file: {e}"))?;
192173
serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?
193174
} else {
194-
ConfigFile {
195-
profiles: HashMap::new(),
196-
}
175+
ConfigFile { profiles: HashMap::new() }
197176
};
198177

199178
let entry = config_file.profiles.entry(profile.to_string()).or_default();
200-
entry
201-
.workspaces
202-
.retain(|w| w.public_id != workspace.public_id);
179+
entry.workspaces.retain(|w| w.public_id != workspace.public_id);
203180
entry.workspaces.insert(0, workspace);
204181

205182
let content = serde_yaml::to_string(&config_file)
@@ -215,9 +192,7 @@ pub fn save_sandbox(profile: &str, sandbox_id: &str) -> Result<(), String> {
215192
.map_err(|e| format!("error reading config file: {e}"))?;
216193
serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?
217194
} else {
218-
ConfigFile {
219-
profiles: HashMap::new(),
220-
}
195+
ConfigFile { profiles: HashMap::new() }
221196
};
222197

223198
config_file
@@ -238,8 +213,8 @@ pub fn clear_sandbox(profile: &str) -> Result<(), String> {
238213
return Ok(());
239214
}
240215

241-
let content =
242-
fs::read_to_string(&config_path).map_err(|e| format!("error reading config file: {e}"))?;
216+
let content = fs::read_to_string(&config_path)
217+
.map_err(|e| format!("error reading config file: {e}"))?;
243218
let mut config_file: ConfigFile =
244219
serde_yaml::from_str(&content).map_err(|e| format!("error parsing config file: {e}"))?;
245220

@@ -252,10 +227,7 @@ pub fn clear_sandbox(profile: &str) -> Result<(), String> {
252227
write_config(&config_path, &content)
253228
}
254229

255-
pub fn resolve_workspace_id(
256-
provided: Option<String>,
257-
profile_config: &ProfileConfig,
258-
) -> Result<String, String> {
230+
pub fn resolve_workspace_id(provided: Option<String>, profile_config: &ProfileConfig) -> Result<String, String> {
259231
if let Some(id) = provided {
260232
return Ok(id);
261233
}
@@ -278,20 +250,14 @@ pub fn load(profile: &str) -> Result<ProfileConfig, String> {
278250
let config_file = config_path()?;
279251

280252
let mut profile_config = if config_file.exists() {
281-
let content = fs::read_to_string(&config_file)
282-
.map_err(|e| format!("error reading config file: {e}"))?;
253+
let content =
254+
fs::read_to_string(&config_file).map_err(|e| format!("error reading config file: {e}"))?;
283255
let config_file: ConfigFile = serde_yaml::from_str(&content).unwrap_or_else(|_| {
284256
eprintln!("{}", "error parsing config file.".red());
285-
eprintln!(
286-
"Run 'hotdata auth login' (or 'hotdata auth') to generate a new config file."
287-
);
257+
eprintln!("Run 'hotdata auth login' (or 'hotdata auth') to generate a new config file.");
288258
std::process::exit(1);
289259
});
290-
config_file
291-
.profiles
292-
.get(profile)
293-
.cloned()
294-
.unwrap_or_default()
260+
config_file.profiles.get(profile).cloned().unwrap_or_default()
295261
} else {
296262
ProfileConfig::default()
297263
};
@@ -339,75 +305,45 @@ pub mod test_helpers {
339305

340306
#[cfg(test)]
341307
mod tests {
342-
use super::test_helpers::with_temp_config_dir;
343308
use super::*;
309+
use super::test_helpers::with_temp_config_dir;
344310

345-
#[test]
346-
fn save_and_load_api_key() {
347-
let (_tmp, _guard) = with_temp_config_dir();
348-
349-
save_api_key("default", "test-key-123").unwrap();
350-
let profile = load("default").unwrap();
351-
assert_eq!(profile.api_key, Some("test-key-123".to_string()));
311+
fn ws(id: &str, name: &str) -> WorkspaceEntry {
312+
WorkspaceEntry { public_id: id.into(), name: name.into() }
352313
}
353314

354315
#[test]
355-
fn save_api_key_creates_config_dir() {
316+
fn save_workspaces_creates_config_dir() {
356317
let (_tmp, _guard) = with_temp_config_dir();
357318

358-
// Config file shouldn't exist yet
359319
let path = config_path().unwrap();
360320
assert!(!path.exists());
361321

362-
save_api_key("default", "key").unwrap();
322+
save_workspaces("default", vec![ws("ws-1", "WS")]).unwrap();
363323
assert!(path.exists());
364324
}
365325

366326
#[test]
367-
fn remove_api_key_clears_key_and_workspaces() {
327+
fn clear_workspaces_empties_the_list() {
368328
let (_tmp, _guard) = with_temp_config_dir();
329+
save_workspaces("default", vec![ws("ws-1", "Test WS")]).unwrap();
369330

370-
save_api_key("default", "key-to-remove").unwrap();
371-
save_workspaces(
372-
"default",
373-
vec![WorkspaceEntry {
374-
public_id: "ws-1".into(),
375-
name: "Test WS".into(),
376-
}],
377-
)
378-
.unwrap();
379-
380-
remove_api_key("default").unwrap();
331+
clear_workspaces("default").unwrap();
381332

382333
let profile = load("default").unwrap();
383-
assert_eq!(profile.api_key, None);
384334
assert!(profile.workspaces.is_empty());
385335
}
386336

387337
#[test]
388-
fn remove_api_key_noop_when_no_config() {
338+
fn clear_workspaces_noop_when_no_config() {
389339
let (_tmp, _guard) = with_temp_config_dir();
390-
391-
// Should not error when config file doesn't exist
392-
assert!(remove_api_key("default").is_ok());
340+
assert!(clear_workspaces("default").is_ok());
393341
}
394342

395343
#[test]
396344
fn save_and_load_workspaces() {
397345
let (_tmp, _guard) = with_temp_config_dir();
398-
399-
save_api_key("default", "key").unwrap();
400-
let workspaces = vec![
401-
WorkspaceEntry {
402-
public_id: "ws-1".into(),
403-
name: "First".into(),
404-
},
405-
WorkspaceEntry {
406-
public_id: "ws-2".into(),
407-
name: "Second".into(),
408-
},
409-
];
410-
save_workspaces("default", workspaces).unwrap();
346+
save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap();
411347

412348
let profile = load("default").unwrap();
413349
assert_eq!(profile.workspaces.len(), 2);
@@ -418,29 +354,10 @@ mod tests {
418354
#[test]
419355
fn save_default_workspace_moves_to_front() {
420356
let (_tmp, _guard) = with_temp_config_dir();
421-
422-
save_api_key("default", "key").unwrap();
423-
let workspaces = vec![
424-
WorkspaceEntry {
425-
public_id: "ws-1".into(),
426-
name: "First".into(),
427-
},
428-
WorkspaceEntry {
429-
public_id: "ws-2".into(),
430-
name: "Second".into(),
431-
},
432-
];
433-
save_workspaces("default", workspaces).unwrap();
357+
save_workspaces("default", vec![ws("ws-1", "First"), ws("ws-2", "Second")]).unwrap();
434358

435359
// Set ws-2 as default — should move to front
436-
save_default_workspace(
437-
"default",
438-
WorkspaceEntry {
439-
public_id: "ws-2".into(),
440-
name: "Second".into(),
441-
},
442-
)
443-
.unwrap();
360+
save_default_workspace("default", ws("ws-2", "Second")).unwrap();
444361

445362
let profile = load("default").unwrap();
446363
assert_eq!(profile.workspaces[0].public_id, "ws-2");
@@ -450,8 +367,7 @@ mod tests {
450367
#[test]
451368
fn load_missing_profile_returns_default() {
452369
let (_tmp, _guard) = with_temp_config_dir();
453-
454-
save_api_key("default", "key").unwrap();
370+
save_workspaces("default", vec![ws("ws-1", "WS")]).unwrap();
455371

456372
let profile = load("nonexistent").unwrap();
457373
assert_eq!(profile.api_key, None);
@@ -467,25 +383,55 @@ mod tests {
467383
}
468384

469385
#[test]
470-
fn multiple_profiles() {
386+
fn multiple_profiles_keep_independent_workspaces() {
471387
let (_tmp, _guard) = with_temp_config_dir();
472-
473-
save_api_key("default", "key-default").unwrap();
474-
save_api_key("staging", "key-staging").unwrap();
388+
save_workspaces("default", vec![ws("ws-default", "Default WS")]).unwrap();
389+
save_workspaces("staging", vec![ws("ws-staging", "Staging WS")]).unwrap();
475390

476391
let default = load("default").unwrap();
477392
let staging = load("staging").unwrap();
478-
assert_eq!(default.api_key, Some("key-default".to_string()));
479-
assert_eq!(staging.api_key, Some("key-staging".to_string()));
393+
assert_eq!(default.workspaces[0].public_id, "ws-default");
394+
assert_eq!(staging.workspaces[0].public_id, "ws-staging");
395+
}
396+
397+
#[test]
398+
fn legacy_api_key_in_yaml_is_ignored_on_load() {
399+
// Older configs (pre-jwt-branch) had `api_key: hd_xxx` written
400+
// to disk. After the migration, the api_key field is purely
401+
// transient — `#[serde(skip)]` must drop any value present in
402+
// YAML on load. This pins down the migration behavior so a
403+
// stale entry can't silently reappear in profile.api_key.
404+
let (_tmp, _guard) = with_temp_config_dir();
405+
let path = config_path().unwrap();
406+
fs::create_dir_all(path.parent().unwrap()).unwrap();
407+
fs::write(
408+
&path,
409+
"profiles:\n default:\n api_key: legacy-hd-token\n",
410+
)
411+
.unwrap();
412+
413+
let profile = load("default").unwrap();
414+
assert_eq!(profile.api_key, None);
415+
}
416+
417+
#[test]
418+
fn save_does_not_persist_transient_api_key() {
419+
// Even if api_key was set in-memory (e.g. via env var), saving
420+
// workspaces must NOT round-trip api_key into YAML.
421+
let (_tmp, _guard) = with_temp_config_dir();
422+
save_workspaces("default", vec![ws("ws-1", "WS")]).unwrap();
423+
424+
let yaml = fs::read_to_string(config_path().unwrap()).unwrap();
425+
assert!(
426+
!yaml.contains("api_key"),
427+
"api_key must not appear in YAML, got:\n{yaml}"
428+
);
480429
}
481430

482431
#[test]
483432
fn resolve_workspace_id_prefers_provided() {
484433
let profile = ProfileConfig {
485-
workspaces: vec![WorkspaceEntry {
486-
public_id: "ws-1".into(),
487-
name: "WS".into(),
488-
}],
434+
workspaces: vec![WorkspaceEntry { public_id: "ws-1".into(), name: "WS".into() }],
489435
..Default::default()
490436
};
491437
let result = resolve_workspace_id(Some("explicit-id".into()), &profile).unwrap();
@@ -495,10 +441,7 @@ mod tests {
495441
#[test]
496442
fn resolve_workspace_id_falls_back_to_first() {
497443
let profile = ProfileConfig {
498-
workspaces: vec![WorkspaceEntry {
499-
public_id: "ws-1".into(),
500-
name: "WS".into(),
501-
}],
444+
workspaces: vec![WorkspaceEntry { public_id: "ws-1".into(), name: "WS".into() }],
502445
..Default::default()
503446
};
504447
let result = resolve_workspace_id(None, &profile).unwrap();

src/embedding.rs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,32 +79,28 @@ pub fn openai_embed(text: &str, model: &str) -> Vec<f64> {
7979
});
8080

8181
let client = reqwest::blocking::Client::new();
82-
let resp = match client
82+
let req = client
8383
.post("https://api.openai.com/v1/embeddings")
8484
.header("Authorization", format!("Bearer {api_key}"))
85-
.header("Content-Type", "application/json")
86-
.json(&body)
87-
.send()
88-
{
89-
Ok(r) => r,
85+
.json(&body);
86+
let (status, resp_body) = match crate::util::send_debug(&client, req, Some(&body)) {
87+
Ok(pair) => pair,
9088
Err(e) => {
9189
eprintln!("error connecting to OpenAI API: {e}");
9290
std::process::exit(1);
9391
}
9492
};
9593

96-
if !resp.status().is_success() {
97-
let status = resp.status();
98-
let body = resp.text().unwrap_or_default();
99-
let message = serde_json::from_str::<Value>(&body)
94+
if !status.is_success() {
95+
let message = serde_json::from_str::<Value>(&resp_body)
10096
.ok()
10197
.and_then(|v| v["error"]["message"].as_str().map(str::to_string))
102-
.unwrap_or(body);
98+
.unwrap_or(resp_body);
10399
eprintln!("error from OpenAI API ({status}): {message}");
104100
std::process::exit(1);
105101
}
106102

107-
let parsed: Value = match resp.json() {
103+
let parsed: Value = match serde_json::from_str(&resp_body) {
108104
Ok(v) => v,
109105
Err(e) => {
110106
eprintln!("error parsing OpenAI response: {e}");

0 commit comments

Comments
 (0)