1use std::path::PathBuf;
8use std::process::Command;
9
10pub async fn run_share(state_dir: &std::path::Path, include_demo_gif: bool) -> anyhow::Result<()> {
15 println!("π Sparrow Share β partage ta session\n");
16
17 let transcripts_dir = state_dir.join("transcripts");
19 let latest = find_latest_transcript(&transcripts_dir)?;
20
21 println!("π Transcript : {}", latest.display());
22
23 let raw = std::fs::read_to_string(&latest)?;
25 let formatted = format_transcript(&raw, &latest, include_demo_gif)?;
26
27 let filename = latest
29 .file_name()
30 .map(|n| n.to_string_lossy().to_string())
31 .unwrap_or_else(|| "sparrow-session.md".into());
32
33 let url = upload_gist(&filename, &formatted).await?;
34
35 println!();
37 println!("ββββββββββββββββββββββββββββββββββββββββββββββββββ");
38 println!(" β
Gist créé avec succès !");
39 println!("ββββββββββββββββββββββββββββββββββββββββββββββββββ");
40 println!();
41 println!(" π {url}");
42 println!();
43 println!(" PartagΓ© depuis Sparrow β https://github.com/ucav/Sparrow");
44 println!();
45
46 Ok(())
47}
48
49fn find_latest_transcript(transcripts_dir: &std::path::Path) -> anyhow::Result<PathBuf> {
52 if !transcripts_dir.exists() {
53 anyhow::bail!(
54 "Aucun transcript trouvΓ©. Le dossier {} n'existe pas.\n\
55 β Lance une tΓ’che Sparrow d'abord : sparrow run \"explique ce projet\"",
56 transcripts_dir.display()
57 );
58 }
59
60 let mut entries: Vec<_> = std::fs::read_dir(transcripts_dir)?
61 .filter_map(|e| e.ok())
62 .filter(|e| {
63 e.path()
64 .extension()
65 .map(|ext| ext == "json" || ext == "jsonl")
66 .unwrap_or(false)
67 })
68 .collect();
69
70 if entries.is_empty() {
71 anyhow::bail!(
72 "Aucun transcript trouvΓ© dans {}.\n\
73 β Lance une tΓ’che Sparrow d'abord !",
74 transcripts_dir.display()
75 );
76 }
77
78 entries.sort_by(|a, b| {
80 b.metadata()
81 .and_then(|m| m.modified())
82 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
83 .cmp(
84 &a.metadata()
85 .and_then(|m| m.modified())
86 .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
87 )
88 });
89
90 Ok(entries[0].path())
91}
92
93fn format_transcript(
96 raw: &str,
97 path: &std::path::Path,
98 include_demo_gif: bool,
99) -> anyhow::Result<String> {
100 let timestamp = path
101 .metadata()
102 .ok()
103 .and_then(|m| m.modified().ok())
104 .map(|t| {
105 let dt: chrono::DateTime<chrono::Local> = t.into();
106 dt.format("%Y-%m-%d %H:%M:%S").to_string()
107 })
108 .unwrap_or_else(|| "date inconnue".into());
109
110 let mut md = String::new();
111
112 md.push_str(&format!("# π¦ Sparrow Session β {timestamp}\n\n"));
114
115 if include_demo_gif {
116 md.push_str("> π¬ *DΓ©mo GIF Γ venir β regarde Sparrow en action !*\n\n");
117 }
118
119 md.push_str(&format!(
120 "Session gΓ©nΓ©rΓ©e par [Sparrow](https://github.com/ucav/Sparrow) v{version}.\n\n",
121 version = env!("CARGO_PKG_VERSION"),
122 ));
123 md.push_str("---\n\n");
124
125 let parsed = if raw.trim().starts_with('{') {
127 format_single_json_transcript(raw)
129 } else if raw.trim().starts_with('[') {
130 format_json_array_transcript(raw)
132 } else {
133 format_ndjson_transcript(raw)
135 };
136
137 match parsed {
138 Ok(formatted) => md.push_str(&formatted),
139 Err(_) => {
140 md.push_str("## Transcript brut\n\n");
142 md.push_str("```json\n");
143 if raw.len() > 500_000 {
145 md.push_str(&raw[..500_000]);
146 md.push_str("\n\n// ... (transcript tronquΓ© β trop volumineux)\n");
147 } else {
148 md.push_str(raw);
149 }
150 md.push_str("\n```\n");
151 }
152 }
153
154 md.push_str("\n---\n");
156 md.push_str("*PartagΓ© avec Sparrow β l'agent CLI qui code avec toi.*\n");
157 md.push_str("*[Installer Sparrow](https://github.com/ucav/Sparrow) | cargo install sparrow*\n");
158
159 Ok(md)
160}
161
162fn format_single_json_transcript(raw: &str) -> anyhow::Result<String> {
164 let v: serde_json::Value = serde_json::from_str(raw)?;
165 let mut md = String::new();
166
167 if let Some(task) = v.get("task").and_then(|t| t.as_str()) {
168 md.push_str(&format!("## π TΓ’che\n\n{task}\n\n"));
169 }
170
171 if let Some(events) = v.get("events").and_then(|e| e.as_array()) {
172 md.push_str("## π ΓvΓ©nements\n\n");
173 for event in events {
174 render_event_to_markdown(&mut md, event);
175 }
176 }
177
178 if let Some(usage) = v.get("usage") {
179 md.push_str("## π Statistiques\n\n");
180 if let Some(input) = usage.get("input_tokens").and_then(|t| t.as_u64()) {
181 md.push_str(&format!("- **Tokens d'entrΓ©e** : {input}\n"));
182 }
183 if let Some(output) = usage.get("output_tokens").and_then(|t| t.as_u64()) {
184 md.push_str(&format!("- **Tokens de sortie** : {output}\n"));
185 }
186 if let Some(cost) = usage.get("cost_usd").and_then(|c| c.as_f64()) {
187 md.push_str(&format!("- **CoΓ»t estimΓ©** : ${cost:.4}\n"));
188 }
189 md.push('\n');
190 }
191
192 Ok(md)
193}
194
195fn format_json_array_transcript(raw: &str) -> anyhow::Result<String> {
197 let events: Vec<serde_json::Value> = serde_json::from_str(raw)?;
198 let mut md = String::from("## π ΓvΓ©nements\n\n");
199
200 for event in &events {
201 render_event_to_markdown(&mut md, event);
202 }
203
204 Ok(md)
205}
206
207fn format_ndjson_transcript(raw: &str) -> anyhow::Result<String> {
209 let mut md = String::from("## π ΓvΓ©nements\n\n");
210 let mut task_found = false;
211
212 for line in raw.lines() {
213 let trimmed = line.trim();
214 if trimmed.is_empty() {
215 continue;
216 }
217
218 if let Ok(event) = serde_json::from_str::<serde_json::Value>(trimmed) {
219 if !task_found {
221 if let Some(task) = event
222 .get("task")
223 .or_else(|| event.get("description"))
224 .and_then(|t| t.as_str())
225 {
226 md.push_str(&format!("## π TΓ’che\n\n{task}\n\n"));
227 task_found = true;
228 }
229 }
230 render_event_to_markdown(&mut md, &event);
231 }
232 }
233
234 if md == "## π ΓvΓ©nements\n\n" {
235 md.push_str("*Aucun Γ©vΓ©nement parsable trouvΓ©.*\n\n");
236 }
237
238 Ok(md)
239}
240
241fn render_event_to_markdown(md: &mut String, event: &serde_json::Value) {
243 let event_type = event
244 .get("type")
245 .or_else(|| event.get("event"))
246 .and_then(|t| t.as_str())
247 .unwrap_or("event");
248
249 match event_type {
250 "RunStarted" | "run_started" => {
251 let task = event
252 .get("task")
253 .and_then(|t| t.as_str())
254 .unwrap_or("(tΓ’che)");
255 md.push_str(&format!("### π DΓ©marrage : {task}\n\n"));
256 }
257 "TextDelta" | "text_delta" | "assistant" => {
258 if let Some(text) = event
259 .get("text")
260 .or_else(|| event.get("content"))
261 .and_then(|t| t.as_str())
262 {
263 if text.len() > 200 {
264 md.push_str(&format!(
265 "<details>\n<summary>π¬ {}</summary>\n\n{}\n\n</details>\n\n",
266 &text[..text.len().min(80)],
267 text,
268 ));
269 } else {
270 md.push_str(&format!("π¬ {text}\n\n"));
271 }
272 }
273 }
274 "ToolUseProposed" | "tool_use" => {
275 let name = event
276 .get("name")
277 .or_else(|| event.get("tool"))
278 .and_then(|n| n.as_str())
279 .unwrap_or("?");
280 let input = event
281 .get("input")
282 .or_else(|| event.get("args"))
283 .map(|i| format!("{i}"))
284 .unwrap_or_default();
285 md.push_str(&format!(
286 "<details>\n<summary>π§ Outil : `{name}`</summary>\n\n```json\n{input}\n```\n\n</details>\n\n"
287 ));
288 }
289 "ToolOutput" | "tool_output" | "tool_result" => {
290 let output = event
291 .get("output")
292 .or_else(|| event.get("content"))
293 .or_else(|| event.get("result"))
294 .map(|o| format!("{o}"))
295 .unwrap_or_default();
296 let truncated = if output.len() > 500 {
297 format!("{}...", &output[..500])
298 } else {
299 output
300 };
301 md.push_str(&format!("β
RΓ©sultat :\n```\n{truncated}\n```\n\n"));
302 }
303 "Error" | "error" => {
304 let msg = event
305 .get("message")
306 .or_else(|| event.get("error"))
307 .and_then(|m| m.as_str())
308 .unwrap_or("erreur inconnue");
309 md.push_str(&format!("β **Erreur** : {msg}\n\n"));
310 }
311 "RunFinished" | "run_finished" | "Done" => {
312 let reason = event
313 .get("reason")
314 .or_else(|| event.get("stop_reason"))
315 .and_then(|r| r.as_str())
316 .unwrap_or("terminΓ©");
317 md.push_str(&format!("### π TerminΓ© ({reason})\n\n"));
318 }
319 _ => {
320 let pretty = serde_json::to_string_pretty(event).unwrap_or_default();
322 if pretty.len() < 200 {
323 md.push_str(&format!("π `{event_type}` :\n```json\n{pretty}\n```\n\n"));
324 }
325 }
326 }
327}
328
329async fn upload_gist(filename: &str, content: &str) -> anyhow::Result<String> {
336 if gh_available() && gh_authenticated() {
338 match upload_via_gh_cli(filename, content) {
339 Ok(url) => return Ok(url),
340 Err(e) => {
341 eprintln!("β οΈ Γchec gh CLI : {e}");
342 eprintln!(" β Tentative via API directe...");
343 }
344 }
345 }
346
347 if let Ok(token) = std::env::var("GITHUB_TOKEN") {
349 if !token.is_empty() {
350 return upload_via_api(filename, content, &token).await;
351 }
352 }
353
354 anyhow::bail!(
356 "Impossible de crΓ©er un Gist.\n\
357 β Installe gh CLI : https://cli.github.com\n\
358 β Puis : gh auth login\n\
359 β Ou dΓ©finis GITHUB_TOKEN : export GITHUB_TOKEN=\"ghp_...\""
360 )
361}
362
363fn gh_available() -> bool {
364 Command::new("gh")
365 .arg("--version")
366 .stdout(std::process::Stdio::null())
367 .stderr(std::process::Stdio::null())
368 .status()
369 .map(|s| s.success())
370 .unwrap_or(false)
371}
372
373fn gh_authenticated() -> bool {
374 Command::new("gh")
375 .args(["auth", "status"])
376 .stdout(std::process::Stdio::null())
377 .stderr(std::process::Stdio::null())
378 .status()
379 .map(|s| s.success())
380 .unwrap_or(false)
381}
382
383fn upload_via_gh_cli(filename: &str, content: &str) -> anyhow::Result<String> {
384 let tmp = std::env::temp_dir().join(format!("sparrow-gist-{}.md", uuid::Uuid::new_v4()));
386 std::fs::write(&tmp, content)?;
387
388 let output = Command::new("gh")
389 .args([
390 "gist",
391 "create",
392 "--public",
393 "--filename",
394 filename,
395 "--desc",
396 &format!(
397 "π¦ Sparrow Session β shared via sparrow share (v{})",
398 env!("CARGO_PKG_VERSION")
399 ),
400 ])
401 .arg(&tmp)
402 .output()?;
403
404 let _ = std::fs::remove_file(&tmp);
406
407 if !output.status.success() {
408 let stderr = String::from_utf8_lossy(&output.stderr);
409 anyhow::bail!("gh gist create a Γ©chouΓ© : {stderr}");
410 }
411
412 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
413 if url.is_empty() {
414 anyhow::bail!("gh gist create n'a pas retournΓ© d'URL.");
415 }
416
417 Ok(url)
418}
419
420async fn upload_via_api(filename: &str, content: &str, token: &str) -> anyhow::Result<String> {
421 let client = reqwest::Client::builder()
422 .user_agent("sparrow-share")
423 .timeout(std::time::Duration::from_secs(30))
424 .build()?;
425
426 let body = serde_json::json!({
427 "description": format!("π¦ Sparrow Session β shared via sparrow share (v{})", env!("CARGO_PKG_VERSION")),
428 "public": true,
429 "files": {
430 filename: {
431 "content": content
432 }
433 }
434 });
435
436 let resp = client
437 .post("https://api.github.com/gists")
438 .header("Authorization", format!("Bearer {token}"))
439 .header("Accept", "application/vnd.github+json")
440 .header("X-GitHub-Api-Version", "2022-11-28")
441 .json(&body)
442 .send()
443 .await?;
444
445 let status = resp.status();
446 let resp_body = resp.text().await?;
447
448 if !status.is_success() {
449 anyhow::bail!(
450 "GitHub API a retournΓ© HTTP {} : {}",
451 status.as_u16(),
452 resp_body,
453 );
454 }
455
456 let parsed: serde_json::Value = serde_json::from_str(&resp_body)?;
457 let html_url = parsed
458 .get("html_url")
459 .and_then(|u| u.as_str())
460 .ok_or_else(|| anyhow::anyhow!("RΓ©ponse GitHub inattendue : pas de html_url"))?;
461
462 Ok(html_url.to_string())
463}