Skip to main content

construct/tools/
linkedin_client.rs

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        // Strip inline comments in unquoted values: KEY=value # comment
70        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            // Attempt token refresh and retry once
176            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        // Add scheduled publish options if a future timestamp is provided.
227        // The timestamp must be ISO 8601 / RFC 3339, e.g. "2026-03-17T08:00:00Z".
228        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                // Scheduled posts use DRAFT lifecycle
236                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        // The post URN is returned in the x-restli-id header
271        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    /// Register an image asset with LinkedIn, upload binary data, and return the asset URN.
549    ///
550    /// LinkedIn's image post flow is three steps:
551    /// 1. Register the upload → get an upload URL + asset URN
552    /// 2. PUT the binary image to the upload URL
553    /// 3. Reference the asset URN when creating the post
554    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        // Step 1: Register upload
563        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, &register_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        // Step 2: Upload binary
597        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    /// Create a post with an attached image.
623    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            // Detect the LINKEDIN_ACCESS_TOKEN line (with or without export prefix)
700            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                // Preserve the export prefix and quoting style
714                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        // Preserve trailing newline if original had one
747        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
760// ── Image Generation ─────────────────────────────────────────────
761
762/// Multi-provider image generator with SVG fallback card.
763///
764/// Tries AI providers in configured priority order. If all fail (missing keys,
765/// API errors, exhausted credits), falls back to generating a branded SVG card.
766pub 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    /// Generate an image for the given prompt text. Returns the path to the saved PNG/SVG file.
780    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        // Try each configured provider in order
791        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        // All AI providers failed — try SVG fallback
815        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    /// Read an env var value from the workspace .env file (same format as LinkedInClient).
827    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, // image gen can be slow
856            10,
857        )
858    }
859
860    // ── Stability AI ────────────────────────────────────────────
861
862    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    // ── Google Imagen (Vertex AI) ───────────────────────────────
915
916    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    // ── OpenAI DALL-E ───────────────────────────────────────────
969
970    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    // ── Flux (fal.ai) ──────────────────────────────────────────
1018
1019    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        // Download the image from the returned URL
1059        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    // ── SVG Fallback Card ───────────────────────────────────────
1070
1071    /// Generate a branded SVG text card with the post title on a gradient background.
1072    pub fn generate_fallback_card(title: &str, accent_color: &str) -> String {
1073        // Truncate title to ~80 chars for clean display
1074        let display_title = if title.len() > 80 {
1075            format!("{}...", &title[..77])
1076        } else {
1077            title.to_string()
1078        };
1079
1080        // Word-wrap at ~35 chars per line, max 3 lines
1081        let lines = word_wrap(&display_title, 35, 3);
1082        let line_height: i32 = 48;
1083        // lines.len() is capped at max_lines=3, so this cast is safe
1084        #[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); // i is max 2, safe
1096                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    /// Clean up a generated image file after successful upload.
1126    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
1134/// Decode a base64-encoded string to bytes.
1135fn 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
1142/// Simple word-wrap: break text into lines of at most `max_width` chars, capped at `max_lines`.
1143fn 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
1169/// Escape XML special characters for SVG text content.
1170fn xml_escape(text: &str) -> String {
1171    text.replace('&', "&amp;")
1172        .replace('<', "&lt;")
1173        .replace('>', "&gt;")
1174        .replace('"', "&quot;")
1175        .replace('\'', "&apos;")
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    // ── Image Generation Tests ──────────────────────────────────
1529
1530    #[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("&amp;"));
1545        assert!(svg.contains("&lt;"));
1546        assert!(svg.contains("&gt;"));
1547        assert!(svg.contains("&quot;"));
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        // Should not contain the full 100-char string
1557        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&amp;b");
1597        assert_eq!(xml_escape("a<b>c"), "a&lt;b&gt;c");
1598        assert_eq!(xml_escape("a\"b'c"), "a&quot;b&apos;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![], // no AI providers — force fallback
1612            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, // no fallback either
1635            ..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        // Create .env without any image API keys
1653        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        // Should fall through to SVG fallback since no API keys
1667        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        // Should not error
1686        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}