Skip to main content

zeroclaw_tools/
linkedin_client.rs

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