1use crate::config::LinkedInImageConfig;
2use anyhow::Context;
3use reqwest::Method;
4use reqwest::header::{HeaderMap, HeaderValue};
5use serde_json::json;
6use std::path::{Path, PathBuf};
7
8const LINKEDIN_API_BASE: &str = "https://api.linkedin.com";
9const LINKEDIN_OAUTH_TOKEN_URL: &str = "https://www.linkedin.com/oauth/v2/accessToken";
10const LINKEDIN_REQUEST_TIMEOUT_SECS: u64 = 30;
11const LINKEDIN_CONNECT_TIMEOUT_SECS: u64 = 10;
12
13pub struct LinkedInClient {
14 workspace_dir: PathBuf,
15 api_version: String,
16}
17
18#[derive(Debug)]
19pub struct LinkedInCredentials {
20 pub client_id: String,
21 pub client_secret: String,
22 pub access_token: String,
23 pub refresh_token: Option<String>,
24 pub person_id: String,
25}
26
27#[derive(Debug, serde::Serialize)]
28pub struct PostSummary {
29 pub id: String,
30 pub text: String,
31 pub created_at: String,
32 pub visibility: String,
33}
34
35#[derive(Debug, serde::Serialize)]
36pub struct ProfileInfo {
37 pub id: String,
38 pub name: String,
39 pub headline: String,
40}
41
42#[derive(Debug, serde::Serialize)]
43pub struct EngagementSummary {
44 pub likes: u64,
45 pub comments: u64,
46 pub shares: u64,
47}
48
49impl LinkedInClient {
50 pub fn new(workspace_dir: PathBuf, api_version: String) -> Self {
51 Self {
52 workspace_dir,
53 api_version,
54 }
55 }
56
57 fn parse_env_value(raw: &str) -> String {
58 let raw = raw.trim();
59
60 let unquoted = if raw.len() >= 2
61 && ((raw.starts_with('"') && raw.ends_with('"'))
62 || (raw.starts_with('\'') && raw.ends_with('\'')))
63 {
64 &raw[1..raw.len() - 1]
65 } else {
66 raw
67 };
68
69 unquoted.split_once(" #").map_or_else(
71 || unquoted.trim().to_string(),
72 |(value, _)| value.trim().to_string(),
73 )
74 }
75
76 pub async fn get_credentials(&self) -> anyhow::Result<LinkedInCredentials> {
77 let env_path = self.workspace_dir.join(".env");
78 let content = tokio::fs::read_to_string(&env_path)
79 .await
80 .with_context(|| format!("Failed to read {}", env_path.display()))?;
81
82 let mut client_id = None;
83 let mut client_secret = None;
84 let mut access_token = None;
85 let mut refresh_token = None;
86 let mut person_id = None;
87
88 for line in content.lines() {
89 let line = line.trim();
90 if line.starts_with('#') || line.is_empty() {
91 continue;
92 }
93 let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
94 if let Some((key, value)) = line.split_once('=') {
95 let key = key.trim();
96 let value = Self::parse_env_value(value);
97
98 match key {
99 "LINKEDIN_CLIENT_ID" => client_id = Some(value),
100 "LINKEDIN_CLIENT_SECRET" => client_secret = Some(value),
101 "LINKEDIN_ACCESS_TOKEN" => access_token = Some(value),
102 "LINKEDIN_REFRESH_TOKEN" => {
103 if !value.is_empty() {
104 refresh_token = Some(value);
105 }
106 }
107 "LINKEDIN_PERSON_ID" => person_id = Some(value),
108 _ => {}
109 }
110 }
111 }
112
113 let client_id =
114 client_id.ok_or_else(|| anyhow::anyhow!("LINKEDIN_CLIENT_ID not found in .env"))?;
115 let client_secret = client_secret
116 .ok_or_else(|| anyhow::anyhow!("LINKEDIN_CLIENT_SECRET not found in .env"))?;
117 let access_token = access_token
118 .ok_or_else(|| anyhow::anyhow!("LINKEDIN_ACCESS_TOKEN not found in .env"))?;
119 let person_id =
120 person_id.ok_or_else(|| anyhow::anyhow!("LINKEDIN_PERSON_ID not found in .env"))?;
121
122 Ok(LinkedInCredentials {
123 client_id,
124 client_secret,
125 access_token,
126 refresh_token,
127 person_id,
128 })
129 }
130
131 fn client() -> reqwest::Client {
132 crate::config::build_runtime_proxy_client_with_timeouts(
133 "tool.linkedin",
134 LINKEDIN_REQUEST_TIMEOUT_SECS,
135 LINKEDIN_CONNECT_TIMEOUT_SECS,
136 )
137 }
138
139 fn api_headers(&self, token: &str) -> HeaderMap {
140 let mut headers = HeaderMap::new();
141 let bearer = format!("Bearer {}", token);
142 headers.insert(
143 reqwest::header::AUTHORIZATION,
144 HeaderValue::from_str(&bearer).expect("valid bearer token header"),
145 );
146 headers.insert(
147 "LinkedIn-Version",
148 HeaderValue::from_str(&self.api_version).expect("valid api version header"),
149 );
150 headers.insert(
151 "X-Restli-Protocol-Version",
152 HeaderValue::from_static("2.0.0"),
153 );
154 headers
155 }
156
157 async fn api_request(
158 &self,
159 method: Method,
160 url: &str,
161 token: &str,
162 body: Option<serde_json::Value>,
163 ) -> anyhow::Result<reqwest::Response> {
164 let client = Self::client();
165 let headers = self.api_headers(token);
166
167 let mut req = client.request(method.clone(), url).headers(headers);
168 if let Some(ref json_body) = body {
169 req = req.json(json_body);
170 }
171
172 let response = req.send().await.context("LinkedIn API request failed")?;
173
174 if response.status() == reqwest::StatusCode::UNAUTHORIZED {
175 let creds = self.get_credentials().await?;
177 let new_token = self.refresh_token(&creds).await?;
178 self.update_env_token(&new_token).await?;
179
180 let retry_headers = self.api_headers(&new_token);
181 let mut retry_req = Self::client().request(method, url).headers(retry_headers);
182 if let Some(json_body) = body {
183 retry_req = retry_req.json(&json_body);
184 }
185
186 let retry_response = retry_req
187 .send()
188 .await
189 .context("LinkedIn API retry request failed")?;
190
191 return Ok(retry_response);
192 }
193
194 Ok(response)
195 }
196
197 pub async fn create_post(
198 &self,
199 text: &str,
200 visibility: &str,
201 article_url: Option<&str>,
202 article_title: Option<&str>,
203 scheduled_at: Option<&str>,
204 ) -> anyhow::Result<String> {
205 let creds = self.get_credentials().await?;
206 let author_urn = format!("urn:li:person:{}", creds.person_id);
207
208 let lifecycle = if scheduled_at.is_some() {
209 "DRAFT"
210 } else {
211 "PUBLISHED"
212 };
213
214 let mut body = json!({
215 "author": author_urn,
216 "lifecycleState": lifecycle,
217 "visibility": visibility,
218 "commentary": text,
219 "distribution": {
220 "feedDistribution": "MAIN_FEED",
221 "targetEntities": [],
222 "thirdPartyDistributionChannels": []
223 }
224 });
225
226 if let Some(ts) = scheduled_at {
229 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
230 let epoch_ms = dt.timestamp_millis();
231 body.as_object_mut().unwrap().insert(
232 "scheduledPublishOptions".to_string(),
233 json!({ "scheduledPublishTime": epoch_ms }),
234 );
235 body["lifecycleState"] = json!("DRAFT");
237 }
238 }
239
240 if let Some(url) = article_url {
241 let mut article = json!({
242 "source": url,
243 "title": article_title.unwrap_or(""),
244 });
245 if article_title.is_none() || article_title.map_or(false, |t| t.is_empty()) {
246 article.as_object_mut().unwrap().remove("title");
247 }
248 body.as_object_mut().unwrap().insert(
249 "content".to_string(),
250 json!({
251 "article": {
252 "source": url,
253 "title": article_title.unwrap_or("")
254 }
255 }),
256 );
257 }
258
259 let url = format!("{}/rest/posts", LINKEDIN_API_BASE);
260 let response = self
261 .api_request(Method::POST, &url, &creds.access_token, Some(body))
262 .await?;
263
264 let status = response.status();
265 if !status.is_success() {
266 let body_text = response.text().await.unwrap_or_default();
267 anyhow::bail!("LinkedIn create_post failed ({}): {}", status, body_text);
268 }
269
270 let post_urn = response
272 .headers()
273 .get("x-restli-id")
274 .and_then(|v| v.to_str().ok())
275 .map(String::from)
276 .unwrap_or_default();
277
278 Ok(post_urn)
279 }
280
281 pub async fn list_posts(&self, count: usize) -> anyhow::Result<Vec<PostSummary>> {
282 let creds = self.get_credentials().await?;
283 let author_urn = format!("urn:li:person:{}", creds.person_id);
284 let url = format!(
285 "{}/rest/posts?author={}&q=author&count={}",
286 LINKEDIN_API_BASE, author_urn, count
287 );
288
289 let response = self
290 .api_request(Method::GET, &url, &creds.access_token, None)
291 .await?;
292
293 let status = response.status();
294 if !status.is_success() {
295 let body_text = response.text().await.unwrap_or_default();
296 anyhow::bail!("LinkedIn list_posts failed ({}): {}", status, body_text);
297 }
298
299 let json: serde_json::Value = response
300 .json()
301 .await
302 .context("Failed to parse list_posts response")?;
303
304 let elements = json
305 .get("elements")
306 .and_then(|e| e.as_array())
307 .cloned()
308 .unwrap_or_default();
309
310 let posts = elements
311 .iter()
312 .map(|el| PostSummary {
313 id: el
314 .get("id")
315 .and_then(|v| v.as_str())
316 .unwrap_or_default()
317 .to_string(),
318 text: el
319 .get("commentary")
320 .and_then(|v| v.as_str())
321 .unwrap_or_default()
322 .to_string(),
323 created_at: el
324 .get("createdAt")
325 .and_then(|v| v.as_u64())
326 .map(|ts| ts.to_string())
327 .unwrap_or_default(),
328 visibility: el
329 .get("visibility")
330 .and_then(|v| v.as_str())
331 .unwrap_or_default()
332 .to_string(),
333 })
334 .collect();
335
336 Ok(posts)
337 }
338
339 pub async fn add_comment(&self, post_id: &str, text: &str) -> anyhow::Result<String> {
340 let creds = self.get_credentials().await?;
341 let actor_urn = format!("urn:li:person:{}", creds.person_id);
342 let url = format!(
343 "{}/rest/socialActions/{}/comments",
344 LINKEDIN_API_BASE, post_id
345 );
346
347 let body = json!({
348 "actor": actor_urn,
349 "message": {
350 "text": text
351 }
352 });
353
354 let response = self
355 .api_request(Method::POST, &url, &creds.access_token, Some(body))
356 .await?;
357
358 let status = response.status();
359 if !status.is_success() {
360 let body_text = response.text().await.unwrap_or_default();
361 anyhow::bail!("LinkedIn add_comment failed ({}): {}", status, body_text);
362 }
363
364 let json: serde_json::Value = response
365 .json()
366 .await
367 .context("Failed to parse add_comment response")?;
368
369 let comment_id = json
370 .get("id")
371 .and_then(|v| v.as_str())
372 .unwrap_or_default()
373 .to_string();
374
375 Ok(comment_id)
376 }
377
378 pub async fn add_reaction(&self, post_id: &str, reaction_type: &str) -> anyhow::Result<()> {
379 let creds = self.get_credentials().await?;
380 let actor_urn = format!("urn:li:person:{}", creds.person_id);
381 let url = format!("{}/rest/reactions?actor={}", LINKEDIN_API_BASE, actor_urn);
382
383 let body = json!({
384 "reactionType": reaction_type,
385 "object": post_id
386 });
387
388 let response = self
389 .api_request(Method::POST, &url, &creds.access_token, Some(body))
390 .await?;
391
392 let status = response.status();
393 if !status.is_success() {
394 let body_text = response.text().await.unwrap_or_default();
395 anyhow::bail!("LinkedIn add_reaction failed ({}): {}", status, body_text);
396 }
397
398 Ok(())
399 }
400
401 pub async fn delete_post(&self, post_id: &str) -> anyhow::Result<()> {
402 let creds = self.get_credentials().await?;
403 let url = format!("{}/rest/posts/{}", LINKEDIN_API_BASE, post_id);
404
405 let response = self
406 .api_request(Method::DELETE, &url, &creds.access_token, None)
407 .await?;
408
409 let status = response.status();
410 if !status.is_success() {
411 let body_text = response.text().await.unwrap_or_default();
412 anyhow::bail!("LinkedIn delete_post failed ({}): {}", status, body_text);
413 }
414
415 Ok(())
416 }
417
418 pub async fn get_engagement(&self, post_id: &str) -> anyhow::Result<EngagementSummary> {
419 let creds = self.get_credentials().await?;
420 let url = format!("{}/rest/socialActions/{}", LINKEDIN_API_BASE, post_id);
421
422 let response = self
423 .api_request(Method::GET, &url, &creds.access_token, None)
424 .await?;
425
426 let status = response.status();
427 if !status.is_success() {
428 let body_text = response.text().await.unwrap_or_default();
429 anyhow::bail!("LinkedIn get_engagement failed ({}): {}", status, body_text);
430 }
431
432 let json: serde_json::Value = response
433 .json()
434 .await
435 .context("Failed to parse get_engagement response")?;
436
437 let likes = json
438 .get("likesSummary")
439 .and_then(|v| v.get("totalLikes"))
440 .and_then(|v| v.as_u64())
441 .unwrap_or(0);
442
443 let comments = json
444 .get("commentsSummary")
445 .and_then(|v| v.get("totalFirstLevelComments"))
446 .and_then(|v| v.as_u64())
447 .unwrap_or(0);
448
449 let shares = json
450 .get("sharesSummary")
451 .and_then(|v| v.get("totalShares"))
452 .and_then(|v| v.as_u64())
453 .unwrap_or(0);
454
455 Ok(EngagementSummary {
456 likes,
457 comments,
458 shares,
459 })
460 }
461
462 pub async fn get_profile(&self) -> anyhow::Result<ProfileInfo> {
463 let creds = self.get_credentials().await?;
464 let url = format!("{}/rest/me", LINKEDIN_API_BASE);
465
466 let response = self
467 .api_request(Method::GET, &url, &creds.access_token, None)
468 .await?;
469
470 let status = response.status();
471 if !status.is_success() {
472 let body_text = response.text().await.unwrap_or_default();
473 anyhow::bail!("LinkedIn get_profile failed ({}): {}", status, body_text);
474 }
475
476 let json: serde_json::Value = response
477 .json()
478 .await
479 .context("Failed to parse get_profile response")?;
480
481 let id = json
482 .get("id")
483 .and_then(|v| v.as_str())
484 .unwrap_or_default()
485 .to_string();
486
487 let first_name = json
488 .get("localizedFirstName")
489 .and_then(|v| v.as_str())
490 .unwrap_or_default();
491
492 let last_name = json
493 .get("localizedLastName")
494 .and_then(|v| v.as_str())
495 .unwrap_or_default();
496
497 let name = format!("{} {}", first_name, last_name).trim().to_string();
498
499 let headline = json
500 .get("localizedHeadline")
501 .and_then(|v| v.as_str())
502 .unwrap_or_default()
503 .to_string();
504
505 Ok(ProfileInfo { id, name, headline })
506 }
507
508 async fn refresh_token(&self, creds: &LinkedInCredentials) -> anyhow::Result<String> {
509 let refresh = creds
510 .refresh_token
511 .as_deref()
512 .filter(|t| !t.is_empty())
513 .ok_or_else(|| anyhow::anyhow!("No refresh token available"))?;
514
515 let client = Self::client();
516 let response = client
517 .post(LINKEDIN_OAUTH_TOKEN_URL)
518 .form(&[
519 ("grant_type", "refresh_token"),
520 ("refresh_token", refresh),
521 ("client_id", &creds.client_id),
522 ("client_secret", &creds.client_secret),
523 ])
524 .send()
525 .await
526 .context("LinkedIn token refresh request failed")?;
527
528 let status = response.status();
529 if !status.is_success() {
530 let body_text = response.text().await.unwrap_or_default();
531 anyhow::bail!("LinkedIn token refresh failed ({}): {}", status, body_text);
532 }
533
534 let json: serde_json::Value = response
535 .json()
536 .await
537 .context("Failed to parse token refresh response")?;
538
539 let new_token = json
540 .get("access_token")
541 .and_then(|v| v.as_str())
542 .map(String::from)
543 .ok_or_else(|| anyhow::anyhow!("Token refresh response missing access_token field"))?;
544
545 Ok(new_token)
546 }
547
548 pub async fn upload_image(
555 &self,
556 image_bytes: &[u8],
557 token: &str,
558 person_id: &str,
559 ) -> anyhow::Result<String> {
560 let owner_urn = format!("urn:li:person:{person_id}");
561
562 let register_body = json!({
564 "initializeUploadRequest": {
565 "owner": owner_urn
566 }
567 });
568 let register_url = format!("{LINKEDIN_API_BASE}/rest/images?action=initializeUpload");
569 let register_resp = self
570 .api_request(Method::POST, ®ister_url, token, Some(register_body))
571 .await?;
572
573 let status = register_resp.status();
574 if !status.is_success() {
575 let body_text = register_resp.text().await.unwrap_or_default();
576 anyhow::bail!("LinkedIn image register failed ({status}): {body_text}");
577 }
578
579 let register_json: serde_json::Value = register_resp
580 .json()
581 .await
582 .context("Failed to parse image register response")?;
583
584 let upload_url = register_json
585 .pointer("/value/uploadUrl")
586 .and_then(|v| v.as_str())
587 .ok_or_else(|| anyhow::anyhow!("Missing uploadUrl in register response"))?
588 .to_string();
589
590 let image_urn = register_json
591 .pointer("/value/image")
592 .and_then(|v| v.as_str())
593 .ok_or_else(|| anyhow::anyhow!("Missing image URN in register response"))?
594 .to_string();
595
596 let client = Self::client();
598 let mut upload_headers = HeaderMap::new();
599 upload_headers.insert(
600 reqwest::header::AUTHORIZATION,
601 HeaderValue::from_str(&format!("Bearer {token}")).expect("valid bearer token header"),
602 );
603
604 let upload_resp = client
605 .put(&upload_url)
606 .headers(upload_headers)
607 .header("Content-Type", "image/png")
608 .body(image_bytes.to_vec())
609 .send()
610 .await
611 .context("LinkedIn image upload failed")?;
612
613 let upload_status = upload_resp.status();
614 if !upload_status.is_success() {
615 let body_text = upload_resp.text().await.unwrap_or_default();
616 anyhow::bail!("LinkedIn image upload failed ({upload_status}): {body_text}");
617 }
618
619 Ok(image_urn)
620 }
621
622 pub async fn create_post_with_image(
624 &self,
625 text: &str,
626 visibility: &str,
627 image_urn: &str,
628 scheduled_at: Option<&str>,
629 ) -> anyhow::Result<String> {
630 let creds = self.get_credentials().await?;
631 let author_urn = format!("urn:li:person:{}", creds.person_id);
632
633 let lifecycle = if scheduled_at.is_some() {
634 "DRAFT"
635 } else {
636 "PUBLISHED"
637 };
638
639 let mut body = json!({
640 "author": author_urn,
641 "lifecycleState": lifecycle,
642 "visibility": visibility,
643 "commentary": text,
644 "distribution": {
645 "feedDistribution": "MAIN_FEED",
646 "targetEntities": [],
647 "thirdPartyDistributionChannels": []
648 },
649 "content": {
650 "media": {
651 "id": image_urn
652 }
653 }
654 });
655
656 if let Some(ts) = scheduled_at {
657 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts) {
658 let epoch_ms = dt.timestamp_millis();
659 body.as_object_mut().unwrap().insert(
660 "scheduledPublishOptions".to_string(),
661 json!({ "scheduledPublishTime": epoch_ms }),
662 );
663 }
664 }
665
666 let url = format!("{LINKEDIN_API_BASE}/rest/posts");
667 let response = self
668 .api_request(Method::POST, &url, &creds.access_token, Some(body))
669 .await?;
670
671 let status = response.status();
672 if !status.is_success() {
673 let body_text = response.text().await.unwrap_or_default();
674 anyhow::bail!("LinkedIn create_post_with_image failed ({status}): {body_text}");
675 }
676
677 let post_urn = response
678 .headers()
679 .get("x-restli-id")
680 .and_then(|v| v.to_str().ok())
681 .map(String::from)
682 .unwrap_or_default();
683
684 Ok(post_urn)
685 }
686
687 async fn update_env_token(&self, new_token: &str) -> anyhow::Result<()> {
688 let env_path = self.workspace_dir.join(".env");
689 let content = tokio::fs::read_to_string(&env_path)
690 .await
691 .with_context(|| format!("Failed to read {}", env_path.display()))?;
692
693 let mut updated_lines: Vec<String> = Vec::new();
694 let mut found = false;
695
696 for line in content.lines() {
697 let trimmed = line.trim();
698
699 let is_token_line = if trimmed.starts_with('#') || trimmed.is_empty() {
701 false
702 } else {
703 let check = trimmed
704 .strip_prefix("export ")
705 .map(str::trim)
706 .unwrap_or(trimmed);
707 check
708 .split_once('=')
709 .map_or(false, |(key, _)| key.trim() == "LINKEDIN_ACCESS_TOKEN")
710 };
711
712 if is_token_line {
713 let has_export = trimmed.starts_with("export ");
715 let after_key = trimmed.strip_prefix("export ").unwrap_or(trimmed).trim();
716 let (_key, old_val) = after_key
717 .split_once('=')
718 .unwrap_or(("LINKEDIN_ACCESS_TOKEN", ""));
719 let old_val = old_val.trim();
720
721 let new_val = if old_val.starts_with('"') {
722 format!("\"{}\"", new_token)
723 } else if old_val.starts_with('\'') {
724 format!("'{}'", new_token)
725 } else {
726 new_token.to_string()
727 };
728
729 let new_line = if has_export {
730 format!("export LINKEDIN_ACCESS_TOKEN={}", new_val)
731 } else {
732 format!("LINKEDIN_ACCESS_TOKEN={}", new_val)
733 };
734
735 updated_lines.push(new_line);
736 found = true;
737 } else {
738 updated_lines.push(line.to_string());
739 }
740 }
741
742 if !found {
743 anyhow::bail!("LINKEDIN_ACCESS_TOKEN not found in .env for update");
744 }
745
746 let mut output = updated_lines.join("\n");
748 if content.ends_with('\n') {
749 output.push('\n');
750 }
751
752 tokio::fs::write(&env_path, &output)
753 .await
754 .with_context(|| format!("Failed to write {}", env_path.display()))?;
755
756 Ok(())
757 }
758}
759
760pub struct ImageGenerator {
767 config: LinkedInImageConfig,
768 workspace_dir: PathBuf,
769}
770
771impl ImageGenerator {
772 pub fn new(config: LinkedInImageConfig, workspace_dir: PathBuf) -> Self {
773 Self {
774 config,
775 workspace_dir,
776 }
777 }
778
779 pub async fn generate(&self, prompt: &str) -> anyhow::Result<PathBuf> {
781 let image_dir = self.workspace_dir.join(&self.config.temp_dir);
782 tokio::fs::create_dir_all(&image_dir).await?;
783
784 let timestamp = std::time::SystemTime::now()
785 .duration_since(std::time::UNIX_EPOCH)
786 .unwrap_or_default()
787 .as_secs();
788 let base_name = format!("post_{timestamp}");
789
790 for provider_name in &self.config.providers {
792 let result = match provider_name.as_str() {
793 "stability" => self.try_stability(prompt, &image_dir, &base_name).await,
794 "imagen" => self.try_imagen(prompt, &image_dir, &base_name).await,
795 "dalle" => self.try_dalle(prompt, &image_dir, &base_name).await,
796 "flux" => self.try_flux(prompt, &image_dir, &base_name).await,
797 other => {
798 tracing::warn!("Unknown image provider '{other}', skipping");
799 continue;
800 }
801 };
802
803 match result {
804 Ok(path) => {
805 tracing::info!("Image generated via {provider_name}: {}", path.display());
806 return Ok(path);
807 }
808 Err(e) => {
809 tracing::warn!("Image provider '{provider_name}' failed: {e}");
810 }
811 }
812 }
813
814 if self.config.fallback_card {
816 let svg_path = image_dir.join(format!("{base_name}.svg"));
817 let svg_content = Self::generate_fallback_card(prompt, &self.config.card_accent_color);
818 tokio::fs::write(&svg_path, &svg_content).await?;
819 tracing::info!("Fallback SVG card generated: {}", svg_path.display());
820 return Ok(svg_path);
821 }
822
823 anyhow::bail!("All image generation providers failed and fallback_card is disabled")
824 }
825
826 async fn read_env_var(workspace_dir: &Path, var_name: &str) -> anyhow::Result<String> {
828 let env_path = workspace_dir.join(".env");
829 let content = tokio::fs::read_to_string(&env_path)
830 .await
831 .with_context(|| format!("Failed to read {}", env_path.display()))?;
832
833 for line in content.lines() {
834 let line = line.trim();
835 if line.starts_with('#') || line.is_empty() {
836 continue;
837 }
838 let line = line.strip_prefix("export ").map(str::trim).unwrap_or(line);
839 if let Some((key, value)) = line.split_once('=') {
840 if key.trim() == var_name {
841 let val = LinkedInClient::parse_env_value(value);
842 if !val.is_empty() {
843 return Ok(val);
844 }
845 }
846 }
847 }
848
849 anyhow::bail!("{var_name} not found or empty in .env")
850 }
851
852 fn http_client() -> reqwest::Client {
853 crate::config::build_runtime_proxy_client_with_timeouts(
854 "tool.linkedin.image",
855 60, 10,
857 )
858 }
859
860 async fn try_stability(
863 &self,
864 prompt: &str,
865 output_dir: &Path,
866 base_name: &str,
867 ) -> anyhow::Result<PathBuf> {
868 let api_key =
869 Self::read_env_var(&self.workspace_dir, &self.config.stability.api_key_env).await?;
870
871 let client = Self::http_client();
872 let url = format!(
873 "https://api.stability.ai/v1/generation/{}/text-to-image",
874 self.config.stability.model
875 );
876
877 let body = json!({
878 "text_prompts": [{"text": prompt, "weight": 1.0}],
879 "cfg_scale": 7,
880 "height": 1024,
881 "width": 1024,
882 "samples": 1,
883 "steps": 30
884 });
885
886 let resp = client
887 .post(&url)
888 .header("Authorization", format!("Bearer {api_key}"))
889 .header("Content-Type", "application/json")
890 .header("Accept", "application/json")
891 .json(&body)
892 .send()
893 .await
894 .context("Stability AI request failed")?;
895
896 let status = resp.status();
897 if !status.is_success() {
898 let body_text = resp.text().await.unwrap_or_default();
899 anyhow::bail!("Stability AI failed ({status}): {body_text}");
900 }
901
902 let json: serde_json::Value = resp.json().await?;
903 let b64 = json
904 .pointer("/artifacts/0/base64")
905 .and_then(|v| v.as_str())
906 .ok_or_else(|| anyhow::anyhow!("No image data in Stability response"))?;
907
908 let bytes = base64_decode(b64)?;
909 let path = output_dir.join(format!("{base_name}_stability.png"));
910 tokio::fs::write(&path, &bytes).await?;
911 Ok(path)
912 }
913
914 async fn try_imagen(
917 &self,
918 prompt: &str,
919 output_dir: &Path,
920 base_name: &str,
921 ) -> anyhow::Result<PathBuf> {
922 let api_key =
923 Self::read_env_var(&self.workspace_dir, &self.config.imagen.api_key_env).await?;
924 let project_id =
925 Self::read_env_var(&self.workspace_dir, &self.config.imagen.project_id_env).await?;
926
927 let client = Self::http_client();
928 let url = format!(
929 "https://{}-aiplatform.googleapis.com/v1/projects/{}/locations/{}/publishers/google/models/imagen-3.0-generate-001:predict",
930 self.config.imagen.region, project_id, self.config.imagen.region
931 );
932
933 let body = json!({
934 "instances": [{"prompt": prompt}],
935 "parameters": {
936 "sampleCount": 1,
937 "aspectRatio": "1:1"
938 }
939 });
940
941 let resp = client
942 .post(&url)
943 .header("Authorization", format!("Bearer {api_key}"))
944 .header("Content-Type", "application/json")
945 .json(&body)
946 .send()
947 .await
948 .context("Imagen request failed")?;
949
950 let status = resp.status();
951 if !status.is_success() {
952 let body_text = resp.text().await.unwrap_or_default();
953 anyhow::bail!("Imagen failed ({status}): {body_text}");
954 }
955
956 let json: serde_json::Value = resp.json().await?;
957 let b64 = json
958 .pointer("/predictions/0/bytesBase64Encoded")
959 .and_then(|v| v.as_str())
960 .ok_or_else(|| anyhow::anyhow!("No image data in Imagen response"))?;
961
962 let bytes = base64_decode(b64)?;
963 let path = output_dir.join(format!("{base_name}_imagen.png"));
964 tokio::fs::write(&path, &bytes).await?;
965 Ok(path)
966 }
967
968 async fn try_dalle(
971 &self,
972 prompt: &str,
973 output_dir: &Path,
974 base_name: &str,
975 ) -> anyhow::Result<PathBuf> {
976 let api_key =
977 Self::read_env_var(&self.workspace_dir, &self.config.dalle.api_key_env).await?;
978
979 let client = Self::http_client();
980 let url = "https://api.openai.com/v1/images/generations";
981
982 let body = json!({
983 "model": self.config.dalle.model,
984 "prompt": prompt,
985 "n": 1,
986 "size": self.config.dalle.size,
987 "response_format": "b64_json"
988 });
989
990 let resp = client
991 .post(url)
992 .header("Authorization", format!("Bearer {api_key}"))
993 .header("Content-Type", "application/json")
994 .json(&body)
995 .send()
996 .await
997 .context("DALL-E request failed")?;
998
999 let status = resp.status();
1000 if !status.is_success() {
1001 let body_text = resp.text().await.unwrap_or_default();
1002 anyhow::bail!("DALL-E failed ({status}): {body_text}");
1003 }
1004
1005 let json: serde_json::Value = resp.json().await?;
1006 let b64 = json
1007 .pointer("/data/0/b64_json")
1008 .and_then(|v| v.as_str())
1009 .ok_or_else(|| anyhow::anyhow!("No image data in DALL-E response"))?;
1010
1011 let bytes = base64_decode(b64)?;
1012 let path = output_dir.join(format!("{base_name}_dalle.png"));
1013 tokio::fs::write(&path, &bytes).await?;
1014 Ok(path)
1015 }
1016
1017 async fn try_flux(
1020 &self,
1021 prompt: &str,
1022 output_dir: &Path,
1023 base_name: &str,
1024 ) -> anyhow::Result<PathBuf> {
1025 let api_key =
1026 Self::read_env_var(&self.workspace_dir, &self.config.flux.api_key_env).await?;
1027
1028 let client = Self::http_client();
1029 let url = format!("https://fal.run/{}", self.config.flux.model);
1030
1031 let body = json!({
1032 "prompt": prompt,
1033 "image_size": "square_hd",
1034 "num_images": 1
1035 });
1036
1037 let resp = client
1038 .post(&url)
1039 .header("Authorization", format!("Key {api_key}"))
1040 .header("Content-Type", "application/json")
1041 .json(&body)
1042 .send()
1043 .await
1044 .context("Flux request failed")?;
1045
1046 let status = resp.status();
1047 if !status.is_success() {
1048 let body_text = resp.text().await.unwrap_or_default();
1049 anyhow::bail!("Flux failed ({status}): {body_text}");
1050 }
1051
1052 let json: serde_json::Value = resp.json().await?;
1053 let image_url = json
1054 .pointer("/images/0/url")
1055 .and_then(|v| v.as_str())
1056 .ok_or_else(|| anyhow::anyhow!("No image URL in Flux response"))?;
1057
1058 let img_resp = client.get(image_url).send().await?;
1060 if !img_resp.status().is_success() {
1061 anyhow::bail!("Failed to download Flux image from {image_url}");
1062 }
1063 let bytes = img_resp.bytes().await?;
1064 let path = output_dir.join(format!("{base_name}_flux.png"));
1065 tokio::fs::write(&path, &bytes).await?;
1066 Ok(path)
1067 }
1068
1069 pub fn generate_fallback_card(title: &str, accent_color: &str) -> String {
1073 let display_title = if title.len() > 80 {
1075 format!("{}...", &title[..77])
1076 } else {
1077 title.to_string()
1078 };
1079
1080 let lines = word_wrap(&display_title, 35, 3);
1082 let line_height: i32 = 48;
1083 #[allow(clippy::cast_possible_truncation)]
1085 let line_count: i32 = lines.len() as i32;
1086 let total_text_height = line_count * line_height;
1087 let start_y = (1024 - total_text_height) / 2 + 24;
1088
1089 let font = "system-ui, sans-serif";
1090 let text_elements: String = lines
1091 .iter()
1092 .enumerate()
1093 .map(|(i, line)| {
1094 #[allow(clippy::cast_possible_truncation)]
1095 let y = start_y + (i as i32 * line_height); format!(
1097 " <text x=\"512\" y=\"{y}\" text-anchor=\"middle\" fill=\"white\" \
1098 font-family=\"{font}\" font-size=\"36\" font-weight=\"600\">{}</text>",
1099 xml_escape(line)
1100 )
1101 })
1102 .collect::<Vec<_>>()
1103 .join("\n");
1104
1105 format!(
1106 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1024\" height=\"1024\" \
1107 viewBox=\"0 0 1024 1024\">\n\
1108 \x20 <defs>\n\
1109 \x20 <linearGradient id=\"bg\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n\
1110 \x20 <stop offset=\"0%\" stop-color=\"{accent_color}\"/>\n\
1111 \x20 <stop offset=\"100%\" stop-color=\"#1a1a2e\"/>\n\
1112 \x20 </linearGradient>\n\
1113 \x20 </defs>\n\
1114 \x20 <rect width=\"1024\" height=\"1024\" fill=\"url(#bg)\" rx=\"0\"/>\n\
1115 \x20 <rect x=\"60\" y=\"60\" width=\"904\" height=\"904\" rx=\"24\" \
1116 fill=\"none\" stroke=\"rgba(255,255,255,0.15)\" stroke-width=\"2\"/>\n\
1117 {text_elements}\n\
1118 \x20 <text x=\"512\" y=\"920\" text-anchor=\"middle\" \
1119 fill=\"rgba(255,255,255,0.5)\" font-family=\"{font}\" \
1120 font-size=\"18\">Construct</text>\n\
1121 </svg>"
1122 )
1123 }
1124
1125 pub async fn cleanup(path: &Path) -> anyhow::Result<()> {
1127 if path.exists() {
1128 tokio::fs::remove_file(path).await?;
1129 }
1130 Ok(())
1131 }
1132}
1133
1134fn base64_decode(input: &str) -> anyhow::Result<Vec<u8>> {
1136 use base64::Engine;
1137 base64::engine::general_purpose::STANDARD
1138 .decode(input)
1139 .context("Failed to decode base64 image data")
1140}
1141
1142fn word_wrap(text: &str, max_width: usize, max_lines: usize) -> Vec<String> {
1144 let mut lines = Vec::new();
1145 let mut current_line = String::new();
1146
1147 for word in text.split_whitespace() {
1148 if current_line.is_empty() {
1149 current_line = word.to_string();
1150 } else if current_line.len() + 1 + word.len() <= max_width {
1151 current_line.push(' ');
1152 current_line.push_str(word);
1153 } else {
1154 lines.push(current_line);
1155 current_line = word.to_string();
1156 if lines.len() >= max_lines {
1157 break;
1158 }
1159 }
1160 }
1161
1162 if !current_line.is_empty() && lines.len() < max_lines {
1163 lines.push(current_line);
1164 }
1165
1166 lines
1167}
1168
1169fn xml_escape(text: &str) -> String {
1171 text.replace('&', "&")
1172 .replace('<', "<")
1173 .replace('>', ">")
1174 .replace('"', """)
1175 .replace('\'', "'")
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181 use std::fs;
1182 use tempfile::TempDir;
1183
1184 #[tokio::test]
1185 async fn credentials_parsed_plain_values() {
1186 let tmp = TempDir::new().unwrap();
1187 let env_path = tmp.path().join(".env");
1188 fs::write(
1189 &env_path,
1190 "LINKEDIN_CLIENT_ID=cid123\n\
1191 LINKEDIN_CLIENT_SECRET=csecret456\n\
1192 LINKEDIN_ACCESS_TOKEN=tok789\n\
1193 LINKEDIN_PERSON_ID=person001\n",
1194 )
1195 .unwrap();
1196
1197 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1198 let creds = client.get_credentials().await.unwrap();
1199
1200 assert_eq!(creds.client_id, "cid123");
1201 assert_eq!(creds.client_secret, "csecret456");
1202 assert_eq!(creds.access_token, "tok789");
1203 assert_eq!(creds.person_id, "person001");
1204 assert!(creds.refresh_token.is_none());
1205 }
1206
1207 #[tokio::test]
1208 async fn credentials_parsed_with_double_quotes() {
1209 let tmp = TempDir::new().unwrap();
1210 let env_path = tmp.path().join(".env");
1211 fs::write(
1212 &env_path,
1213 "LINKEDIN_CLIENT_ID=\"cid_quoted\"\n\
1214 LINKEDIN_CLIENT_SECRET=\"csecret_quoted\"\n\
1215 LINKEDIN_ACCESS_TOKEN=\"tok_quoted\"\n\
1216 LINKEDIN_PERSON_ID=\"person_quoted\"\n",
1217 )
1218 .unwrap();
1219
1220 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1221 let creds = client.get_credentials().await.unwrap();
1222
1223 assert_eq!(creds.client_id, "cid_quoted");
1224 assert_eq!(creds.client_secret, "csecret_quoted");
1225 assert_eq!(creds.access_token, "tok_quoted");
1226 assert_eq!(creds.person_id, "person_quoted");
1227 }
1228
1229 #[tokio::test]
1230 async fn credentials_parsed_with_single_quotes() {
1231 let tmp = TempDir::new().unwrap();
1232 let env_path = tmp.path().join(".env");
1233 fs::write(
1234 &env_path,
1235 "LINKEDIN_CLIENT_ID='cid_sq'\n\
1236 LINKEDIN_CLIENT_SECRET='csecret_sq'\n\
1237 LINKEDIN_ACCESS_TOKEN='tok_sq'\n\
1238 LINKEDIN_PERSON_ID='person_sq'\n",
1239 )
1240 .unwrap();
1241
1242 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1243 let creds = client.get_credentials().await.unwrap();
1244
1245 assert_eq!(creds.client_id, "cid_sq");
1246 assert_eq!(creds.access_token, "tok_sq");
1247 }
1248
1249 #[tokio::test]
1250 async fn credentials_parsed_with_export_prefix() {
1251 let tmp = TempDir::new().unwrap();
1252 let env_path = tmp.path().join(".env");
1253 fs::write(
1254 &env_path,
1255 "export LINKEDIN_CLIENT_ID=cid_exp\n\
1256 export LINKEDIN_CLIENT_SECRET=\"csecret_exp\"\n\
1257 export LINKEDIN_ACCESS_TOKEN='tok_exp'\n\
1258 export LINKEDIN_PERSON_ID=person_exp\n",
1259 )
1260 .unwrap();
1261
1262 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1263 let creds = client.get_credentials().await.unwrap();
1264
1265 assert_eq!(creds.client_id, "cid_exp");
1266 assert_eq!(creds.client_secret, "csecret_exp");
1267 assert_eq!(creds.access_token, "tok_exp");
1268 assert_eq!(creds.person_id, "person_exp");
1269 }
1270
1271 #[tokio::test]
1272 async fn credentials_ignore_comments_and_blanks() {
1273 let tmp = TempDir::new().unwrap();
1274 let env_path = tmp.path().join(".env");
1275 fs::write(
1276 &env_path,
1277 "# LinkedIn credentials\n\
1278 \n\
1279 LINKEDIN_CLIENT_ID=cid_c\n\
1280 # secret below\n\
1281 LINKEDIN_CLIENT_SECRET=csecret_c\n\
1282 LINKEDIN_ACCESS_TOKEN=tok_c # inline comment\n\
1283 LINKEDIN_PERSON_ID=person_c\n",
1284 )
1285 .unwrap();
1286
1287 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1288 let creds = client.get_credentials().await.unwrap();
1289
1290 assert_eq!(creds.client_id, "cid_c");
1291 assert_eq!(creds.client_secret, "csecret_c");
1292 assert_eq!(creds.access_token, "tok_c");
1293 assert_eq!(creds.person_id, "person_c");
1294 }
1295
1296 #[tokio::test]
1297 async fn credentials_with_refresh_token() {
1298 let tmp = TempDir::new().unwrap();
1299 let env_path = tmp.path().join(".env");
1300 fs::write(
1301 &env_path,
1302 "LINKEDIN_CLIENT_ID=cid\n\
1303 LINKEDIN_CLIENT_SECRET=csecret\n\
1304 LINKEDIN_ACCESS_TOKEN=tok\n\
1305 LINKEDIN_REFRESH_TOKEN=refresh123\n\
1306 LINKEDIN_PERSON_ID=person\n",
1307 )
1308 .unwrap();
1309
1310 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1311 let creds = client.get_credentials().await.unwrap();
1312
1313 assert_eq!(creds.refresh_token.as_deref(), Some("refresh123"));
1314 }
1315
1316 #[tokio::test]
1317 async fn credentials_empty_refresh_token_becomes_none() {
1318 let tmp = TempDir::new().unwrap();
1319 let env_path = tmp.path().join(".env");
1320 fs::write(
1321 &env_path,
1322 "LINKEDIN_CLIENT_ID=cid\n\
1323 LINKEDIN_CLIENT_SECRET=csecret\n\
1324 LINKEDIN_ACCESS_TOKEN=tok\n\
1325 LINKEDIN_REFRESH_TOKEN=\n\
1326 LINKEDIN_PERSON_ID=person\n",
1327 )
1328 .unwrap();
1329
1330 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1331 let creds = client.get_credentials().await.unwrap();
1332
1333 assert!(creds.refresh_token.is_none());
1334 }
1335
1336 #[tokio::test]
1337 async fn credentials_fail_missing_client_id() {
1338 let tmp = TempDir::new().unwrap();
1339 let env_path = tmp.path().join(".env");
1340 fs::write(
1341 &env_path,
1342 "LINKEDIN_CLIENT_SECRET=csecret\n\
1343 LINKEDIN_ACCESS_TOKEN=tok\n\
1344 LINKEDIN_PERSON_ID=person\n",
1345 )
1346 .unwrap();
1347
1348 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1349 let err = client.get_credentials().await.unwrap_err();
1350 assert!(err.to_string().contains("LINKEDIN_CLIENT_ID"));
1351 }
1352
1353 #[tokio::test]
1354 async fn credentials_fail_missing_access_token() {
1355 let tmp = TempDir::new().unwrap();
1356 let env_path = tmp.path().join(".env");
1357 fs::write(
1358 &env_path,
1359 "LINKEDIN_CLIENT_ID=cid\n\
1360 LINKEDIN_CLIENT_SECRET=csecret\n\
1361 LINKEDIN_PERSON_ID=person\n",
1362 )
1363 .unwrap();
1364
1365 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1366 let err = client.get_credentials().await.unwrap_err();
1367 assert!(err.to_string().contains("LINKEDIN_ACCESS_TOKEN"));
1368 }
1369
1370 #[tokio::test]
1371 async fn credentials_fail_missing_person_id() {
1372 let tmp = TempDir::new().unwrap();
1373 let env_path = tmp.path().join(".env");
1374 fs::write(
1375 &env_path,
1376 "LINKEDIN_CLIENT_ID=cid\n\
1377 LINKEDIN_CLIENT_SECRET=csecret\n\
1378 LINKEDIN_ACCESS_TOKEN=tok\n",
1379 )
1380 .unwrap();
1381
1382 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1383 let err = client.get_credentials().await.unwrap_err();
1384 assert!(err.to_string().contains("LINKEDIN_PERSON_ID"));
1385 }
1386
1387 #[tokio::test]
1388 async fn credentials_fail_no_env_file() {
1389 let tmp = TempDir::new().unwrap();
1390 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1391 let err = client.get_credentials().await.unwrap_err();
1392 assert!(err.to_string().contains("Failed to read"));
1393 }
1394
1395 #[tokio::test]
1396 async fn update_env_token_preserves_other_keys() {
1397 let tmp = TempDir::new().unwrap();
1398 let env_path = tmp.path().join(".env");
1399 fs::write(
1400 &env_path,
1401 "# Config\n\
1402 LINKEDIN_CLIENT_ID=cid\n\
1403 LINKEDIN_CLIENT_SECRET=csecret\n\
1404 LINKEDIN_ACCESS_TOKEN=old_token\n\
1405 LINKEDIN_PERSON_ID=person\n\
1406 OTHER_KEY=keepme\n",
1407 )
1408 .unwrap();
1409
1410 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1411 client.update_env_token("new_token_value").await.unwrap();
1412
1413 let updated = fs::read_to_string(&env_path).unwrap();
1414 assert!(updated.contains("LINKEDIN_ACCESS_TOKEN=new_token_value"));
1415 assert!(updated.contains("LINKEDIN_CLIENT_ID=cid"));
1416 assert!(updated.contains("LINKEDIN_CLIENT_SECRET=csecret"));
1417 assert!(updated.contains("LINKEDIN_PERSON_ID=person"));
1418 assert!(updated.contains("OTHER_KEY=keepme"));
1419 assert!(updated.contains("# Config"));
1420 assert!(!updated.contains("old_token"));
1421 }
1422
1423 #[tokio::test]
1424 async fn update_env_token_preserves_export_prefix() {
1425 let tmp = TempDir::new().unwrap();
1426 let env_path = tmp.path().join(".env");
1427 fs::write(
1428 &env_path,
1429 "export LINKEDIN_CLIENT_ID=cid\n\
1430 export LINKEDIN_CLIENT_SECRET=csecret\n\
1431 export LINKEDIN_ACCESS_TOKEN=\"old_tok\"\n\
1432 export LINKEDIN_PERSON_ID=person\n",
1433 )
1434 .unwrap();
1435
1436 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1437 client.update_env_token("refreshed_tok").await.unwrap();
1438
1439 let updated = fs::read_to_string(&env_path).unwrap();
1440 assert!(updated.contains("export LINKEDIN_ACCESS_TOKEN=\"refreshed_tok\""));
1441 assert!(updated.contains("export LINKEDIN_CLIENT_ID=cid"));
1442 }
1443
1444 #[tokio::test]
1445 async fn update_env_token_preserves_single_quote_style() {
1446 let tmp = TempDir::new().unwrap();
1447 let env_path = tmp.path().join(".env");
1448 fs::write(
1449 &env_path,
1450 "LINKEDIN_CLIENT_ID=cid\n\
1451 LINKEDIN_CLIENT_SECRET=csecret\n\
1452 LINKEDIN_ACCESS_TOKEN='old'\n\
1453 LINKEDIN_PERSON_ID=person\n",
1454 )
1455 .unwrap();
1456
1457 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1458 client.update_env_token("new_sq").await.unwrap();
1459
1460 let updated = fs::read_to_string(&env_path).unwrap();
1461 assert!(updated.contains("LINKEDIN_ACCESS_TOKEN='new_sq'"));
1462 }
1463
1464 #[tokio::test]
1465 async fn update_env_token_fails_if_key_missing() {
1466 let tmp = TempDir::new().unwrap();
1467 let env_path = tmp.path().join(".env");
1468 fs::write(
1469 &env_path,
1470 "LINKEDIN_CLIENT_ID=cid\n\
1471 LINKEDIN_PERSON_ID=person\n",
1472 )
1473 .unwrap();
1474
1475 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1476 let err = client.update_env_token("tok").await.unwrap_err();
1477 assert!(err.to_string().contains("LINKEDIN_ACCESS_TOKEN not found"));
1478 }
1479
1480 #[test]
1481 fn parse_env_value_strips_double_quotes() {
1482 assert_eq!(LinkedInClient::parse_env_value("\"hello\""), "hello");
1483 }
1484
1485 #[test]
1486 fn parse_env_value_strips_single_quotes() {
1487 assert_eq!(LinkedInClient::parse_env_value("'hello'"), "hello");
1488 }
1489
1490 #[test]
1491 fn parse_env_value_strips_inline_comment() {
1492 assert_eq!(LinkedInClient::parse_env_value("value # comment"), "value");
1493 }
1494
1495 #[test]
1496 fn parse_env_value_trims_whitespace() {
1497 assert_eq!(LinkedInClient::parse_env_value(" spaced "), "spaced");
1498 }
1499
1500 #[test]
1501 fn parse_env_value_plain() {
1502 assert_eq!(LinkedInClient::parse_env_value("plain"), "plain");
1503 }
1504
1505 #[test]
1506 fn api_headers_contains_required_headers() {
1507 let tmp = TempDir::new().unwrap();
1508 let client = LinkedInClient::new(tmp.path().to_path_buf(), "202602".to_string());
1509 let headers = client.api_headers("test_token");
1510 assert_eq!(
1511 headers.get("Authorization").unwrap().to_str().unwrap(),
1512 "Bearer test_token"
1513 );
1514 assert_eq!(
1515 headers.get("LinkedIn-Version").unwrap().to_str().unwrap(),
1516 "202602"
1517 );
1518 assert_eq!(
1519 headers
1520 .get("X-Restli-Protocol-Version")
1521 .unwrap()
1522 .to_str()
1523 .unwrap(),
1524 "2.0.0"
1525 );
1526 }
1527
1528 #[test]
1531 fn fallback_card_contains_svg_structure() {
1532 let svg = ImageGenerator::generate_fallback_card("Test Title", "#0A66C2");
1533 assert!(svg.starts_with("<svg"));
1534 assert!(svg.contains("1024"));
1535 assert!(svg.contains("#0A66C2"));
1536 assert!(svg.contains("Test Title"));
1537 assert!(svg.contains("Construct"));
1538 }
1539
1540 #[test]
1541 fn fallback_card_escapes_xml_characters() {
1542 let svg =
1543 ImageGenerator::generate_fallback_card("AI & ML <Trends> for \"2026\"", "#0A66C2");
1544 assert!(svg.contains("&"));
1545 assert!(svg.contains("<"));
1546 assert!(svg.contains(">"));
1547 assert!(svg.contains("""));
1548 assert!(!svg.contains("& "));
1549 }
1550
1551 #[test]
1552 fn fallback_card_truncates_long_titles() {
1553 let long_title = "A".repeat(100);
1554 let svg = ImageGenerator::generate_fallback_card(&long_title, "#0A66C2");
1555 assert!(svg.contains("..."));
1556 assert!(!svg.contains(&long_title));
1558 }
1559
1560 #[test]
1561 fn fallback_card_uses_custom_accent_color() {
1562 let svg = ImageGenerator::generate_fallback_card("Title", "#FF5733");
1563 assert!(svg.contains("#FF5733"));
1564 assert!(!svg.contains("#0A66C2"));
1565 }
1566
1567 #[test]
1568 fn word_wrap_basic() {
1569 let lines = word_wrap("Hello world this is a test", 15, 3);
1570 assert_eq!(lines.len(), 2);
1571 assert_eq!(lines[0], "Hello world");
1572 assert_eq!(lines[1], "this is a test");
1573 }
1574
1575 #[test]
1576 fn word_wrap_respects_max_lines() {
1577 let lines = word_wrap("one two three four five six seven eight", 10, 2);
1578 assert!(lines.len() <= 2);
1579 }
1580
1581 #[test]
1582 fn word_wrap_single_word() {
1583 let lines = word_wrap("Hello", 35, 3);
1584 assert_eq!(lines.len(), 1);
1585 assert_eq!(lines[0], "Hello");
1586 }
1587
1588 #[test]
1589 fn word_wrap_empty() {
1590 let lines = word_wrap("", 35, 3);
1591 assert!(lines.is_empty());
1592 }
1593
1594 #[test]
1595 fn xml_escape_handles_all_special_chars() {
1596 assert_eq!(xml_escape("a&b"), "a&b");
1597 assert_eq!(xml_escape("a<b>c"), "a<b>c");
1598 assert_eq!(xml_escape("a\"b'c"), "a"b'c");
1599 }
1600
1601 #[test]
1602 fn xml_escape_preserves_normal_text() {
1603 assert_eq!(xml_escape("hello world 123"), "hello world 123");
1604 }
1605
1606 #[tokio::test]
1607 async fn image_generator_fallback_creates_svg_file() {
1608 let tmp = TempDir::new().unwrap();
1609 let config = LinkedInImageConfig {
1610 enabled: true,
1611 providers: vec![], fallback_card: true,
1613 card_accent_color: "#0A66C2".into(),
1614 temp_dir: "images".into(),
1615 ..Default::default()
1616 };
1617
1618 let generator = ImageGenerator::new(config, tmp.path().to_path_buf());
1619 let path = generator.generate("Test post about Rust").await.unwrap();
1620
1621 assert!(path.exists());
1622 assert_eq!(path.extension().unwrap(), "svg");
1623
1624 let content = fs::read_to_string(&path).unwrap();
1625 assert!(content.contains("Test post about Rust"));
1626 }
1627
1628 #[tokio::test]
1629 async fn image_generator_fails_when_no_providers_and_no_fallback() {
1630 let tmp = TempDir::new().unwrap();
1631 let config = LinkedInImageConfig {
1632 enabled: true,
1633 providers: vec![],
1634 fallback_card: false, ..Default::default()
1636 };
1637
1638 let generator = ImageGenerator::new(config, tmp.path().to_path_buf());
1639 let result = generator.generate("Test").await;
1640 assert!(result.is_err());
1641 assert!(
1642 result
1643 .unwrap_err()
1644 .to_string()
1645 .contains("All image generation providers failed")
1646 );
1647 }
1648
1649 #[tokio::test]
1650 async fn image_generator_skips_provider_without_key() {
1651 let tmp = TempDir::new().unwrap();
1652 fs::write(tmp.path().join(".env"), "SOME_OTHER_KEY=value\n").unwrap();
1654
1655 let config = LinkedInImageConfig {
1656 enabled: true,
1657 providers: vec!["stability".into(), "dalle".into()],
1658 fallback_card: true,
1659 temp_dir: "images".into(),
1660 ..Default::default()
1661 };
1662
1663 let generator = ImageGenerator::new(config, tmp.path().to_path_buf());
1664 let path = generator.generate("Test").await.unwrap();
1665
1666 assert_eq!(path.extension().unwrap(), "svg");
1668 }
1669
1670 #[tokio::test]
1671 async fn image_generator_cleanup_removes_file() {
1672 let tmp = TempDir::new().unwrap();
1673 let file_path = tmp.path().join("test.png");
1674 fs::write(&file_path, b"fake image data").unwrap();
1675 assert!(file_path.exists());
1676
1677 ImageGenerator::cleanup(&file_path).await.unwrap();
1678 assert!(!file_path.exists());
1679 }
1680
1681 #[tokio::test]
1682 async fn image_generator_cleanup_noop_for_missing_file() {
1683 let tmp = TempDir::new().unwrap();
1684 let file_path = tmp.path().join("nonexistent.png");
1685 ImageGenerator::cleanup(&file_path).await.unwrap();
1687 }
1688
1689 #[tokio::test]
1690 async fn read_env_var_reads_value() {
1691 let tmp = TempDir::new().unwrap();
1692 fs::write(
1693 tmp.path().join(".env"),
1694 "STABILITY_API_KEY=sk-test-123\nOTHER=val\n",
1695 )
1696 .unwrap();
1697
1698 let val = ImageGenerator::read_env_var(tmp.path(), "STABILITY_API_KEY")
1699 .await
1700 .unwrap();
1701 assert_eq!(val, "sk-test-123");
1702 }
1703
1704 #[tokio::test]
1705 async fn read_env_var_fails_for_missing_key() {
1706 let tmp = TempDir::new().unwrap();
1707 fs::write(tmp.path().join(".env"), "OTHER=val\n").unwrap();
1708
1709 let result = ImageGenerator::read_env_var(tmp.path(), "STABILITY_API_KEY").await;
1710 assert!(result.is_err());
1711 assert!(
1712 result
1713 .unwrap_err()
1714 .to_string()
1715 .contains("STABILITY_API_KEY")
1716 );
1717 }
1718
1719 #[test]
1720 fn image_config_default_has_all_providers() {
1721 let config = LinkedInImageConfig::default();
1722 assert_eq!(config.providers.len(), 4);
1723 assert_eq!(config.providers[0], "stability");
1724 assert_eq!(config.providers[1], "imagen");
1725 assert_eq!(config.providers[2], "dalle");
1726 assert_eq!(config.providers[3], "flux");
1727 assert!(config.fallback_card);
1728 assert!(!config.enabled);
1729 }
1730}